fix(datastore): report the hosted container reap's freed bytes
What this delivers
ContainerReaper.Reap now names the repository it reaped and reports the bytes its own chunk freed.
Before this change it reported neither.
The cause
A hosted OCI push raises repositories.size_bytes, because wireOCI builds an oci.CounterSink.
The reap side reported no repository and no byte figure.
The size gate at internal/lifecycle/emit.go:34 reads chunk.Rows != 0 && chunk.SizeBytes != 0 && chunk.RepositoryID != uuid.Nil, so it stayed closed for every container chunk.
An artifact purge of a hosted OCI image therefore emitted the component delta alone, and repositories.size_bytes kept the value the push put on it.
The column read high from the purge until the next reconciliation pass, which recomputes it from source rather than adjusting it.
What the code now does
Reapresolves the repository once per chunk, throughresolveReapRepositoryon a newcontainer_imagesroute. The resolve runs afterreapManifestsreturns and beforereapImageRow, the one statement that removes the row this chain starts at.- Both legs collect the addresses they freed, and
sumFreedRepositoryBytessums them over a new statement for the hosted container family. The probe keys onblob_sha256rather than the OCI digest. It counts an address only where no surviving row of the same repository still carries it. - Each leg contributes an address only for a row its own delete removed.
The manifest leg takes
DeleteManifestOutcome.PayloadSHA256on aDeletedanswer. The blob leg takescontainer_blobs.blob_sha256on the unlink's answer, andreapBlobPageStmtnow projects that column beside the digest. A delete race that another transaction wins therefore charges no bytes twice.
ReapTotals gains no field.
RepositoryID and SizeBytes were already declared on it, and the emit gate opens as soon as the arm fills them.
docs/dev/storage-accounting.md and docs/specs/S20-a-lifecycle-closed-beta.md move the container arm out of the group that computes no byte figure.
NpmRemoteVersionReaper is the one member left in that group.
Test coverage
Rows come from the issue card's own acceptance list — no plan governs, and the spec states the rule rather than a numbered criterion for this arm.
The Source column names the ## Done when bullet each item was derived from.
Test keys:
T1 TestContainerReaper_Reap_ReportsTheRepositoryScopedBytes (new)
T2 TestArtifactReapers_Reap_NameNoRepositoryOnADrainedSubtree (+row)
T3 TestReapSizeStmts_EveryAliasBindsThePartitionKey (+2 rows)
T4 TestContainerReaper_Reap_ChunkBound (2 assertions updated)
T5 TestContainerReaper_Reap_ArtifactsAlreadyGone (assertion updated)
T6 TestEmitChunkCounters_IssuesTheDeltasTheChunkOwes (merged)
T7 TestResolveReapRepository_AnswersWithoutAStatementWhenTheRowDeclines
T8 TestRepositoryReaper_Reap_WalksEveryFormatAndKindArm (merged)
| # | Card acceptance item | Source | Tests |
|----|----------------------|--------|-------|
| 1 | RepositoryID is the image's repositories.id | DW1 | T1, T4, T5 |
| 2 | A digest no surviving row carries frees its bytes | DW1 | T1, T3 |
| 3 | A digest a survivor carries frees zero, names the repo | DW1 | T1, T3 |
| 4 | A copy in another repository holds nothing alive | DW1 | T1 |
| 5 | A tombstoned but unpurged survivor holds the digest | DW1 | T1 |
| 6 | One page carrying one digest twice counts it once | DW1 | T1 |
| 7 | Declining row, unknown id: uuid.Nil and no bytes | DW1 | T2, T7, T8 |
| 8 | SizeBytes non-zero implies RepositoryID non-nil | DW1 | T1, T2 |
| 9 | The emit gate opens for a container chunk | DW4 | T6 + rows 1-2 |
| 10 | storage-accounting.md container-arm text reads true | DW3 | none: docs |
| 11 | Out of scope: the npm arm, and "neither arm" | DW3 | none: by design |Row 9 needs no new emit case.
The emit decision is a pure function of the ReapTotals the chunk reports, and T6 covers it generically.
Rows 1 and 2 are what open both of the gate's remaining conjuncts.
Row 10 is the documentation obligation.
Row 11 is the scope this merge request excludes, and the issue link below says what remains.
The e2e scenario catalog
No scenario is added, and none is edited. An existing scenario becomes true instead.
docs/testing/e2e/oci.md:152 (e2e.oci.lifecycle.delete-storage-counters) and docs/testing/e2e/docker.md:127 (e2e.docker.lifecycle.delete-storage-counters) each already read "while size_bytes holds until the reap frees the rows".
Before this change the reap moved no bytes on that column, so both scenarios named an outcome the reap did not produce.
This change is what makes the clause hold, so the catalogs need no new row and no edit.
Diff size
docs/dev/development-model.md asks for a split or a justification past 500 reviewable lines.
This diff is 823 changed lines across 8 files, measured at b014e7e5c.
| Group | Lines |
|---|---|
| Tests | 528 |
| Production Go | 165 |
| Docs | 130 |
| Total | 823 |
64 percent of the diff is tests, and the production change is 165 lines. A split does not help here. If the tests move to a merge request of their own, the conversion arrives without what holds its answer apart from a plausible wrong one. If the documents move, the conversion arrives without the contract it now meets.
Merge order and base
- The branch is rebased onto
a5c3759b5, which already carries the siblingMavenPackageReaperarm from work item 1012. The S20-A bullet in this diff therefore records two arms leaving the first group: this one and the Maven package arm. The rebase reconciled them, because the sibling merge leftmain's own copy of the bullet internally inconsistent. !2123, the write side of the npm remote cache fill, is merged at96d8532b5. The spec and document text namingCacheStore.emitCommittedFillCountersis true at this base.- The branch commit keeps its
feat(datastore)subject on purpose: this project squash-merges, so thefix(datastore)title is what reachesmain.
Every verdict below was measured at 2026-09-03T14:25Z with git merge-tree --write-tree --name-only, against this branch at b014e7e5c and against origin/main at dad725fa6.
A verdict is a claim about the two heads it was measured at rather than a standing property of the merge request: !2262 moved from 6e8803f31 to ed6eaa332 to 665250a79 inside the day this section was written.
| Merge request | Head | Against this branch | Against origin/main |
|---|---|---|---|
!2262 feat(oci): container remote fill emits its storage-accounting deltas |
665250a79 |
Conflicts: docs/dev/storage-accounting.md, docs/specs/S20-a-lifecycle-closed-beta.md |
Conflicts: docs/dev/storage-accounting.md, internal/format/oci/emit_dispatch.go |
!2232 fix(npm): credit the freed packument-cache bytes on every delete arm |
413315345 |
Conflicts: docs/dev/storage-accounting.md |
Clean |
Both are open and neither is a draft.
This branch is clean against origin/main.
Merge order in docs/dev/storage-accounting.md
Both siblings contend here.
!2232's conflict belongs to this branch, because !2232 is clean against origin/main.
!2262's does not belong to this branch alone: !2262 conflicts with its own target branch on the same path, so it carries a rebase of its own, and this measurement does not settle whether the pair still conflicts after that rebase.
The resolution against !2262 keeps both facts.
!2262 establishes that a container remote cache fill raises repositories.size_bytes, and this branch establishes that one artifact-scope arm is left reporting nothing.
Both are true after both land, so neither statement replaces the other.
Against !2232 the two changes are adjacent rather than exclusive.
!2232 inserts sentences after the pair this branch rewrites, and its text reads correctly against the rewritten pair.
Merge order in docs/specs/S20-a-lifecycle-closed-beta.md
This conflict belongs to this branch: !2262 and origin/main do not contend on this path.
The reap-arms bullet does not take the keep-both resolution.
Both merge requests rewrite the same count sentence in ways that exclude each other.
This branch moves ContainerReaper out of the first group and writes "one from one side and two from the other".
!2262 moves ContainerRemoteManifestReaper out of the second and writes "two from one side and one from the other".
After both land the count is two arms, one from each side: NpmRemoteVersionReaper and NpmPackageReaper.
Whoever lands second recounts that sentence rather than keeping both hunks.
Two further lines sit in the same hunk:
- The work-item sentences below the bullet do split cleanly, one from each side.
This branch's
#942sentence coversNpmRemoteVersionReaper, and!2262's#939sentence coversNpmPackageReaper. - The
Closing this takes ...sentence is wrong from both sides. This branch reads "the missing increments and the one remaining conversion" and!2262reads "the one missing increment and the two remaining conversions", where after both land it is one missing increment and one remaining conversion.
Merge order in internal/datastore/query_names.go
The third path both merge requests change, and it merges clean against both.
This branch adds queryBlobStorageBlobsByNamespaceSelectReapFreedSizeContainerHosted and queryContainerImagesSelectReapRepository, and neither name is among !2262's additions.
internal/metrics/cardinality.go caps labelName at 600 and records in its own comment that no count is inlined and that the audit reads observed values, so a merged catalog is not gated on a declared figure.
The plan guardrail
AGENTS.md guardrail 3 asks for a merged plan merge request before an implementation merge request opens.
This work has no plan under docs/plans/.
The merged plan for this spec does name the arm, to exclude it: docs/plans/2026-08-11-s20a-lifecycle-closed-beta.md carries ContainerReaper at :1210, :1211 and :1213.
Those three lines sit in the Step 19a scope block that opens at :1206, and :1215 hands the arm to this issue.
The operator settled that deviation on 2026-08-25.
Seen and left unchanged
Each row was checked on its own evidence, and none of them is an oversight.
Every line number here is measured at b014e7e5c.
| Site | What is left | Why |
|---|---|---|
docs/specs/S20-a-lifecycle-closed-beta.md, the sentence that groups ContainerRemoteManifestReaper with NpmPackageReaper |
The ground it states is false for NpmPackageReaper. An npm packument write does raise the column, through emitRebuildCounters in internal/format/npm/packument_cache.go:1395, wired at cmd/artifact-registry/wire_npm.go:123-:126. docs/dev/storage-accounting.md:2011 says the same, so the two inherited lines disagree. The container remote half of the same sentence holds while !2262 is open. Once !2262 merges, a container remote cache fill raises the column too, and the sentence has two false halves. !2262 at 665250a79 does edit this spec, and its new ContainerRemoteManifestReaper sentence is that repair; the merge-order section above records how the two hunks compose |
The sentence is inherited from main, and the operator chose to leave the inherited lines as they are. A plus-and-minus scan of the spec diff matches neither line |
| The closing sentence of the same bullet, which names work items 834 and 939 | Inherited from main in the same way |
The same decision covers it. This merge request takes no position on either work item |
ADR 007 :498-:499, against two migrations already at the merge base |
The ADR says the container tables carry no (namespace_id, blob_sha256) index and accepts a partition scan. Two migrations add such indexes, and docs/ records no amendment. Work item #762 tracks the amendment |
This branch does not deviate. Its two survivor arms read container_blobs.blob_sha256 and container_manifests.blob_sha256 under namespace_id. internal/datastore/container_blob_footprint.go:100-:125 already does that at the merge base, so the branch adds a consumer of those indexes |
docs/dev/storage-accounting.md:1999 |
It claims that an arm which reports a family zero says so in its own Reap doc. The :2001 rewrite narrows that group to NpmRemoteVersionReaper, whose Reap doc does not say it |
The claim was already false at the base for both members of the group. This diff removes one false instance and leaves the other, so it introduces nothing |
The two range guards in internal/datastore/lifecycle_reap_size.go |
checkReapRepositoryResolveArgs and checkReapSizeSumArgs bind by hand against whichever constant is last, and each out-of-range test case is written the same way. Nothing enforces that pairing |
The claim is narrow and covers the guards alone. The dispatch half is lint-enforced: exhaustive runs repo-wide and neither switch carries a default clause, so an unhandled route or family fails lint |
docs/specs/S20-a-lifecycle-closed-beta.md:1065 |
It reads that work item 1012 closes with the merge request that lands the figure. That merge request is merged and 1012 is closed | The sentence names a condition and its event, which is the form the project asks for. The line is outside this diff |
Evidence
Against a running service. A push, a management delete and the purge net repositories.size_bytes back to its pre-push value.
The column reads 0, then 543, then 543 while the tombstone stands, then 0 after the purge.
That 543 is the pushed footprint exactly: config 105 plus layer 37 plus manifest payload 401.
The buffered delta was read out of Redis before the drain claimed it, as size -543 on the repository-scoped hash.
Before this change the emit gate wrote no repository-scoped hash at all, which is the signature of the defect.
The survivor case, on the same run. A second repository holds two images pushed from byte-identical content.
A purge of one image leaves size_bytes at 550, unmoved, and writes no repository-scoped hash.
The surviving image still carries every address that chunk freed.
The blob leg's discipline, proved by mutation. The blob leg appends an address only inside its deleted guard.
A run with that guard taken out fails exactly one test, the lost-race case.
That case gives the arm an unlinker which removes the row and answers deleted == false.
No surviving row of the repository holds the address.
The survivor probe therefore cannot keep those bytes out of the figure, and only the unlink's own answer can.
Suite. The full internal/datastore integration suite is green at this tree.
A full run of that package sits at or past Go's 600 s default timeout, so a re-run needs an explicit -timeout.
Related to #942 (closed)
What remains is the NpmRemoteVersionReaper arm, which is ## Done when bullet 2, and the "neither arm" half of bullet 3 that goes with it.
This merge request converts the ContainerReaper arm alone, and it excludes the npm arm deliberately.
That arm's stated precondition is discharged, because its write side !2123 is merged, so the remaining half is actionable rather than blocked.
Database Review Evidence
Every plan in this section was measured at 4bf6368f5, which this branch's
rebase carried to 760514dcd. The nine commits between that commit and
11424185f change five files, and only
internal/datastore/lifecycle_reap_container.go is production Go. Its whole
diff is a renamed interface parameter, one corrected comment, and
cascadeLayer converted to named returns. reapBlobPageStmt is byte-identical
across the two commits, and internal/datastore/lifecycle_reap_size.go,
internal/datastore/query_names.go and internal/datastore/migrations/ are
unchanged, so no statement, index or partition the plans rest on moved.
Queries
Note
Plans are from EXPLAIN (ANALYZE, BUFFERS) against an ephemeral
PostgreSQL 17.10 container (matching GL_PG_CURR_VERSION: "17" 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.reapBlobPageStmt |
Limit → Index Scan | unique_container_blobs_ns_id_ci_id_digest |
10 / 10 | 20.23 | 0.016ms | 3 / 0 | 1/64 container_blobs |
datastore.resolveReapRepository.ContainerImage |
Limit → Nested Loop | pk_container_images |
1 / 1 | 9.33 | 0.018ms | 4 / 0 | 1/64 container_images, 1/64 container_repositories |
datastore.sumFreedRepositoryBytes.ContainerHosted |
Aggregate → Nested Loop over two Anti Joins | index_container_blobs_on_ns_id_blob_sha256, index_container_manifests_on_ns_id_blob_sha256, pk_container_images, pk_container_repositories, pk_blob_storage_blobs_by_namespace |
1 / 1 | 107.01 | 0.209ms | 85 / 0 | 1/64 container_blobs, 1/64 container_manifests, 1/64 container_images, 1/64 container_repositories, 1/64 blob_storage_blobs_by_namespace |
Every partitioned table in every plan pruned to one partition of 64. No statement reaches a second partition, so no fan-out warning applies.
Query notes:
datastore.sumFreedRepositoryBytes.ContainerHosted: the page size decides the plan shape. At 50 addresses thecontainer_blobsNOT EXISTSruns as an index-driven Nested Loop Anti Join overindex_container_blobs_on_ns_id_blob_sha256. From 100 addresses up it becomes a Hash Anti Join whose build side sequentially scans a wholecontainer_blobspartition and joins it tocontainer_images.lifecycle.DefaultPurgeChunkSizeis 100 atinternal/lifecycle/purge.go:445, andcmd/artifact-registry/wire_lifecycle.go:70-:74passes noChunkSize, sointernal/lifecycle/purge.go:536-:537fills that zero with the default. A full chunk therefore lands on the scanning side, andMaxLifecycleReapPageSizeis a 1000-row ceiling rather than the size production runs. Execution then tracks the scanned partition's own row count rather than the page size. The times are 0.335 ms at 50 addresses, 2.508 ms at 100, 2.524 ms at 200 and 4.294 ms at 1000, over one namespace seeded with 4990container_blobsrows. That sweep seeded one namespace per partition, so the partition's row count and the namespace's coincide in these plans and the turn locates a share-of-partition threshold rather than an absolute address count. In general the two differ:container_blobsandcontainer_imagesare eachPARTITION BY HASH (namespace_id)over 64 partitions ininternal/datastore/migrations/structure.sql, and ADR 007 prices a partition as a fleet-scale quantity inferred from the node type and the partition count rather than measured. Below the threshold the governing term is the reaped namespace's own rows, whichcb.namespace_id = $1and the namespace-leadingindex_container_blobs_on_ns_id_blob_sha256give the planner a path to, so a partition holding other tenants moves the threshold toward larger reaps rather than making the term fleet-wide at any size. Pruning holds at 1/64 in every variant, so the exposure is the partition's own size and not a fan-out. The crossover is a cost ratio between the bound array and the table, so a larger table raises it. Production need not turn at a hundred addresses at all, and the plan shape is what the measurement establishes rather than the threshold.datastore.resolveReapRepository.ContainerImage: under the skill's one-row-per-ancestor seed thecontainer_repositoriesarm readsSeq Scan, because that partition holds a single row. A re-run with 500container_repositoriesrows in the namespace takespk_container_repositorieson that arm and leaves the rest of the plan unchanged, at 6 shared hits and 0.053 ms execution. TheSeq Scanin the table above is a seed artifact rather than a property of the statement.- Planning cost is visible on the sum: 6.690 ms planning against 0.379 ms execution on the session's first plan, from plan-time pruning across five hash-partitioned tables. A repeat of the same prepared statement in the same session plans in 0.817 ms, so the figure amortizes over a pooled connection's prepared-statement cache rather than recurring per reap chunk.
datastore.reapBlobPageStmt
Summary: The plan matches the method's intent. The namespace equality prunes
container_blobs to one partition of 64, and
unique_container_blobs_ns_id_ci_id_digest answers both equalities from the
index, with the LIMIT stopping the scan at 10 rows. The column this change
adds, blob_sha256, is not in that index — but neither is the already-projected
id, so the read was a heap fetch before the change and stays one: no new
buffer class, 3 shared hits for a 10-row page. No anomalies.
Seed shape: namespaces=1, repositories=1, container_repositories=1, container_images=5000, blob_storage_blobs=5000, blob_storage_blobs_by_namespace=5000 (trigger), blob_storage_attachments=5000, container_manifests=4990, container_blobs=4990
Rendered SQL:
SELECT container_blobs.namespace_id AS "container_blobs.namespace_id",
container_blobs.id AS "container_blobs.id",
container_blobs.digest AS "container_blobs.digest",
container_blobs.blob_sha256 AS "container_blobs.blob_sha256"
FROM public.container_blobs
WHERE (container_blobs.namespace_id = $1::uuid) AND (container_blobs.container_image_id = $2::uuid)
LIMIT $3;Bound args: [00000000-0000-7000-8000-000000000001, 00000000-0000-7000-0000-000000000001, 10]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.28..20.23 rows=10 width=98) (actual time=0.014..0.016 rows=10 loops=1)
Buffers: shared hit=3
-> Index Scan using container_blobs_p57_namespace_id_container_image_id_digest_idx on container_blobs_p57 container_blobs (cost=0.28..40.18 rows=20 width=98) (actual time=0.014..0.015 rows=10 loops=1)
Index Cond: ((namespace_id = '00000000-0000-7000-8000-000000000001'::uuid) AND (container_image_id = '00000000-0000-7000-0000-000000000001'::uuid))
Buffers: shared hit=3
Planning:
Buffers: shared hit=167
Planning Time: 0.785 ms
Execution Time: 0.029 msTimings: planning 0.785ms, execution 0.029ms, total 0.814ms.
datastore.resolveReapRepository.ContainerImage
Summary: The plan matches the method's intent. pk_container_images answers
(id, namespace_id) on the driving side, and the namespace literal prunes both
tables to one partition of 64, so the one-hop chain costs 4 shared hits. The
Seq Scan on the container_repositories partition comes from the skill's
one-row-per-ancestor seed rather than from the statement: at 500 rows in that
namespace the planner takes pk_container_repositories, as the second plan
below shows. No anomalies.
Seed shape: namespaces=1, repositories=1, container_repositories=1, container_images=5000, blob_storage_blobs=5000, blob_storage_blobs_by_namespace=5000 (trigger), blob_storage_attachments=5000, container_manifests=4990, container_blobs=4990
Rendered SQL:
SELECT r.repository_id
FROM container_images i
JOIN container_repositories r
ON r.id = i.container_repository_id AND r.namespace_id = i.namespace_id
WHERE i.namespace_id = $1 AND r.namespace_id = $1
AND i.id = $2
LIMIT 1Bound args: [00000000-0000-7000-8000-000000000001, 00000000-0000-7000-0000-000000000001]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.28..9.33 rows=1 width=16) (actual time=0.017..0.018 rows=1 loops=1)
Buffers: shared hit=4
-> Nested Loop (cost=0.28..9.33 rows=1 width=16) (actual time=0.016..0.017 rows=1 loops=1)
Join Filter: (r.id = i.container_repository_id)
Buffers: shared hit=4
-> Index Scan using container_images_p57_pkey on container_images_p57 i (cost=0.28..8.30 rows=1 width=32) (actual time=0.009..0.009 rows=1 loops=1)
Index Cond: ((id = '00000000-0000-7000-0000-000000000001'::uuid) AND (namespace_id = '00000000-0000-7000-8000-000000000001'::uuid))
Buffers: shared hit=3
-> Seq Scan on container_repositories_p57 r (cost=0.00..1.01 rows=1 width=48) (actual time=0.005..0.005 rows=1 loops=1)
Filter: (namespace_id = '00000000-0000-7000-8000-000000000001'::uuid)
Buffers: shared hit=1
Planning:
Buffers: shared hit=347
Planning Time: 1.495 ms
Execution Time: 0.040 msTimings: planning 1.495ms, execution 0.040ms, total 1.535ms.
Ancestor-cardinality re-run — same statement and same bind, seeded with 500
repositories and 500 container_repositories rows in the namespace and 5000
container_images spread across them:
Limit (cost=0.55..16.61 rows=1 width=16) (actual time=0.029..0.029 rows=1 loops=1)
Buffers: shared hit=6
-> Nested Loop (cost=0.55..16.61 rows=1 width=16) (actual time=0.028..0.028 rows=1 loops=1)
Buffers: shared hit=6
-> Index Scan using container_images_p57_pkey on container_images_p57 i (cost=0.28..8.30 rows=1 width=32) (actual time=0.015..0.015 rows=1 loops=1)
Index Cond: ((id = '00000000-0000-7000-0000-000000000001'::uuid) AND (namespace_id = '00000000-0000-7000-8000-000000000001'::uuid))
Buffers: shared hit=3
-> Index Scan using container_repositories_p57_pkey on container_repositories_p57 r (cost=0.27..8.29 rows=1 width=48) (actual time=0.011..0.011 rows=1 loops=1)
Index Cond: ((id = i.container_repository_id) AND (namespace_id = '00000000-0000-7000-8000-000000000001'::uuid))
Buffers: shared hit=3
Planning:
Buffers: shared hit=286
Planning Time: 1.943 ms
Execution Time: 0.053 msdatastore.sumFreedRepositoryBytes.ContainerHosted
Summary: On a reap-sized page the plan matches the method's intent: both
NOT EXISTS arms probe (namespace_id, blob_sha256) on their own table, the
blob_storage_blobs_by_namespace join is an Index Only Scan over
pk_blob_storage_blobs_by_namespace, and all five partitioned tables prune to
one partition of 64. The answer is right on the seed — a 10-address page holding
5 addresses a survivor still carries and 5 it does not sums to 29965, the
exact total of the 5 freed blobs' size values. The one thing to weigh is the
page-size flip in the query notes: from 100 addresses the container_blobs arm
stops probing the index and hashes a full scan of the namespace's partition, so
cost at the 1000-address maximum tracks the namespace's blob-link count.
Seed shape: namespaces=1, repositories=1, container_repositories=1, container_images=5000, blob_storage_blobs=5000, blob_storage_blobs_by_namespace=5000 (trigger), blob_storage_attachments=5000, container_manifests=4990, container_blobs=4990
Rendered SQL:
SELECT COALESCE(SUM(b.size), 0)::bigint
FROM (SELECT DISTINCT u.sha256 FROM unnest($3::bytea[]) AS u(sha256)) AS d
JOIN blob_storage_blobs_by_namespace b
ON b.namespace_id = $1 AND b.sha256 = d.sha256
WHERE NOT EXISTS (
SELECT 1
FROM container_manifests m
JOIN container_images i
ON i.id = m.container_image_id AND i.namespace_id = m.namespace_id
JOIN container_repositories r
ON r.id = i.container_repository_id AND r.namespace_id = i.namespace_id
WHERE m.namespace_id = $1 AND i.namespace_id = $1 AND r.namespace_id = $1
AND r.repository_id = $2 AND m.blob_sha256 = d.sha256
)
AND NOT EXISTS (
SELECT 1
FROM container_blobs cb
JOIN container_images i
ON i.id = cb.container_image_id AND i.namespace_id = cb.namespace_id
JOIN container_repositories r
ON r.id = i.container_repository_id AND r.namespace_id = i.namespace_id
WHERE cb.namespace_id = $1 AND i.namespace_id = $1 AND r.namespace_id = $1
AND r.repository_id = $2 AND cb.blob_sha256 = d.sha256
)Bound args: $1 = 00000000-0000-7000-8000-000000000001, $2 = 00000000-0000-7000-8000-000000000002, $3 = a 10-address bytea[] page — the 32-byte addresses whose big-endian value is 1, 2, 3, 4, 5, 4991, 4992, 4993, 4994 and 4995. The first five are carried by a surviving container_manifests and container_blobs row of the same repository, the last five by nothing. The statement answers 29965, the exact sum of those five blobs' size values.
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Aggregate (cost=107.00..107.01 rows=1 width=8) (actual time=0.207..0.209 rows=1 loops=1)
Buffers: shared hit=85
-> Nested Loop (cost=1.79..106.99 rows=1 width=8) (actual time=0.105..0.201 rows=5 loops=1)
Buffers: shared hit=85
-> Nested Loop Anti Join (cost=1.51..98.68 rows=1 width=32) (actual time=0.057..0.144 rows=5 loops=1)
Buffers: shared hit=70
-> Nested Loop Anti Join (cost=0.82..89.73 rows=1 width=32) (actual time=0.041..0.119 rows=5 loops=1)
Buffers: shared hit=60
-> HashAggregate (cost=0.13..0.23 rows=10 width=32) (actual time=0.020..0.022 rows=10 loops=1)
Group Key: u.sha256
Batches: 1 Memory Usage: 24kB
-> Function Scan on unnest u (cost=0.00..0.10 rows=10 width=32) (actual time=0.013..0.013 rows=10 loops=1)
-> Nested Loop (cost=0.69..8.94 rows=1 width=33) (actual time=0.009..0.009 rows=0 loops=10)
Buffers: shared hit=60
-> Nested Loop (cost=0.56..8.78 rows=1 width=65) (actual time=0.007..0.007 rows=0 loops=10)
Buffers: shared hit=50
-> Index Scan using container_blobs_p57_namespace_id_blob_sha256_idx on container_blobs_p57 cb (cost=0.28..8.30 rows=1 width=65) (actual time=0.004..0.004 rows=0 loops=10)
Index Cond: ((namespace_id = '00000000-0000-7000-8000-000000000001'::uuid) AND (blob_sha256 = u.sha256))
Buffers: shared hit=30
-> Index Scan using container_images_p57_pkey on container_images_p57 i_1 (cost=0.28..0.47 rows=1 width=48) (actual time=0.005..0.005 rows=1 loops=5)
Index Cond: ((id = cb.container_image_id) AND (namespace_id = '00000000-0000-7000-8000-000000000001'::uuid))
Buffers: shared hit=15
-> Index Scan using container_repositories_p57_pkey on container_repositories_p57 r_1 (cost=0.12..0.15 rows=1 width=32) (actual time=0.004..0.004 rows=1 loops=5)
Index Cond: ((id = i_1.container_repository_id) AND (namespace_id = '00000000-0000-7000-8000-000000000001'::uuid))
Filter: (repository_id = '00000000-0000-7000-8000-000000000002'::uuid)
Buffers: shared hit=10
-> Nested Loop (cost=0.69..8.94 rows=1 width=33) (actual time=0.005..0.005 rows=0 loops=5)
Buffers: shared hit=10
-> Nested Loop (cost=0.56..8.78 rows=1 width=65) (actual time=0.004..0.004 rows=0 loops=5)
Buffers: shared hit=10
-> Index Scan using container_manifests_p57_namespace_id_blob_sha256_idx on container_manifests_p57 m (cost=0.28..8.30 rows=1 width=65) (actual time=0.004..0.004 rows=0 loops=5)
Index Cond: ((namespace_id = '00000000-0000-7000-8000-000000000001'::uuid) AND (blob_sha256 = u.sha256))
Buffers: shared hit=10
-> Index Scan using container_images_p57_pkey on container_images_p57 i (cost=0.28..0.47 rows=1 width=48) (never executed)
Index Cond: ((id = m.container_image_id) AND (namespace_id = '00000000-0000-7000-8000-000000000001'::uuid))
-> Index Scan using container_repositories_p57_pkey on container_repositories_p57 r (cost=0.12..0.15 rows=1 width=32) (never executed)
Index Cond: ((id = i.container_repository_id) AND (namespace_id = '00000000-0000-7000-8000-000000000001'::uuid))
Filter: (repository_id = '00000000-0000-7000-8000-000000000002'::uuid)
-> Index Only Scan using blob_storage_blobs_by_namespace_p57_pkey on blob_storage_blobs_by_namespace_p57 b (cost=0.28..8.30 rows=1 width=41) (actual time=0.011..0.011 rows=1 loops=5)
Index Cond: ((namespace_id = '00000000-0000-7000-8000-000000000001'::uuid) AND (sha256 = u.sha256))
Heap Fetches: 5
Buffers: shared hit=15
Planning:
Buffers: shared hit=632 read=3
Planning Time: 6.690 ms
Execution Time: 0.379 msTimings: planning 6.690ms, execution 0.379ms, total 7.069ms.
Page-size sweep — same seed, same statement, same repository, the array widened to 50, 100, 200 and 1000 addresses. Ten of the addresses are always free; every other one is carried by a surviving row.
| Page size | Root cost | Execution | Buffers (hit / read) | container_blobs arm |
|---|---|---|---|---|
| 10 | 107.01 | 0.379 ms | 85 / 0 | Nested Loop Anti Join over index_container_blobs_on_ns_id_blob_sha256 |
| 50 | 423.95 | 0.335 ms | 469 / 0 | Nested Loop Anti Join over index_container_blobs_on_ns_id_blob_sha256 |
| 100 | 650.20 | 2.508 ms | 1005 / 0 | Hash Anti Join over Seq Scan on container_blobs_p57, Memoize on pk_container_images |
| 200 | 650.49 | 2.524 ms | 1005 / 0 | Hash Anti Join over Seq Scan on container_blobs_p57, Memoize on pk_container_images |
| 1000 | 524.59 | 4.294 ms | 225 / 0 | Hash Anti Join over Seq Scan on container_blobs_p57, Hash Join to Seq Scan on container_images_p57 |
The 1000-address plan, which is MaxLifecycleReapPageSize:
Aggregate (cost=524.58..524.59 rows=1 width=8) (actual time=3.942..3.947 rows=1 loops=1)
Buffers: shared hit=225
-> Nested Loop (cost=513.72..524.57 rows=1 width=8) (actual time=3.776..3.936 rows=10 loops=1)
Buffers: shared hit=225
-> Nested Loop Anti Join (cost=513.44..521.06 rows=1 width=32) (actual time=3.684..3.833 rows=10 loops=1)
Buffers: shared hit=195
-> Hash Anti Join (cost=512.75..515.50 rows=1 width=32) (actual time=3.639..3.776 rows=10 loops=1)
Hash Cond: (u.sha256 = cb.blob_sha256)
Buffers: shared hit=175
-> HashAggregate (cost=12.50..14.50 rows=200 width=32) (actual time=0.408..0.476 rows=1000 loops=1)
Group Key: u.sha256
Batches: 1 Memory Usage: 209kB
-> Function Scan on unnest u (cost=0.00..10.00 rows=1000 width=32) (actual time=0.091..0.121 rows=1000 loops=1)
-> Hash (cost=437.87..437.87 rows=4990 width=33) (actual time=3.179..3.182 rows=4990 loops=1)
Buckets: 8192 Batches: 1 Memory Usage: 381kB
Buffers: shared hit=175
-> Nested Loop (cost=197.00..437.87 rows=4990 width=33) (actual time=1.234..2.478 rows=4990 loops=1)
Join Filter: (r_1.id = i_1.container_repository_id)
Buffers: shared hit=175
-> Seq Scan on container_repositories_p57 r_1 (cost=0.00..1.01 rows=1 width=32) (actual time=0.021..0.022 rows=1 loops=1)
Filter: ((namespace_id = '00000000-0000-7000-8000-000000000001'::uuid) AND (repository_id = '00000000-0000-7000-8000-000000000002'::uuid))
Buffers: shared hit=1
-> Hash Join (cost=197.00..374.48 rows=4990 width=65) (actual time=1.211..2.191 rows=4990 loops=1)
Hash Cond: (cb.container_image_id = i_1.id)
Buffers: shared hit=174
-> Seq Scan on container_blobs_p57 cb (cost=0.00..164.38 rows=4990 width=65) (actual time=0.006..0.510 rows=4990 loops=1)
Filter: (namespace_id = '00000000-0000-7000-8000-000000000001'::uuid)
Buffers: shared hit=102
-> Hash (cost=134.50..134.50 rows=5000 width=48) (actual time=1.165..1.166 rows=5000 loops=1)
Buckets: 8192 Batches: 1 Memory Usage: 455kB
Buffers: shared hit=72
-> Seq Scan on container_images_p57 i_1 (cost=0.00..134.50 rows=5000 width=48) (actual time=0.010..0.442 rows=5000 loops=1)
Filter: (namespace_id = '00000000-0000-7000-8000-000000000001'::uuid)
Buffers: shared hit=72
-> Nested Loop (cost=0.69..5.55 rows=1 width=33) (actual time=0.005..0.005 rows=0 loops=10)
Buffers: shared hit=20
-> Nested Loop (cost=0.56..5.38 rows=1 width=65) (actual time=0.005..0.005 rows=0 loops=10)
Buffers: shared hit=20
-> Index Scan using container_manifests_p57_namespace_id_blob_sha256_idx on container_manifests_p57 m (cost=0.28..4.96 rows=1 width=65) (actual time=0.005..0.005 rows=0 loops=10)
Index Cond: ((namespace_id = '00000000-0000-7000-8000-000000000001'::uuid) AND (blob_sha256 = u.sha256))
Buffers: shared hit=20
-> Index Scan using container_images_p57_pkey on container_images_p57 i (cost=0.28..0.41 rows=1 width=48) (never executed)
Index Cond: ((id = m.container_image_id) AND (namespace_id = '00000000-0000-7000-8000-000000000001'::uuid))
-> Index Scan using container_repositories_p57_pkey on container_repositories_p57 r (cost=0.12..0.15 rows=1 width=32) (never executed)
Index Cond: ((id = i.container_repository_id) AND (namespace_id = '00000000-0000-7000-8000-000000000001'::uuid))
Filter: (repository_id = '00000000-0000-7000-8000-000000000002'::uuid)
-> Index Only Scan using blob_storage_blobs_by_namespace_p57_pkey on blob_storage_blobs_by_namespace_p57 b (cost=0.28..3.50 rows=1 width=41) (actual time=0.010..0.010 rows=1 loops=10)
Index Cond: ((namespace_id = '00000000-0000-7000-8000-000000000001'::uuid) AND (sha256 = u.sha256))
Heap Fetches: 10
Buffers: shared hit=30
Planning:
Buffers: shared hit=739
Planning Time: 8.201 ms
Execution Time: 4.294 msMigrations
Migration mode did not run.
git diff --name-only --diff-filter=ACMR origin/main...HEAD -- 'internal/datastore/migrations/sql/' at 11424185f returns no path, so this merge request adds and modifies no migration.
This is a bot message