feat(lifecycle): the purger emits the deltas its chunks earn (S20-A plan: 19b/22)
What this merge request delivers
The lifecycle purger now emits storage-accounting counter deltas.
emitChunkCounters in internal/lifecycle/emit.go is the site.
It runs once per committed purge chunk, immediately after that chunk's transaction commits.
It does not run off the walk's aggregate.
A walk that ends through an error never reaches a caller that can read that aggregate, and the chunks behind the error already committed.
Which delta fires is read off the chunk, and the two arms are independent.
A non-zero Components fires the namespace-scoped delta, negated.
A chunk that removed at least one row and can name its repository fires the repository-scoped delta, carrying the negated SizeBytes.
A repository purge reaches the namespace arm alone.
Its walk drops the repository its sub-reapers name, because repositories.size_bytes goes with the row the finalizer deletes.
Both companion figures stay zero: no purge site emits Δartifacts, and the deduplicated figure comes off at garbage collection under ADR-025.
lifecycle.CounterEmitter is the seam, declared in internal/lifecycle rather than imported, so the package does not depend on internal/accounting.
PurgeWorkerDeps gains an Emitter field.
wireLifecycle reads w.counterEmitter off the wiring, and newPurgeWorkerDeps refuses a nil one at boot.
The emitter travels as the concrete *accounting.Emitter and not as the interface.
A nil pointer assigned into an interface field is a non-nil interface value.
An interface-typed parameter therefore passes that guard and panics later, inside a purge that already deleted rows.
This is the merge request after which a purge on a running server moves repositories.size_bytes.
That column is a required field of the public Repository schema, and a sort key on the repository list.
This work therefore carries the feat guardrails.
The plan's original chore was a forecast written when the step computed no delta at all.
This branch converts one reap arm rather than leaving it to #681, and that conversion closes a ratchet rather than opening a window.
MavenRemoteVersionReaper now reports the bytes its page frees and the repository they are charged to.
!1901 (merged) merged on 2026-08-25, so a Maven remote cache fill already raises repositories.size_bytes through RemoteCacheStore.emitCommittedFillCounters.
Without the conversion, every Maven remote repository's size_bytes rises on each fill and no purge removes those bytes again.
Only the hourly reconciliation pass corrects it.
That is a one-way ratchet on size_bytes, and this branch closes it.
The artifacts_count half of the same asymmetry stays open, because that decrement belongs at the eviction rather than at the purge, and #775 records it.
Two artifact-scope arms still report zero, ContainerReaper and NpmRemoteVersionReaper.
oci.NewCounterSink exists and no composition root constructs it, and internal/format/npm carries no remote-fill emit at all.
Four smaller changes travel with the emit site.
test:lifecycle-failpoints in .gitlab-ci.yml enrolls the one new seam-driven case in both its grep guard and its -run alternation.
docs/dev/storage-accounting.md loses two claims that stop being true, and gains the purger's entry.
docs/specs/S20-a-lifecycle-closed-beta.md gains one ## Follow-ups entry, because five reap arms cannot meet the artifact-purge criterion as written.
Three files in internal/format/npm carry comments that promise three figures settle at the reap.
This merge request makes the repositories.size_bytes half true, and corrects the other half.
Spec coverage
test-author produced one coverage table for step 19 before the split.
The rows below are that table's rows for this half, in its own wording.
Step 19a carries the rest.
Spec: docs/specs/S20-a-lifecycle-closed-beta.md, Accounting call sites.
Design properties the criteria rest on
| # | Property | Tests |
|---|---|---|
| D-5 | The emit fires per committed chunk, not once per tombstone | TestPurgeWorker_Work_IssuesOneDeltaSetPerCommittedChunk, TestPurgeWorker_Work_KeepsTheDeltasOfChunksCommittedBeforeAnInterruption |
| D-9 | A chunk that removed no row issues no repository-scoped delta, however the repository resolved | TestEmitChunkCounters_IssuesTheDeltasTheChunkOwes, TestPurgeWorker_Work_CountsAnArtifactOnceAgainstAClientDelete |
D-1 through D-4, D-6, D-7, D-8 and D-10 are Step 19a's rows and are not repeated here.
Error cases
| # | Condition | Tests |
|---|---|---|
| E-1 | Emit API unavailable at a purge or tombstone site: the purge succeeds, the delta is dropped and logged at Warn, never retried | TestPurgeWorker_Work_SurvivesAnUnavailableEmitAPI |
| E-2 | The emitter is missing from the assembled dependency set: the boot aborts rather than every purge reaping without accounting | TestNewPurgeWorkerDeps_PanicsOnAnUnusableDependencySet |
E-3 is Step 19a's row.
Security considerations
| # | Concern | Tests |
|---|---|---|
| S-1 | No injection surface is added: the probe's raw SQL is fully parameterized | The statements are package constants with $1/$2/$3 binds only; the spec's own Security Considerations bless the shape, naming DeleteIfUnreferenced's correlated NOT EXISTS as the reused pattern. No test asserts parameterization directly |
| S-2 | Authorization is unchanged; the purger performs no authorization decision | Not this step's. No emit site reads a caller identity |
e2e scenario catalogs
Guardrail 12 applies, because this merge request is a feat.
One catalog row is affected, this branch does not update it, and the update is owed rather than done.
docs/testing/e2e/npm.md carries e2e.npm.lifecycle.repository-storage-counters.
That row already explains why size_bytes holds after an unpublish.
The bytes come off when the lifecycle purger hard-deletes the rows, and not at the tombstone.
This merge request is what makes that clause true for the first time.
Until now no purge moved the column, so the clause described an intent rather than a behavior.
The row needs two things, and neither belongs in this diff.
It needs a purge leg, because its journey stops at the unpublish and never waits for the reap.
It also needs the deduplication trap that #681 states, because the freed figure is deduplicated within the repository.
A tarball frees no bytes when a second version of the same repository still carries its digest.
A purge leg written against the published byte length therefore asserts the wrong number.
Writing that leg is a catalog change with its own review.
The row is blocked today rather than automated, so nothing in the suite goes red while it waits.
The other three catalogs are untouched, and the reason differs per file.
| File | Why it is untouched |
|---|---|
docs/testing/e2e/maven.md |
It names no counter, no size_bytes and no artifacts_count anywhere. No claim in it becomes true or false here. Its Out of scope until the capability ships list puts virtual and remote Maven repositories outside the catalog, owned by S30 and S14, so MavenRemoteVersionReaper reaps an arm with no surface there and a counter row for it contradicts that list. A hosted-Maven counterpart to the npm row is a real gap, and it is not owed by this merge request. |
docs/testing/e2e/oci.md |
It names no counter column either, and hosted OCI emits nothing on this branch. Its Out of scope until the capability ships list puts remote repositories outside the catalog, so ContainerRemoteManifestReaper has no surface there. |
docs/testing/e2e/docker.md |
The same two reasons, in the same words. It carries the identical out-of-scope list and names no counter column. |
Diff size, guardrail 18
This merge request reports 40 files and +4701 / −2047 against its base, prozlach/s20a-lifecycle-closed-beta-step-18 at 8686d21cc.
Read that figure with care.
The branch carries Step 19a's commits, so most of it is Step 19a's diff and not this step's work.
Step 19a's own merge request is !1912, against main.
Three measurements describe this branch, and a reviewer needs all three.
| Measurement | Command | Files | +/− |
|---|---|---|---|
| This merge request's own diff | git diff --shortstat 8686d21cc 93fc975b8 |
40 | +4701 / −2047 |
| Step 19a's own diff, against its own base | git diff --shortstat 91909cdf9 48a51a2b4 |
24 | +3021 / −1573 |
| Files only this step changes | the 40 less Step 19a's 24 | 16 | +1322 / −302 |
48a51a2b4 is Step 19a's branch head as measured, and 91909cdf9 is its merge base with main.
Every one of Step 19a's 24 files is also in this branch's 40, so the third row is a set difference and not an estimate.
Two of the 24 carry more change here than on Step 19a's head.
docs/dev/storage-accounting.md adds 356 lines and removes 6 beyond its copy there, and internal/datastore/lifecycle_scan.go removes 166 more.
Both carry the comment migration into the accounting document, which continues on this branch, and the third row excludes them.
The own share splits by file group as follows.
| Group | Files | +/− |
|---|---|---|
The emit seam and the chunk driver, internal/lifecycle |
3 | +53 / −57 |
The composition root, cmd/artifact-registry |
2 | +17 / −110 |
The npm comment corrections, internal/format/npm |
2 | +3 / −57 |
| Production Go total | 7 | +73 / −224 |
The new emit suites, emit_integration_test.go and emit_internal_test.go |
2 | +1143 / −0 |
| Edits to the merged purge, wiring and npm suites | 3 | +37 / −68 |
| Test Go total | 5 | +1180 / −68 |
| Documentation, the spec follow-up, the CI job and the run recipe | 4 | +69 / −10 |
| Total | 16 | +1322 / −302 |
Reviewable production Go adds 73 lines and removes 224 across seven files, so this step's production diff is net smaller than what it replaces.
The removals are comment blocks that move into docs/dev/storage-accounting.md.
Tests are 1180 of the added lines in the own share, which is the large half by design.
Splitting further does not help here, and the honest answer is what it costs. This merge request is already the second half of a split. The operator cut Step 19 at the seam between the reap's computation and the emit site. The remaining seam inside this half is the emit site against its wiring, and the two cannot be split. The wiring exists to supply the emitter, and the boot refusal that guards it is one of the two error cases the coverage table names. A split there gives a first merge request whose new field nothing fills and whose guard nothing reaches.
Stated deviations
Guardrail 3 says that no step merge request opens before its plan merge request lands.
The S20-A plan merged long ago and is on main today.
What is in flight is an amendment to that merged plan, and not a plan that arrives late.
That amendment is !1914 (merged), a docs(plans) merge request against docs/plans/2026-08-11-s20a-lifecycle-closed-beta.md.
It splits the plan's row 19 into 19a and 19b, which moves the Status-table row count from 21 to 22.
This title's denominator is 22 because it names the row count that amendment sets.
The operator decided that the amendment and this merge request run in parallel, with no merge order between them. This is a stated and accepted deviation rather than an oversight. Neither merge request gates the other, and nothing here waits for !1914 (merged).
This merge request edits no plan file, the Status table included.
The 19b row is recorded in !1914 (merged).
The datastore.Reaper godoc keeps its pointer form, and that is a second stated deviation.
docs/dev/go-style.md says that pages under docs/dev/ are not overflow homes for comment text.
This merge request moves the seam's fifteen implementer and caller obligations into docs/dev/storage-accounting.md, section ### The Reaper seam, and leaves a one-line pointer at internal/datastore/lifecycle_scan.go:154.
The reason is in the bodies of 08ec9ecb1 and e3d632490: the comment-cap gate charges every line of a block that a diff touches, and this branch's edit lands inside that block.
Review note 3739065705 asks for the obligations inline.
That is declined on scope for this merge request: the placement decision stands, and the two commits stand with it.
The wider question, about relocating comment text into docs/dev/ at all, is a separate open thread at note 3739065786.
Merge order
Three facts about this branch bind a reviewer, and the third is an obligation the current base has already discharged.
One. This branch targets Step 18's branch and carries Step 19a's commits.
The emit site reads ReapTotals.RepositoryID, and Step 18's branch does not contain Step 19a.
So Step 19a merges to main first through !1912.
Once the stack rebases over it, the diff here reduces to the emit half.
No arrangement of two branches avoids this.
Step 19a targets main, this half needs Step 18 for the tombstone criterion, and Step 18's branch does not contain Step 19a.
Two. That rebase is a git rebase --onto repair gated on a patch-id equality check, and not a fast-forward.
This project squash-merges, so Step 19a's commits arrive on main as one commit with a hash this branch has never seen.
A plain rebase re-applies them against a main that already carries the same content.
Three. internal/lifecycle/emit_integration_test.go owed an adoption of counterbuf.IsolatedDirtySets on rebase, and that rebase has happened.
!1892 (merged) merged on 2026-08-25 and deleted Scope.DirtySetKey(), which the suite used to call.
!1892 (merged) could not migrate the call, because the file holding it was not on main.
While the branch sat on a base predating !1892 (merged) the suite compiled, and it stopped compiling the moment the base carried !1892 (merged).
The base 8686d21cc carries !1892 (merged) now, and the suite calls counterbuf.IsolatedDirtySets at emit_integration_test.go:52.
No Scope.DirtySetKey() call is left anywhere in internal/lifecycle, so this point records a discharged obligation rather than a pending one.
!1892 (merged) also merged a rule into the ## Testing a call site section of docs/dev/storage-accounting.md.
That rule says that a suite which emits counters or ticks a drain claims from a dirty-set pair of its own.
The rule reaches this suite, which does both, and the adopted isolation is what satisfies it.
Known risks a reviewer accepts on the record
Risks 1 and 2 are the two LOW exposures of the counter-drift family, and this register omits that family's HIGH one.
Both are decrements against a column that nothing raised, and #836 (closed) bounds both.
The Maven remote case ran the other way: an increment with no decrement, correcting only at the reconciliation pass.
This branch closes the size_bytes half of it, so that half is described under What this merge request delivers rather than recorded here.
While #775 stays open, a Maven remote repository's artifacts_count reads high between an eviction and the next reconciliation pass.
That window is open on main today rather than opened by this merge request.
1. A container remote repository's size_bytes can read negative, per #836 (closed)
ContainerRemoteManifestReaper computes real freed bytes and this merge request turns them into a decrement.
No merged path raises repositories.size_bytes for a container remote cache fill.
ADR-007 puts the remote cache inside the repository's footprint, so the decrement conforms and the missing increment is the gap.
#836 (closed) records it.
While #836 (closed) stays open, a container remote repository's size_bytes can read negative between a cached-manifest purge and the next reconciliation pass.
Once #836 (closed) closes, the fill raises the column and the decrement has something to take off.
The window opens when this merge request merges, and not before: on Step 19a no caller turned the figure into a delta.
Two facts bound the exposure.
The magnitude is manifest documents rather than layers, because cached layers live in container_remote_blobs and ContainerRemoteImageReaper still reports zero.
The blast radius is display and ordering.
The management API serializes the column and sorts on it, no serializer fails, and no aggregate sums these rows.
2. The npm packument asymmetry, the same issue
NpmPackageReaper reports the bytes its cache leg frees for packument digests in npm_metadata_files.
No merged path raises repositories.size_bytes for an npm packument write either.
This is the same shape as risk 1, against a different write, and #836 (closed) covers it.
While that issue stays open, an npm repository's size_bytes can read low after a package purge that freed packument bytes.
Once the packument write emits, the decrement matches an increment.
3. namespace_statistics.components_count can read below the true value
Three populations reach a purge that decrements components_count against a write that never incremented it: hosted OCI, container remote and npm remote.
While those fills emit nothing, a purge of one of their artifacts takes a component off a count that never gained one.
Once each fill emits, its purge decrement matches an increment.
Maven remote is not a fourth population, on either counter.
RemoteCacheStore.emitCommittedFillCounters emits the fill's new cache versions as the component delta beside the size delta, so that purge decrement already meets an increment.
Three facts bound this one.
No OpenAPI contract exposes the column, so no client reads it.
No reader outside internal/accounting and internal/datastore consults it.
The reconciliation pass recomputes the column from source rather than adjusting it.
Merged code registers that pass and schedules it on a default reconciliation_interval of 1h, with no enable flag.
One further caveat covers every counter in this family, and this merge request does not introduce it. Both columns are recorded as deltas on a buffered pipeline and written by a periodic drain. A write shed under load can lose a delta, which is #632.
4. The shared dirty-set exposure this suite carried, and the base that closed it
internal/lifecycle/emit_integration_test.go marks scopes dirty per committed chunk, and it writes two members each time.
Issues #817 (closed) and #818 (closed) recorded what a shared dirty-set pair costs.
The test:integration job resolves every package to one CI Redis.
One suite's dirty-set member can therefore be claimed by another suite's drain tick.
The counter then reads zero rather than a partial value.
!1883 (merged) and !1892 (merged) closed both issues, and the exposure was live only while this branch sat on a base predating !1892 (merged).
On such a base the suite wrote into counterbuf's deployment-named pair and was one more writer into that shared key.
The base 8686d21cc carries !1892 (merged), so the suite claims from a pair of its own at emit_integration_test.go:52 and contributes nothing to the shared key.
A red test:integration job naming an internal/accounting counter test was that mechanism rather than a defect in the code under review, and this suite is no longer a source of it.
Merge order point three records the same adoption from the compile side.
5. File overlap that no pipeline reports
An overlap conflicts whichever merge request lands second, and no pipeline reports a conflict of that kind.
| File | Merge requests that also touch it | What a reviewer needs to know |
|---|---|---|
internal/datastore/query_names.go |
Four open: !1895, !1976, !1977 and !1997 | The names this branch adds arrive with Step 19a. A same-named constant in two changes is a compile error rather than a merge conflict. |
docs/specs/S20-a-lifecycle-closed-beta.md |
!1871 (merged) and !1870 (merged) | Both edit the ## Follow-ups region that this branch's new entry lands in, so all three write adjacent lines of one section. |
.claude/skills/run-artifact-registry/SKILL.md |
!1895 | !1895 also touches cmd/artifact-registry/wire.go, docs/dev/storage-accounting.md, internal/datastore/query_names.go and internal/datastore/queries_test.go, so it overlaps this branch on five files rather than one. |
6. One pre-commit hook bypass, and it is the documented carve-out
1e393da29, the test commit, used the /implement-step hook-bypass carve-out.
At that commit the implementation is a panic skeleton, so the go-test hook runs the suite against a tree the tests cannot pass on.
That is the test-first authorship contract rather than a defect, and the carve-out is scoped to that one commit.
The pre-split branch carried three such commits and the split squashed them to one, so a reviewer who counts bypasses finds exactly one.
Every other commit on this branch ran the full hook chain.
The repository walk pays nothing for the figure it drops
TombstoneRow gains SkipFreedBytes.
RepositoryReaper's walk sets it on every row it hands a sub-reaper.
resolveReapRepository answers uuid.Nil without a statement on such a row, which is the input sumFreedRepositoryBytes already short-circuits on.
The skip therefore reuses the mechanism that was already there, and adds none of its own.
Two statements describe the effect, and the second is the one that is easy to lose.
- Removed: the multiplier. At
lifecycle.DefaultPurgeChunkSize100, and withoutSkipFreedBytes, the walk pays one resolve and one correlatedNOT EXISTSper id. That cost sits inside the one transaction holding every row lock the chunk took. - Unchanged: the base per-arm cost on the artifact-purge path. Where production sits on that curve is still unmeasured, and the two production lookups that settle it were not run.
emitChunkCounters reads a chunk's RepositoryID.
The repository arm's chunk.RepositoryID != uuid.Nil gate therefore already relies on a walk that reports no repository.
Nothing at the emit site changes.
Runtime recipe and checks
Guardrail 21 applies, because cmd/artifact-registry/wire_lifecycle.go changes what the composition root assembles.
.claude/skills/run-artifact-registry/SKILL.md moves with it in this merge request, and driver.sh smoke re-ran.
The boot itself does not change, and the recipe says so rather than implying a new failure mode.
wireAccounting builds w.counterEmitter eagerly and never leaves it nil, and it runs before wireLifecycle.
So the new refusal guards a hand-assembled dependency set and a running service does not reach it.
driver.sh smoke passed from a cold start, 48 checks and 0 failures, after driver.sh down --all removed both containers and the build ran again.
The emit path was then driven by hand at both purge scopes.
The host runs a Redis, so the pipeline was live rather than dropping.
A repository purge, reached through the destructive repository DELETE, left components -1 and dedup_size 0 on the namespace hash and wrote no repository-scoped hash at all.
An artifact purge was reached by stamping maven_versions.soft_deleted_at and waiting out one sweep_interval.
For a 33-byte artifact it left artifacts 0 and size -33 on the repository hash, and components -1 and dedup_size 0 on the namespace one.
After the drain claimed both, repositories.size_bytes fell from 33 to 0 while artifacts_count held, and namespace_statistics.components_count fell from 1 to 0 while deduplicated_size_bytes held.
Those four movements are the emit rule read off a running server.
The recipe gains a ### Driving the purge's counter deltas section carrying that drive, and its snippet was then run as written and reproduced every figure.
The same run corrected a claim the recipe already carried.
Its list of what emits counter deltas named npm publish and the two npm unpublish routes.
A Maven PUT emits all four fields as well.
That sentence was one short before this work, and a correction of the purge half alone leaves it false.
Guardrail 7: ./internal/lifecycle/... and ./cmd/artifact-registry/... were linted with the pinned golangci-lint 2.12.2, under --build-tags=integration --max-same-issues=0 --max-issues-per-linter=0 --uniq-by-line=false.
The run reports 11 findings, all in cmd/artifact-registry, and none on a file this merge request writes.
They sit in wire_npm_boot_integration_test.go, wire_npm_observability_integration_test.go, wire_oci_boot_integration_test.go and wire_root_dispatcher_boot_integration_test.go, which this diff does not touch.
internal/lifecycle is clean, and so are wire_lifecycle.go and wire_lifecycle_test.go.
The pipeline does not compile //go:build integration files at all, which is why guardrail 7 asks for that separate run.
The name metric-label budget needed a raise, and the rebase is what moved the figure.
The branch declares 457 query names: 445 in internal/datastore/query_names.go and 12 in internal/storage/queries.go.
The ceiling was 450, so TestNameBudget_CoversEveryDeclaredQueryName failed.
93fc975b8 raises labelName in internal/metrics/cardinality.go to 500.
The base, 8686d21cc, declares 448.
The stale merge base hid main's names from the earlier count, which is why the figure moved.
The branch's own nine datastore names are what cross the ceiling once the base is current.
Database Review Evidence
Query mode only, and it found nothing of this half's own to plan. Migration mode did not run: this merge request changes no file under internal/datastore/migrations/sql/.
Queries
No query-producing method is new or changed by this half. The B half's own files against the step 18 base are internal/lifecycle/emit.go, internal/lifecycle/chunk.go, internal/lifecycle/purge.go, internal/format/npm/unpublish_package.go, internal/format/npm/unpublish_version.go, cmd/artifact-registry/wire.go and cmd/artifact-registry/wire_lifecycle.go. None of them dispatches a statement: none carries .QueryContext(, .QueryRowContext(, .ExecContext(, instrumentQuery(, instrumentExec( or execAffected(. emitChunkCounters reads a committed chunk's totals and hands deltas to lifecycle.CounterEmitter; the counter pipeline behind that seam is merged code this diff does not change.
The internal/datastore files this diff also shows are the A half's, carried on top. internal/datastore/lifecycle_reap_size.go is byte-identical to its copy at the head of !1912, and the nine query names in internal/datastore/query_names.go are the same nine. The plans, seed shapes, bound arguments and the page-size and namespace-size sweep for all nine statements are on !1912 under its own Database Review Evidence section, and are not repeated here.
Related to #681
This is a bot message