chore(datastore): blob-footprint sum (S17 Phase 8 plan: 11/13)
Why
Phase 8 serves a version's byte footprint by deriving it at read time: collect
the distinct blob digests the version's own file rows reach, then resolve each
digest's size on S22's blob_storage_blobs_by_namespace shadow. Two walks will
need that second half, the Maven version footprint and the npm one, and they
land on different stores, so neither can own it. This lands the half they share
ahead of either caller, so both are built against one execution seam.
What (the parts a reviewer would not guess)
The join is not new, and this MR does not write it again. The plan's Files
entry says to create the distinct-digests-to-shadow-join sum. It already exists:
reconcileBlobSizeStmt in internal/datastore/reconcile_repository.go, same
shadow, same COALESCE(SUM(size), 0), same three alias constants, composed by
six reconcile walk builders. The plan's near-miss survey named the npm and Maven
version sums and missed this one, which is the closer match. So
sumDistinctBlobSizes composes it and adds only the execution half those six
each hand-roll: the guards, the instrumented call, the destination struct, and
the wrap. A third copy of the join would have been the finding.
namespaceID is a parameter the plan's arg list did not have. The plan
sketches four arguments. Without the namespace as a bound literal nothing prunes
the shadow, and a digest two namespaces both hold is counted twice — so the
sketch could not satisfy the step's own Acceptance line. the sum is scoped to the addressed namespace is the subtest that fails if the literal is dropped.
queryName is a parameter, not a constant, because the catalog assigns the
name of a statement a shared helper executes to that helper's caller. This adds
nothing to query_names.go, and the two consumers keep their own names without
editing this file. The probes pass a name no catalog enumerates.
The unit-test file is untagged on purpose. .golangci.yaml sets no
run.build-tags, so no linter compiles a //go:build integration file. With
the integration probes invisible, unused reports the helper as dead. That
file's calls are what keep it visible, which is why it is not folded into the
integration suite.
The plan pin runs with enable_seqscan off, and the plan it pins is
version-dependent. TestRecomputeContainerBlobsSizeStmt_PrunesEveryTableToOnePartition
asserts the prune and declines the Index Only Scan, because at default GUCs a
few-row partition plans as a sequential scan the planner is right to pick. This
pin is that missing half, and it holds across CI's whole matrix for different
reasons on each leg. Measured on postgres:{16,17,18}-alpine against
structure.sql with CI's own service settings, growing the addressed
namespace's shadow rows with the digest set held at two: PG18 nested-loops
through the composite Index Cond and keeps the primary key past 1,000,002
rows; PG16 and PG17 merge-join and keep it to roughly 100 rows, then take a
bitmap heap scan, and only reach the narrower (namespace_id) INCLUDE (size)
index past about 1,000. That is why footprintDigestJoinRe carries four
alternatives rather than asserting one shape, and the per-leg figures are on the
test so a future red pin can be told apart from a grown fixture.
Two things measured on this branch, for whoever reads the plan next
The statement plans what ADR-007 measured, but this pin cannot show it. For
the shape the version walks will issue — a version-scoped digest set from
maven_files — the plan is Nested Loop over
Index Only Scan using blob_storage_blobs_by_namespace_pNN_pkey,
Index Cond: ((namespace_id = ...) AND (sha256 = maven_files.blob_sha256)), one
partition, ~25 buffers, ~0.20 ms, holding at both 100k and 1M shadow rows in the
namespace. On PG16 and PG17 the probe here cannot reach that shape, because its
digest source is blob_storage_attachments, which is
PARTITION BY HASH (sha256): the DISTINCT arrives as a 64-way Merge Append
and the outer estimate floors at 64 however few digests exist, so a per-digest
nested loop never wins. PG18 estimates the same subquery at 3 and reaches the
composite Index Cond directly, which is the ADR-007 shape. So the pin does
demonstrate the per-digest probe, on one of three legs, and the version walks'
own pins are what will demonstrate it on all of them.
ADR-007 says two different things at two scopes, and the second one is not
this MR's to settle. Its repository-level reconciliation section prescribes
exactly this shape and is what reconcileBlobSizeStmt implements. Its
artifact-level section reads blob_storage_blobs directly for a per-version
walk and declines the shadow, on the grounds that such a walk holds its digests
up front and can prune HASH(sha256) with sha256 = ANY(...). Both in-tree
version walks follow the ADR today. The spec's version scope asserts the
opposite premise. Measured against a namespace holding 100k to 1M shadow rows,
for one six-digest version: the shadow join reaches one partition; the
base-table shape reaches all 64 when the digests come from a subquery, and
matches the shadow only when they are passed as a bound array. That last cost is
uneven across the ADR's own two walks, so it is worth stating precisely rather
than as a blanket claim — RecomputeVersionSize pays it per keyset page, and
SumDistinctNpmFileSizesByVersion joins the base table in a single statement and
pays nothing. Of the ADR's three grounds for declining the shadow, one is spent
and one is live. Trigger-maintenance write amplification is sunk: S22 landed the
shadow and its triggers, so a reader adds none. The stale-copy ground still
stands, and it is the only one of the three about read correctness rather than
write cost — reconcileBlobSizeStmt's own doc records that there is no
AFTER UPDATE trigger, so an in-place blob_storage_blobs.size update leaves
the shadow answering the pre-update value without erroring. Reconciling that
with the ADR text belongs with the version walks and a handbook amendment, not
with an unexported helper that has no caller.
Diff size
561 reviewable LOC, over the 500 threshold: 73 production, 488 test (348 integration, 140 unit). Every line over the ceiling is test. Splitting them off forfeits the test-first cycle, and splitting the two suites from each other leaves either a plan pin with no helper or a helper with no plan pin, neither reviewable alone. The 73 production lines carry five guards and one composed statement; the rest is seven largely parallel table-shaped subtests, three argument-guard tables, and one EXPLAIN pin.
Test plan
go test ./internal/datastore/
go test -tags=integration -count=1 -run 'TestSumDistinctBlobSizes' ./internal/datastore/
golangci-lint run ./internal/datastore/...
golangci-lint run --build-tags=integration --max-same-issues=0 --max-issues-per-linter=0 --uniq-by-line=false ./internal/datastore/...
bash scripts/ci/check-comment-caps.sh --base origin/mainFour test functions and twelve subtests, green in both builds. A bare testcontainer fails the namespace-delete cascade in
the sum is scoped to the addressed namespace with out of shared memory (SQLSTATE 53200); that is the default max_locks_per_transaction=64, which
.gitlab-ci.yml raises to 1024 for the integration services and documents as
this exact symptom. Against a Postgres carrying that setting the suite is green.
The integration-tagged lint pass reports only contextcheck, 19 findings from
calling pre-existing helpers that take no ctx, against a package baseline of
4177 identical ones, unsuppressed everywhere.
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), 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.
| Method | Plan node | Index | Rows (plan / actual) | Cost | Time | Buffers (hit / read) | Partitions |
|---|---|---|---|---|---|---|---|
datastore.sumDistinctBlobSizes |
Aggregate | n/a |
1 / 1 | 550.15 | 3.248ms | 187 / 0 | blob_storage_blobs_by_namespace, 64/64 blob_storage_attachments |
Warning
Partition fan-out. These statements scan more than one partition of a hash-partitioned table:
datastore.sumDistinctBlobSizes: 64/64 partitions ofblob_storage_attachments. The fan-out is in the caller-supplieddigeststable, not in the shadow join this helper composes.blob_storage_attachmentsisPARTITION BY HASH (sha256), so itsnamespace_idpredicate prunes nothing at any cardinality. The digest source here is the branch's own test probe; the two named future callers readmaven_filesandnpm_files, bothPARTITION BY HASH (namespace_id), which do prune. The shadow half this MR owns pruned to 1 of 64.
datastore.sumDistinctBlobSizes
Summary: The plan matches the method's intent on the half it owns. The
namespace_id literal prunes the shadow to one of 64 partitions and the
COALESCE(SUM(size), 0) answers 17622500, the addressed namespace's bytes
only, with the second namespace's 5000 rows excluded. Two anomalies, both in
the seed rather than the statement: the digest source fans out over all 64
blob_storage_attachments partitions because that table hashes on sha256,
and the pruned shadow partition is read by Seq Scan because the seed's
5000-digest set needs every row in it.
Seed shape: namespaces=2, blob_storage_blobs=10000, blob_storage_blobs_by_namespace=10000 (trigger-populated), blob_storage_attachments=10000
Rendered SQL:
SELECT COALESCE(SUM(blob_storage_blobs_by_namespace.size), $1) AS "total"
FROM (
SELECT DISTINCT blob_storage_attachments.sha256 AS "blob_sha256"
FROM public.blob_storage_attachments
WHERE blob_storage_attachments.namespace_id = $2::uuid
) AS uniq_blobs
INNER JOIN public.blob_storage_blobs_by_namespace ON ((blob_storage_blobs_by_namespace.namespace_id = $3::uuid) AND (blob_storage_blobs_by_namespace.sha256 = uniq_blobs.blob_sha256));Bound args: [0, 48eb9957-5ab6-41f5-9d61-8b5f852fb2de, 48eb9957-5ab6-41f5-9d61-8b5f852fb2de]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Aggregate (cost=550.14..550.15 rows=1 width=32) (actual time=3.243..3.248 rows=1 loops=1)
Buffers: shared hit=187
-> Hash Join (cost=405.00..537.64 rows=5000 width=8) (actual time=2.193..3.079 rows=5000 loops=1)
Hash Cond: (blob_storage_blobs_by_namespace.sha256 = blob_storage_attachments.sha256)
Buffers: shared hit=187
-> Seq Scan on blob_storage_blobs_by_namespace_p52 blob_storage_blobs_by_namespace (cost=0.00..119.50 rows=5000 width=41) (actual time=0.004..0.308 rows=5000 loops=1)
Filter: (namespace_id = '48eb9957-5ab6-41f5-9d61-8b5f852fb2de'::uuid)
Buffers: shared hit=57
-> Hash (cost=342.50..342.50 rows=5000 width=33) (actual time=2.166..2.171 rows=5000 loops=1)
Buckets: 8192 Batches: 1 Memory Usage: 382kB
Buffers: shared hit=130
-> HashAggregate (cost=292.50..342.50 rows=5000 width=33) (actual time=1.399..1.669 rows=5000 loops=1)
Group Key: blob_storage_attachments.sha256
Batches: 1 Memory Usage: 721kB
Buffers: shared hit=130
-> Append (cost=0.00..280.00 rows=5000 width=33) (actual time=0.002..0.732 rows=5000 loops=1)
Buffers: shared hit=130
-> Seq Scan on blob_storage_attachments_p00 blob_storage_attachments_1 (cost=0.00..4.18 rows=86 width=33) (actual time=0.002..0.009 rows=86 loops=1)
Filter: (namespace_id = '48eb9957-5ab6-41f5-9d61-8b5f852fb2de'::uuid)
Rows Removed by Filter: 88
Buffers: shared hit=2
-> Seq Scan on blob_storage_attachments_p01 blob_storage_attachments_2 (cost=0.00..4.05 rows=79 width=33) (actual time=0.002..0.008 rows=79 loops=1)
Filter: (namespace_id = '48eb9957-5ab6-41f5-9d61-8b5f852fb2de'::uuid)
Rows Removed by Filter: 85
Buffers: shared hit=2
[62 further Seq Scan nodes, one per remaining blob_storage_attachments partition p02..p63, identical in shape: 58-101 rows each, 59-99 removed by the same namespace_id filter, 2-3 buffers each]
Planning Time: 0.987 ms
Execution Time: 3.536 msTimings: planning 0.987ms, execution 3.536ms, total 4.523ms.
Related to #316