Skip to content

fix(controlplane, console): stop listing expired nodes on every refresh - #701

Merged
aojea merged 7 commits into
google:mainfrom
kaisoz:kaisoz/debug-console-slowness-bananas
Oct 15, 2026
Merged

aojea merged 7 commits into
google:mainfrom
kaisoz:kaisoz/debug-console-slowness-bananas

Conversation

@kaisoz

@kaisoz kaisoz commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

The console's 15 s refresh downloaded every node row the control plane keeps for its retention period. On a mesh with probe CronJobs most of those rows are expired identities, so the console got slower every day.

  • GET /admin/status and GET /user/status list admitted and banned nodes only. A user's list is filtered by owner in SQL, not in Go after loading the whole mesh.
  • storage.Store lists nodes through one ListNodes(ctx, NodeFilter). The filter selects by state (admitted, expired, banned), owner and role, and pages with a keyset cursor and a limit. It replaces ListUnexpiredNodes and ListExpiredNodes.
  • GET /user/nodes returns NodeListResponse: one page of nodes, newest enrollment first, with an opaque keyset page_token. state (repeatable) and role narrow the list. An administrator sees every owner's nodes and may pick one with owner_id. A user sees only their own nodes, and naming another owner returns 403. page_size defaults to 50, at most 200.
  • An expired OIDC enrollment can renew in place with a fresh JWT, so it must stay revocable. The console's Nodes view gets a collapsed Expired Nodes section in internal/console/public/expired-nodes.js. It fetches state=expired only when opened, appends with Load more, removes a revoked row in place, and is cleared when the session ends.
  • /metrics counts nodes by role and state with one COUNT(*) ... GROUP BY. It no longer loads every row, biscuits included, every 30 s.
  • Migration 19 adds the indexes idx_nodes_owner_id (owner_id) and idx_nodes_enrolled_at (enrolled_at, peer_id).
  • controlplane.Options.Default() sets NodeRetention to 30 days, so agentmesh-one deletes lapsed node rows too. A negative retention keeps them forever. --node-retention 0 still keeps them forever.

Timings

Measured locally with 25,040 node rows, 40 of them live, and 2 KB biscuits.

One console refresh, measured on the first revision of this PR. The status lists return the same rows in this revision.

Before After
Response size 54.6 MB 94 KB
Control-plane time about 650 ms 33 ms
Browser refresh 1,359 ms 15 ms

One GET /user/nodes?state=expired page of 51 rows, query time:

Caller Postgres 16 Postgres 16 after ANALYZE SQLite SQLite after PRAGMA optimize
Administrator 6.4 ms 0.03 ms 108 ms 0.17 ms
User who owns 3 of the rows 0.64 ms 0.02 ms 37 ms 0.07 ms

Postgres collects table statistics on its own through autovacuum, so its planner uses the new indexes. SQLite collects none unless asked. On SQLite, the default store of agentmesh-one, both new indexes stay unused and a page costs tens of milliseconds at this table size. Only opening the Expired Nodes section or pressing Load more pays that cost. Running PRAGMA optimize on SQLite is left for a follow-up.

Tests

Tests cover each state filter, the owner and role filters, keyset paging across equal enrollment times, a cursor from another owner's row, CountNodes against the same states, owner_id staying admin-only, claims staying admin-only, and the default retention deleting a row that lapsed more than 30 days ago. Two Playwright cases cover the console section. TestListNodes also passes against Postgres 16.

Follow-up commits (aojea)

Rebased on main and extended after the ladder runs on bananas showed the control plane stalling on a 119k-row nodes table.

  • --node-retention must be positive. Options.Default fills an unset retention with 30 days as it does every other duration; Options.Validate and the flag refuse a value that is not positive. There is no "keep forever": an unbounded table is what this PR exists to prevent. agentmesh-one gets --control-plane-node-retention.
  • ListNodes reads no blobs. public_key and biscuit_token (2 KB a row) stay out of every list; GetNode reads them for the one node a caller acts on. The admitted arm of nodeStateWhere is parenthesised like the others, and parseNodeStates drops a state named twice.
  • A user's status lists admitted nodes only: banned rows are never collected and only an administrator can restore one. The console makes one status call per refresh (an admin's later refreshes read /admin/status alone).
  • A store that does not answer is 503, not 401. admittedNode tells a store failure from a missing credential; the mesh endpoints answer 503 with one log line, the OAuth paths temporarily_unavailable. A database stall on the testnet had produced 484 silent 401s in one step of the ladder.
  • The GC sweeps at a tenth of the retention, within a second and an hour, so the default still sweeps hourly and a short retention is honoured within seconds.
  • Integration test TestLapsedMembersAreCollected: the testnet in seconds. Sixteen members enroll and leave, one refreshes with a fresh token; the lapsed ones are refused at /refresh, out of the status, paged by GET /user/nodes?state=expired, counted by /metrics, and deleted once the retention passes while the live one is untouched.

