feat(npm): dist-tag datastore write layer (step 21, 1/2)
Part 1 of 2 of the npm hosted plan — Step 21: dist-tag PUT / DELETE.
🗂️ Stacked MRs
Split into 2 stacked MRs to keep each within the review size limit (~600 ideal reviewable LoC). Each part targets the previous one (part 1 → main); review and merge bottom-up.
- feat(npm): dist-tag datastore write layer (step... (!771 - merged) • Dzmitry (Dima) Meshcharakou • 19.2
👈 - feat(npm): dist-tag PUT/DELETE handler (step 21... (!772 - merged) • Dzmitry (Dima) Meshcharakou • 19.2
📦 What this part adds
Lands the datastore write layer the handler (part 2/2) consumes:
NpmDistTagTxWriter(SetTag/DeleteTag): composesUpsertNpmTag/DeleteNpmTagwith the transactional force-expire underRunInTx, so thenpm_tagswrite and the packument-cache invalidation commit atomically. MirrorsNpmPackageUnpublishDeleter; the pooledForceExpireNpmMetadatafrom Step 5 is not tx-capable.ForceExpireNpmMetadataTx: theqrm.DB-accepting force-expire variant the tx envelope needs.Increment/DecrementNpmPackageTagsCount: the bufferednpm_packages.tags_countadjusters (decrement clamps at zero viaGREATEST).
ℹ️ Known gap — these seams have no caller yet. The handler in part 2 (!772 (merged)) consumes them; production composition-root wiring is deferred to Step 23.
✅ Spec coverage
| Acceptance criterion | Test |
|---|---|
| AC 27 — force-expire commits atomically with the tag write | TestNpmDistTagTxWriter_SetTag |
| AC 27 — force-expire commits atomically with the tag removal | TestNpmDistTagTxWriter_DeleteTag |
| In-tx force-expire commits/rolls back all kinds; absent-tag no-op skips it | TestNpmMetadataFileStore_ForceExpireNpmMetadataTx |
tags_count increment/decrement, decrement clamps at zero |
TestNpmPackageStore_TagsCountIncrementDecrement |
| Adjuster argument guards (nil ctx, zero UUIDs) | TestNpmPackageStore_TagsCountAdjuster_ArgumentGuards |
| Constructor rejects a nil client | TestNewNpmStores_NilClient |
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), seeded with ~2,000 npm_packages and ~2,000 npm_metadata_files rows in one namespace; each UPDATE ran inside BEGIN/ROLLBACK and the container was torn down after the run. Numbers reflect moderate cardinality, not production scale. All three are single-row/small indexed writes; ForceExpireNpmMetadataTx is the same statement as the already-reviewed pooled ForceExpireNpmMetadata (evidence on !713 (merged)), differing only in running on the caller's tx.
| Method | Plan node | Index | Rows (plan / actual) | Cost | Exec time | Buffers (hit / read) | Partitions |
|---|---|---|---|---|---|---|---|
NpmPackageStore.IncrementNpmPackageTagsCount |
Index Scan | npm_packages_pkey |
1 / 1 | 8.30 | 0.56ms | 53 / 0 | 1 of 64 |
NpmPackageStore.DecrementNpmPackageTagsCount |
Index Scan | npm_packages_pkey |
1 / 1 | 8.30 | 0.59ms | 57 / 0 | 1 of 64 |
NpmMetadataFileStore.ForceExpireNpmMetadataTx |
Index Scan | unique_npm_metadata_files_ns_id_pkg_id_kind |
3 / 3 | 11.85 | 0.36ms | 77 / 0 | 1 of 64 |
NpmPackageStore.IncrementNpmPackageTagsCount
Summary: Plan matches intent — the (namespace_id, id) predicate prunes to one of 64 partitions and an Index Scan on the primary key updates exactly one row (1 / 1). All buffers are cache hits (index-scan portion 3), no reads; execution 0.56ms. No anomalies.
Seed shape: npm_packages=2001, npm_metadata_files=2003
Rendered SQL:
UPDATE npm_packages
SET tags_count = tags_count + 1
WHERE namespace_id = $1 AND id = $2;Bound args: ['11111111-1111-1111-1111-111111111111', '44444444-4444-4444-4444-444444444444']
Plan:
Update on npm_packages (cost=0.28..8.30 rows=0 width=0) (actual time=0.333..0.333 rows=0 loops=1)
Update on npm_packages_p28 npm_packages_1
Buffers: shared hit=53
-> Index Scan using npm_packages_p28_pkey on npm_packages_p28 npm_packages_1 (cost=0.28..8.30 rows=1 width=14) (actual time=0.021..0.022 rows=1 loops=1)
Index Cond: ((id = '44444444-4444-4444-4444-444444444444'::uuid) AND (namespace_id = '11111111-1111-1111-1111-111111111111'::uuid))
Buffers: shared hit=3
Planning:
Buffers: shared hit=436
Planning Time: 1.550 ms
Execution Time: 0.557 msNpmPackageStore.DecrementNpmPackageTagsCount
Summary: Same shape as the increment — partition pruning + primary-key Index Scan, one row (1 / 1), all cache hits, 0.59ms. The GREATEST(tags_count - 1, 0) clamp is a scalar expression on the updated row, adding no scan cost. No anomalies.
Seed shape: npm_packages=2001, npm_metadata_files=2003
Rendered SQL:
UPDATE npm_packages
SET tags_count = GREATEST(tags_count - 1, 0)
WHERE namespace_id = $1 AND id = $2;Bound args: ['11111111-1111-1111-1111-111111111111', '44444444-4444-4444-4444-444444444444']
Plan:
Update on npm_packages (cost=0.28..8.30 rows=0 width=0) (actual time=0.286..0.286 rows=0 loops=1)
Update on npm_packages_p28 npm_packages_1
Buffers: shared hit=57
-> Index Scan using npm_packages_p28_pkey on npm_packages_p28 npm_packages_1 (cost=0.28..8.30 rows=1 width=14) (actual time=0.031..0.033 rows=1 loops=1)
Index Cond: ((id = '44444444-4444-4444-4444-444444444444'::uuid) AND (namespace_id = '11111111-1111-1111-1111-111111111111'::uuid))
Buffers: shared hit=5
Planning:
Buffers: shared hit=436
Planning Time: 1.503 ms
Execution Time: 0.591 msNpmMetadataFileStore.ForceExpireNpmMetadataTx
Summary: The (namespace_id, npm_package_id) predicate is the leading prefix of the unique (namespace_id, npm_package_id, kind) index, so the planner prunes to one partition and Index Scans exactly the package's ≤3 kind rows (3 / 3). All cache hits, 0.36ms. Identical plan to the pooled ForceExpireNpmMetadata (reviewed on !713 (merged)). No anomalies.
Seed shape: npm_metadata_files=2003 (3 kind rows for the target package)
Rendered SQL:
UPDATE npm_metadata_files
SET expires_at = NOW()
WHERE namespace_id = $1 AND npm_package_id = $2;Bound args: ['11111111-1111-1111-1111-111111111111', '44444444-4444-4444-4444-444444444444']
Plan:
Update on npm_metadata_files (cost=0.28..11.85 rows=0 width=0) (actual time=0.223..0.223 rows=0 loops=1)
Update on npm_metadata_files_p28 npm_metadata_files_1
Buffers: shared hit=77
-> Index Scan using npm_metadata_files_p28_namespace_id_npm_package_id_kind_idx on npm_metadata_files_p28 npm_metadata_files_1 (cost=0.28..11.85 rows=3 width=18) (actual time=0.015..0.016 rows=3 loops=1)
Index Cond: ((namespace_id = '11111111-1111-1111-1111-111111111111'::uuid) AND (npm_package_id = '44444444-4444-4444-4444-444444444444'::uuid))
Buffers: shared hit=3
Planning:
Buffers: shared hit=472
Planning Time: 1.221 ms
Execution Time: 0.355 ms📚 References
- Plan: https://gitlab.com/gitlab-org/ops/artifact-registry/-/blob/main/docs/plans/2026-05-11-npm-hosted.md
- Spec: https://gitlab.com/gitlab-org/ops/artifact-registry/-/blob/main/docs/specs/S11-npm-hosted.md
Related to #139 (closed)