fix(datastore): fence the npm publish and container push on a tombstone
What this delivers
A repository tombstone that commits between a request's repository resolve and that request's own commit was invisible to both hosted write paths.
The cause is that neither transaction re-read its repositories row.
NpmPublishCommitter.CommitPublish and ContainerManifestPersister.PersistManifest wrote rows against ids the pool-side resolver produced earlier in the request.
No statement inside either transaction touched repositories.
A non-locking resolve cannot see an uncommitted tombstone and cannot fence a committing one, so the transaction committed, the client was told 201, and the purge then took the rows it had just been told about.
Both transactions now open with a per-format ReVerifyRepositoryAlive, a SELECT ... FOR SHARE OF repositories that locks the parent row alone.
It runs on the transaction's own handle rather than the pool, where the lock ends with the statement.
It refuses a pool handle at run time, with its own per-store sentinel, on the shape validateDrainWriteHandle already uses.
It is the first statement in each transaction, ahead of the npm_packages FOR UPDATE and the container_images upsert, so parent-before-child lock order stays deadlock-safe against a repository-first delete cascade.
No transaction boundary moves.
A miss becomes the shared sentinel ErrRepositoryConcurrentlyDeleted.
npm answers it as its repository-tier 404, and the container push as NAME_UNKNOWN at the repository tier.
On the container arm the answer is byte-identical to what a name the namespace never held gets, code, message and the whole detail map.
On the npm arm the two envelopes differ only in request_id, which is per request.
The container arm never retries that sentinel and leaves PartialPersist false.
The tombstone stands, so every attempt repeats the verdict.
The payload blob left behind is an attachment-less blob_storage_blobs row, which ADR-025 owns as a garbage-collection class. The concurrently marked image leaves the same shape. Its arm at internal/format/oci/store.go:707 still points at ADR-011, and this merge request does not correct that pointer.
The npm arm leaves the same orphan.
Both call sites now bound the wait, because the service sets no lock_timeout and no statement_timeout.
publishCommitTimeout at internal/format/npm/publish_commit.go:118 and manifestPersistTimeout at internal/format/oci/store.go:747 are each lifecycle.TombstoneTimeout, read from the symbol rather than copied as a literal.
That constant is 10 * time.Second at internal/lifecycle/tombstone.go:57.
A commit that outruns the bound answers 500 rather than 404, which the section ## Two waits this change makes reachable sets out.
Four caller-facing surfaces come into agreement with the code: the S20-A ## Error Cases row, the npm and OCI e2e catalog rows, and the OpenAPI deleteRepository description with its Bruno mirror.
Two changes outside the fence ride with it, and each has its own section below.
9c93aa5cb throttles the repository last_updated_at stamp, because the fence gives that post-commit UPDATE a lock to wait behind.
1128f5ec4 narrows the merged Maven re-verify's FOR SHARE to the parent row, which is the same one-line change dc50fdcb3 makes on the npm and container arms.
Coverage
Source: the issue card's acceptance list for work item 901, narrowed to the npm publish and the container manifest push.
| # | Acceptance item | Tests |
|---|---|---|
| 1 | A publish and a push whose tombstone commits after the resolve and before the commit answer no 2xx | TestPublishLivenessRace_RepositoryTombstonedMidCommit, TestManifestPushLivenessRace_RepositoryTombstonedMidPersist, TestNpmPublishCommitter_CommitPublish_RepositoryTombstonedMidTransaction, TestContainerManifestPersister_RepositoryTombstonedMidTransaction |
| 2 | Each answers what the same route answers for a name that never existed | TestPublishLivenessRace_RepositoryTombstonedMidCommit (body byte-identical to the unknown-name baseline), TestManifestPushLivenessRace_RepositoryTombstonedMidPersist (code, message, and the whole detail map) |
| 3 | No row the refused request would have written survives | the row-count assertions in all four tests above. The payload carve-out is asserted positively on both arms: the blob_storage_blobs row survives, which the card records for the container arm only — the npm arm leaves the same orphan |
| 4 | The non-racing path is unchanged | TestManifestPushLivenessRace_RepositoryTombstonedMidPersist's live control (201), plus the pre-existing TestPublishCommitIntegration_*, TestNpmPublishCommitter_* and TestManifestPush* suites |
| 5 | The check runs inside the transaction, on the caller's handle, never on the pool | the fenceWaited assertion in all four race tests; the holds a FOR SHARE lock that blocks a concurrent soft-delete subtests pass a *sql.Tx; the nil db handle rows in both argument-guard suites. None of that evidence can observe a *sql.DB, so the never on the pool half is held separately. 46a5d1121 makes both methods type-assert db.(*sql.DB) and return a per-store sentinel, and each argument-guard suite carries a connection pool row asserting that sentinel. Both rows were measured in both directions: with the assertion removed, each one fails. The call-site argument holds beside it — NpmPublishCommitter.reVerifyRepository and ContainerManifestPersister.reVerifyRepository both take tx *sql.Tx, and neither store field is an interface — so nothing reaches either method with client.DB() today. The three merged Maven ReVerify*Alive methods keep the old contract, and #1166 carries that |
| 6 | Neither transaction UPDATEs the repositories row it locked | no test. The only shape that catches it — two concurrent publishes into one repository deadlocking on a lock upgrade — passes against the code as it stands, so it would assert nothing |
| 7 | Both request paths carry a context deadline covering the FOR SHARE wait | TestCommitPublish_BoundsTheRowLockWaitOnTheTombstoneTimeout and TestManifestPersister_PersistManifest_OneRowLockBudgetForEveryAttempt. Each pins its constant to lifecycle.TombstoneTimeout rather than to a copied value, and asserts that a deadline of exactly that length reaches the call. The container test also asserts that all three persist attempts share one budget. A cancelled context is pinned at the store level: the a transient DB failure is not misreported as ErrNotFound subtest in both re-verify suites passes a pre-cancelled context and asserts the error is not ErrNotFound, so it cannot become the sentinel's 404. What an expired wait answers end to end is still not covered: forcing it needs the repositories row held for more than 10 seconds |
| 8 | The container method pins Format IN (docker, oci) and Kind = hosted, both formats positive |
TestContainerRepositoryStore_ReVerifyRepositoryAlive. docker and oci are each a positive hit. Virtual, remote, soft-deleted, missing and a cross-namespace id are each a negative. A pre-cancelled context must not report ErrNotFound. TestNpmRepositoryStore_ReVerifyRepositoryAlive mirrors that set for npm. The format leg is now held in both directions as well: each suite carries a wrong-format parent seeded with its child binding, so the INNER JOIN matches and the format predicate is the only leg that can refuse the row. The container suite covers maven and npm, the whole complement of IN (docker, oci). Measured: with both predicates deleted, exactly those three subtests fail |
| 9 | The OpenAPI and Bruno text names the two operations this unit closes | done. api/openapi/v1.yaml and api/bruno/management-api/repositories/delete-repository.bru both carry the corrected text, which derives the total rather than quoting it. A later commit, ac27a8c59, discloses the bound on the raced write's 404 in both files and moves the Maven comparison onto the re-verification, which Maven shares |
| 10 | Each operation has a staged-race test whose interleaving is forced | TestPublishLivenessRace_RepositoryTombstonedMidCommit, TestManifestPushLivenessRace_RepositoryTombstonedMidPersist, and the two datastore-level race tests |
| 11 | A //nolint:dupl appears only where the linter reports one |
measured. dupl fires on the three ReVerifyRepositoryAlive store suites' FOR SHARE subtests, which each carry the token; the Maven one earned it when the lock-order subtest was ported, measured at ebf52e3e5 with the pinned golangci-lint 2.13.2 as maven_repositories_integration_test.go:336: 336-388 lines are duplicate of npm_repositories_integration_test.go:651-704. At 46a5d1121 the npm half of that range reads 653-706, because 46a5d1121 adds two lines above it and none inside it. It fires on neither new race test, and the new guard suites are already inside their files' whole-file directive |
| 12 | The two e2e catalog rows are revisited | not a Go test. Both rows were edited, then corrected twice more. The npm row's 404 is identical to the unknown-name answer but for the per-request request_id, and both rows now say that no garbage-collection worker is registered |
Three later tests pin properties that no acceptance item names.
TestNpmRepositoryStore_ReVerifyRepositoryAlive's leaves npm_repositories unlocked while it waits on the repositories row subtest, its container twin, and the Maven port of it hold the repositories row from one transaction, park the fence in a second, and then take FOR UPDATE NOWAIT on the binding row from a third.
The npm one was measured against a counterfactual: with .OF(table.Repositories) removed from internal/datastore/npm_repositories.go, it fails at that probe.
The Maven one was measured the same way: with .OF(table.Repositories) removed from internal/datastore/maven_repositories.go, it alone fails, with SQLSTATE 55P03 on maven_repositories_p02.
TestRepositoryStore_MarkRepositoryLastUpdated_Window covers the throttle, and the section ## The repository last_updated_at stamp is now throttled describes it.
How the four race tests are staged
The obvious npm seam was declined, and the reason matters for a reviewer who wonders why no production hook was added.
NpmPublishCommitter.SetBeforeCommitHook fires at internal/datastore/npm_publish_committer.go:408 on this branch, and at :384 at the merge base.
Either way it fires after establishPackageRow has taken the npm_packages FOR UPDATE.
The Maven precedent this work mirrors puts the re-verification first in its transaction, at internal/format/maven/reconciler.go:778-782, for the parent-before-child lock order that :800-802 of the same file names.
A test parked at the hook therefore either misses a correctly placed fence entirely, or deadlocks: the test's UPDATE waits on the publish's FOR SHARE while the publish waits for the test to release the hook.
A test staged there goes green against a wrongly placed fence.
All four race tests use a placement-agnostic device instead, which works on both arms and needs no new production surface.
The test opens its own transaction and runs UPDATE repositories SET soft_deleted_at = ... in it without committing.
It then launches the request in a goroutine.
PostgreSQL gives that uncommitted UPDATE a FOR NO KEY UPDATE row lock, which conflicts with the fence's FOR SHARE, so the request blocks inside its own transaction on exactly the row the fence reads.
The resolver's non-locking read is unaffected and still mints a live resolution.
The test then commits the tombstone, and the blocked request observes it.
The device was proved against a live database with psql before anything relied on it: an uncommitted UPDATE blocked a SELECT ... FOR SHARE for 2467 ms, and once committed the FOR SHARE returned zero rows, which is the ErrNotFound the fence needs.
e2e scenario catalogs
Both catalog rows were revisited and both were edited. Neither Status cell moved, because this merge request adds no e2e test.
e2e.npm.setup.delete-repositoryindocs/testing/e2e/npm.mdgains the answer an in-flight publish gets:404, identical to the answer for a repository name the namespace never held but for the envelope'srequest_id, which is per request.e2e.oci.setup.delete-repositoryindocs/testing/e2e/oci.mdgains the same for an in-flight manifest push:404 NAME_UNKNOWNat the repository tier rather than201.- Both rows name the surviving
blob_storage_blobsorphan, and both state that no garbage-collection worker is registered, so nothing reclaims that row today.
The last_updated_at throttle affects no catalog row.
No file under docs/testing/ names that column.
Diff size
git diff --numstat e0de46676 46a5d1121 gives 36 files, +1926/-166, so 2092 reviewable lines.
e0de46676 is git merge-base origin/main HEAD, measured on 2026-09-04 at head 46a5d1121.
That is past 500 reviewable lines, so here is the split by file group.
| Group | Files | Added | Deleted | Added and deleted |
|---|---|---|---|---|
| Go, not tests | 12 | 349 | 119 | 468 |
| Go tests | 14 | 1515 | 2 | 1517 |
| Markdown | 8 | 17 | 26 | 43 |
| OpenAPI and Bruno | 2 | 45 | 19 | 64 |
| Total | 36 | 1926 | 166 | 2092 |
Over 72 percent of the diff is test code, 1517 lines of 2092, and it is close to pure addition at 1515 added against 2 deleted. The production Go half is 468 lines across 12 files, which sits under the 500-line guide on its own. So the part a reviewer reads as new logic is just over a fifth of the total, and the rest is the evidence for it.
Splitting the production half does not help. The two arms close one window with one primitive, and a reviewer who checks that the container arm mirrors the npm arm needs both in front of them.
The Markdown group's 17 added lines understate its review burden.
Four of them are single-line table rows or list items that carry about 10 KB of prose between them: the S20-A ## Error Cases row at 6636 bytes, the OCI catalog row at 1548, the npm catalog row at 1497, and the S11 throttle line at 346.
Merge order
This branch merges cleanly into origin/main.
It was rebased onto origin/main e0de46676 on 2026-09-04, and git merge-tree --write-tree --name-only origin/main HEAD now exits 0.
It is 21 ahead of its merge base e0de46676 and 0 behind origin/main.
Three files conflicted during that rebase, and all three carry one shape.
origin/main inserts a new table row directly after a row this branch rewrites, and leaves that row byte-identical.
The proof is per file and was taken before the rebase started: the md5 of grep -n <row key> over the old merge base e9ba641b3 and over origin/main matches in all three files, so the line and its line number are the same on both sides.
10f1295b1, from !2329, feat(managementapi): accept repeated format and kind list filters, appends e2e.npm.setup.list-repositories-kind-filter and e2e.oci.setup.list-repositories-format-family after the delete-repository row this branch rewrites in docs/testing/e2e/npm.md and in docs/testing/e2e/oci.md.
71cdc5b1c, from !2302, test(datastore): pin the delete-wins outcome under a package tombstone, appends the A Maven publish that resolved the package row to docs/specs/S20-a-lifecycle-closed-beta.md, after the row this branch rewrites into A write that resolved the repository.
The resolution in each file is this branch's rewritten row, then origin/main's new row.
Each resolved file then differs from origin/main's own copy by exactly one line replaced, and that line is this branch's own edit, so both sides survive whole.
All three files conflicted twice, at commit 1 of 21 and again at commit 9 of 21, which rewrites the same three rows.
The shape and the resolution are the same both times.
git range-diff --creation-factor=100 pairs all 21 commits across the rebase and reports 19 byte-identical.
The two it marks changed are exactly commits 1 and 9, the two that conflicted, and in both the commit's own added and deleted lines are unchanged: only the hunk context moved.
Everything below was measured on 2026-09-03 at the pre-rebase head b087d327f, whose merge base was a3a48a0c7, by cross-matching this branch's paths against the diffs of the 81 open merge requests that target main.
That head and the trees named under it were rewritten by three rebases and no longer resolve, and two of the merge requests named below have since merged: !2232 (merged) as 01bbae06a and !2228 (merged) as f8dbdf033.
The paths and the per-file findings are unchanged, on the range-diff result stated above.
Both merged entries predicted their own conflict correctly, and each was resolved as its entry names, on the rebase onto e9ba641b3.
Neither file conflicts on the rebase onto e0de46676.
Sixteen of them share a file with this branch.
Where a shared file is byte-identical at both merge bases, the two line sets compare directly, and the text below says so per file.
!2210 (merged), feat(npm): meter the publish window between blob and row commits, merged at 09:56 UTC on 2026-09-03.
Its counter publish_post_blob_commit_failures_total is on origin/main and not on this branch, so this branch's new 404 starts to increment it when this merge request merges.
!2210 (merged) sets rowsCommitted := false on entry to commitPublish and arms a defer that increments the counter while the flag is false.
It sets the flag true only after CommitPublish returns no error.
This branch's ErrRepositoryConcurrentlyDeleted arm returns before that assignment, so a tombstone-fenced 404 counts as a post-blob-commit failure.
Whether that accounting is intended is not settled here.
internal/format/npm/metrics.md on origin/main lists the arms that enter the window with nothing defective behind them, and it states that the list is open rather than closed.
This branch adds a member to that list and does not edit it.
The merge of the two is clean and it is also coherent.
In the merged tree 41818dd3b, internal/format/npm/publish_commit.go carries !2210 (merged)'s counter and this branch's six-argument writeCommitError together.
!2052, test(e2e): authorization profile, scenario catalogs and reporting, open, targets main.
It flips the Status cell of e2e.npm.setup.delete-repository and e2e.oci.setup.delete-repository from not started to implemented.
This merge request rewrites the Expectation cell of the same two lines.
Same line, both branches, so whichever lands second rebases those two rows.
The resolution is mechanical: take !2052's status and this branch's expectation text.
implemented means the suite contains a test with the scenario's exact name, at docs/testing/e2e/README.md:73, which this merge request does not add.
!2232 (merged), fix(npm): credit the freed packument-cache bytes on every delete arm, open, targets main.
It shares six files with this branch, and one of them collides.
Five of the six are byte-identical at both merge bases, a3a48a0c7 here and f9f9184e4 there, so those line sets compare directly.
Its hunk in docs/specs/S17-rest-management-api.md is @@ -1623,11 +1623,16 @@.
It removes old lines 1626, 1627, 1628 and 1630, and it carries old line 1629 as an unchanged context line.
Old line 1629 is the line 08076ab24 rewrote to name the throttle, so the two changed regions interleave with no unchanged line between them.
The resolution: keep !2232 (merged)'s rewritten bullets, and carry this branch's throttle clause into that context line.
The other four comparable files are clear.
!2232 (merged) changes S11 lines 701 to 711 and 1200 to 1207, against this branch's line 565 and its insertion after line 585.
It changes S20-A lines 582 to 588 and 762 to 768, against this branch's line 1002.
It changes docs/testing/e2e/npm.md lines 198 to 206, against this branch's line 52.
It changes internal/datastore/repositories.go lines 459 to 477, against this branch's 516 to 554 and its insertion after line 769.
The sixth shared file, internal/format/npm/metrics.md, is not byte-identical at the two bases, so its line sets do not compare.
!2228 (merged), fix(npm): accept yarn's _attachments-first publish envelope, open, targets main.
It shares six files with this branch, and all six are byte-identical at both merge bases, so every line set compares directly.
None of the six overlaps.
Its changes nearest to this branch's are:
docs/specs/S11-npm-hosted.mdlines 457 to 466 and 620 to 627, against this branch's line 565 and its insertion after line 585.docs/testing/e2e/npm.mdlines 96 to 107, against this branch's line 52.internal/format/npm/metrics.golines 644 to 667, against this branch's 237 to 247.internal/format/npm/metrics.mdlines 547 to 553 and 574 to 579, against this branch's 73 to 86 and 218.internal/format/npm/publish_internal_test.golines 640 to 645, against this branch's append after line 1114.internal/format/npm/publish_usagedata_test.golines 93 to 106, against this branch's append after line 244.
!2272 (merged), Draft: fix(datastore): report the hosted container reap's freed bytes, open, draft.
Its base is a3a48a0c7, this branch's own merge base, so its line numbers are this branch's.
It changes docs/specs/S20-a-lifecycle-closed-beta.md at lines 1075 to 1089, and internal/datastore/query_names.go at lines 40 to 45 and 66 to 71.
This merge request changes S20-A line 1002 and adds two lines to query_names.go, after lines 171 and 535.
Neither pair overlaps, and the row this merge request amends appears in !2272 (merged)'s diff nowhere, as a changed line or as context.
!1011 (closed), Draft: feat(pypi): implement S34 PyPI hosted format, open, draft.
internal/datastore/pypi_publish_committer.go on that branch contains no ReVerifyRepositoryAlive call, so a PyPI publish has the same write shape this merge request fences on the npm and container arms.
!1011 (closed) also copies into internal/format/pypi/metrics.go the exact comment line this merge request corrects in npm's: CodeRepositoryNotFound, // resolver middleware; never observed on this metric.
If !1011 (closed) lands after this merge request, that line arrives already wrong for the reason npm's was.
It shares four files with this branch, and all four differ at the two merge bases, so the statements below name symbols rather than line numbers.
Its hunk in internal/datastore/npm_repositories.go is a whole-file //nolint:dupl at line 1, and this merge request's new method lands far below it.
Its hunks in internal/datastore/repositories.go add the RepositoryFormatPypi constant, widen validRepositoryFormat, add a PyPI arm to insertFormatChild, and rewrite two doc comments.
None of them is MarkRepositoryLastUpdated.
Its hunks in internal/datastore/repositories_integration_test.go stop well before the end of that file, and this merge request appends its 87 new lines after the last line.
Eleven more open merge requests share a file with this branch.
| Merge request | Shared files |
|---|---|
| !2103 (merged) | docs/testing/e2e/oci.md |
| !2162 (merged) | internal/datastore/lifecycle_reap_npm.md, internal/datastore/query_names.go |
| !2207 (merged), !2261 (merged), !2287 (merged) | api/openapi/v1.yaml |
| !2230 (merged) | internal/datastore/query_names.go |
| !2259 (merged) | docs/testing/e2e/npm.md, internal/format/npm/publish_internal_test.go |
| !2262 (merged) | docs/testing/e2e/oci.md, internal/datastore/query_names.go |
| !2270 (merged) | docs/testing/e2e/npm.md |
| !2277 (merged) | internal/datastore/repositories_integration_test.go |
| !2283 (merged) | docs/testing/e2e/npm.md, internal/format/npm/metrics.md |
Two of the eleven were compared at line level, because their shared files are byte-identical at both merge bases.
!2162 (merged) changes internal/datastore/lifecycle_reap_npm.md lines 228 to 236 against this branch's 159 to 163, and internal/datastore/query_names.go lines 303 to 308, 467 to 472 and 525 to 530 against this branch's two insertions after lines 171 and 535.
!2230 (merged) changes query_names.go lines 34 to 39, 251 to 259 and 295 to 310, against those same two insertions.
The other nine were compared at file level only, and their line sets were not compared.
The tracking issue at #1122 carries the six write paths this merge request does not close.
The OpenAPI and Bruno text pointed at issue 901, whose scope is the whole window rather than the remainder.
15b19867b repoints both files at the tracking issue.
Two waits this change makes reachable
Both are disclosures rather than decisions.
A live repository can now answer 500 where it answered 201.
Under contention past the 10-second bound, a publish or a push into a repository with no tombstone answers 500: a generic 500 on the npm arm, and 500 INTERNAL carrying oci.manifest.partial_persist=true on the container arm.
Two holders can produce that contention.
Accounting's buffered counter drain takes FOR NO KEY UPDATE on every repositories row of a chunk, in two statements per chunk: lockRepoScopesStmt at internal/datastore/counter_drain.go:708 and the guarded applyRepoDeltasStmt at :730.
It holds those locks across a Redis pipeline.
The chunk is not one row, and the fan-out is per pod.
One drain tick claims up to drain_batch_size scopes, 20000 by default at internal/accounting/drain_trigger.go:207, then partitions them by drain_chunk_size, 500 by default at internal/accounting/drain_trigger.go:266, for up to 40 chunk jobs.
defaultMaxWorkers is 25 at internal/jobsriver/client.go:36, so one pod holds about 12500 repositories rows under FOR NO KEY UPDATE at the same time, against a per-chunk ceiling of 850 at internal/config/storageaccounting.go:92.
internal/accounting/chunk_worker.go records the slow-Redis tail on deleteFlushed: at the go-redis defaults one attempt runs to about 15s and four to about 60s, which is where River cancels the job.
RepositoryStore.Delete's cascading DELETE is the second holder, and it is unbounded.
That includes the 23503 abort, in which the repository row survives and the lock was still held.
A blocked write holds one pooled connection, and the pool has no configured maximum.
Both arms open the transaction before the fence runs, so a request that parks on the FOR SHARE holds a pooled connection for the whole wait.
The hold is bounded rather than unbounded.
publishCommitTimeout and manifestPersistTimeout end the transaction, and the deferred rollback returns the connection, so one blocked write holds one connection for at most that budget.
Nothing sets a maximum pool size in this repository's code, in the connection string LabKit builds, or in the deployment manifests this repository holds.
pgxpool's own default of max(4, runtime.NumCPU()) therefore applies.
The core count a production node reports is not measured here, so the pool's real size is an open question.
Saturation is also not observable today.
internal/metrics/database.go:70-122 registers one collector, database_connection_pool_size{state} over Acquired, Idle and Constructing, with no capacity series and no acquire-wait series.
Merge request !2269 (merged) publishes the capacity gauge that gives those counts a denominator.
It was open and unmerged on 2026-09-03, at dfb9c1dd1, with detailed_merge_status: discussions_not_resolved.
Two work items own the halves of this: #978 owns whether an internal/datastore statement carries a row-lock wait budget, and #559 owns the pool's capacity and the in-flight caps drawn from it.
This class of holder already exists on main, on a request path, with no bound at all.
internal/managementapi/delete.go:84 calls the repository delete with the request context and no context.WithTimeout.
The merged S20-A ## Error Cases table rules on that wait at docs/specs/S20-a-lifecycle-closed-beta.md:1000: "Unbounded ... Accepted for closed beta and tracked in #902".
A merged client route holds the same FOR SHARE on repositories.
internal/format/maven/upload.go:690 calls MavenRepositoryStore.ReVerifyRepositoryAlive inside the Maven upload's own commit transaction, and that statement is FOR(pg.SHARE()) at internal/datastore/maven_repositories.go:284 with no bound of any kind.
internal/datastore/repository_parent_gate.go:147-152 on main already names the drain as the holder a gate waits behind, and states that nothing there caps that wait.
So the conflict class is reachable on main from a client route already.
This merge request adds two more members of that class, and unlike the merged ones each of the two carries a bound.
How an expired wait is classified is not settled here.
The merged S20-A ## Error Cases table names work item #252 as the owner of how a deadline that fires is classified.
This merge request does not classify it.
The repository last_updated_at stamp is now throttled
9c93aa5cb adds a freshness arm to the WHERE of RepositoryStore.MarkRepositoryLastUpdated.
The row is eligible when it holds no stamp, or when its stamp has aged past repositoryLastUpdatedWindow, one hour at internal/datastore/repositories.go:517.
A publish inside the window still issues the statement.
The statement then matches no row, so it takes no row lock.
The fence is why the change is here.
The fence holds FOR SHARE on the repositories row for the whole publish commit.
The stamp is a plain UPDATE of a non-key column, so PostgreSQL gives it FOR NO KEY UPDATE, which conflicts with FOR SHARE.
Before this branch the publish transaction never touched the parent row, so the stamp conflicted with nothing on the publish path.
The throttle drops the conflicting arrival rate from one per publish to at most one per repository per hour, and a skipped stamp waits behind nothing and blocks nothing.
The stamp already runs off the request path: internal/format/npm/publish_commit.go dispatches it through bufferedUpdate after the 201 is written, on the pool, never inside the commit transaction.
The near-miss in the precedent, stated rather than left for a reader to find.
Two merged statements have this shape.
mavenAccessBumpWindow is one hour as a package constant at internal/datastore/maven_packages.go:243-244.
containerRemoteDownloadStaleManifest at internal/datastore/container_remote_download.go:300-305 takes an operator-configured window whose default is one hour at internal/config/container_remote.go:27.
Both throttle last_downloaded_at, which is a retention signal.
last_updated_at is a user-visible sort key.
So a repository published to 20 minutes ago can sort as though it were an hour stale.
The precedent is also narrower than two merged instances make it look.
Throttling is not the settled pattern across formats.
NpmPackageStore.BumpLastDownloadedAt and NpmVersionStore.BumpLastDownloadedAt both write last_downloaded_at = NOW() with no freshness arm, and work item #680 records three open surfaces of the same write-amplification argument.
What the coarser value costs, concretely.
last_updated_at is the monolith repository list's default sort, descending, at docs/specs/monolith/S04-repositories-list.md:402-403 and :481.
The AR endpoint orders that sort on COALESCE(last_updated_at, created_at), at docs/specs/S17-rest-management-api.md:454.
At hour granularity ties become common, and a busy namespace's ordering is much coarser than "most recently published first".
Pagination stays correct, because the id DESC tiebreaker is in the expression index.
No text asserts the fine ordering, so nothing is contradicted.
A skipped stamp leaves no trace, and the metric a reader expects does not exist.
There is no bufferedCounterUpdates{result="error"} series to alert on.
The result enum at internal/format/npm/buffered.go:28-31 is bufferedResultOK, bufferedResultPanic and bufferedResultDropped, and a failed UPDATE still counts result=ok.
A failed UPDATE does leave one trace, a Warn log line reading buffered counter update failed with column=last_updated_at.
A window-skipped UPDATE does not fail, so it leaves not even that.
The comment-caps casualty, which 9c93aa5cb's own message defers to here.
Rewriting the doc of MarkRepositoryLastUpdated charged the whole 16-line block against the 3-line cap scripts/ci/check-comment-caps.sh sets for a touched exported doc.
Leaving the block alone was not available: its opening sentence is falsified by this change.
The doc now runs three lines, and these sentences left it.
- The column is a content-change display timestamp, not a counter. That is the column's one-line definition.
docs/dev/storage-accounting.md:1409anddocs/specs/S12-container-oci-hosted.md:1703both call it a content-change display timestamp, and the doc on this symbol no longer does. GREATESTignores a NULLlast_updated_at, and that is the mechanism which stamps a never-updated repository toNOW(). The new doc states the result and not the mechanism.- The publish's two other repository counters,
artifacts_countandsize_bytes, are recorded on the buffered-counter pipeline throughaccounting.Emitterand drain into the row from there. The call site keeps the first half atinternal/datastore/npm_publish_committer.go:260-262. The nameaccounting.Emitterand the clause "drain into the row from there" are now written nowhere. - The method runs on the pool through
s.client.DB(), and a failure is best-effort, because a failed emission must not fail the publish that already answered. The call site keeps the placement atinternal/datastore/npm_publish_committer.go:253-256. The handles.client.DB()and that best-effort sentence are now written nowhere on this symbol. - The scoping "for the given (namespaceID, repositoryID)" and the words "or a stale row" left the first sentence. The parameter names carry the scoping alone now.
The third sentence was kept because internal/datastore/npm_publish_committer.go:262-263 cites this block by name for the reason the timestamp keeps a direct write.
Compressing that reason out obliges the same commit to bring an 11-line block to its own cap.
Test.
TestRepositoryStore_MarkRepositoryLastUpdated_Window at internal/datastore/repositories_integration_test.go:2731 carries three subtests: a row holding no stamp is stamped, a second stamp inside the window leaves the row byte-identical, and a row backdated past the window moves forward.
The third arm is the one that matters, because a predicate which only ever matched NULL passes the other two.
Three merged specs this merge request amends
08076ab24 corrects two merged specs that the throttle contradicts, and 9ced1450a and c62565da8 amend a third for the fence.
A reviewer can check each replacement against what it replaced.
docs/specs/S11-npm-hosted.md:565, the npm publish flow's buffered-write step.
- Was:
repositories.last_updated_at = max(existing, NOW()), - Now:
repositories.last_updated_at = max(existing, NOW()), throttled to one write per repository per hour,
One line was added after line 585, and it states that the throttle is a WHERE arm on that same UPDATE, that the row is eligible when it records no timestamp or when its timestamp has aged past the window, and that a publish inside the window issues the statement, matches no row, and takes no lock on the row its own re-verification holds FOR SHARE.
docs/specs/S17-rest-management-api.md:1629, the Phase 4 counter-ownership bullet.
- Was: "the publish commit advances it in a buffered
UPDATE(RepositoryStore.MarkRepositoryLastUpdated), whileartifacts_countandsize_bytesare recorded as deltas ..." - Now: "the publish commit advances it in a buffered
UPDATE(RepositoryStore.MarkRepositoryLastUpdated) throttled to one write per repository per hour, whileartifacts_countandsize_bytesare recorded as deltas ..."
docs/specs/S20-a-lifecycle-closed-beta.md:1002, the ## Error Cases row on a write that races a tombstone.
9ced1450a and c62565da8 amend this row.
It gains the two new exclusions, their sentinel ErrRepositoryConcurrentlyDeleted, their lifecycle.TombstoneTimeout bound, and the statement that the Maven upload derives no deadline of its own.
The row's subject also widens, from "A publish that resolved the repository" to "A write that resolved the repository".
Two known imprecisions in that amendment. Both sit in the row this merge request rewrites, so they are named here rather than left for a reader to find.
- The enumeration's predicate is now weaker than the set it lists. The row reads "Seven client-protocol operations satisfy the criterion's first two clauses today", where before this branch it read "satisfy that" against all three clauses. A hosted Maven upload satisfies the first two clauses, and the same row excludes it on the third, so the set under the weakened predicate is eight while the row lists seven. The OpenAPI
deleteRepositorydescription and its Bruno mirror carry the same shape. Two answers work: restore the three-clause predicate, or add the Maven upload and make the count eight. The second answer moves the counts in this description, in the OpenAPI text, in the Bruno mirror and in work item 1122, whose title carries the number six. - The row's lead sentence promises success for a write it now refuses. The Behavior cell opens "The write answers its own success status, and the purge then destroys the rows it wrote, with no error reaching the writer", and its closing sentence repeats the claim. For a Maven upload, an npm publish and a container manifest push, that condition now yields
404, which the same cell states further down. The subject widened from "publish" to "write" and the lead claim did not follow.
A forward note, not a defect.
Two other merged specs still state a per-event write of the column, and the throttle falsifies neither, because neither has an implementation.
docs/specs/S12-container-oci-hosted.md carries seven table rows between lines 1715 and 1722 that each end repositories.last_updated_at = max(existing, NOW()), plus line 1731 on the idempotent manifest re-push, and its own line 1705 says the S12 handlers update none of these columns.
docs/specs/S10-maven-hosted.md:1199 says the Maven upload updates repositories.last_updated_at to NOW(), and MavenRepositoryStore.MarkRepoLastUpdated is an explicit no-op stub that builds the statement and returns without running it.
What the throttle does to both is make them harder to satisfy later, not false today.
No ADR is deviated from.
docs/adr/007_database_schema.md states no per-event contract and no granularity contract for repositories.last_updated_at, and it excludes that column from its buffered set.
No handbook amendment is owed.
The Maven re-verify's lock scope
1128f5ec4 changes one line of merged Maven code.
MavenRepositoryStore.ReVerifyRepositoryAlive in internal/datastore/maven_repositories.go now takes FOR(pg.SHARE().OF(table.Repositories)) in place of a bare FOR(pg.SHARE()).
Why a fix to merged code is in this merge request.
A bare FOR SHARE over a two-table FROM locks a row in every table of that list, child first.
RepositoryStore.Delete is one DELETE FROM repositories that locks the parent and reaches the child through ON DELETE CASCADE, so the two orders invert.
That is the same cycle dc50fdcb3 closes on the npm and container arms, and the Maven statement is its third instance.
When the upload loses, RunInTx does not retry, and the 40P01 reaches dispatchHandler.writeCommitOutcome, matches no sentinel, and answers 500 where a tombstone answers 404.
The site is reachable: the Maven upload path is wired at cmd/artifact-registry/wire_maven.go:136, and it calls the method inside its own commit transaction.
It costs no comment budget and it makes four merged comments true.
scripts/ci/check-comment-caps.sh charges only a block the diff touches, and this hunk lands at the statement.
Four merged blocks assert the parent-before-child order the bare clause contradicted, and all four become true: internal/datastore/maven_packages.go:176-194, internal/format/maven/upload.go:32-48, internal/format/maven/upload.go:670-689, and internal/format/maven/reconciler.go:800-811.
internal/datastore/maven_repositories.go:240-247 is the fifth block, and it was deliberately not edited.
Its sentence "The SELECT takes a FOR SHARE lock so the row cannot be soft-deleted" has "the maven_repositories row" as its nearest antecedent, and that row has no soft_deleted_at column at all.
The narrowed statement makes the parent reading the only one available, so what is left is an imprecise antecedent rather than a false claim.
The block is 8 lines against the 3-line cap, so an edit for one word deletes five lines of live rationale.
That is the same trade this merge request already takes on ContainerManifestPersister.PersistManifest.
The deadline half is deliberately not in this merge request.
The Maven upload's own wait on that lock has no bound: neither internal/format/maven/upload.go nor internal/format/maven/handler.go calls context.WithTimeout or context.WithDeadline.
Adding one puts a new 500 class on a merged client path, and its value depends on the same unmeasured budget the section ## Two waits this change makes reachable describes.
Work item #978 owns the shape of that answer across three sites rather than one.
c62565da8 states the absence in the merged S20-A row instead of leaving it implied by a parity claim.
The revisit of !1942 (merged) that issue 901 asks for
Issue 901 asks whoever closes it to read merge request !1942 (merged)'s position on repository-level re-checks first, and, because !1942 (merged) merged first, to have the closing change revisit that statement. This section is that revisit. No file on this branch names !1942 (merged).
The two changes do not bear on each other. Four grounds.
- Kind. Both new predicates pin
kind = hosted, and they select from the hosted binding tablesnpm_repositoriesandcontainer_repositories. !1942 (merged)'s statement reaches its repository tier throughnpm_remote_repositories. There is no overlap in rows, by construction rather than by convention. - Row class. This branch fences rows a client was told exist. !1942 (merged)'s row is a cache-freshness clock. The merged S20-A
## Error Casesrow already states that difference, and this branch leaves the sentence unchanged: "Its cache rows are filled by the read path instead, and losing one to the purge costs a re-fetch rather than an artifact the caller was told it had." - Mechanism. !1942 (merged) takes no lock. It is an in-statement correlated
EXISTS. This branch takesFOR SHAREand holds it to commit. - The argument. !1942 (merged)'s exclusion of the repository tier rests on the fill path undoing any gate placed there. This branch changes nothing on the fill path.
A dated observation about which revision the issue quotes.
Issue 901's body describes !1942 (merged) as excluding the repository level "because the resolver applies the repository's soft_deleted_at gate upstream".
That describes a revision of !1942 (merged) that a review superseded.
The timeline below was re-read from the API on 2026-09-02.
| Time, 2026-08-26 | Event |
|---|---|
| 10:23:45Z | Issue 901 created |
| 10:57:52Z | Issue 901's description last changed, and it is the only such change on the issue |
| 11:48:17Z | A blocking: review note on !1942 (merged) (note 3738210263) |
| 14:27:40Z | The author rewrites the comment and the description (note 3739266526, "Fixed in 59423eb1") |
| 15:08:38Z | !1942 (merged) merges |
The merged position therefore rests on fill-path futility rather than on the resolver gate the issue attributes to it.
The merged ## How also carries the sentence the issue says it lacks: "the read does not cover the window."
One boundary this branch found without naming it. The OpenAPI text on this branch narrows its remote-repository exclusion from "every write" to "every client write". A server-initiated cache bump is exactly what that narrowing puts outside the claim.
Notes for the reviewer
The AppSec review has not read the current head.
Its public session notes on this merge request record a start at 18:48 UTC on 2026-09-02 and a completion at 18:56 UTC the same day.
Fourteen commits landed on the branch after that completion, and a rebase then replayed the branch onto a newer main.
The head is now bd8ab997e, and that review did not read it.
One exported doc block was deliberately left un-extended.
ContainerManifestPersister.PersistManifest at internal/datastore/container_manifest_persister.go:201-213 is 13 lines against the 3-line cap scripts/ci/check-comment-caps.sh sets for an exported top-level doc.
It is not falsified by this change.
That gate charges a touched doc block together with its body head, so an edit to it deletes the ErrImageConcurrentlyMarked and partial-persist rationale to add one sentence.
Its namesake oci.ManifestPersister.PersistManifest was rewritten instead, in 689cbf2ef, and now runs three lines at internal/format/oci/store.go:629-631.
It names the arms that leave PartialPersist false with the payload write landed, and it drops the handler's 500 INTERNAL emission and the cap sentinels' mapping, both of which are visible on the arms themselves.
ErrRepositoryConcurrentlyDeleted's own doc carries the new contract once, which is the shape AGENTS.md asks for.
The third block this merge request declines to edit is internal/datastore/maven_repositories.go:240-247, which the section ## The Maven re-verify's lock scope covers.
reVerifyRepository was extracted on both committers, and one of the two extractions was forced.
Inline, the fence pushed CommitPublish past the gocognit threshold of 16 at .golangci.yaml:477.
The container extraction is for symmetry rather than for a lint finding of its own.
A raced container push leaves a container_images row, and it is not a fence leak.
The blob-upload initiate writes that row through CreateSession at internal/format/oci/store.go:82, on the pool and before the persist transaction opens, so its created_at predates the tombstone.
That operation is out of this change's scope.
internal/datastore/lifecycle_reap_repository.go does reap container_images, so the purge removes the row.
The row a reviewer running the race finds and the purge does not take is the blob_storage_blobs one, which that file names nowhere.
One process deviation.
One commit carried the fix together with comment-only edits to the test files, which the comment-caps hook forced.
That hook resolves the merge base and diffs it against the worktree, so it charges the test commit's own findings against every later commit on the branch, and a separate fixup commit is not reachable while pre-commit stashes the unstaged implementation.
The deviation is tracked at #1041.
A squash and then a rebase have rewritten that part of the history, so the branch a reviewer reads today does not show the shape the deviation produced.
Conformance
All three conformance suites fire on this merge request's own pipeline through their changes: rules.
The OCI suite matches on internal/format/oci/**/* and internal/datastore/container_*.go.
The npm and Maven suites both match on internal/datastore/**/*, and each also matches its own format package.
Two of the three bear on the change: an npm publish now answers 404 repository_not_found where it answered 201, and a container manifest push answers 404 NAME_UNKNOWN at the repository tier.
Database Review Evidence
Queries
Note
Plans are from EXPLAIN (ANALYZE, BUFFERS) against an ephemeral
PostgreSQL 17 container (matching GL_PG_CURR_VERSION from
.gitlab-ci-other-versions.yml), with synthesized seed data rolled
back per query and the container torn down at the end of the run.
Numbers reflect moderate cardinality and do not capture
production-scale effects. See
Database review evidence
for seed sizing, methodology, and the anomalies the skill flags.
Expand each row's details for the seed shape, rendered SQL, bound args,
and raw plan.
The two plans below ran at commit 15b19867b, the second commit on this branch, and both statements they measured changed after that.
dc50fdcb3 narrowed both re-verifications from a bare FOR SHARE to FOR SHARE OF repositories, which locks the parent row alone.
The rendered SQL under each row is the statement at head bd8ab997e.
The plan under it is the plan that ran, so it shows the join, the partition pruning and the LockRows node of the earlier statement.
The narrowing changes which relation the LockRows node marks.
It changes no scan, no index, no join order and no bound argument.
9c93aa5cb then added a freshness arm to RepositoryStore.MarkRepositoryLastUpdated, and 1128f5ec4 narrowed the merged Maven re-verification the same way dc50fdcb3 narrowed these two.
The section #### Statements this run did not measure, and why covers both of those.
| Method | Plan node | Index | Rows (plan / actual) | Cost | Time | Buffers (hit / read) | Partitions |
|---|---|---|---|---|---|---|---|
datastore.ContainerRepositoryStore.ReVerifyRepositoryAlive |
Limit | container_repositories_p32_pkey, repositories_p32_pkey |
1 / 1 | 16.63 | 0.092ms | 8 / 0 | 1/64 container_repositories, 1/64 repositories |
datastore.NpmRepositoryStore.ReVerifyRepositoryAlive |
Limit | npm_repositories_p59_pkey, repositories_p59_pkey |
1 / 1 | 16.63 | 0.029ms | 8 / 0 | 1/64 npm_repositories, 1/64 repositories |
datastore.ContainerRepositoryStore.ReVerifyRepositoryAlive
Summary: The plan matches the method's intent.
Postgres prunes both hash-partitioned tables to one partition of 64, scans container_repositories_p32_pkey on the composite (id, namespace_id) key, and joins to repositories_p32_pkey on (id, namespace_id), with format, kind, and soft_deleted_at applied as a filter on the single fetched row.
The LockRows node above the join is the row lock, and the estimate matches reality at 1 / 1 with 8 shared buffer hits and no reads.
The method at head takes that lock on the repositories row alone.
This plan ran before that narrowing, so its LockRows node marks both rows.
No anomalies.
Seed shape: namespaces=1, repositories=5000, container_repositories=5000
Rendered SQL:
SELECT container_repositories.id AS "container_repositories.id"
FROM public.container_repositories
INNER JOIN public.repositories ON ((repositories.id = container_repositories.repository_id) AND (repositories.namespace_id = container_repositories.namespace_id))
WHERE ((((container_repositories.namespace_id = $1::uuid) AND (container_repositories.id = $2::uuid)) AND (repositories.format IN ($3, $4))) AND (repositories.kind = $5)) AND (repositories.soft_deleted_at IS NULL)
LIMIT $6
FOR SHARE OF repositories;Bound args: [9dc71419-ed7b-7c20-82aa-a4247d49511b, e481583a-f0c0-7ac5-9b86-28b6ab4586d6, 0, 3, 0, 1]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.56..16.63 rows=1 width=36) (actual time=0.091..0.092 rows=1 loops=1)
Buffers: shared hit=8
-> LockRows (cost=0.56..16.63 rows=1 width=36) (actual time=0.089..0.090 rows=1 loops=1)
Buffers: shared hit=8
-> Nested Loop (cost=0.56..16.62 rows=1 width=36) (actual time=0.083..0.084 rows=1 loops=1)
Buffers: shared hit=6
-> Index Scan using container_repositories_p32_pkey on container_repositories_p32 container_repositories (cost=0.28..8.30 rows=1 width=58) (actual time=0.053..0.053 rows=1 loops=1)
Index Cond: ((id = 'e481583a-f0c0-7ac5-9b86-28b6ab4586d6'::uuid) AND (namespace_id = '9dc71419-ed7b-7c20-82aa-a4247d49511b'::uuid))
Buffers: shared hit=3
-> Index Scan using repositories_p32_pkey on repositories_p32 repositories (cost=0.28..8.31 rows=1 width=42) (actual time=0.028..0.028 rows=1 loops=1)
Index Cond: ((id = container_repositories.repository_id) AND (namespace_id = '9dc71419-ed7b-7c20-82aa-a4247d49511b'::uuid))
Filter: ((soft_deleted_at IS NULL) AND (format = ANY ('{0,3}'::smallint[])) AND (kind = '0'::smallint))
Buffers: shared hit=3
Planning:
Buffers: shared hit=463
Planning Time: 2.333 ms
Execution Time: 0.422 msTimings: planning 2.333ms, execution 0.422ms, total 2.755ms.
datastore.NpmRepositoryStore.ReVerifyRepositoryAlive
Summary: The plan matches the method's intent.
Postgres prunes both hash-partitioned tables to one partition of 64, scans npm_repositories_p59_pkey on the composite (id, namespace_id) key, and joins to repositories_p59_pkey on (id, namespace_id), with format, kind, and soft_deleted_at applied as a filter on the single fetched row.
The LockRows node above the join is the row lock, and the estimate matches reality at 1 / 1 with 8 shared buffer hits and no reads.
The method at head takes that lock on the repositories row alone.
This plan ran before that narrowing, so its LockRows node marks both rows.
No anomalies.
Seed shape: namespaces=1, repositories=5000, npm_repositories=5000
Rendered SQL:
SELECT npm_repositories.id AS "npm_repositories.id"
FROM public.npm_repositories
INNER JOIN public.repositories ON ((repositories.id = npm_repositories.repository_id) AND (repositories.namespace_id = npm_repositories.namespace_id))
WHERE ((((npm_repositories.namespace_id = $1::uuid) AND (npm_repositories.id = $2::uuid)) AND (repositories.format = $3)) AND (repositories.kind = $4)) AND (repositories.soft_deleted_at IS NULL)
LIMIT $5
FOR SHARE OF repositories;Bound args: [48321adc-6ce3-7173-91a2-3c7dd9a44a4e, b45fee25-d4c6-7466-a7f3-0c419a72a7ec, 2, 0, 1]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.56..16.63 rows=1 width=36) (actual time=0.028..0.029 rows=1 loops=1)
Buffers: shared hit=8
-> LockRows (cost=0.56..16.63 rows=1 width=36) (actual time=0.027..0.028 rows=1 loops=1)
Buffers: shared hit=8
-> Nested Loop (cost=0.56..16.62 rows=1 width=36) (actual time=0.024..0.025 rows=1 loops=1)
Buffers: shared hit=6
-> Index Scan using npm_repositories_p59_pkey on npm_repositories_p59 npm_repositories (cost=0.28..8.30 rows=1 width=58) (actual time=0.013..0.013 rows=1 loops=1)
Index Cond: ((id = 'b45fee25-d4c6-7466-a7f3-0c419a72a7ec'::uuid) AND (namespace_id = '48321adc-6ce3-7173-91a2-3c7dd9a44a4e'::uuid))
Buffers: shared hit=3
-> Index Scan using repositories_p59_pkey on repositories_p59 repositories (cost=0.28..8.31 rows=1 width=42) (actual time=0.010..0.010 rows=1 loops=1)
Index Cond: ((id = npm_repositories.repository_id) AND (namespace_id = '48321adc-6ce3-7173-91a2-3c7dd9a44a4e'::uuid))
Filter: ((soft_deleted_at IS NULL) AND (format = '2'::smallint) AND (kind = '0'::smallint))
Buffers: shared hit=3
Planning:
Buffers: shared hit=663 read=1
Planning Time: 2.676 ms
Execution Time: 0.050 msTimings: planning 2.676ms, execution 0.050ms, total 2.726ms.
How the partition key reaches repositories
repositories is hash-partitioned on namespace_id with 64 partitions, the same as npm_repositories and container_repositories.
Each statement binds the partition key on its own binding table as a literal, and carries it to repositories through the join predicate repositories.namespace_id = <binding>.namespace_id.
That is the shape the composite foreign key (repository_id, namespace_id) REFERENCES repositories (id, namespace_id) exists for, and the plans confirm it works: both statements prune repositories to one partition, and the Index Cond on repositories_pNN_pkey carries the namespace literal.
So both statements satisfy the rule in
Database Query Patterns that a query on a partitioned table filters on the partition key.
Two notes on method, so a reader can reproduce the numbers.
The plans come from PREPARE plus EXPLAIN ... EXECUTE, which is what the evidence skill runs, while production uses the simple query protocol with no server-side prepared statement.
The first EXECUTE of a prepared statement takes a custom plan built from the actual parameter values, which is why the literals appear in the Index Cond and the pruning happens at plan time — the same shape the simple protocol produces.
Seed rows also mix kind and soft_deleted_at (10% remote, 5% tombstoned) so the two predicates discriminate rather than matching every row.
Statements this run did not measure, and why
No file under internal/datastore/migrations/sql/ changed on this branch, so migration mode did not run.
Seven internal/datastore files change on this branch, and five of them dispatch a statement.
npm_repositories.go and container_repository.go each add one method, and both are measured above.
container_manifest_persister.go dispatches only statements that are byte-identical to their merge-base versions.
repositories.go and maven_repositories.go each carry one changed statement, and neither of those two was planned, because both changes landed after this run.
npm_publish_committer.go and query_names.go dispatch nothing.
The two new reVerifyRepository helpers on the persister and the committer build no statement of their own.
They call the store methods measured above.
The two unplanned statements:
RepositoryStore.MarkRepositoryLastUpdatedininternal/datastore/repositories.go.9c93aa5cbadded a freshness arm to itsWHERE. The arm addslast_updated_at IS NULL OR last_updated_at <= NOW() - INTERVAL '1 HOUR'to a predicate that already binds the primary key(namespace_id, id), so it filters the one row that key selects and reaches no index of its own. An unmatched row takes no tuple lock, which is the property the section## The repository last_updated_at stamp is now throttledturns on.MavenRepositoryStore.ReVerifyRepositoryAliveininternal/datastore/maven_repositories.go.1128f5ec4changed its row lock fromFOR SHAREtoFOR SHARE OF repositories, and nothing else in that statement changes. Its shape is the npm plan above withmaven_repositoriesin place ofnpm_repositories.
Related to #901 (closed)
What remains: this merge request closes two of the eight write paths with this shape, which the OpenAPI text on the branch enumerates in full. Six still answer their own success status after a repository tombstone and lose their rows to the purge, and #1122 tracks them. Every acceptance item graded for this unit comes from the enrichment step rather than from the issue author, so an automatic close removes the author's chance to disagree with that reading.
This is a bot message