Repository navigation
fix(controlplane, console): stop listing expired nodes on every refresh - #701
Conversation
|
Warning You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again! |
|
should we garbage collect or add an option to allow admins to define a time to purge expired nodes |
|
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
left a comment
There was a problem hiding this comment.
good directiion but instead of fixing the symptom let's fix the root cause
d55e8ac to
b740914
Compare
Done. Also added a table with timings to the description |
b740914 to
c932a18
Compare
aojea
left a comment
There was a problem hiding this comment.
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.
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.
c932a18 to
c49a989
Compare
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/statusandGET /user/statuslist admitted and banned nodes only. A user's list is filtered by owner in SQL, not in Go after loading the whole mesh.storage.Storelists nodes through oneListNodes(ctx, NodeFilter). The filter selects by state (admitted,expired,banned), owner and role, and pages with a keyset cursor and a limit. It replacesListUnexpiredNodesandListExpiredNodes.GET /user/nodesreturnsNodeListResponse: one page of nodes, newest enrollment first, with an opaque keysetpage_token.state(repeatable) androlenarrow the list. An administrator sees every owner's nodes and may pick one withowner_id. A user sees only their own nodes, and naming another owner returns 403.page_sizedefaults to 50, at most 200.internal/console/public/expired-nodes.js. It fetchesstate=expiredonly when opened, appends with Load more, removes a revoked row in place, and is cleared when the session ends./metricscounts nodes by role and state with oneCOUNT(*) ... GROUP BY. It no longer loads every row, biscuits included, every 30 s.idx_nodes_owner_id (owner_id)andidx_nodes_enrolled_at (enrolled_at, peer_id).controlplane.Options.Default()setsNodeRetentionto 30 days, so agentmesh-one deletes lapsed node rows too. A negative retention keeps them forever.--node-retention 0still 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.
One
GET /user/nodes?state=expiredpage of 51 rows, query time:ANALYZEPRAGMA optimizePostgres 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 optimizeon 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,
CountNodesagainst the same states,owner_idstaying 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.TestListNodesalso passes against Postgres 16.Follow-up commits (aojea)
Rebased on
mainand extended after the ladder runs on bananas showed the control plane stalling on a 119k-rownodestable.--node-retentionmust be positive.Options.Defaultfills an unset retention with 30 days as it does every other duration;Options.Validateand 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-onegets--control-plane-node-retention.ListNodesreads no blobs.public_keyandbiscuit_token(2 KB a row) stay out of every list;GetNodereads them for the one node a caller acts on. The admitted arm ofnodeStateWhereis parenthesised like the others, andparseNodeStatesdrops a state named twice./admin/statusalone).admittedNodetells a store failure from a missing credential; the mesh endpoints answer 503 with one log line, the OAuth pathstemporarily_unavailable. A database stall on the testnet had produced 484 silent 401s in one step of the ladder.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 byGET /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.