fix(npm): gate repository size deltas on first attach
What this delivers
npm's two storage-accounting emit sites disagreed with the reconciliation walk that ADR-007 defines. That walk sums the distinct blob digests a repository's rows reference. Neither site asked that question.
emitPublishCounters in internal/format/npm/publish_commit.go charged the whole tarball on every publish, with the repository-scoped size delta gated on nothing.
An unpublish leaves its npm_files row in place, still referencing the digest.
The partial unique index behind ErrNpmVersionExists covers active rows only.
So a republication inserts a second row for one digest, and repositories.size_bytes goes one tarball high.
rebuildCommitAndStamp in internal/format/npm/packument_cache.go had no emit site at all.
Every full packument, dist-tags document and abbreviated packument the rebuild stores is bytes both recomputes reach, and no delta ever recorded them.
What the fix does:
- Publish gates its size delta. The publish transaction runs a repository-membership probe,
NpmFileStore.RepositoryHoldsBlob, before its ownnpm_filesinsert. It reports the verdict onNpmPublishCommitResult.RepositoryHeldBlob, and the handler charges the tarball's bytes only when the verdict is false.artifacts_countstill moves on every publish, because it counts rows rather than distinct blobs. - The rebuild emits its packument bytes, one delta per stamped
npm_metadata_fileskind. The charge and the credit net insideUpsertNpmMetadataFileForBlob's transaction. The probe runs twice there: once on the new digest before the attachment lands, and once on the displaced digest after it goes. A repoint therefore credits the displaced blob only when no other row of the repository still carries that digest.emitRebuildCountersemits the netted figure. - The namespace-scoped column is unchanged.
namespace_statistics.deduplicated_size_byteskeeps its existing gate, the blob commit's deduplication verdict, which was already correct.
Issue coverage
Issue: #762 Spec: docs/specs/S22-storage-accounting.md
Acceptance (the issue card's own list; the spec's criterion 17 is the text this issue corrects)
| # | Item | Tests |
|---|---|---|
| A-1 | A publish whose tarball digest the repository already references emits Δsize = 0, while Δartifacts stays +1 | TestPublishEmitIntegration_RepeatAttachInOneRepositoryEmitsNoSizeDelta, TestNpmPublishCommitter_CommitPublish_ReportsRepositoryBlobMembership/a_republication... |
| A-2 | A publish of a digest new to the repository still emits the tarball's bytes, in a second repository of the namespace too | TestPublishEmitIntegration_DeltaArgumentsAndSourceFirstOrdering (existing, unchanged), TestNpmPublishCommitter_CommitPublish_ReportsRepositoryBlobMembership (first-attach and sibling-repository rows) |
| A-3 | A rebuild charges the repository each committed metadata blob's bytes, on all three kinds | TestRebuildEmitIntegration_ColdCacheChargesEveryKind, TestNpmFileStore_RepositoryHoldsBlob (per-kind packument arm) |
| A-4 | The rebuild credits the displaced blob when the repoint leaves the repository reaching no other row for that digest, netted | TestRebuildEmitIntegration_CreditsTheDisplacedBlob; the "still reached" arm is TestRebuildEmitIntegration_ByteIdenticalRebuildMovesNoCounter, where the row just written carries the displaced digest |
| A-5 | A byte-identical rebuild moves no counter in either scope | TestRebuildEmitIntegration_ByteIdenticalRebuildMovesNoCounter |
| A-6 | Δdedup_size is +the blob's bytes exactly when the blob was new to the namespace, with no namespace-scoped credit for the displaced | TestRebuildEmitIntegration_ColdCacheChargesEveryKind, TestRebuildEmitIntegration_CreditsTheDisplacedBlob, TestRebuildEmitIntegration_ByteIdenticalRebuildMovesNoCounter |
| A-7 | Every emit follows its source rows' commit, and a superseded rebuild emits nothing | TestRebuildEmitIntegration_CommitsItsRowsBeforeEmitting, TestRebuildEmitIntegration_SupersededRebuildEmitsNothing, TestPublishEmitIntegration_DeltaArgumentsAndSourceFirstOrdering (existing) |
| A-8 | After the emits drain, a reconciliation pass recomputes both columns to the value already stored | Not covered. The card's tests entry names no reconciliation suite, and the assertion needs a drain plus a pass from internal/accounting. What is covered is the mechanism it rests on: TestNpmFileStore_RepositoryHoldsBlob pins the probe's predicate against the row set recomputeNpmFilesSizeStmt sums, and TestNpmFileStore_RepositoryHoldsBlob_PrunesToOnePartition pins its plan |
Spec criteria this issue touches
| # | Criterion | Tests |
|---|---|---|
| AC-17 | npm's publish increment moves all four counters, first-attach where it applies | TestPublishEmitIntegration_RepeatAttachInOneRepositoryEmitsNoSizeDelta adds the repository-scoped half; the namespace-scoped half and the drain are the existing TestPublishEmitIntegration_* cases, unchanged |
| AC-25 | A call site commits its source rows before issuing the pipeline increment | npm publish row: existing TestPublishEmitIntegration_DeltaArgumentsAndSourceFirstOrdering and ..._RolledBackPublishEmitsNothing. The rebuild is a site the matrix does not list; TestRebuildEmitIntegration_CommitsItsRowsBeforeEmitting and ..._SupersededRebuildEmitsNothing hold it to the same contract |
The other 36 S22 criteria belong to that spec's own plan steps and no assertion here touches them.
Datastore seams the two emit sites read
| Seam | Tests |
|---|---|
NpmFileStore.RepositoryHoldsBlob |
TestNpmFileStore_RepositoryHoldsBlob, TestNpmFileStore_RepositoryHoldsBlob_PrunesToOnePartition |
NpmPublishCommitResult.RepositoryHeldBlob |
TestNpmPublishCommitter_CommitPublish_ReportsRepositoryBlobMembership |
UpsertNpmMetadataFileForBlob displaced report |
TestNpmMetadataFileStore_UpsertNpmMetadataFileForBlob (first-stamp, repoint and deterministic subtests), TestNpmMetadataFileStore_UpsertNpmMetadataFileForBlob_GuardReturnShape |
| The composition root's two emitter seams | TestBuildNpmDispatcher_ThreadsCounterEmitterIntoPublish (existing), TestBuildNpmDispatcher_ThreadsCounterEmitterIntoTheRebuild |
One row of that table is now out of date, and it is left as the test author wrote it.
A-8 reads "Not covered".
A later design ruling moved the netting inside the upsert transaction and brought a done-when test with it: TestNpmMetadataFileStore_UpsertNpmMetadataFileForBlob_ReconciliationParity, in internal/datastore/npm_metadata_accounting_integration_test.go.
It asserts that the size_bytes recompute moves by exactly the sum of the deltas the stamps reported.
Two limits on that row still stand.
The namespace half is asserted only in the no-movement direction.
The repository half does not hold on a repository that saw a whole-package unpublish, for the reason the next section gives.
The e2e scenario catalog
docs/testing/e2e/npm.md is updated.
The e2e.npm.lifecycle.repository-storage-counters row now carries the publish gate, the rebuild's second movement once per npm_metadata_files kind, and the netted credit.
It also carries the dist-tags collision case, where two packages of one repository render one blob.
The ## Lifecycle intro adds the rebuild to the paths that record deltas.
No new scenario is added.
A runtime pass checked the row clause by clause against a booted service, and every clause holds.
Verification
Nine behaviours were driven against a booted service, including the defect's own reproduction.
A re-attach after an unpublish charged size 0 while artifacts still moved 1, where the same publish charged size 197 before the fix.
The other eight cover first attach, a second repository of the namespace, all three rebuild kinds, and the netted repoint.
They also cover a byte-identical re-render, a superseded fence, the namespace gate, and the management-API read after the drain.
Conformance.
mise run conformance:npm cannot run on this machine, for two pre-existing reasons filed as #924.
The pass came from scripts/conformance/npm-e2e.sh against a clean database: 17 steps, 51 assertions, 0 failures.
The run recipe.
.claude/skills/run-artifact-registry/SKILL.md enumerated the counter emit sites, and the rebuild is a fourth one that is not a route.
The recipe is corrected in this change, and driver.sh smoke reports 72 passed, 0 failed.
The parity test is built so that no two errors can cancel, which is what makes it evidence. Three packages of one repository share one dist-tags digest. The gated arithmetic totals 295 over that sequence, where an ungated implementation totals 335. The test asserts the recompute's movement against the reported sum and against that literal.
Lint. This section previously claimed zero findings on added lines. That claim was wrong and is corrected here.
golangci-lint run --new-from-rev=origin/main --build-tags=integration --max-same-issues=0 --max-issues-per-linter=0 --uniq-by-line=false over the four touched trees, at the pinned 2.12.2, reports 50 findings on lines this branch adds: 48 contextcheck, 1 staticcheck, 1 wsl.
The earlier "zero" came from a delta between two runs of the branch against itself, never against origin/main, so it could not have detected a finding the branch introduces. Correcting it in place rather than quietly, because the number was published and was read as evidence.
CI cannot see any of these. .golangci.yaml sets no run.build-tags, so CI lint never compiles //go:build integration files — which is why the project guardrail asks for this run explicitly, and why a false all-clear here is worse than no claim.
One of the 50 is a defect rather than a pattern: //nolint:staticcheck at internal/datastore/npm_write_integration_test.go:2631 sits on the closing }, while the nil context it targets is at :2628, a line this branch rewrote. The directive suppresses nothing, and SA1012 is live there.
Of the 50, one is fixed: the //nolint:staticcheck in internal/datastore/npm_write_integration_test.go now sits on the nil context it covers instead of three lines below it, so SA1012 is suppressed where it actually fires.
The other 49 are not fixed and are not defects of this branch. The 48 contextcheck findings report three seed chains that bottom out in internal/testutil.SeedNamespace, and in seedAttachment and seedNpmVersion, which are local to the internal/datastore test package; all take testing.TB and build their own context; origin/main carries 4218 of them across 86 files in internal/datastore, including 436 in this same file. The one wsl finding is the var plus copy idiom this file already carries 12 times on origin/main. Closing either class means changing a shared test fixture used across the tree, which is out of scope here.
One //nolint token is added: funlen on repointMetadataAttachment.
It is the only watched linter that fired there, so cyclop, gocyclo and gocognit are deliberately absent.
What a reviewer will meet, and why
A +828 residue after a whole-package unpublish
A whole-package npm unpublish leaves repositories.size_bytes high by the package's packument bytes.
Measured on a booted service, npm-repo reads a counter of 2677 against a recompute of 1849.
The 828 = 455 + 355 + 18 is the three packument blobs the unpublish hard-deleted with no credit.
npm-repo-b, which saw no unpublish, agrees exactly at 1029.
This branch does not create it.
The gap is on main today: the request transaction hard-deletes the npm_metadata_files rows and the handler emits Δsize = 0.
What this branch changes is the frequency and the visibility, not the magnitude and not the duration.
Before, the bytes reached the counter only after a reconciliation pass put them there.
Now the write path puts them there continuously, so the residue is reproducible inside one session.
The drift is bounded, and reconciliation is what corrects it.
RepositoryReconcileStore.WriteBackCounters takes an absolute value rather than a delta, and the pass selects by staleness rather than by the dirty set.
The bound is roughly one to two reconciliation_intervals, which is about one to two hours at the 1h default.
#761 (closed) owns it.
Its scope was corrected today to cover both hard-delete routes: the management package DELETE and the protocol whole-package unpublish.
The two share the DeleteNpmMetadataFiles helper and the same remedy.
The pointer lives once, on datastore.NpmMetadataFileStore.DeleteNpmMetadataFiles at internal/datastore/npm_metadata_files.go:221.
internal/format/npm/unpublish_package.go and internal/managementapi/package_delete.go refer to that symbol without repeating the pointer, which is what the once-per-obligation rule asks for.
The docs/dev/go-style.md exception
docs/dev/go-style.md:140 on origin/main reads: "Pages under docs/dev/ are written on purpose and are not overflow homes for comment text."
This branch relocates 89 lines of comment content into a new ### npm metadata-file upsert transaction section of docs/dev/database-query-patterns.md.
As merged, that sentence forbids the relocation.
Three things about it, stated here because silence reads as concealment:
- The relocation was made under an explicit operator ruling that overrides the merged sentence.
- The style-guide amendment travels in this merge request by the operator's assignment, so a reviewer meets the rule and the instance it admits together.
- The amendment narrows the rule rather than removing it. Three of its four sentences restrict, and the default stays compress-and-delete. The last sentence closes the misreading directly: survival on a page is not on its own a reason to move prose there.
Two large comment blocks came down
The comment-caps gate ratchets any pre-caps block a diff touches to its cap. Two blocks lost a lot of text that way. Both hunks read as deleted rationale unless a reviewer knows where the content went.
| Block | Before | After | Where the content is now |
|---|---|---|---|
buildNpmDispatcher's doc, cmd/artifact-registry/wire_npm.go |
31 lines | 1 line | The load-bearing whys ride the declarations as one-line trailing comments, which docs/dev/go-style.md sanctions. The #256 and #260 pointers both survive |
NpmMetadataFileWriter.UpsertNpmMetadataFileForBlob's interface doc, internal/format/npm/packument_cache.go |
18 lines | 2 lines | The datastore method's own doc, and the ### npm metadata-file upsert transaction section of docs/dev/database-query-patterns.md |
Nothing left the repository.
One card entry is not implemented, and the operator accepted it
The issue card asked for NpmRepositoryID on rebuildTarget.
This branch adds no field.
rebuildTarget already carries RepositoryID at the merge base, and the chosen design gives a new field no consumer.
The charge and the credit net inside the datastore transaction rather than in the rebuild.
The operator accepted the divergence, and the card records the correction.
Reviewable size
The diff is 35 files, +1989 −683, which is past the 500 reviewable-LOC line that docs/dev/development-model.md draws.
| Group | Files | Added | Removed |
|---|---|---|---|
| Test Go | 16 | 1427 | 152 |
| Production Go | 13 | 437 | 518 |
Docs (docs/) |
5 | 121 | 10 |
Run recipe (.claude/skills/) |
1 | 4 | 3 |
| Total | 35 | 1989 | 683 |
A split does not help here, for four reasons.
Test Go is 1579 of the 2672 changed lines, and it is the fix's evidence. The branch is test-first, so those files fail without the production change and prove nothing without it.
Production Go is net negative at −81 lines.
The behaviour change inside it is small.
It is the publish probe (internal/datastore/npm_files.go, +114), a transaction-level probe helper (internal/datastore/repo_blob_references.go, +26), two query names, one result field, and the two emit sites.
The rest is the comment compression the caps gate forced, and the 89 lines the relocation moved out.
Much of the remaining churn is mechanical and cannot be deferred.
Widening SetRebuildDependencies reaches 35 call sites and widening UpsertNpmMetadataFileForBlob reaches 37.
Split from the widening, none of it compiles.
Each of the five documentation files has its own reason to travel here.
docs/dev/storage-accounting.md and docs/testing/e2e/npm.md state the old behaviour and become false the moment the code lands.
docs/dev/storage-accounting.md also carries the #762 pointer this change retires.
docs/specs/S11-npm-hosted.md is under a specific operator grant for exactly that one path.
docs/dev/database-query-patterns.md is the relocation's target, and docs/dev/go-style.md is the amendment that admits it.
Merge order
- !1916 conflicts with this branch, deliberately.
Both rewrite the
e2e.npm.lifecycle.repository-storage-countersrow ofdocs/testing/e2e/npm.md. The two edits are complementary in substance and incompatible in text. This branch adds the publish gate and the rebuild's movement, and!1916appends what the purger does after the retention window. Whichever merges second resolves by hand and keeps both additions. A correct row that conflicts is better than a wrong row that merges.!1916targetsprozlach/s20a-lifecycle-closed-beta-step-18rather thanmain. - !1912 carries a sentence this branch makes false. Its added prose reads "Nothing raises it for an npm packument write either — the packument is written by the cache rebuild a publish enqueues, and the publish's own emit carries the tarball alone." The rebuild now raises it. The two changes touch different lines, so git flags nothing.
- !1973 carries three more. A text merge of the two branches exits 0 with no conflict markers. So the wrong sentences survive the merge rather than the merge resolving them. All four corrections are posted as a review note on that merge request, at !1973#note_3741710221.
- !1990 breaks the build silently, and it is the same shape this branch's own rebase hit.
It targets
mainand addsSetRebuildDependenciescall sites andUpsertNpmMetadataFileForBlobimplementations on two fake stores, all at the arity this branch widens. A text merge exits 0 andgo build ./...exits 0, because the breaks sit in test files. Onlygo vetcatches it, which is exactly how this branch's rebase surfacedscriptedFenceWriterand thebuildNpmDispatcherarity. Whichever merges second runsgo vetrather than trusting a clean merge.!1885,!1895,!1976,!1982and!2003were each checked for prose this branch makes false, and none carries any. - The rebase onto
origin/mainis done. Two upstream signature changes had landed since the merge base:scriptedFenceWriterininternal/format/npm/packument_pager_internal_test.goand the arity ofbuildNpmDispatcher. Both breaks sat in test files, sogo buildexited 0 over them andgo vetwas the check that caught them. Both are repaired, and the branch is now two commits ahead oforigin/mainand none behind.
The decrement half of this issue stays open after this merge request, and #761 (closed) plus handbook merge request !20887 carry it.
!20887 also settles the ADR question this change raises on its own account.
ADR-007 currently says size_bytes "decrements when garbage collection hard-deletes the last attachment".
The rebuild credits a displaced blob at repoint time, which that clause does not describe.
!20887 replaces it with "decrements when the last attachment of that sha256 leaves the repository … Neither path waits for a garbage-collection pass", so the repoint-time credit conforms once it merges and is not an unrecorded deviation before then.
Pipeline history
Three pipelines on this branch went red. None was caused by this diff, and each was checked against the branch's own file list rather than assumed.
2794450926—test:integration: [POSTGRES, 18]hit the 25-minute job wall. Not a test failure: the 681 reported failures all read(unknown), which is what an in-flight test reports when the runner kills the process. The same timeout hit four other branches, a release tag andmainitself within six hours; measurements are on #709.2794609859— the retry ran to completion and surfaced what the timeout had masked:TestContainerRemoteTagStore_DeepPageIsIndexBacked, failing because39e9ee71fwas absent from this branch. That commit is a fix already onmainfor a flap in that exact test. The rebase above picked it up and the failure went away. A separate leg then failed on the flake in 3 below, and passed on retry.2794851337—TestSumDistinctBlobSizes_PlanIsIndexOnlyOnOneShadowPartition, the same flake as 2, on a different PostgreSQL leg. It is #922, whose own description records it flipping on all three legs, and its fix is !1996, open and unmerged. Passed on retry; this pipeline is green.
The head of this branch, f1138b9f7, is green on pipeline 2794851337 — 47 jobs succeeded, 2 manual, zero failed, the build-jobs bridge succeeded and its downstream pipeline succeeded.
One duration worth recording, because it is the argument rather than a detail. The same internal/datastore suite on the same code took 1179 s, 1320 s and 1424 s across these runs, against a 1500 s wall. The spread between runs is about a fifth of the budget, so the problem is not the average but that the spread reaches the wall. Measurements are on #709.
This branch touches internal/datastore/statistics_footprint_integration_test.go zero times and never names that test, and it touches internal/datastore/container_remote_tags_integration_test.go zero times.
Database Review Evidence
Collected by the db-review-prep skill, query mode only.
Migration mode does not apply.
The three-dot diff against origin/main adds no file under internal/datastore/migrations/sql/, so this branch has no migration to time and no schema change.
Queries
Note
Plans are from EXPLAIN (ANALYZE, BUFFERS) against an ephemeral
PostgreSQL 17.10 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.NpmFileStore.RepositoryHoldsBlob.NotHeld |
Limit | npm_files_pNN_namespace_id_blob_sha256_idx, npm_metadata_files_pNN_namespace_id_blob_sha256_idx |
1 / 0 | 17.21 | 0.276ms | 4 / 0 | 1/64, 1/64, 1/64, 1/64 |
datastore.NpmFileStore.RepositoryHoldsBlob.HeldByTarball |
Limit | npm_files_pNN_namespace_id_blob_sha256_idx, npm_versions_pNN_pkey, npm_packages_pNN_pkey |
1 / 1 | 17.21 | 0.167ms | 9 / 0 | 1/64, 1/64, 1/64, 1/64 |
datastore.NpmFileStore.RepositoryHoldsBlob.HeldByPackument |
Limit | npm_metadata_files_pNN_namespace_id_blob_sha256_idx, npm_packages_pNN_pkey |
1 / 1 | 17.21 | 0.132ms | 8 / 0 | 1/64, 1/64, 1/64, 1/64 |
datastore.NpmFileStore.RepositoryHoldsBlob.NotHeld500Siblings |
Limit | none on the tarball arm (Seq Scan on npm_files_pNN), npm_metadata_files_pNN_namespace_id_blob_sha256_idx |
1 / 0 | 10.96 | 2.513ms | 3104 / 0 | 1/64, 1/64, 1/64, 1/64 |
datastore.npmRepoReferencesBlobTx.NotReferenced |
Result | npm_files_pNN_namespace_id_blob_sha256_idx, npm_metadata_files_pNN_namespace_id_blob_sha256_idx |
1 / 1 | 33.59 | 0.094ms | 4 / 0 | 1/64, 1/64, 1/64, 1/64 |
datastore.npmRepoReferencesBlobTx.StillReferenced |
Result | npm_metadata_files_pNN_namespace_id_blob_sha256_idx, npm_packages_pNN_pkey |
1 / 1 | 33.59 | 0.151ms | 8 / 0 | 1/64, 1/64, 1/64, 1/64 |
datastore.readPackumentFenceRow.LockRow |
Limit | npm_packages_pNN_pkey |
1 / 1 | 8.31 | 0.042ms | 4 / 0 | 1/64 |
datastore.readPackumentFenceRow.NoLock |
Limit | npm_packages_pNN_pkey |
1 / 1 | 8.30 | 0.038ms | 3 / 0 | 1/64 |
datastore.NpmMetadataFileStore.existingAttachment |
Limit | npm_metadata_files_pNN_namespace_id_npm_package_id_kind_idx, blob_storage_blobs_by_namespace_pNN_pkey |
1 / 1 | 16.63 | 0.094ms | 10 / 0 | 1/64, 1/64 |
Every table in every plan prunes to exactly 1 partition of 64, so there is no partition fan-out to report.
The partition number pNN varies per run, because the seed mints a fresh namespace UUID each time and that UUID selects the partition.
Query notes:
-
NpmFileStore.RepositoryHoldsBlob: the probe is driven byindex_npm_files_on_ns_id_blob_sha256, which indexes(namespace_id, blob_sha256).npm_repository_idsits two joins abovenpm_files, onnpm_packages, so PostgreSQL applies the repository predicate only as a post-joinFilter. The cost of the tarball arm therefore grows with the number of rows the whole namespace holds on the probed digest, not the number the target repository holds. Measured on one seed, changing only how many sibling-repository rows carry the probed digest, with the target repository holding none of them:Namespace-wide npm_filesrows on the digestTarball-arm plan node Buffers Execution 1 Index Scan 4 0.276 ms 5 Index Scan 35 0.187 ms 50 Index Scan 306 0.408 ms 500 Seq Scan on the partition 3104 2.513 ms Between the 1-row and 500-row shapes that is 776 times the buffers and 9 times the execution time, and by 500 rows the planner abandons the index. Read the buffer column as the signal. These execution times are sub-millisecond and carry run-to-run noise, which is why the 5-row time sits below the 1-row time. At 500 the Seq Scan reads the whole partition and removes 5100 rows by filter, then the plan walks all 500 survivors up through
npm_versionsandnpm_packages. No index corrects this. No index onnpm_filescan bindnpm_repository_id, because that column belongs tonpm_packages. A fix has to change the shape, by denormalizingnpm_repository_idontonpm_filesor by restructuring the probe, and that is a design decision rather than an index review. The shape that produces the curve is one tarball attached in several npm repositories of one namespace. This evidence measures the shape and does not measure how often the shape occurs on GitLab.com. This branch adds a call site on the publish path, atinternal/datastore/npm_publish_committer.go:393, where the probe runs on the tarball digest inside the publish transaction. Tracked as #929, which carries this evidence and the shape that reaches it. -
The two packument probes are safe from this curve.
npmRepoReferencesBlobTxruns on a packument digest, and a packument digest is specific to one package, so the namespace-wide fan-out does not arise. Thenpm_metadata_filesarm keeps its Index Scan even in the 500-row measurement above. -
Parity with the Maven precedent is accurate in kind and understates the per-row cost.
MavenFileStore.RepositoryHoldsBlobhas the same driver,index_maven_files_on_ns_id_blob_sha256, and the same post-join repositoryFilter. The npm probe pays two primary-key lookups per candidate row, throughnpm_versionsand thennpm_packages, where the Maven probe pays one. -
NpmMetadataFileStore.existingAttachment: go-jet renders thekindbind asbigintagainst asmallintcolumn. PostgreSQL still usesunique_npm_metadata_files_ns_id_pkg_id_kind, and the plan showskind = '0'::bigintinside theIndex Condrather than as a filter, so the cross-type comparison costs nothing here. -
No other anomaly from the skill's catalog appears. There is no unbounded
SELECT, noSortabove anIndex Scan, no plan-versus-actual divergence over 10 times, and no partition fan-out.
datastore.NpmFileStore.RepositoryHoldsBlob.NotHeld
Summary: The plan matches the probe's intent.
Both union arms take an Index Scan on (namespace_id, blob_sha256), and each of the four tables prunes to 1 partition of 64.
The outer LIMIT 1 stops the Append at the first row, and here no arm returns one.
No anomalies at this seed shape. The NotHeld500Siblings block below carries the shape that degrades.
Seed shape: namespaces=1, repositories=2, npm_repositories=2, npm_packages=1700, npm_versions=5100, blob_storage_blobs=10200, blob_storage_blobs_by_namespace=10200, blob_storage_attachments=10200, npm_files=5100, npm_metadata_files=5100
Rendered SQL:
SELECT held_blobs.blob_sha256 AS "blob_sha256"
FROM (
(
SELECT npm_files.blob_sha256 AS "blob_sha256"
FROM public.npm_files
INNER JOIN public.npm_versions ON ((npm_versions.id = npm_files.npm_version_id) AND (npm_versions.namespace_id = npm_files.namespace_id))
INNER JOIN public.npm_packages ON ((npm_packages.id = npm_versions.npm_package_id) AND (npm_packages.namespace_id = npm_versions.namespace_id))
WHERE ((((npm_files.namespace_id = $1::uuid) AND (npm_versions.namespace_id = $2::uuid)) AND (npm_packages.namespace_id = $3::uuid)) AND (npm_packages.npm_repository_id = $4::uuid)) AND (npm_files.blob_sha256 = $5::bytea)
LIMIT $6
)
UNION ALL
(
SELECT npm_metadata_files.blob_sha256 AS "blob_sha256"
FROM public.npm_metadata_files
INNER JOIN public.npm_packages ON ((npm_packages.id = npm_metadata_files.npm_package_id) AND (npm_packages.namespace_id = npm_metadata_files.namespace_id))
WHERE (((npm_metadata_files.namespace_id = $7::uuid) AND (npm_packages.namespace_id = $8::uuid)) AND (npm_packages.npm_repository_id = $9::uuid)) AND (npm_metadata_files.blob_sha256 = $10::bytea)
LIMIT $11
)
) AS held_blobs
LIMIT $12;Bound args: [ns, ns, ns, npm_repository_a, 0xabab...abab (32 bytes, a digest no seeded row carries), 1, ns, ns, npm_repository_a, 0xabab...abab, 1, 1]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.84..17.21 rows=1 width=33) (actual time=0.202..0.203 rows=0 loops=1)
Buffers: shared hit=4
-> Append (cost=0.84..33.59 rows=2 width=33) (actual time=0.201..0.203 rows=0 loops=1)
Buffers: shared hit=4
-> Limit (cost=0.84..16.96 rows=1 width=33) (actual time=0.019..0.019 rows=0 loops=1)
Buffers: shared hit=2
-> Nested Loop (cost=0.84..16.96 rows=1 width=33) (actual time=0.019..0.019 rows=0 loops=1)
Buffers: shared hit=2
-> Nested Loop (cost=0.56..16.62 rows=1 width=65) (actual time=0.018..0.019 rows=0 loops=1)
Buffers: shared hit=2
-> Index Scan using npm_files_p01_namespace_id_blob_sha256_idx on npm_files_p01 npm_files (cost=0.28..8.30 rows=1 width=65) (actual time=0.018..0.018 rows=0 loops=1)
Index Cond: ((namespace_id = '6df11c12-64d4-41d0-b48a-90d88dd6e72b'::uuid) AND (blob_sha256 = '\xabababababababababababababababababababababababababababababababab'::bytea))
Buffers: shared hit=2
-> Index Scan using npm_versions_p01_pkey on npm_versions_p01 npm_versions (cost=0.28..8.30 rows=1 width=48) (never executed)
Index Cond: ((id = npm_files.npm_version_id) AND (namespace_id = '6df11c12-64d4-41d0-b48a-90d88dd6e72b'::uuid))
-> Index Scan using npm_packages_p01_pkey on npm_packages_p01 npm_packages (cost=0.28..0.34 rows=1 width=32) (never executed)
Index Cond: ((id = npm_versions.npm_package_id) AND (namespace_id = '6df11c12-64d4-41d0-b48a-90d88dd6e72b'::uuid))
Filter: (npm_repository_id = '33064939-ab53-4cd4-849d-ac1a0d39c915'::uuid)
-> Limit (cost=0.56..16.61 rows=1 width=33) (actual time=0.182..0.182 rows=0 loops=1)
Buffers: shared hit=2
-> Nested Loop (cost=0.56..16.61 rows=1 width=33) (actual time=0.182..0.182 rows=0 loops=1)
Buffers: shared hit=2
-> Index Scan using npm_metadata_files_p01_namespace_id_blob_sha256_idx on npm_metadata_files_p01 npm_metadata_files (cost=0.28..8.30 rows=1 width=65) (actual time=0.181..0.182 rows=0 loops=1)
Index Cond: ((namespace_id = '6df11c12-64d4-41d0-b48a-90d88dd6e72b'::uuid) AND (blob_sha256 = '\xabababababababababababababababababababababababababababababababab'::bytea))
Buffers: shared hit=2
-> Index Scan using npm_packages_p01_pkey on npm_packages_p01 npm_packages_1 (cost=0.28..8.30 rows=1 width=32) (never executed)
Index Cond: ((id = npm_metadata_files.npm_package_id) AND (namespace_id = '6df11c12-64d4-41d0-b48a-90d88dd6e72b'::uuid))
Filter: (npm_repository_id = '33064939-ab53-4cd4-849d-ac1a0d39c915'::uuid)
Planning:
Buffers: shared hit=11
Planning Time: 1.069 ms
Execution Time: 0.276 msTimings: planning 1.069ms, execution 0.276ms, total 1.345ms.
datastore.NpmFileStore.RepositoryHoldsBlob.HeldByTarball
Summary: The tarball arm returns its row and the packument arm is never executed.
That short circuit is what UNION_ALL buys over the reconciliation walk's deduplicating UNION.
The chain is an Index Scan on (namespace_id, blob_sha256) and then two primary-key lookups, with the repository predicate as a Filter on npm_packages.
No anomalies.
Seed shape: namespaces=1, repositories=2, npm_repositories=2, npm_packages=1700, npm_versions=5100, blob_storage_blobs=10200, blob_storage_blobs_by_namespace=10200, blob_storage_attachments=10200, npm_files=5100, npm_metadata_files=5100 The probed digest is one the target repository holds through npm_files.
Rendered SQL:
SELECT held_blobs.blob_sha256 AS "blob_sha256"
FROM (
(
SELECT npm_files.blob_sha256 AS "blob_sha256"
FROM public.npm_files
INNER JOIN public.npm_versions ON ((npm_versions.id = npm_files.npm_version_id) AND (npm_versions.namespace_id = npm_files.namespace_id))
INNER JOIN public.npm_packages ON ((npm_packages.id = npm_versions.npm_package_id) AND (npm_packages.namespace_id = npm_versions.namespace_id))
WHERE ((((npm_files.namespace_id = $1::uuid) AND (npm_versions.namespace_id = $2::uuid)) AND (npm_packages.namespace_id = $3::uuid)) AND (npm_packages.npm_repository_id = $4::uuid)) AND (npm_files.blob_sha256 = $5::bytea)
LIMIT $6
)
UNION ALL
(
SELECT npm_metadata_files.blob_sha256 AS "blob_sha256"
FROM public.npm_metadata_files
INNER JOIN public.npm_packages ON ((npm_packages.id = npm_metadata_files.npm_package_id) AND (npm_packages.namespace_id = npm_metadata_files.namespace_id))
WHERE (((npm_metadata_files.namespace_id = $7::uuid) AND (npm_packages.namespace_id = $8::uuid)) AND (npm_packages.npm_repository_id = $9::uuid)) AND (npm_metadata_files.blob_sha256 = $10::bytea)
LIMIT $11
)
) AS held_blobs
LIMIT $12;Bound args: [ns, ns, ns, npm_repository_a, 0x00...0f62 (a seeded tarball digest), 1, ns, ns, npm_repository_a, 0x00...0f62, 1, 1]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.84..17.21 rows=1 width=33) (actual time=0.079..0.081 rows=1 loops=1)
Buffers: shared hit=9
-> Append (cost=0.84..33.58 rows=2 width=33) (actual time=0.078..0.080 rows=1 loops=1)
Buffers: shared hit=9
-> Limit (cost=0.84..16.96 rows=1 width=33) (actual time=0.077..0.078 rows=1 loops=1)
Buffers: shared hit=9
-> Nested Loop (cost=0.84..16.96 rows=1 width=33) (actual time=0.077..0.078 rows=1 loops=1)
Buffers: shared hit=9
-> Nested Loop (cost=0.56..16.62 rows=1 width=65) (actual time=0.054..0.054 rows=1 loops=1)
Buffers: shared hit=6
-> Index Scan using npm_files_p26_namespace_id_blob_sha256_idx on npm_files_p26 npm_files (cost=0.28..8.30 rows=1 width=65) (actual time=0.029..0.029 rows=1 loops=1)
Index Cond: ((namespace_id = '45fcd7b1-aa93-46ed-806a-402d390a30fa'::uuid) AND (blob_sha256 = '\x0000000000000000000000000000000000000000000000000000000000000f62'::bytea))
Buffers: shared hit=3
-> Index Scan using npm_versions_p26_pkey on npm_versions_p26 npm_versions (cost=0.28..8.30 rows=1 width=48) (actual time=0.023..0.023 rows=1 loops=1)
Index Cond: ((id = npm_files.npm_version_id) AND (namespace_id = '45fcd7b1-aa93-46ed-806a-402d390a30fa'::uuid))
Buffers: shared hit=3
-> Index Scan using npm_packages_p26_pkey on npm_packages_p26 npm_packages (cost=0.28..0.34 rows=1 width=32) (actual time=0.022..0.022 rows=1 loops=1)
Index Cond: ((id = npm_versions.npm_package_id) AND (namespace_id = '45fcd7b1-aa93-46ed-806a-402d390a30fa'::uuid))
Filter: (npm_repository_id = '5c2ef717-7153-40d8-9024-322d6608144d'::uuid)
Buffers: shared hit=3
-> Limit (cost=0.56..16.61 rows=1 width=33) (never executed)
-> Nested Loop (cost=0.56..16.61 rows=1 width=33) (never executed)
-> Index Scan using npm_metadata_files_p26_namespace_id_blob_sha256_idx on npm_metadata_files_p26 npm_metadata_files (cost=0.28..8.30 rows=1 width=65) (never executed)
Index Cond: ((namespace_id = '45fcd7b1-aa93-46ed-806a-402d390a30fa'::uuid) AND (blob_sha256 = '\x0000000000000000000000000000000000000000000000000000000000000f62'::bytea))
-> Index Scan using npm_packages_p26_pkey on npm_packages_p26 npm_packages_1 (cost=0.28..8.30 rows=1 width=32) (never executed)
Index Cond: ((id = npm_metadata_files.npm_package_id) AND (namespace_id = '45fcd7b1-aa93-46ed-806a-402d390a30fa'::uuid))
Filter: (npm_repository_id = '5c2ef717-7153-40d8-9024-322d6608144d'::uuid)
Planning:
Buffers: shared hit=11
Planning Time: 1.556 ms
Execution Time: 0.167 msTimings: planning 1.556ms, execution 0.167ms, total 1.723ms.
datastore.NpmFileStore.RepositoryHoldsBlob.HeldByPackument
Summary: The tarball arm misses first and the packument arm answers, so this is the more expensive of the two hit orders, and it still costs 8 buffers.
Both arms keep their (namespace_id, blob_sha256) Index Scan, and every table prunes to 1 partition of 64.
No anomalies.
Seed shape: namespaces=1, repositories=2, npm_repositories=2, npm_packages=1700, npm_versions=5100, blob_storage_blobs=10200, blob_storage_blobs_by_namespace=10200, blob_storage_attachments=10200, npm_files=5100, npm_metadata_files=5100 The probed digest is one the target repository holds through npm_metadata_files.
Rendered SQL:
SELECT held_blobs.blob_sha256 AS "blob_sha256"
FROM (
(
SELECT npm_files.blob_sha256 AS "blob_sha256"
FROM public.npm_files
INNER JOIN public.npm_versions ON ((npm_versions.id = npm_files.npm_version_id) AND (npm_versions.namespace_id = npm_files.namespace_id))
INNER JOIN public.npm_packages ON ((npm_packages.id = npm_versions.npm_package_id) AND (npm_packages.namespace_id = npm_versions.namespace_id))
WHERE ((((npm_files.namespace_id = $1::uuid) AND (npm_versions.namespace_id = $2::uuid)) AND (npm_packages.namespace_id = $3::uuid)) AND (npm_packages.npm_repository_id = $4::uuid)) AND (npm_files.blob_sha256 = $5::bytea)
LIMIT $6
)
UNION ALL
(
SELECT npm_metadata_files.blob_sha256 AS "blob_sha256"
FROM public.npm_metadata_files
INNER JOIN public.npm_packages ON ((npm_packages.id = npm_metadata_files.npm_package_id) AND (npm_packages.namespace_id = npm_metadata_files.namespace_id))
WHERE (((npm_metadata_files.namespace_id = $7::uuid) AND (npm_packages.namespace_id = $8::uuid)) AND (npm_packages.npm_repository_id = $9::uuid)) AND (npm_metadata_files.blob_sha256 = $10::bytea)
LIMIT $11
)
) AS held_blobs
LIMIT $12;Bound args: [ns, ns, ns, npm_repository_a, 0x00...1429 (a seeded packument digest), 1, ns, ns, npm_repository_a, 0x00...1429, 1, 1]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.84..17.21 rows=1 width=33) (actual time=0.059..0.061 rows=1 loops=1)
Buffers: shared hit=8
-> Append (cost=0.84..33.58 rows=2 width=33) (actual time=0.058..0.060 rows=1 loops=1)
Buffers: shared hit=8
-> Limit (cost=0.84..16.96 rows=1 width=33) (actual time=0.020..0.020 rows=0 loops=1)
Buffers: shared hit=2
-> Nested Loop (cost=0.84..16.96 rows=1 width=33) (actual time=0.019..0.020 rows=0 loops=1)
Buffers: shared hit=2
-> Nested Loop (cost=0.56..16.62 rows=1 width=65) (actual time=0.019..0.020 rows=0 loops=1)
Buffers: shared hit=2
-> Index Scan using npm_files_p49_namespace_id_blob_sha256_idx on npm_files_p49 npm_files (cost=0.28..8.30 rows=1 width=65) (actual time=0.019..0.019 rows=0 loops=1)
Index Cond: ((namespace_id = '47f95180-a151-4411-bdb7-fb6c3e31a91d'::uuid) AND (blob_sha256 = '\x0000000000000000000000000000000000000000000000000000000000001429'::bytea))
Buffers: shared hit=2
-> Index Scan using npm_versions_p49_pkey on npm_versions_p49 npm_versions (cost=0.28..8.30 rows=1 width=48) (never executed)
Index Cond: ((id = npm_files.npm_version_id) AND (namespace_id = '47f95180-a151-4411-bdb7-fb6c3e31a91d'::uuid))
-> Index Scan using npm_packages_p49_pkey on npm_packages_p49 npm_packages (cost=0.28..0.34 rows=1 width=32) (never executed)
Index Cond: ((id = npm_versions.npm_package_id) AND (namespace_id = '47f95180-a151-4411-bdb7-fb6c3e31a91d'::uuid))
Filter: (npm_repository_id = 'adafc6bf-bce0-4d16-8023-c6c291bd157a'::uuid)
-> Limit (cost=0.56..16.61 rows=1 width=33) (actual time=0.038..0.038 rows=1 loops=1)
Buffers: shared hit=6
-> Nested Loop (cost=0.56..16.61 rows=1 width=33) (actual time=0.038..0.038 rows=1 loops=1)
Buffers: shared hit=6
-> Index Scan using npm_metadata_files_p49_namespace_id_blob_sha256_idx on npm_metadata_files_p49 npm_metadata_files (cost=0.28..8.30 rows=1 width=65) (actual time=0.021..0.021 rows=1 loops=1)
Index Cond: ((namespace_id = '47f95180-a151-4411-bdb7-fb6c3e31a91d'::uuid) AND (blob_sha256 = '\x0000000000000000000000000000000000000000000000000000000000001429'::bytea))
Buffers: shared hit=3
-> Index Scan using npm_packages_p49_pkey on npm_packages_p49 npm_packages_1 (cost=0.28..8.30 rows=1 width=32) (actual time=0.016..0.016 rows=1 loops=1)
Index Cond: ((id = npm_metadata_files.npm_package_id) AND (namespace_id = '47f95180-a151-4411-bdb7-fb6c3e31a91d'::uuid))
Filter: (npm_repository_id = 'adafc6bf-bce0-4d16-8023-c6c291bd157a'::uuid)
Buffers: shared hit=3
Planning:
Buffers: shared hit=11
Planning Time: 1.132 ms
Execution Time: 0.132 msTimings: planning 1.132ms, execution 0.132ms, total 1.264ms.
datastore.NpmFileStore.RepositoryHoldsBlob.NotHeld500Siblings
Summary: This is the flagged shape, and it is the same statement as NotHeld on different data.
500 npm_files rows of the sibling repository carry the probed digest, and the target repository carries none of them.
The planner drops index_npm_files_on_ns_id_blob_sha256 for a Seq Scan of the partition, removes 5100 rows by filter, and then walks all 500 survivors up to npm_packages before it answers false.
Buffers rise from 4 to 3104. The npm_metadata_files arm is unaffected and keeps its Index Scan.
Seed shape: namespaces=1, repositories=2, npm_repositories=2, npm_packages=1700, npm_versions=5100, blob_storage_blobs=10200, blob_storage_blobs_by_namespace=10200, blob_storage_attachments=10200, npm_files=5100, npm_metadata_files=5100, plus 500 extra npm_files rows of the sibling repository on one shared digest.
Rendered SQL:
SELECT held_blobs.blob_sha256 AS "blob_sha256"
FROM (
(
SELECT npm_files.blob_sha256 AS "blob_sha256"
FROM public.npm_files
INNER JOIN public.npm_versions ON ((npm_versions.id = npm_files.npm_version_id) AND (npm_versions.namespace_id = npm_files.namespace_id))
INNER JOIN public.npm_packages ON ((npm_packages.id = npm_versions.npm_package_id) AND (npm_packages.namespace_id = npm_versions.namespace_id))
WHERE ((((npm_files.namespace_id = $1::uuid) AND (npm_versions.namespace_id = $2::uuid)) AND (npm_packages.namespace_id = $3::uuid)) AND (npm_packages.npm_repository_id = $4::uuid)) AND (npm_files.blob_sha256 = $5::bytea)
LIMIT $6
)
UNION ALL
(
SELECT npm_metadata_files.blob_sha256 AS "blob_sha256"
FROM public.npm_metadata_files
INNER JOIN public.npm_packages ON ((npm_packages.id = npm_metadata_files.npm_package_id) AND (npm_packages.namespace_id = npm_metadata_files.namespace_id))
WHERE (((npm_metadata_files.namespace_id = $7::uuid) AND (npm_packages.namespace_id = $8::uuid)) AND (npm_packages.npm_repository_id = $9::uuid)) AND (npm_metadata_files.blob_sha256 = $10::bytea)
LIMIT $11
)
) AS held_blobs
LIMIT $12;Bound args: [ns, ns, ns, npm_repository_a, 0x00...0f423f (the shared digest, held only by the sibling repository), 1, ns, ns, npm_repository_a, 0x00...0f423f, 1, 1]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.57..10.96 rows=1 width=33) (actual time=2.421..2.424 rows=0 loops=1)
Buffers: shared hit=3104
-> Append (cost=0.57..21.36 rows=2 width=33) (actual time=2.420..2.422 rows=0 loops=1)
Buffers: shared hit=3104
-> Limit (cost=0.57..4.73 rows=1 width=33) (actual time=2.402..2.403 rows=0 loops=1)
Buffers: shared hit=3102
-> Nested Loop (cost=0.57..1050.15 rows=252 width=33) (actual time=2.401..2.402 rows=0 loops=1)
Buffers: shared hit=3102
-> Nested Loop (cost=0.29..870.83 rows=500 width=65) (actual time=0.635..1.723 rows=500 loops=1)
Buffers: shared hit=1602
-> Seq Scan on npm_files_p50 npm_files (cost=0.00..186.00 rows=500 width=65) (actual time=0.574..0.663 rows=500 loops=1)
Filter: ((namespace_id = 'e428c648-08b2-47b9-8546-5dc85b27cd11'::uuid) AND (blob_sha256 = '\x00000000000000000000000000000000000000000000000000000000000f423f'::bytea))
Rows Removed by Filter: 5100
Buffers: shared hit=102
-> Memoize (cost=0.29..1.52 rows=1 width=48) (actual time=0.002..0.002 rows=1 loops=500)
Cache Key: npm_files.npm_version_id
Cache Mode: logical
Hits: 0 Misses: 500 Evictions: 0 Overflows: 0 Memory Usage: 79kB
Buffers: shared hit=1500
-> Index Scan using npm_versions_p50_pkey on npm_versions_p50 npm_versions (cost=0.28..1.51 rows=1 width=48) (actual time=0.001..0.001 rows=1 loops=500)
Index Cond: ((id = npm_files.npm_version_id) AND (namespace_id = 'e428c648-08b2-47b9-8546-5dc85b27cd11'::uuid))
Buffers: shared hit=1500
-> Index Scan using npm_packages_p50_pkey on npm_packages_p50 npm_packages (cost=0.28..0.35 rows=1 width=32) (actual time=0.001..0.001 rows=0 loops=500)
Index Cond: ((id = npm_versions.npm_package_id) AND (namespace_id = 'e428c648-08b2-47b9-8546-5dc85b27cd11'::uuid))
Filter: (npm_repository_id = '399e4c1f-e6c8-4869-9d5d-24ac28620357'::uuid)
Rows Removed by Filter: 1
Buffers: shared hit=1500
-> Limit (cost=0.56..16.61 rows=1 width=33) (actual time=0.017..0.018 rows=0 loops=1)
Buffers: shared hit=2
-> Nested Loop (cost=0.56..16.61 rows=1 width=33) (actual time=0.017..0.017 rows=0 loops=1)
Buffers: shared hit=2
-> Index Scan using npm_metadata_files_p50_namespace_id_blob_sha256_idx on npm_metadata_files_p50 npm_metadata_files (cost=0.28..8.30 rows=1 width=65) (actual time=0.017..0.017 rows=0 loops=1)
Index Cond: ((namespace_id = 'e428c648-08b2-47b9-8546-5dc85b27cd11'::uuid) AND (blob_sha256 = '\x00000000000000000000000000000000000000000000000000000000000f423f'::bytea))
Buffers: shared hit=2
-> Index Scan using npm_packages_p50_pkey on npm_packages_p50 npm_packages_1 (cost=0.28..8.30 rows=1 width=32) (never executed)
Index Cond: ((id = npm_metadata_files.npm_package_id) AND (namespace_id = 'e428c648-08b2-47b9-8546-5dc85b27cd11'::uuid))
Filter: (npm_repository_id = '399e4c1f-e6c8-4869-9d5d-24ac28620357'::uuid)
Planning:
Buffers: shared hit=122
Planning Time: 2.060 ms
Execution Time: 2.513 msTimings: planning 2.06ms, execution 2.513ms, total 4.573ms.
datastore.npmRepoReferencesBlobTx.NotReferenced
Summary: repoStillReferencesBlobTx runs npmRepoStillReferencesBlobStmt, whose text this branch does not change. The new part is the call site.
PostgreSQL turns the two EXISTS probes into InitPlan nodes, each an Index Scan on (namespace_id, blob_sha256) and then primary-key lookups.
Both InitPlan nodes short circuit at the first row.
This is the charge probe's answer for a digest the repository does not hold. No anomalies.
Seed shape: namespaces=1, repositories=2, npm_repositories=2, npm_packages=1700, npm_versions=5100, blob_storage_blobs=10200, blob_storage_blobs_by_namespace=10200, blob_storage_attachments=10200, npm_files=5100, npm_metadata_files=5100
Rendered SQL:
SELECT EXISTS (
SELECT 1 FROM npm_files nf
JOIN npm_versions nv
ON nv.namespace_id = nf.namespace_id AND nv.id = nf.npm_version_id
JOIN npm_packages np
ON np.namespace_id = nv.namespace_id AND np.id = nv.npm_package_id
WHERE nf.namespace_id = $1 AND np.npm_repository_id = $2 AND nf.blob_sha256 = $3
) OR EXISTS (
SELECT 1 FROM npm_metadata_files nmf
JOIN npm_packages np
ON np.namespace_id = nmf.namespace_id AND np.id = nmf.npm_package_id
WHERE nmf.namespace_id = $1 AND np.npm_repository_id = $2 AND nmf.blob_sha256 = $3
);Bound args: [ns, npm_repository_a, 0xabab...abab (32 bytes, a digest no seeded row carries)]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Result (cost=33.58..33.59 rows=1 width=1) (actual time=0.036..0.036 rows=1 loops=1)
Buffers: shared hit=4
InitPlan 1
-> Nested Loop (cost=0.84..16.96 rows=1 width=0) (actual time=0.019..0.020 rows=0 loops=1)
Buffers: shared hit=2
-> Nested Loop (cost=0.56..16.62 rows=1 width=32) (actual time=0.019..0.019 rows=0 loops=1)
Buffers: shared hit=2
-> Index Scan using npm_files_p10_namespace_id_blob_sha256_idx on npm_files_p10 nf (cost=0.28..8.30 rows=1 width=32) (actual time=0.019..0.019 rows=0 loops=1)
Index Cond: ((namespace_id = 'f8ad8e7a-5c39-4151-ad61-e6e26cf348c0'::uuid) AND (blob_sha256 = '\xabababababababababababababababababababababababababababababababab'::bytea))
Buffers: shared hit=2
-> Index Scan using npm_versions_p10_pkey on npm_versions_p10 nv (cost=0.28..8.30 rows=1 width=48) (never executed)
Index Cond: ((id = nf.npm_version_id) AND (namespace_id = 'f8ad8e7a-5c39-4151-ad61-e6e26cf348c0'::uuid))
-> Index Scan using npm_packages_p10_pkey on npm_packages_p10 np (cost=0.28..0.34 rows=1 width=32) (never executed)
Index Cond: ((id = nv.npm_package_id) AND (namespace_id = 'f8ad8e7a-5c39-4151-ad61-e6e26cf348c0'::uuid))
Filter: (npm_repository_id = '43521534-760c-487f-a5e3-f49659bf725e'::uuid)
InitPlan 2
-> Nested Loop (cost=0.56..16.61 rows=1 width=0) (actual time=0.014..0.014 rows=0 loops=1)
Buffers: shared hit=2
-> Index Scan using npm_metadata_files_p10_namespace_id_blob_sha256_idx on npm_metadata_files_p10 nmf (cost=0.28..8.30 rows=1 width=32) (actual time=0.014..0.014 rows=0 loops=1)
Index Cond: ((namespace_id = 'f8ad8e7a-5c39-4151-ad61-e6e26cf348c0'::uuid) AND (blob_sha256 = '\xabababababababababababababababababababababababababababababababab'::bytea))
Buffers: shared hit=2
-> Index Scan using npm_packages_p10_pkey on npm_packages_p10 np_1 (cost=0.28..8.30 rows=1 width=32) (never executed)
Index Cond: ((id = nmf.npm_package_id) AND (namespace_id = 'f8ad8e7a-5c39-4151-ad61-e6e26cf348c0'::uuid))
Filter: (npm_repository_id = '43521534-760c-487f-a5e3-f49659bf725e'::uuid)
Planning:
Buffers: shared hit=11
Planning Time: 1.028 ms
Execution Time: 0.094 msTimings: planning 1.028ms, execution 0.094ms, total 1.122ms.
datastore.npmRepoReferencesBlobTx.StillReferenced
Summary: The credit probe's answer for a displaced packument digest that another row of the repository still references.
InitPlan 1 misses on npm_files and InitPlan 2 hits on npm_metadata_files, both through the (namespace_id, blob_sha256) index.
netRepoSizeDelta runs this probe only when the displaced digest differs from the new one, so a byte-identical re-render skips it. No anomalies.
Seed shape: namespaces=1, repositories=2, npm_repositories=2, npm_packages=1700, npm_versions=5100, blob_storage_blobs=10200, blob_storage_blobs_by_namespace=10200, blob_storage_attachments=10200, npm_files=5100, npm_metadata_files=5100 The probed digest is one the target repository holds through npm_metadata_files.
Rendered SQL:
SELECT EXISTS (
SELECT 1 FROM npm_files nf
JOIN npm_versions nv
ON nv.namespace_id = nf.namespace_id AND nv.id = nf.npm_version_id
JOIN npm_packages np
ON np.namespace_id = nv.namespace_id AND np.id = nv.npm_package_id
WHERE nf.namespace_id = $1 AND np.npm_repository_id = $2 AND nf.blob_sha256 = $3
) OR EXISTS (
SELECT 1 FROM npm_metadata_files nmf
JOIN npm_packages np
ON np.namespace_id = nmf.namespace_id AND np.id = nmf.npm_package_id
WHERE nmf.namespace_id = $1 AND np.npm_repository_id = $2 AND nmf.blob_sha256 = $3
);Bound args: [ns, npm_repository_a, 0x00...1a4d (a seeded packument digest)]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Result (cost=33.58..33.59 rows=1 width=1) (actual time=0.074..0.075 rows=1 loops=1)
Buffers: shared hit=8
InitPlan 1
-> Nested Loop (cost=0.84..16.96 rows=1 width=0) (actual time=0.025..0.026 rows=0 loops=1)
Buffers: shared hit=2
-> Nested Loop (cost=0.56..16.62 rows=1 width=32) (actual time=0.025..0.025 rows=0 loops=1)
Buffers: shared hit=2
-> Index Scan using npm_files_p34_namespace_id_blob_sha256_idx on npm_files_p34 nf (cost=0.28..8.30 rows=1 width=32) (actual time=0.024..0.025 rows=0 loops=1)
Index Cond: ((namespace_id = '2bf33449-913f-40cd-a0cc-bc1c762ddd49'::uuid) AND (blob_sha256 = '\x0000000000000000000000000000000000000000000000000000000000001a4d'::bytea))
Buffers: shared hit=2
-> Index Scan using npm_versions_p34_pkey on npm_versions_p34 nv (cost=0.28..8.30 rows=1 width=48) (never executed)
Index Cond: ((id = nf.npm_version_id) AND (namespace_id = '2bf33449-913f-40cd-a0cc-bc1c762ddd49'::uuid))
-> Index Scan using npm_packages_p34_pkey on npm_packages_p34 np (cost=0.28..0.34 rows=1 width=32) (never executed)
Index Cond: ((id = nv.npm_package_id) AND (namespace_id = '2bf33449-913f-40cd-a0cc-bc1c762ddd49'::uuid))
Filter: (npm_repository_id = '8110e510-1f29-43fb-a788-0de68ec25d08'::uuid)
InitPlan 2
-> Nested Loop (cost=0.56..16.61 rows=1 width=0) (actual time=0.046..0.046 rows=1 loops=1)
Buffers: shared hit=6
-> Index Scan using npm_metadata_files_p34_namespace_id_blob_sha256_idx on npm_metadata_files_p34 nmf (cost=0.28..8.30 rows=1 width=32) (actual time=0.024..0.025 rows=1 loops=1)
Index Cond: ((namespace_id = '2bf33449-913f-40cd-a0cc-bc1c762ddd49'::uuid) AND (blob_sha256 = '\x0000000000000000000000000000000000000000000000000000000000001a4d'::bytea))
Buffers: shared hit=3
-> Index Scan using npm_packages_p34_pkey on npm_packages_p34 np_1 (cost=0.28..8.30 rows=1 width=32) (actual time=0.020..0.020 rows=1 loops=1)
Index Cond: ((id = nmf.npm_package_id) AND (namespace_id = '2bf33449-913f-40cd-a0cc-bc1c762ddd49'::uuid))
Filter: (npm_repository_id = '8110e510-1f29-43fb-a788-0de68ec25d08'::uuid)
Buffers: shared hit=3
Planning:
Buffers: shared hit=11
Planning Time: 1.518 ms
Execution Time: 0.151 msTimings: planning 1.518ms, execution 0.151ms, total 1.669ms.
datastore.readPackumentFenceRow.LockRow
Summary: The fence read under FOR UPDATE, as the upsert transaction's first statement.
The added npm_repository_id column costs nothing in plan shape, because the primary-key Index Scan already reads the whole row.
This plan differs from the unlocked form only by the LockRows node and 1 buffer. No anomalies.
Seed shape: namespaces=1, repositories=2, npm_repositories=2, npm_packages=1700, npm_versions=5100, blob_storage_blobs=10200, blob_storage_blobs_by_namespace=10200, blob_storage_attachments=10200, npm_files=5100, npm_metadata_files=5100
Rendered SQL:
SELECT npm_packages.packument_rebuild_token AS "npm_packages.packument_rebuild_token",
npm_packages.npm_repository_id AS "npm_packages.npm_repository_id"
FROM public.npm_packages
WHERE ((npm_packages.namespace_id = $1::uuid) AND (npm_packages.id = $2::uuid)) AND (npm_packages.soft_deleted_at IS NULL)
LIMIT $3
FOR UPDATE;Bound args: [ns, npm_package_of_repository_a, 1]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.28..8.31 rows=1 width=42) (actual time=0.021..0.022 rows=1 loops=1)
Buffers: shared hit=4
-> LockRows (cost=0.28..8.31 rows=1 width=42) (actual time=0.021..0.021 rows=1 loops=1)
Buffers: shared hit=4
-> Index Scan using npm_packages_p34_pkey on npm_packages_p34 npm_packages (cost=0.28..8.30 rows=1 width=42) (actual time=0.017..0.017 rows=1 loops=1)
Index Cond: ((id = 'bc830d18-503f-4627-b8b4-aff94b4c0ee9'::uuid) AND (namespace_id = 'ee7de730-f5bc-4908-832b-0cf7cec1cde3'::uuid))
Filter: (soft_deleted_at IS NULL)
Buffers: shared hit=3
Planning:
Buffers: shared hit=8
Planning Time: 0.190 ms
Execution Time: 0.042 msTimings: planning 0.19ms, execution 0.042ms, total 0.232ms.
datastore.readPackumentFenceRow.NoLock
Summary: The same read without FOR UPDATE, which PackumentRebuildFenceHolds runs on a pooled connection.
A single primary-key Index Scan on 1 partition of 64, with soft_deleted_at IS NULL as a cheap filter on the fetched row.
No anomalies.
Seed shape: namespaces=1, repositories=2, npm_repositories=2, npm_packages=1700, npm_versions=5100, blob_storage_blobs=10200, blob_storage_blobs_by_namespace=10200, blob_storage_attachments=10200, npm_files=5100, npm_metadata_files=5100
Rendered SQL:
SELECT npm_packages.packument_rebuild_token AS "npm_packages.packument_rebuild_token",
npm_packages.npm_repository_id AS "npm_packages.npm_repository_id"
FROM public.npm_packages
WHERE ((npm_packages.namespace_id = $1::uuid) AND (npm_packages.id = $2::uuid)) AND (npm_packages.soft_deleted_at IS NULL)
LIMIT $3;Bound args: [ns, npm_package_of_repository_a, 1]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.28..8.30 rows=1 width=32) (actual time=0.021..0.022 rows=1 loops=1)
Buffers: shared hit=3
-> Index Scan using npm_packages_p24_pkey on npm_packages_p24 npm_packages (cost=0.28..8.30 rows=1 width=32) (actual time=0.021..0.021 rows=1 loops=1)
Index Cond: ((id = 'f5576c76-2013-4a64-9d1c-78859ac4fc19'::uuid) AND (namespace_id = '2d50f6ad-42ab-494a-b8a2-3bbff3afa97e'::uuid))
Filter: (soft_deleted_at IS NULL)
Buffers: shared hit=3
Planning:
Buffers: shared hit=8
Planning Time: 0.151 ms
Execution Time: 0.038 msTimings: planning 0.151ms, execution 0.038ms, total 0.189ms.
datastore.NpmMetadataFileStore.existingAttachment
Summary: The new LEFT JOIN onto the by-namespace shadow adds one primary-key lookup and 3 buffers, and it does not change how the cache row is found.
The driver stays unique_npm_metadata_files_ns_id_pkg_id_kind, and FOR UPDATE OF npm_metadata_files keeps the lock off the shadow table, which is what lets the join stay a left join.
Both tables prune to 1 partition of 64. No anomalies.
Seed shape: namespaces=1, repositories=2, npm_repositories=2, npm_packages=1700, npm_versions=5100, blob_storage_blobs=10200, blob_storage_blobs_by_namespace=10200, blob_storage_attachments=10200, npm_files=5100, npm_metadata_files=5100
Rendered SQL:
SELECT npm_metadata_files.blob_storage_attachment_id AS "npm_metadata_files.blob_storage_attachment_id",
npm_metadata_files.blob_sha256 AS "npm_metadata_files.blob_sha256",
blob_storage_blobs_by_namespace.size AS "blob_storage_blobs_by_namespace.size"
FROM public.npm_metadata_files
LEFT JOIN public.blob_storage_blobs_by_namespace ON ((blob_storage_blobs_by_namespace.namespace_id = $1::uuid) AND (blob_storage_blobs_by_namespace.sha256 = npm_metadata_files.blob_sha256))
WHERE ((npm_metadata_files.namespace_id = $2::uuid) AND (npm_metadata_files.npm_package_id = $3::uuid)) AND (npm_metadata_files.kind = $4)
LIMIT $5
FOR UPDATE OF npm_metadata_files;Bound args: [ns, ns, npm_package_of_repository_a, 0, 1]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.57..16.63 rows=1 width=69) (actual time=0.062..0.062 rows=1 loops=1)
Buffers: shared hit=10
-> LockRows (cost=0.57..16.63 rows=1 width=69) (actual time=0.061..0.062 rows=1 loops=1)
Buffers: shared hit=10
-> Nested Loop Left Join (cost=0.57..16.62 rows=1 width=69) (actual time=0.057..0.057 rows=1 loops=1)
Buffers: shared hit=9
-> Index Scan using npm_metadata_files_p18_namespace_id_npm_package_id_kind_idx on npm_metadata_files_p18 npm_metadata_files (cost=0.28..8.30 rows=1 width=51) (actual time=0.038..0.038 rows=1 loops=1)
Index Cond: ((namespace_id = 'e6077096-1f04-418f-9e98-1a6dbed0dff2'::uuid) AND (npm_package_id = 'edafbfbd-a0f1-4463-9b75-c0d0eabd1fc4'::uuid) AND (kind = '0'::bigint))
Buffers: shared hit=6
-> Index Scan using blob_storage_blobs_by_namespace_p18_pkey on blob_storage_blobs_by_namespace_p18 blob_storage_blobs_by_namespace (cost=0.29..8.30 rows=1 width=51) (actual time=0.017..0.017 rows=1 loops=1)
Index Cond: ((namespace_id = 'e6077096-1f04-418f-9e98-1a6dbed0dff2'::uuid) AND (sha256 = npm_metadata_files.blob_sha256))
Buffers: shared hit=3
Planning:
Buffers: shared hit=156 read=1
Planning Time: 1.294 ms
Execution Time: 0.094 msTimings: planning 1.294ms, execution 0.094ms, total 1.388ms.
Related to #762
This is a bot message