Not in this PR: a bootstrap enrollment still has no session, so a node that joined with a token and left is never collected. That is a separate change with its own trade-off (a member offline longer than the session must re-enroll) and follows in its own PR.

@kaisoz
kaisoz requested a review from aojea as a code owner October 13, 2026 22:05
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@aojea

aojea commented Oct 13, 2026

Copy link
Copy Markdown
Collaborator

should we garbage collect or add an option to allow admins to define a time to purge expired nodes

@aojea

aojea commented Oct 14, 2026

Copy link
Copy Markdown
Collaborator

Talked offline, I think we should find a way to improve the list of nodes with multiple discriminators for filtering and paging instead of adding a new API for each Node state ... that will scale better

@aojea

aojea commented Oct 14, 2026

Copy link
Copy Markdown
Collaborator

@kaisoz

  • Consolidate node listing into ListNodes(ctx, NodeFilter) in storage.Store: Replace ListUnexpiredNodes and ListExpiredNodes with a NodeFilter struct on ListNodes that supports filtering by state (Admitted, Expired, Banned), OwnerID, Role, and keyset pagination (Cursor, Limit). Use that same filter in HandleUserStatus so non-admin owner_id filtering happens in SQL rather than in Go after loading all mesh nodes.
  • Generalize the HTTP & Proto API (GET /user/nodes + NodeListResponse): Instead of GET /user/expired-nodes and ExpiredNodesResponse, introduce NodeListResponse (matching BootstrapTokenListResponse / EnrollmentRequestListResponse) and GET /user/nodes?state=expired&page_size=...&page_token=... so the same endpoint can page through admitted, banned, or expired nodes (and filter by owner_id / role).
  • Fix /metrics full-table scan in readMeshSnapshot (internal/controlplane/metrics.go:297): readMeshSnapshot still calls store.ListNodes(ctx) every 30s, loading all 25k+ rows (including biscuit_token blobs) into memory just to count nodes by (role, state). Replace this with a SQL COUNT(*) ... GROUP BY method on Store.
  • Add an index covering owner_id and default NodeRetention in Options.Default(): Add an index for owner_id on nodes so per-owner queries don't scan all workload rows, and set NodeRetention = DefaultNodeRetention in controlplane.Options.Default() so agentmesh-one also garbage-collects expired nodes by default.

@aojea aojea left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

good directiion but instead of fixing the symptom let's fix the root cause

@kaisoz
kaisoz force-pushed the kaisoz/debug-console-slowness-bananas branch from d55e8ac to b740914 Compare October 14, 2026 20:25
@kaisoz kaisoz changed the title controlplane, console: stop listing expired nodes on every refresh fix(controlplane, console): stop listing expired nodes on every refresh Oct 14, 2026
@kaisoz

kaisoz commented Oct 14, 2026

Copy link
Copy Markdown
Collaborator Author

good directiion but instead of fixing the symptom let's fix the root cause

Done. Also added a table with timings to the description

@kaisoz
kaisoz force-pushed the kaisoz/debug-console-slowness-bananas branch from b740914 to c932a18 Compare October 14, 2026 20:33
@aojea aojea added this to the v0.1.1 milestone Oct 14, 2026
Comment thread cmd/agentmesh-control-plane/main.go Outdated

@aojea aojea left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Overall the ListNodes(ctx, NodeFilter) consolidation, GET /user/nodes keyset pagination, and CountNodes metrics query look great. Left a few inline comments on NodeRetention semantics, SQL expression parenthesization, state parameter deduplication, and avoiding unnecessary payloads in status polls.

