Repository navigation
api: filter the admin sandbox list by node and assignment, and tighten its parameter checks - #1385
Conversation
|
Thanks for opening this pull request! A SPACE maintainer will take a look. CONTRIBUTING.md explains how we review outside contributions. |
Review — 2 findingsMedium
Low
Verified against What I traced:
I didn't run anything beyond reading; CI's Postgres tests at this head are green. Not done: a Kind Direct run. |
the-gigi-pplx
left a comment
There was a problem hiding this comment.
Approved. 2 non-blocking findings in the review comment above.
node_id and assigned come back, matching the node the response reports as active_node_id. The Direct module gains current_routes, the owning read of the routed sandboxes; the admin statements take its IDs as bound arrays, so a routed sandbox matches by its route's node and any other by its row's.
…in the page's snapshot The admin list's node filters read every current Direct route and bound all their IDs into the page statement, so each node-filtered page moved the whole route table, whatever its org, cursor or limit. A route matters to these filters only where its node differs from its row's active_node_id, including a row with no node; everywhere else the row answers alone. The Direct module's read (now route_overrides) returns only those routes: for node_id, the ones onto that node from elsewhere and away from a row on it; for assigned, the ones of rows with no node. The admin statements are unchanged. The route read and the page statement were also separate snapshots, so a route moving between them could list a sandbox under one node while the same response reported another. With a node filter set they now run in one REPEATABLE READ, READ ONLY transaction; without one the list runs as before. Tests: the unpaged statement is checked in every node and filter case; the combined cases now depend on their node filter; and a new test commits a route move or removal between the two reads and checks that the page agrees with the node its responses report.
Like every filter, node_id and assigned list sandbox rows only, so assigned=false leaves out the node-reported pool slots and observed-local sandboxes that the unfiltered list appends, although their responses report no active_node_id. The OpenAPI description of assigned, the NodeAssignment doc, and is_unfiltered's doc now say so; is_unfiltered no longer claims no filter could describe those rows.
…o route read The node filters read the Direct routes that disagree with their row and bound them back into the page statement, in one REPEATABLE READ transaction. That read is not small: writing off a sandbox from a gone node, reverting a restore and rolling back an activation clear the row's active_node_id and leave its route, so every written-off Direct sandbox kept its route in the assigned read until its row went. Direct now exports two whole boolean predicates for the node a response reports, and both admin list statements interpolate them in place of the read: reported_on_node narrows by idx_sandboxes_active_node and the primary key before its exact COALESCE test, and reported_on_a_node probes the route by key. The unpaged statement already joins the route, so its assigned filter tests the response's own expression. Each request is one statement, with no transaction and no second round trip. On 500,000 synthetic sandboxes with 300,000 written-off routes, an assigned page went from a 270 ms route read and a 355 ms statement to 4 ms (unpaged: 10 ms), and node pages stay at 13-22 ms; both statements return the old rows for every case measured.
Before these were filters the unpaged list ignored the names, so a caller sending node_id= or assigned= now got a 400. An empty value is unset, paged or not.
created_before was rounded up to its microsecond before it was compared with created_after, so created_after=...0.0000006Z with created_before=...0.0000003Z passed the check. The range is now compared as given and rounded afterwards.
A created_after or created_before before 4714-11-24 BC is valid RFC 3339, and a hand-built cursor can carry any instant, but Postgres's timestamptz cannot hold them, so the statement failed and the list answered 500. The parameters and the cursor are now checked against that range when parsed. The malformed-parameter test labels its cases by position, so a failure prints no cursor or body.
The comment moved with the list handler still said the projection reads erased the SQLSTATE and that the arm flattened every error to a 500. Both reads go through db_contention_error, and controller_read_error answers contention as a retryable 503, so the comment and its pointer on get_sandbox now say that.
lifecycle=, template=, user_id=, created_after= and created_before= answered 400 on the unpaged list, which ignored those names before they were filters. Like node_id, assigned and prefix, an empty value is now unset, paged or not; an empty item in a lifecycle list is still refused. org_id, which the unpaged list already parsed, keeps refusing an empty value. The OpenAPI description says so once for the endpoint.
prefix searches sandbox rows only, like every filter, so a pool slot or observed-local sandbox never matches, even by its own ID. The field's doc comment and the OpenAPI parameter now say so, and the route test pins it.
The count could drop the stored deleted rows no node reports before its FULL JOIN. Postgres already applies the deleted filter at the join, below the inventory lookup, so each such row costs a hash probe. On 1.5 million synthetic rows (1 million tombstones, 102,500 observed) an EXISTS prefilter measured 1,180 ms against 1,058 ms, and a hashed IN, 980 ms, becomes a per-row subplan once the observed rows outgrow hash_mem. The doc comment records that so the statement keeps its shape.
78818d0 to
14a4dbc
Compare
…he clock
`reported_on_node` was NULL, not FALSE, for a sandbox with no Direct route
and no `active_node_id`: both conjuncts compared NULL with the node. The
list's `$10 IS NULL OR {on_node}` dropped such a row either way, but a
negation would drop it too. The exact test is now `IS NOT DISTINCT FROM`, so
the predicate is never NULL, and the node-filter test checks that `NOT` of it
keeps the unassigned sandboxes.
`an_empty_filter_parameter_is_unset` compared whole bodies from two requests,
and each body's `age` counts whole seconds on the wall clock, so a second
ticking between them failed it. It now compares the listed IDs and
`next_cursor`, which also keeps the bodies out of its failure message.
|
Thanks. Both findings are addressed; the head is now
The PR also takes #1321's lows:
A test checks that the page and unpaged statements return the same rows for each filter and combination. Could you take another look? |
Re-review — 5 findings, none blockingThe head is now
I also checked two things in the new code:
Both Low
Verified against |
Summary
GET /admin/api/v1/sandboxesgains back thenode_idandassignedfiltersthat #1321 dropped in review, and its parameter checks get stricter where they
answered 500 and looser where they broke callers of the unpaged list.
Node filters. For sandbox rows, the filters match the node the same
response reports as
active_node_id. A sandbox with a current Direct route ison its route's node, whatever its row's
active_node_idsays. Any othersandbox, Kubernetes-managed or Direct without a route, is on its row's node.
assigned=truekeeps the sandboxes reported with a node,assigned=falsetheones without, and
node_idwithassigned=falseanswers 400. Like everyfilter, they list sandbox rows only. The node-reported rows with no sandbox row
(pool slots and observed-local sandboxes) still come only with the unfiltered
list, so
assigned=falseleaves them out although their responses report noactive_node_id. The OpenAPI description ofassignedsays so.Each request is one statement, with no route read, no transaction and no
second round trip. A read of the disagreeing routes would not stay small. When
a sandbox is written off from a gone node, when a restore is reverted, and when
an activation is rolled back, its row's
active_node_idis cleared but itsroute is kept until
clear_direct_route_identityruns or the row goes. Socontrollers/substrate/direct/reported_node.rsexports two whole booleanpredicates, and the admin statements interpolate them. The Direct tables stay
behind Direct's module, so there is no new
pattern_ratchet.direct_tablesfinding:
reported_on_node(s, $n)first narrows to the sandboxes whose row or routenames the node. The planner finds those through
idx_sandboxes_active_nodeand the primary key. It then tests
COALESCE(route.current_node_id, s.active_node_id) IS NOT DISTINCT FROM $nexactly, so it is never NULL: negated, it keeps a sandbox on no node. Both
the page and the unpaged statement use it.
reported_on_a_node(s)iss.active_node_id IS NOT NULLor anEXISTSonthe route's primary key. The page statement uses it. The unpaged statement
already joins the route through
{sandbox_from}, soassignedtests theresponse's own expression there.
Parameter checks.
org_id(lifecycle=,template=,node_id=,assigned=,user_id=,created_after=,created_before=) isunset, as
prefix=already was. Those used to answer 400 on the unpagedlist, which ignored the names before they were filters.
org_id, which thatlist already parsed, still refuses an empty value. An empty item inside a
lifecyclelist is still refused.created_after/created_beforepair is compared as given, beforecreated_beforeis rounded up to its microsecond. A range inverted insideone microsecond now answers 400 instead of being accepted.
timestamptzholds (before4714-11-24 BC) answers 400 instead of 500. That covers the parameters and a
hand-built cursor.
prefix, and its doc comment, say that itsearches sandbox rows only. A node-reported row never matches, even by its
own ID.
Comments. The projection arm's comment, which moved with the handler, said
the projection reads erase the SQLSTATE. They keep it through
db_contention_error, andsupport::controller_read_erroranswers contentionas a retryable 503. The comment and its pointer on
get_sandboxnow say that.The spaced-version count's doc records why stored
deletedrows are filteredat its
FULL JOINrather than before it (see Validation).With this, the console can filter its sandbox list by node on the server,
instead of fetching every page and filtering client-side.
Validation
cargo nextest run -p pplx-space-api --all-features: 2196 passed and 1skipped, with Postgres from the nextest setup script.
cargo clippy -p pplx-space-api --all-targets --all-features -- -D warningsand
cargo fmt --all -- --checkpass.cargo archagainstmain's baseline andARCHITECTURE.toml: 112 advisoryfindings, the same as
main. The predicates are under the ratchet's allowedcontrollers/substrate/direct/**. The only changed line is the existingroutes/admin/docs.rssize finding (already over the limit onmain),which grows by 2 lines.
the_node_filters_match_the_node_the_response_reportscovers Kubernetesrows on a node and unassigned, and Direct rows with and without a route. It
includes routes whose node differs from the row's in both directions, and a
route on a row with no node, which is the written-off shape. For each of
node_id(two nodes),assigned=trueandassigned=false, it checks thepage, a cursor walk one row a page, and the unpaged statement. For
node_id,it also checks against the response's own
active_node_id.Every filter goes through both statements.
each_filter_returns_exactly_its_rowsand the node test above compare the page and the unpaged list with the same
expected rows, so the duplicated filter
WHEREcannot drift unnoticed.an_empty_filter_parameter_is_unsetcompares each empty filter with thesame list without it, paged and unpaged. It fails without the change.
malformed_list_parameters_answer_400adds the inverted-microsecond range,a pre-4714 BC
created_after/created_before, and an out-of-range cursor;the cursor answered 500 before the change. The list's route test pins that
prefixleaves out the node-reported row.Measured in Postgres 16 on 500,000 synthetic sandboxes: 300,000 written off,
each with no row node and a route on one of 1,000 gone nodes (about 300
each); 100,000 live on 40 nodes, with agreeing routes; 100,000 unassigned
with no route. Times are warm
psqlround trips of the built statements."Before" is the route read plus the statement with its arrays bound.
assigned, pageassigned, unpaged,limit=1000node_id, live node (2,500 rows), pagenode_id, gone node (291 rows), pagenode_id, no rows, pagenode_id, unpaged,limit=1000Every case was repeated 8 times with no change in plan. Old and new return
identical rows for each filter on this table. Testing only the
COALESCEscans the list's order: 690 ms for a node with no rows, which is why
reported_on_nodenarrows by index first. The unpaged statement's joinedCOALESCEcost 190-420 ms per node, which is why it takes the predicate too.The count's stored-
deletedrows were measured on 1,500,000 rows(1,000,000 stored
deleted, 102,500 observed). Postgres already applies thedeletedfilter at theFULL JOIN, below the inventoryLATERAL. Droppingthose rows from
sandboxesfirst measured 1,180 ms as anEXISTS, against1,058 ms. A hashed
INran in 980 ms, but it becomes a per-row subplan oncethe observed rows outgrow
hash_mem. The statement keeps its shape, and itsdoc says why.
the_count_is_the_tally_of_the_full_liststill covers storeddeletedrows with and without a node reporting them.Kind was not run at this head. A route that disagrees with its row cannot be
produced through the API, so the Postgres tests above carry the filters, and
the route tests in
routes/admin/sandbox_list/tests.rscarry parsing and the400s.
Severity and risk
Severity: does not apply (feature restore, plus parameter-check fixes).
Risk: low. The node filters are new optional parameters. Without them, each
statement's node conditions are
$n IS NULLtests that Postgres folds away;the unfiltered page measured 3.6 ms on the table above.
reported_on_nodescans the routetable once per statement for the node's routes, inside the database, because
current_node_idhas no index: about 10 ms at 400,000 routes. More requestsnow succeed: an empty filter is unset instead of 400. Fewer fail as 500: an
out-of-range timestamp is 400. Roll back by reverting.
Rollout
While replicas on current
mainstill serve, a paged request (cursor) thatnames
node_idorassignedanswers 400 there, and an unpaged one ignoresthem. Send the filters from the console once every API replica runs this
release.
Not covered
sandbox_direct_route_identities (current_node_id)would letreported_on_nodeskip its scan of the route table. It is left out becauseit needs a migration on a table the activation path writes.
AI assistance: Claude Code drafted the code, tests and this description.