Skip to content

controlplane: bootstrap members that fall silent are forgotten; ephemeral tokens; every table enrollment grows is bounded - #728

Merged
aojea merged 4 commits into
google:mainfrom
aojea:node-gc-bootstrap
Oct 15, 2026
Merged

aojea merged 4 commits into
google:mainfrom
aojea:node-gc-bootstrap

Conversation

@aojea

@aojea aojea commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

Follow-up to #701. The node GC there matches a node whose session expired; a member enrolled with a bootstrap token has no session (expires_at is zero), so the 10,000 rows each scale ladder left on bananas were never collected, and a control plane audit after the merge found four more tables and one in-memory cache growing with every enrollment.

Bootstrap members are forgotten after silence (--node-retention, 30d)

Every /refresh rewrites enrolled_at, so the row already records when the member was last seen. A bootstrap member that has not refreshed for --node-retention is deleted; one that comes back after that enrolls again (its credential still verifies, /refresh answers 401 "enroll again"). No new session semantics, no behaviour change for a member that refreshes.

Ephemeral tokens (--ephemeral-node-retention, 1h)

A fleet, a CI job or a probe that enrolls a fresh identity on every run leaves one row per run. BootstrapTokenCreateRequest.ephemeral marks a token whose members are kept for --ephemeral-node-retention after their last refresh instead of a month. The flag is copied onto the node row at enrollment, shown in the console and the token/node views, and the scale scripts mint ephemeral tokens. This is the kubeadm model: the token says how long its members are remembered.

One sweep bounds every table that grows with enrollment

The bananas audit (2026-10-15): enrollment_requests 99,990 rows (auto-approval writes one per member, nothing deleted them), routers 290 for 13 live, bootstrap_tokens 325 for 9 live, and one in-memory service report per member that ever reported, dropped only on a ban. The same hourly sweep now deletes requests resolved or pending for the retention (the ephemeral retention when an ephemeral token made them), tokens expired for the retention that no request refers to (requests go first; the FK), router leases expired for the retention, and the catalog entry of every peer that is no longer admitted. Each step logs its own failure and the next still runs.

A reaper that stops is visible

  • agentmesh_control_plane_storage_rows{table}: row count of every growing table, read with the mesh state snapshot
  • agentmesh_control_plane_storage_rows_deleted_total{table} and agentmesh_control_plane_nodes_deleted_total{reason=session|silent|ephemeral}
  • agentmesh_control_plane_catalog_entries

Risk

Retention-gated, idempotent deletes; defaults (30d / 1h) remove nothing a running mesh needs. Both retentions must be positive (no "keep forever"). Schema migration 20 adds a BOOLEAN DEFAULT FALSE NOT NULL column to bootstrap_tokens and nodes. The only new hot-path query is a per-table COUNT(*) every 30s in the metrics snapshot.

Tests

Store round trips for each delete and the count; unit tests for the silent/ephemeral sweep, the four-table sweep and the catalog prune, the ephemeral flag propagation, and the positive-retention validation; integration TestSilentEphemeralMembersAreForgotten (12 ephemeral + 2 regular members, --ephemeral-node-retention 1s): the fleet is gone from /admin/status within seconds, its requests go with its rows, the regular members refresh, a forgotten member gets 401, and the gauges are served. Build, make lint, unit, integration, verify-generated/verify-sdk-generated, console UI tests pass.

Review

  • The request predicate is resolved_at < ? OR (resolved_at IS NULL AND created_at < ?) so the existing idx_enrollment_requests_created_at serves it; COALESCE over the two columns could not use an index.
  • Every service report the sweep drops leaves a debug line with the peer ID; the count stays at info.
  • One store method per delete rather than DeleteNodes(ctx, NodeFilter): the sweep is the only caller, each predicate is fixed per table (and three of the five are on tables without a filter type), the banned = false guard stays in the SQL instead of in every caller's filter, and a zero filter cannot mean "delete everything". A filtered delete fits an operator-facing bulk forget (DELETE /admin/nodes?state=expired), which can introduce it when it arrives, with the cutoff fields the list view would want too.

Testnet

