fix(npm): align versions_count and tags_count with ADR-007 on unpublish
🎯 What this MR does
Fixes the unpublish-path counter defects from #549 (closed) item 4, settling versions_count on ADR-007's semantics: the counter includes soft-deleted versions and moves only at hard-delete. Step 3 (final) of the three-MR stack.
| Step | MR | Status | What it delivers |
|---|---|---|---|
| 1 | !1401 (merged) | merged | npm_versions.size_bytes column + index + backfill |
| 2 | !1402 (merged) | merged | Maintenance on publish & cache-fill, reconciliation recompute, size in the API |
| 3 · this MR | open | versions_count / tags_count semantics fixes on unpublish |
main and its diff is the step-3 commit alone.
⚖️ The decision this MR implements
ADR-007 says versions_count "counts the package's npm_versions rows including soft-deleted ones, decrementing only when garbage collection hard-deletes a row", with an explicit anti-cap-gaming rationale: a publisher who could exclude tombstones from the 25,000-version cap could cycle unpublish + republish to stay under it forever while every tombstone keeps its storage. The implemented S11 unpublish decremented at soft-delete instead — a divergence S17's follow-ups flagged as "needs reconciling". This MR reconciles it in ADR-007's favor (settled with the DRI on #549 (closed)).
That flips the issue's framing of item 4, and the research is worth recording: the whole-package path's missing versions_count decrement was never a defect — it is the ADR-conformant behavior. The real defects were the mirror image:
🐛 Single-version unpublish decrementedversions_countat soft-delete → the decrement is removed.emitCounterDecrementsnow dispatches only the tags decrement. The handler seam is renamedNpmPackageTagsCountMaintainer: it lostversions_countand gained the reset below, so it is no longer a decrementer at all.DecrementNpmPackageVersionsCountstays in the datastore, redocumented: the lifecycle purger (S20-A) becomes its caller when its reap hard-deletes a still-live package's versions.🐛 Whole-package unpublish hard-deleted everynpm_tagsrow without movingtags_count→ the handler now emits a post-commitResetNpmPackageTagsCount. A reset to zero, not a counted decrement: zero is the exact recompute of the terminal state (all tag rows are gone, and restore does not bring tags back), so the reset also self-heals any drift the buffered counter had accumulated — a decrement would carry it into the tombstone forever.ResetNpmPackageTagsCount's doc comment also records the window the reset cannot close on its own. Round 3 corrected its scope: the PUT and the cascade already serialize on thenpm_packagesrow lock (both take it as their first statement, throughRotatePackumentRebuildTokenTx), so only the detached counter writes are unordered — see## 🔁 Review round 3below.🐛 Single-version unpublish of a package's last active version left the same staletags_count→ that path reaches the identical terminal state (package row tombstoned, nonpm_tagsrows left), butpackageDeletedskipped every post-commit counter write, so the reset above made the two paths disagree on identical state.ServeHTTPnow picks an arm instead of skipping:afterCascadeCommitemits the same reset. Unconditional there, not gated ontagsRemoved— the stale count can come from versions unpublished earlier. The packument rebuild stays skipped, which is the one side effect the tombstone really rules out (NpmPackageByIDfilterssoft_deleted_at IS NULL; the reset carries no such filter, which is what lets it reach the tombstone).📝 The deleter's stale comment ("the publish-side increment ships later, so it reads ~0 today" — it shipped) is rewritten to state the ADR-007 rule.📝 NpmPackageUnpublisher's doc said the cascade "cascades soft-deletes tonpm_versions,npm_files, andnpm_tags".npm_tagshas nosoft_deleted_atcolumn andcascadeDeleteNpmTagshard-deletes those rows — which is the whole reasontags_countgets a reset and not a decrement — so the comment now says which tables are soft-deleted and which are hard-deleted.
📐 Cap behavior consequence
Until the purger lands, versions_count only grows. That is the fail-safe direction for the 25k cap: over-counting can block a pathological publisher early, never admit gaming. The purger-side decrement is recorded as a follow-up on #549 (closed) — the dependency is S20-A's reaper, not S28 as the issue originally said (S20-A: "the purger is the per-artifact hard-deleter"; S28 keeps only blob reclamation).
📄 Spec corrections riding along
S22-storage-accounting.md: the "Accounting layers" sentence claimed version counters are maintained "synchronously, in the same transaction as the underlying file rows" — corrected to ADR-007's post-commit contract for the package-level counters only, with the correction named in place (#549 (closed) declares ADR-007 the authority). The corrected sentence is also scoped to what ships: it originally read as a claim about Maven too, andmaven_versionscarries nosize_bytescolumn, so Maven's half is named as intended design rather than current behavior.S17-rest-management-api.md: the follow-up carrying theversions_countdivergence now records it as reconciled.
✅ Verification
- Unit + integration assertions flipped, not dropped: the success-path tests now positively pin that no
versions_countevent is dispatched at soft-delete (the recording fake deliberately keeps the method beyond the interface to prove absence); the whole-package integration test pinsversions_countkept +tags_countreset to 0 on the tombstone, and the last-active-version integration test now does the same (seededversions_count = 3, tags_count = 4, asserted3/0; its fixture seeds no tags, so it also covers thetagsRemoved == 0case).ResetNpmPackageTagsCount's argument guards are a row in the package's existingTagsCountAdjustertable rather than a suite of their own, plus a new datastore integration suite. golangci-lint run --build-tags=integration --max-same-issues=0 --max-issues-per-linter=0over./internal/datastore/... ./internal/format/npm/... ./cmd/..., cross-referenced line by line against the lines this MR adds. Two findings the MR itself caused are fixed:thelperon the new test's subtest closure (silenced with a function-level directive naming docs/dev/go-testing.md, which reservestfor the outer test), anddogsledfrom the widenedUnpublishPackagereturn (fixed by asserting the returned package id rather than adding a third blank). Aduplon the reset's own guard table is gone with that table, folded into the existing one. Sixcontextcheckfindings remain on added lines, all raised through the sharedseedNamespace/seedNpmPackageFixturehelpers: the identical message already appears 420 times ininternal/datastore/npm_write_integration_test.go, so the new subtests inherit that file's baseline rather than adding a new kind of violation.- Full
go test -tags=integration ./internal/datastore/and./internal/format/npm/suites pass locally against PostgreSQL 17. 🧪 npm conformance (real npm CLI e2e): all steps pass on the full stack, including the unpublish flows this MR changes.
🧪 E2E scenario impact
docs/testing/e2e/npm.md landed on main (571b4e55) while this MR was in review, so the earlier "no npm catalog exists" note is stale. acebff4d adds e2e.npm.lifecycle.unpublish-package-counters: the existing unpublish rows assert installability and packument presence, not the counters the management API returns, which is exactly what this MR settles. The row states why the two counters diverge, that both writes land after the request commits (so the read needs a retry), and that a shed write can lose the value (#632 (closed)). Its "No event" row is added to the usage-data table too, which lists every scenario.
🔁 Review round 2 (66a138ff)
@jdrpereira's round-1 findings, all addressed:
| Finding | Fix |
|---|---|
blocking last-active-version keeps a stale tags_count |
The third afterCascadeCommit + unit and integration coverage |
blocking versions_count's client contract asserts the opposite |
api/openapi/v1.yaml and internal/managementapi/package_resources.go rewritten — it includes tombstones and only grows, so it sits above the version list rather than lagging it. S17:457 carried the identical sentence and is fixed too |
blocking the S22 correction overshoots for size_bytes |
Scoped to the package-level counters; the paragraph now says npm_versions.size_bytes is written on the version INSERT alongside its file row, so it needs no drift or recompute tolerance |
blocking four comments still promise the versions_count decrement |
All four rewritten; the deleter's contract now states both arms |
escalate the anti-gaming rationale is bypassable today |
Verified and recorded on #549 (closed) (both the bypass and its mirror cost). The seam's doc now scopes the protection to the single-version path |
remember two-vs-three counter events |
Header, ServeHTTP doc, and the const block (the third label is an npm_packages column from disttags.go) |
remember two doc comments call versions_count a counter nobody writes |
Both now say why CountActiveNpmVersionsByPackage is used instead |
suggestion the race note misses a reachable ordering |
Rewritten as the full cross-product (see the second |
suggestion duplicated test scaffolding |
readNpmPackageTagsCount and the existing TagsCountAdjuster table reused; helper, 35-line table, and //nolint:dupl dropped |
Process note
🔍 : this stack ships without a prior plan MR by explicit operator decision — the plan-level context lives in these MR descriptions (see !1401 (merged) for the full background).
/cc @vespian_gl — draft !1351 (merged)'s Step 17 touches the same two unpublish handlers for the repository-level emits; sequencing note so we don't churn the same lines under review.
🔁 Review round 3 (cd505a60)
@jdrpereira's escalate on the race thread ("where/when is the lock going to happen?") turned out to have a better answer than a follow-up: the lock already exists. Both the dist-tag PUT and the unpublish cascade update the npm_packages row as their first statement (RotatePackumentRebuildTokenTx), so the transactions serialize, and a PUT ordered after the cascade refuses on its in-transaction active-version re-check. cd505a60:
- adds
TestNpmPackageUnpublishDeleter_SerializesConcurrentDistTagPut, pinning the serialization against a real database (lock-wait asserted viapg_stat_activity; negative control verified by removing the rotation locally); - rewrites
ResetNpmPackageTagsCount's doc, which wrongly claimed no path locks the row.
The ordering half is then closed in 0461802f: both buffered increments skip a soft-deleted package, with IncrementNpmPackagePublishCounters gating only its tags_count arm through a CASE (versions_count must keep reaching the tombstone per ADR-007), covered by TestNpmPackageStore_BufferedIncrementsSkipTombstones. What remains is loss rather than ordering — bufferedUpdate sheds a write when its in-flight cap is saturated, which no predicate reaches. That is general rather than npm-specific and is tracked on #632 (closed); the npm-side record stays on #549 (closed).
Database Review Evidence
Queries
Note
Plans are from EXPLAIN (ANALYZE, BUFFERS) against an ephemeral
PostgreSQL 17 container (matching GL_PG_CURR_VERSION from
.gitlab-ci-other-versions.yml; server reported 17.10), 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.
No migrations in this MR, so migration mode did not run. One new query-producing
method: ResetNpmPackageTagsCount. The only other change to
internal/datastore/npm_packages.go is the DecrementNpmPackageVersionsCount
doc comment, whose jet chain is byte-identical to the merge-base, so it is not
re-planned here.
The method is reported at two cardinalities because it is an UPDATE. The
50-row shape is the skill's default for a write target (1 target row plus 49
siblings); at that size the target partition is a single page and the planner
correctly prefers a scan of it, which says nothing about production behavior.
The 5000-row shape is what answers the reviewer's question.
| Method | Plan node | Index | Rows (plan / actual) | Cost | Time | Buffers (hit / read) | Partitions |
|---|---|---|---|---|---|---|---|
NpmPackageStore.ResetNpmPackageTagsCount (50 rows) |
Update → Seq Scan | n/a |
1 / 1 | 1.75 | 0.117ms | 4 / 0 | 1 of 64 |
NpmPackageStore.ResetNpmPackageTagsCount (5000 rows) |
Update → Index Scan | npm_packages_p59_pkey |
1 / 1 | 8.30 | 0.118ms | 15 / 0 | 1 of 64 |
Query notes:
- No anomalies. The
Seq Scanin the 50-row row is not a flag: the skill flags aSeq Scanonly against 5000+ seeded rows, and at that size this statement uses the primary key. Both rows prune to one partition of 64. - The first
EXECUTEin a cold container charges up to 6.8ms to thefk_npm_packages_npm_repository_id_npm_repositoriesconstraint trigger. On repeat executions in the same transaction it settles to 0.02-0.10ms (measured over three consecutive executions). The plans below are warm runs, so the reported execution times are steady state rather than first-call.
NpmPackageStore.ResetNpmPackageTagsCount (50 rows, skill default for a write target)
Summary: Plan matches the method's intent — a single-row counter reset located
by (namespace_id, id). The planner pruned to one partition of 64 and then chose a
Seq Scan over that partition's single page, filtering out the 49 siblings; at 50
rows an index descent costs more than reading the page, so this is the planner being
right rather than a missing index. Estimate matches reality (1 / 1). No anomalies.
Seed shape: namespaces=1, repositories=1, npm_repositories=1, npm_packages=50
Rendered SQL:
UPDATE public.npm_packages
SET tags_count = $1::integer
WHERE (npm_packages.namespace_id = $2::uuid) AND (npm_packages.id = $3::uuid);Bound args: [0, c2cda5ac-155e-49d1-9947-0d00fab08662, 2b960f9a-5b9e-4bc0-83bd-6cba2e56e1cf]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Update on npm_packages (cost=0.00..1.75 rows=0 width=0) (actual time=0.116..0.117 rows=0 loops=1)
Update on npm_packages_p36 npm_packages_1
Buffers: shared hit=4
-> Seq Scan on npm_packages_p36 npm_packages_1 (cost=0.00..1.75 rows=1 width=14) (actual time=0.023..0.027 rows=1 loops=1)
Filter: ((namespace_id = 'c2cda5ac-155e-49d1-9947-0d00fab08662'::uuid) AND (id = '2b960f9a-5b9e-4bc0-83bd-6cba2e56e1cf'::uuid))
Rows Removed by Filter: 49
Buffers: shared hit=1
Planning Time: 0.310 ms
Trigger for constraint fk_npm_packages_namespace_id_namespaces on npm_packages_p36: time=0.044 calls=1
Trigger for constraint fk_npm_packages_npm_repository_id_npm_repositories on npm_packages_p36: time=0.196 calls=1
Execution Time: 0.450 msTimings: planning 0.310ms, execution 0.450ms, total 0.760ms.
NpmPackageStore.ResetNpmPackageTagsCount (5000 rows in one partition)
Summary: At production-shaped cardinality the plan is an Index Scan using npm_packages_p59_pkey, the partition-local pk_npm_packages on
(id, namespace_id), whose Index Cond covers both predicate columns with no
residual filter. The namespace_id literal prunes to one partition of 64, the
estimate matches reality (1 / 1), and execution stays at 0.24ms with 15 buffer hits
and zero reads. No anomalies.
Seed shape: namespaces=1, repositories=1, npm_repositories=1, npm_packages=5000
Rendered SQL:
UPDATE public.npm_packages
SET tags_count = $1::integer
WHERE (npm_packages.namespace_id = $2::uuid) AND (npm_packages.id = $3::uuid);Bound args: [0, 900ef1d6-1007-4046-95e3-4c1f1d056182, 60004e17-02f5-4f2f-bd6b-7ee574489008]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Update on npm_packages (cost=0.28..8.30 rows=0 width=0) (actual time=0.118..0.118 rows=0 loops=1)
Update on npm_packages_p59 npm_packages_1
Buffers: shared hit=15
-> Index Scan using npm_packages_p59_pkey on npm_packages_p59 npm_packages_1 (cost=0.28..8.30 rows=1 width=14) (actual time=0.025..0.026 rows=1 loops=1)
Index Cond: ((id = '60004e17-02f5-4f2f-bd6b-7ee574489008'::uuid) AND (namespace_id = '900ef1d6-1007-4046-95e3-4c1f1d056182'::uuid))
Buffers: shared hit=3
Planning Time: 0.126 ms
Trigger for constraint fk_npm_packages_namespace_id_namespaces on npm_packages_p59: time=0.022 calls=1
Trigger for constraint fk_npm_packages_npm_repository_id_npm_repositories on npm_packages_p59: time=0.068 calls=1
Execution Time: 0.242 msTimings: planning 0.126ms, execution 0.242ms, total 0.368ms.
Related to #549 (closed)