Comment thread internal/controlplane/config.go
Comment thread internal/storage/sql_store.go Outdated
Comment thread internal/storage/sql_store.go
Comment thread internal/controlplane/ui.go
Comment thread internal/controlplane/server.go
kaisoz and others added 7 commits October 15, 2026 08:08
GET /admin/status and GET /user/status returned every node row kept for
the retention period, so each console refresh grew with history. At 25k
rows, mostly expired probe identities, one refresh moved 54.6 MB and took
about 650 ms of control-plane time. Both lists now hold admitted and
banned nodes only: 94 KB in 33 ms. A user's list is filtered by owner in
SQL instead of in Go after loading the whole mesh.

storage.Store lists nodes through one ListNodes(ctx, NodeFilter), which
selects by state (admitted, expired, banned), owner and role, with keyset
paging. GET /user/nodes pages NodeListResponse, newest enrollment first,
narrowed by state, role and, for an administrator, owner_id. An expired
OIDC enrollment can renew in place, so the console pages state=expired
in its Expired Nodes section, only when the section is opened.

/metrics counts nodes with one GROUP BY instead of loading every row.
Migration 19 indexes owner_id and (enrolled_at, peer_id).
controlplane.Options.Default() sets NodeRetention to 30 days, so
agentmesh-one deletes lapsed rows too. A negative retention keeps them,
and --node-retention 0 still means keep forever.
…rever

A retention of 0 travelled as -1 and meant keep forever, the one duration
in Options with such a sentinel and the setting whose absence let the
nodes table grow to 119k rows on the testnet. Options.Default fills an
unset retention with DefaultNodeRetention as it does every other
duration, Options.Validate refuses one that is not positive, and the
flag refuses 0 on its own, since there it is a value the operator typed
and not an unset field. agentmesh-one exposes it as
--control-plane-node-retention like the other control plane tunables.
ListNodes read public_key and biscuit_token for every row, about 2 KB a
row, and every caller discarded them: the status pages, the node list,
the egress check. The columns stay out of the list; GetNode reads them
for the one node a caller acts on, and the round-trip test pins that a
listed node carries neither.

nodeStateWhere's admitted arm was a bare OR where the other arms were
parenthesised, so a caller that appended AND would have bound it wrong;
it is parenthesised like the rest. parseNodeStates drops a state named
twice, so a query string cannot grow the OR beyond three arms.
…us call per refresh

GET /user/status listed a user's banned nodes with the admitted ones.
A banned row is never garbage-collected and only an administrator can
restore it, and the console never showed a user their banned nodes, so
every node a user ever revoked travelled in their 15 s poll forever. A
user's status now lists admitted nodes; an administrator's lists banned
ones too, for the revoked table.

The console fetched /user/status on every refresh to learn the role and
then, for an administrator, /admin/status, which ran the same mesh-wide
queries again and replaced the first answer. It learns the role once per
session and then calls the one status that matches it.
admittedNode returned nil for a store read that failed as it did for a
credential that did not verify, so a database stall answered every
member request with 401 Unauthorized and no log line. On the testnet one
such stall produced 484 of them in a step of the ladder: members were
told their credential was refused when nothing about it had changed, and
the cause was read from the database's own log.

admittedNode now tells a store failure from a missing credential; the
mesh endpoints answer it with 503 Service Unavailable and one error log
line naming the path and the store error, and the OAuth paths answer
temporarily_unavailable. A member retries at its next sync, which is
what it does for a 503 and not for a 401.
The sweep ran hourly whatever the retention, so a retention shorter than
an hour was honoured only at the next restart. It now runs at a tenth of
the retention, no more often than every second and no less often than
hourly: the default 30 days still sweeps hourly, and a retention of
seconds, which an integration test sets to watch the table shrink, is
honoured within seconds.
An integration test for what the scale runs found on the testnet: a
probe CronJob enrolls a fresh identity every run and never comes back
for it, such rows stayed forever, the metrics scrape read every one of
them every 30 seconds, and the control plane stalled on it.

TestLapsedMembersAreCollected is that in seconds: one member keeps its
session alive by refreshing with a fresh token, sixteen enroll and
leave. Their sessions lapse; a lapsed member is refused at /refresh; the
admin status lists only the member that refreshes; GET
/user/nodes?state=expired pages the lapsed rows; /metrics counts them by
state without listing them. The retention passes, the GC deletes them,
nodes_deleted_total says so, and the member that refreshed throughout is
untouched.
@aojea
aojea force-pushed the kaisoz/debug-console-slowness-bananas branch from c932a18 to c49a989 Compare October 15, 2026 09:12
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.

2 participants