Bananas held 19,601 admitted OIDC rows from probe runs with ~90-day sessions (#729 moves the cluster issuer to --workload-issuer). The finished runs, those not connected to any live router and silent for over an hour (19,307), had expires_at set to now rather than being deleted, so the deployed sweep collects them after the 72h retention and nodes_deleted_total{reason="session"} is observed on real data. Live members, routers, requests, tokens and leases were left for the reapers in this PR.

aojea added 3 commits October 15, 2026 13:49
…eral tokens

A bootstrap enrollment has no session, so the node GC, which deletes
rows whose session lapsed, never matched one: every member that joined
with a token and left stayed enrolled for good. The ladder tests alone
left a hundred thousand such rows on the testnet, and the control
plane's full scans of that table were what stalled it under load.

The GC now also deletes an unbanned bootstrap row that has not refreshed
for --node-retention: enrolled_at is rewritten by every refresh, so it
already is the last time the member was heard from. A member that keeps
refreshing keeps its row; one that fell silent for a month is forgotten,
and enrolls again if it comes back, as a self-hosted CI runner is removed
after a fortnight without connecting and re-registers. Nothing a running
member can observe changes: no session, no refusal, no new state.

A bootstrap token can be ephemeral (BootstrapTokenCreateRequest.ephemeral,
a checkbox in the console). The members it enrolls carry the flag and are
forgotten after --ephemeral-node-retention (an hour) without a refresh:
a fleet in a scale test, a probe, a CI job. The ladder and journey
harnesses mint ephemeral tokens, so a scale run leaves the table as it
found it within the hour.

agentmesh_control_plane_nodes_deleted_total gains a reason label:
session, silent, ephemeral. Migration 20 adds the ephemeral column to
tokens and nodes. agentmesh-one exposes the retention as
--control-plane-ephemeral-node-retention.
TestSilentEphemeralMembersAreForgotten is the ladder's fleet in seconds:
twelve members enroll with an ephemeral token and fall silent, two with
a regular one, of which one refreshes. The fleet is gone after the
ephemeral retention, counted under reason=ephemeral; the regular members
stay within their retention and refresh; a forgotten member that comes
back is a stranger and is told to enroll again. The scale harnesses mint
their tokens ephemeral.
…catalog cache

The node GC left four other things growing with every enrollment on the
testnet: 99,990 enrollment requests (auto-approval writes one per member
and nothing deleted them), 290 router leases for 13 live routers, 325
bootstrap tokens for 9 live ones, and one in-memory service report per
member that ever reported, dropped only on a ban.

One sweep now covers all of them. Enrollment requests resolved or left
pending for --node-retention go, for --ephemeral-node-retention when an
ephemeral token made them; bootstrap tokens expired for the retention go
once no request refers to them (the request carries a foreign key to its
token, so requests go first); router leases expired for the retention go;
and the catalog cache keeps a report only for a node still admitted. Each
step logs its own failure and the next one still runs.

Two metrics make a reaper that stopped visible without a database shell:
agentmesh_control_plane_storage_rows{table} is the row count of every
table that grows with mesh activity, read with the mesh state snapshot,
and agentmesh_control_plane_storage_rows_deleted_total{table} counts what
each sweep removed. agentmesh_control_plane_catalog_entries is the size
of the in-memory cache.

Store round trips cover each delete and the count; the controlplane unit
test runs one sweep over all four and the cache; the ephemeral-fleet
integration test asserts the fleet's requests go with its rows while the
regular members' stay, and that the gauges are served.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces ephemeral bootstrap tokens and nodes, allowing nodes enrolled via these tokens to be garbage-collected after a shorter retention period (defaulting to one hour) once they fall silent. It adds the --ephemeral-node-retention configuration, updates database schemas and queries, expands garbage collection to cover stale enrollment requests, expired tokens, and router leases, and introduces new Prometheus metrics for storage monitoring. The review feedback highlights a performance concern in the SQL store where the use of COALESCE prevents index utilization on the enrollment_requests table, and identifies a style guide violation regarding auditability, recommending debug logging of peer IDs when pruning the service catalog.

Comment thread internal/storage/sql_store.go Outdated
Comment thread internal/controlplane/node_gc.go
…eport is traced

Review: COALESCE(resolved_at, created_at) < ? cannot use the index on
created_at; resolved_at < ? OR (resolved_at IS NULL AND created_at < ?)
can. And a peer whose service report the sweep drops is a decision about
that peer, so it leaves a debug line with its ID; the count stays at info.
@aojea
aojea merged commit 17e7b68 into google:main Oct 15, 2026
22 checks passed
@aojea aojea mentioned this pull request Oct 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant