feat(npm): the batched upstream rules read 2/2 (S31 plan: 4/19)
The second of Step 4's two fixed-count reads. Part 1 (!2164 (merged)) has merged, so this MR
now targets main directly and is no longer gated behind anything. Neither read is
wired yet, Step 11 being the first caller, so nothing here is reachable in
production on merge.
One statement returns the rules of every named association, grouped by association
id. It keys on the ids part 1's list read already returned, so it re-joins neither
the association table nor repositories: membership and liveness were gated there,
and re-deriving either would only add partition metadata to every execution.
The stack
| # | MR | Branch | Contents | LOC | State |
|---|---|---|---|---|---|
| 1/2 | !2164 (merged) | dmeshcharakou/s31-npm-virtual-step-4a |
the ordered upstream list read | 1221 | merged |
| 2/2 | this MR | dmeshcharakou/s31-npm-virtual-step-4b |
the batched rules read, plus the assertion that needs both reads | 873 | open |
Both figures are re-derived at this MR's head against each MR's own merge base, not carried over from an earlier revision.
The namespace equality is required, not decorative
index_nvur_on_ns_id_repository_upstream_id leads on namespace_id, so without
that equality the predicate matches no usable index prefix, gets no hash pruning,
and the read becomes an Append over all 64 partitions, growing with the table
rather than the rule set. Measured on PostgreSQL 16.15 over 200k rows in 20k
namespaces: 0.039 ms and 3 buffers with the equality, 11.5 ms and 2303 buffers
without. TestNpmVirtualUpstreamRules_PlanShape gates it with EXPLAIN.
Absence, emptiness and the zero id
An association holding no rules has no entry, so "no rules" and "rules this read dropped" stay apart. An empty id list issues no statement at all, and that check sits ahead of the client guard so a zero-value store answers an empty batch with an empty map rather than an error: with no association to ask about there is no round trip to refuse.
A zero id anywhere in the batch is rejected. It binds like any other id and matches no row, so it would report a position the caller failed to resolve as one holding no rules, leaving it eligible for everything its rules denied.
That guard closes one instance of a class it cannot close in general: any id matching no row reads the same way, whether it is stale, from another repository, or an association deleted after the list read returned it. The zero UUID is rejected because it is the wiring slip a type cannot express; the rest is the composed read's to own, since separating them means re-joining the association table on every request. The sidecar names all three readings and says which fail open, per A Documented Absence Names Every Reading It Leaves Open.
The sidecar also now says what the composed read can and cannot do about the
third reading. Taking the ids from its own list read in the same request
narrows that window; it does not close it, because both methods query
s.client.DB() and neither takes a qrm.DB, so the two statements always run as
separate autocommit READ COMMITTED statements. Closing it outright would need
both reads under one snapshot. The residual window is recorded as accepted.
Neither discriminator is filtered
Both are projected verbatim, out-of-range values included. The column's range
CHECK is the first line of defense and the matcher the second, so a read that
dropped an unrecognized value would turn a corrupt deny rule into a no-op. All six
rule_type x target_field combinations get a positive hit, each with its own
pattern so a swapped projection fails.
The bound fails closed
Nothing caps rules per association, so this read's row count is not bounded by the upstream count. It selects one row past its ceiling and returns a data error when that row arrives, rather than serving a shortened set: a short rule set is indistinguishable from a permissive one, and the rules past the bound may all be denials.
No caller exists yet, so nothing routes this error anywhere. Which status a refusal
becomes is the resolution facade's to decide when it lands at Step 11; until then
the returned error is the only signal and nothing reads it. An earlier revision of
this description asserted the error already answers 500 through a caller's
data-error arm, which was not true on either side of that MR and is corrected here.
Both sides of the off-by-one are covered: at the ceiling every row comes back, one past it is refused whole and returns no rules at all.
The error names no identifier
The wrap names the operation, the table and the association count, and nothing
else, per Identifier-free error strings in docs/dev/database-query-patterns.md
("the rule attaches when a method is added"). A handler logs a wrapped error as one
error_message field that operators group and alert on, so a per-tenant value
there would give every refusal its own group. The association count stays because a
magnitude is not an identifier. namespace_id is now the caller's to log as its own
structured field, which the exported error's doc comment says and the sidecar
explains. The sibling NpmVirtualRemoteLookupStore.MetadataByPositions follows the
same rule and cites it the same way.
The refusal is also invisible in metrics and traces on its own: instrumentQuery
has no outcome dimension and the error is raised after the statement succeeded, so
the span is marked OK. That makes the caller's log line the only place the two
identifiers can appear.
What the ceiling is sized on
The ceiling is sized off ADR-004's 20-upstream cap and not off part 1's list
bound, and it bounds the sum of rules rather than a per-association share. One
association holding 20,001 rules therefore crosses it from a perfectly legal
one-position list. Making the bound unreachable from every legal configuration needs
two write-time caps rather than one: ADR-004's 20 upstreams, and a cap on rules per
association. Neither exists yet, and docs/specs/S31-npm-virtual.md leaves the
second an open question routed to the management API's rule-CRUD surface, so this MR
neither adds it nor changes the ceiling.
maxRulesPerAssociation is a multiplicand and not an enforced per-association
limit, which its own comment and the sidecar both say. The name overstates what the
code does, and it is kept because the merged plan fixes it at that spelling; a
rename belongs with the write-time cap that would make it real.
The bound rests on an absent struct tag
npmVirtualUpstreamRuleRow carries no sql:"primary_key" tag, so go-jet keys each
row on its own row number and len(rows) is the true returned count the bound is
checked against. Tagging the association id, which is the obvious move for anyone
attacking the allocation cost the sidecar quantifies, would instead collapse every
row of one association into a single struct: len(rows) would become the number of
distinct associations, the ceiling could never be reached, and each association
would come back holding one rule. The absence is load-bearing rather than an
oversight, and the sidecar now says so, since nothing about a missing tag looks
deliberate. The bound's own test is what fails if it is ever added.
One deviation from the spec's notation
The spec writes the predicate as
WHERE namespace_id = $1 AND npm_virtual_repository_upstream_id = ANY(...). go-jet's
builder has no array-bind form, so this renders IN ($1, $2, ...): 40 positions bind
42 parameters instead of 3. The two are the same predicate, and PostgreSQL folds an
IN-list of same-typed constants into the one ScalarArrayOpExpr that = ANY produces,
so the plan and the partition pruning are identical. The deployment also runs
pgx.QueryExecModeSimpleProtocol, so nothing is server-side prepared and the
65535-parameter ceiling does not apply. Dropping to raw SQL to match the notation
would forfeit the EXPLAIN plan-shape test, which builds from the statement builder.
Named here rather than left for a reader to notice.
The assertion that needs both reads, and the two plan-shape tests
Per-read statement count is independent of upstream count is the one assertion that exercises both statements and so cannot live in part 1: seeded lists of 2 and 20 upstreams produce an identical count.
Plan shape is asserted per read, in two tests rather than one over both. An
earlier revision added a second EXPLAIN of the list read alongside the rules one,
which would have planned the list read twice under two test names once part 1
merged. Instead:
TestNpmVirtualUpstreamList_PlanShapeis part 1's, and gains the exact-set assertion this MR was carrying, so a fifth joined table fails rather than passing quietly. Nothing before it caught a table being added.TestNpmVirtualUpstreamRules_PlanShapeis new and covers the rules read only. It pins single-partition pruning, pins that the read joins nothing, then runsEXPLAIN (ANALYZE, BUFFERS)withenable_seqscanoff and requires an Index Scan on a child ofindex_nvur_on_ns_id_repository_upstream_idwithnamespace_idbound as an index qual. Pruning alone also passes a plan that pruned to one partition and then read all of it, and the 3-buffers-against-2303 measurement is an index claim. The association column is deliberately not required in theIndex Cond: the planner moves it betweenIndex CondandFilteron row counts, so requiring it would assert a planner choice the fixture does not control.
Acceptance criteria
The spec's ### Resolution list has been renumbered since the plan was written.
The plan's Step 4 acceptance cites #14 (soft delete), #15 (query count) and #16 (closed)
(health); those are now #15, #16 (closed) and #17 (closed). This table uses the spec's current
numbering. Stated rather than silently corrected, because a step MR must not edit
the plan.
| Spec AC (current numbering) | Covered here | Test |
|---|---|---|
| #16 (closed) per-read query count independent of upstream count | store half, both reads | ..._StatementCountIsIndependentOfUpstreamCount |
| #15 a soft-deleted upstream is skipped | part 1 | — |
| #17 (closed) the health status costs no statement | not this step's. Asserted at Step 11 over a composed resolution, for the reason the plan gives: a store-level statement count passes while the composed read fails | — |
Separately, docs/specs/S31-npm-virtual.md line 257 says the per-read cost is "one
query per upstream association", and the section it cites specifies the opposite,
"one query for the rules of every association in that list", which is what this MR
implements and what AC #16 (closed) asserts. The implementation follows the design section;
the one-line summary is the defect and belongs in its own docs(specs) MR. Named
here so a reader landing on that line first does not conclude the code drifted.
Files-list deviation from the plan
Two departures, both of which part 1 also records:
npm_virtual_resolution_test.go (implied by the Step 4 Tests list, absent from
its Files list) and the npm_virtual_resolution_contract.md sidecar.
internal/metrics/cardinality.go was a third departure in an earlier revision of
this MR and is no longer touched. That line used to inline a query-name count;
part 1 removed the count on the ground that nothing enforces it and every merge
adding a name rots it, and re-landing a bumped figure would have reinstated exactly
that. The catalog has already moved from 481 to 482 since, which is the argument.
Size
873 reviewable LOC (843 added, 30 removed). By file group, on that same
added-plus-removed metric: 139 source (npm_virtual_resolution.go 136,
query_names.go 3), 589 test (integration 398, unit 191), 145 sidecar doc.
Not split further. This is already 2/2 of the split the plan mandates, 589 of the 873 lines are tests, and the remaining source is one store method plus its row struct and two constants. The statement-count assertion cannot move to part 1 because it needs both reads to exist, and moving the sidecar out would separate the bound from the argument for its size. What is left after the tests and the doc is 139 lines.
The 30 deleted lines, by file rather than as one category:
| File | Deleted | What they are |
|---|---|---|
npm_virtual_resolution.go |
5 | the file header (2), the maxNpmVirtualUpstreams comment (2) and one line of the store's doc comment, all rewritten to cover both reads |
npm_virtual_resolution_test.go |
15 | the file header (2), the realigned var block (2), two doc comments (4), and 7 lines of ..._ZeroValue restructured into subtests with both original assertions kept verbatim |
npm_virtual_resolution_integration_test.go |
6 | the file header (2) and the four assertSinglePartition calls in part 1's plan-shape test, replaced by a loop over the same set plus the exact-set assertion |
npm_virtual_resolution_contract.md |
4 | the H1, the intro's two-subject sentence, and the old section title |
Most of it is churn from the split, but not all of it, so it is enumerated rather than waved past.
e2e scenarios
None. The plan assigns the whole e2e catalog to Step 19 and states that every other
step's e2e statement is "none, because ...". This MR adds one datastore read with
no caller and no route, so no docs/testing/ scenario is added or affected.
Review fixes in this revision
Rebased onto main after part 1 merged, resolving four conflicting files by hand
rather than by hunk. The two commits are squashed into one, because the feat
commit's body claimed the error carries a row count that the follow-up had already
removed, and this MR has squash off, so both bodies land verbatim.
Beyond the conflicts: the error's namespace=%s is gone (identifier-free rule); the
RulesByUpstreams godoc now points at the sidecar's three readings instead of
naming one; the sidecar says "narrows" rather than "closes" for the
delete-between-reads window, names the absent primary_key tag as load-bearing,
gives docs/specs/S31-npm-virtual.md by path, and drops a "needs a plan amendment"
clause that would have gone stale; and the duplicate list-read EXPLAIN is gone.
Database Review Evidence
Queries
Note
Plans are from EXPLAIN (ANALYZE, BUFFERS) against an ephemeral
PostgreSQL 17 container (matching GL_PG_CURR_VERSION from
.gitlab-ci-other-versions.yml; server reported 17.11), with synthesized
seed data rolled back per query and the container torn down at the end of the
run. Numbers reflect moderate cardinality and do not capture
production-scale effects. See
Database review evidence
for seed sizing, methodology, and the anomalies the skill flags.
Expand each row's details for the seed shape, rendered SQL, bound args,
and raw plan.
Only RulesByUpstreams is covered. UpstreamsByVirtualRepository also
dispatches a statement, but npmVirtualUpstreamListStmt's chain is
byte-identical to the merge base, so this revision renders no new SQL for it.
| Method | Plan node | Index | Rows (plan / actual) | Cost | Time | Buffers (hit / read) | Partitions |
|---|---|---|---|---|---|---|---|
datastore.RulesByUpstreams.OneAssociation |
Limit -> Index Scan | index_nvur_on_ns_id_repository_upstream_id |
25 / 25 | 45.53 | 0.040ms | 27 / 0 | 1/64 |
datastore.RulesByUpstreams.TwentyAssociations |
Limit -> Bitmap Heap Scan | index_nvur_on_ns_id_repository_upstream_id |
500 / 500 | 182.51 | 0.760ms | 53 / 0 | 1/64 |
No anomalies. In particular the two the plan names as this step's risk,
full partition fan-out and partial partition fan-out, did not fire:
the namespace_id equality the statement carries alongside the association
IN (...) prunes to a single partition in every run below, including the
ceiling probe. Reported index names are the declared parent index; the plans
below name the partition-local child
(npm_virtual_upstream_rules_p0_namespace_id_npm_virtual_rep_idx7).
datastore.RulesByUpstreams.OneAssociation
Summary: Plan matches the method's intent. The planner takes an Index Scan
on index_nvur_on_ns_id_repository_upstream_id, whose (namespace_id, npm_virtual_repository_upstream_id) leading columns are exactly the two
equalities the statement carries, and the namespace_id literal prunes to 1 of
64 partitions. Estimate matches reality (25 / 25), all 27 buffers are hits, and
execution is 0.040ms. No anomalies.
Seed shape: namespaces=1, repositories=210, npm_virtual_repositories=10, npm_virtual_repository_upstreams=200, npm_virtual_upstream_rules=5000
Rendered SQL:
SELECT npm_virtual_upstream_rules.npm_virtual_repository_upstream_id AS "npm_virtual_upstream_rules.npm_virtual_repository_upstream_id",
npm_virtual_upstream_rules.rule_type AS "npm_virtual_upstream_rules.rule_type",
npm_virtual_upstream_rules.target_field AS "npm_virtual_upstream_rules.target_field",
npm_virtual_upstream_rules.pattern AS "npm_virtual_upstream_rules.pattern"
FROM public.npm_virtual_upstream_rules
WHERE (npm_virtual_upstream_rules.namespace_id = $1::uuid) AND (npm_virtual_upstream_rules.npm_virtual_repository_upstream_id IN ($2::uuid))
LIMIT $3;Bound args: [11111111-1111-4111-8111-111111111111, 22222222-2222-4222-8222-222222220001, 20001]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.28..45.53 rows=25 width=47) (actual time=0.009..0.031 rows=25 loops=1)
Buffers: shared hit=27
-> Index Scan using npm_virtual_upstream_rules_p0_namespace_id_npm_virtual_rep_idx7 on npm_virtual_upstream_rules_p07 npm_virtual_upstream_rules (cost=0.28..45.53 rows=25 width=47) (actual time=0.009..0.030 rows=25 loops=1)
Index Cond: ((namespace_id = '11111111-1111-4111-8111-111111111111'::uuid) AND (npm_virtual_repository_upstream_id = '22222222-2222-4222-8222-222222220001'::uuid))
Buffers: shared hit=27
Planning:
Buffers: shared hit=14
Planning Time: 0.134 ms
Execution Time: 0.040 msTimings: planning 0.134ms, execution 0.040ms, total 0.174ms.
datastore.RulesByUpstreams.TwentyAssociations
Summary: The batch at ADR-004's 20-upstream cap keeps the same index and the same single partition; the planner switches from an Index Scan to a Bitmap Heap Scan, which is the expected shape once a 20-element array probe makes heap access worth batching. Estimate matches reality (500 / 500), all 53 buffers are hits, and execution is 0.760ms. No anomalies; the partition key stays bound, so there is no fan-out at 20 associations any more than at one.
Seed shape: namespaces=1, repositories=210, npm_virtual_repositories=10, npm_virtual_repository_upstreams=200, npm_virtual_upstream_rules=5000
Rendered SQL:
SELECT npm_virtual_upstream_rules.npm_virtual_repository_upstream_id AS "npm_virtual_upstream_rules.npm_virtual_repository_upstream_id",
npm_virtual_upstream_rules.rule_type AS "npm_virtual_upstream_rules.rule_type",
npm_virtual_upstream_rules.target_field AS "npm_virtual_upstream_rules.target_field",
npm_virtual_upstream_rules.pattern AS "npm_virtual_upstream_rules.pattern"
FROM public.npm_virtual_upstream_rules
WHERE (npm_virtual_upstream_rules.namespace_id = $1::uuid) AND (npm_virtual_upstream_rules.npm_virtual_repository_upstream_id IN ($2::uuid, $3::uuid, $4::uuid, $5::uuid, $6::uuid, $7::uuid, $8::uuid, $9::uuid, $10::uuid, $11::uuid, $12::uuid, $13::uuid, $14::uuid, $15::uuid, $16::uuid, $17::uuid, $18::uuid, $19::uuid, $20::uuid, $21::uuid))
LIMIT $22;Bound args: [11111111-1111-4111-8111-111111111111, 22222222-2222-4222-8222-222222220001, 22222222-2222-4222-8222-222222220002, 22222222-2222-4222-8222-222222220003, 22222222-2222-4222-8222-222222220004, 22222222-2222-4222-8222-222222220005, 22222222-2222-4222-8222-222222220006, 22222222-2222-4222-8222-222222220007, 22222222-2222-4222-8222-222222220008, 22222222-2222-4222-8222-222222220009, 22222222-2222-4222-8222-222222220010, 22222222-2222-4222-8222-222222220011, 22222222-2222-4222-8222-222222220012, 22222222-2222-4222-8222-222222220013, 22222222-2222-4222-8222-222222220014, 22222222-2222-4222-8222-222222220015, 22222222-2222-4222-8222-222222220016, 22222222-2222-4222-8222-222222220017, 22222222-2222-4222-8222-222222220018, 22222222-2222-4222-8222-222222220019, 22222222-2222-4222-8222-222222220020, 20001]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=39.76..182.51 rows=500 width=47) (actual time=0.666..0.734 rows=500 loops=1)
Buffers: shared hit=53
-> Bitmap Heap Scan on npm_virtual_upstream_rules_p07 npm_virtual_upstream_rules (cost=39.76..182.51 rows=500 width=47) (actual time=0.666..0.713 rows=500 loops=1)
Recheck Cond: ((namespace_id = '11111111-1111-4111-8111-111111111111'::uuid) AND (npm_virtual_repository_upstream_id = ANY ('{22222222-2222-4222-8222-222222220001,22222222-2222-4222-8222-222222220002,22222222-2222-4222-8222-222222220003,22222222-2222-4222-8222-222222220004,22222222-2222-4222-8222-222222220005,22222222-2222-4222-8222-222222220006,22222222-2222-4222-8222-222222220007,22222222-2222-4222-8222-222222220008,22222222-2222-4222-8222-222222220009,22222222-2222-4222-8222-222222220010,22222222-2222-4222-8222-222222220011,22222222-2222-4222-8222-222222220012,22222222-2222-4222-8222-222222220013,22222222-2222-4222-8222-222222220014,22222222-2222-4222-8222-222222220015,22222222-2222-4222-8222-222222220016,22222222-2222-4222-8222-222222220017,22222222-2222-4222-8222-222222220018,22222222-2222-4222-8222-222222220019,22222222-2222-4222-8222-222222220020}'::uuid[])))
Heap Blocks: exact=50
Buffers: shared hit=53
-> Bitmap Index Scan on npm_virtual_upstream_rules_p0_namespace_id_npm_virtual_rep_idx7 (cost=0.00..39.58 rows=500 width=0) (actual time=0.023..0.023 rows=1000 loops=1)
Index Cond: ((namespace_id = '11111111-1111-4111-8111-111111111111'::uuid) AND (npm_virtual_repository_upstream_id = ANY ('{22222222-2222-4222-8222-222222220001,22222222-2222-4222-8222-222222220002,22222222-2222-4222-8222-222222220003,22222222-2222-4222-8222-222222220004,22222222-2222-4222-8222-222222220005,22222222-2222-4222-8222-222222220006,22222222-2222-4222-8222-222222220007,22222222-2222-4222-8222-222222220008,22222222-2222-4222-8222-222222220009,22222222-2222-4222-8222-222222220010,22222222-2222-4222-8222-222222220011,22222222-2222-4222-8222-222222220012,22222222-2222-4222-8222-222222220013,22222222-2222-4222-8222-222222220014,22222222-2222-4222-8222-222222220015,22222222-2222-4222-8222-222222220016,22222222-2222-4222-8222-222222220017,22222222-2222-4222-8222-222222220018,22222222-2222-4222-8222-222222220019,22222222-2222-4222-8222-222222220020}'::uuid[])))
Buffers: shared hit=3
Planning:
Buffers: shared hit=23
Planning Time: 0.248 ms
Execution Time: 0.760 msTimings: planning 0.248ms, execution 0.760ms, total 1.008ms.
Supplementary: the rules ceiling. npm_virtual_resolution_contract.md
states under ## Per-request cost of the rules ceiling that crossing the bound
pays the full scan and then discards it. A separate run seeds 1001 rules on
each of one virtual repository's 20 associations (20,020 rows) plus 25 on each
of the other 180, for a 24,520-row partition, and asks for the same 20
associations. The read returns LIMIT-many rows (20,001) and the planner
switches to a Seq Scan of the one partition, still pruned 1/64, at 2.781ms and
268 buffer hits with no reads. That is the database-side half of the sidecar's
cost claim; the Go-side allocation cost it also describes is not measured here.
Limit (cost=0.05..755.43 rows=20001 width=46) (actual time=0.005..2.367 rows=20001 loops=1)
Buffers: shared hit=268
-> Seq Scan on npm_virtual_upstream_rules_p07 npm_virtual_upstream_rules (cost=0.05..756.15 rows=20020 width=46) (actual time=0.004..1.553 rows=20001 loops=1)
Filter: ((namespace_id = '11111111-1111-4111-8111-111111111111'::uuid) AND (npm_virtual_repository_upstream_id = ANY ('{22222222-2222-4222-8222-222222220001,22222222-2222-4222-8222-222222220002,22222222-2222-4222-8222-222222220003,22222222-2222-4222-8222-222222220004,22222222-2222-4222-8222-222222220005,22222222-2222-4222-8222-222222220006,22222222-2222-4222-8222-222222220007,22222222-2222-4222-8222-222222220008,22222222-2222-4222-8222-222222220009,22222222-2222-4222-8222-222222220010,22222222-2222-4222-8222-222222220011,22222222-2222-4222-8222-222222220012,22222222-2222-4222-8222-222222220013,22222222-2222-4222-8222-222222220014,22222222-2222-4222-8222-222222220015,22222222-2222-4222-8222-222222220016,22222222-2222-4222-8222-222222220017,22222222-2222-4222-8222-222222220018,22222222-2222-4222-8222-222222220019,22222222-2222-4222-8222-222222220020}'::uuid[])))
Buffers: shared hit=268
Planning:
Buffers: shared hit=26
Planning Time: 0.218 ms
Execution Time: 2.781 msRelated to #885 (closed)