chore(lifecycle): unleased purger signals and their registration (S20-A plan: 17a/21)

What this step delivers

The three purger signals that need no lease and no S22 dependency, and the seam that registers them.

Family Type Labels What one observation is
gitlab_artifact_registry_lifecycle_purge_outcomes_total Counter purge_scope, purge_format, purge_outcome One tombstone row a purge attempt drove, under how that row's reap ended
gitlab_artifact_registry_lifecycle_purge_duration_seconds Histogram purge_scope One purge attempt's drive of one tombstone row, across the chunks that attempt ran it through
gitlab_artifact_registry_lifecycle_purge_lag_seconds Histogram purge_scope How long the tombstone already waited when its reap started

None of the three needs a mutual-exclusion lease. Each observation belongs to the pod that made it, and the values sum correctly across replicas. This property separates step 17a from step 17b, which owns the one signal that needs the lease S22 introduces.

The step also adds:

  • lifecycle.RegisterMetrics, the registration entry point a composition root calls.
  • observePurge, called once per row inside PurgeWorker.purgeLevel. It moves all three families in one call.
  • repositories.format on the repository discovery scan, and datastore.TombstoneRow.Format to carry it.
  • The three label pins in internal/metrics/cardinality.go, with their closed value sets.
  • Three catalog rows in docs/dev/observability.md.
  • One comment paragraph in internal/datastore/lifecycle_reap_repository.go. It is the tenth file, and the design note "The tenth file" gives the reason.

Spec coverage

Spec: docs/specs/S20-a-lifecycle-closed-beta.md Plan: docs/plans/2026-08-11-s20a-lifecycle-closed-beta.md, Step 17a

This table is re-derived against the branch as it stands, at cd2aac4b9. The copy in the test-author commit body predates both the purge_format label and the no_verdict outcome value. The last subsection names every correction.

Acceptance criteria

# Criterion Tests
OBS-1 Observability: tombstone count, labeled by level, as a single-writer scrape-time collector Step 17b's, and gated on S22's leased-collector step. Not covered here.
OBS-2 Observability: purge outcome, labeled by format and terminal reason, separating "nothing left to reap" from a foreign-key stall or an ordering refusal Terminal reason: TestObservePurge_ClassifiesTheReapOutcome (8 cases — two stops that carry no error, one drained and one with no verdict, and six failures that each arrive through the chunk envelope's wrap, two of them with an errors.Join around that wrap), TestPurgeWorker_Work_CountsEachOutcomeUnderItsOwnArm (4 arms end to end). The two errors.Join cases nest the join outside the wrap. Production nests the join inside the wrap: datastore.RunInTx joins a failed rollback to the bare reap error, and runPurgeChunk wraps that join. errors.Is reads through either nesting, so the label is the same. Format: the counter carries purge_format. TestObservePurge_LabelsEachFormatFamilyOnTheOutcomeCounter (all 4 values plus an out-of-set one), TestPurgeLevel_FormatOf_ReadsTheLevelAtOneScopeAndTheRowAtTheOther, TestPurgeLevel_FormatOf_ResolvesEveryDefinedRepositoryFormat, TestDefaultPurgeLevels_NameTheFamilyOfEveryArtifactLevel, TestPurgeWorker_Work_FoldsBothContainerFormatsAndKeepsTheOthersApart, and the "each discovered row carries its own repository's format" case of TestLifecycleScanStore_ScanTombstonedRepositories
OBS-3 Observability: purge duration, labeled by level, feeding the latency bound TestObservePurge_RecordsHowLongTheReapItselfTook, TestObservePurge_LabelsEachScopeByItsOwnLevel, TestObservePurge_RecordsTheDocumentedFamiliesAndLabelSets, TestPurgeDurationBuckets_EndAtTheAttemptDeadline, TestPurgeWorker_Work_RecordsARealRepositoryPurgeInAllThreeSignals. TestObservePurge_RecordsHowLongTheReapItselfTook still carries "the reap" in its name, and the histogram times one purge attempt's drive of one tombstone row. The name predates that wording.
LAT-1 Purge latency and its bound: the purge's own duration is not bounded and scales with a repository's contents, so it needs a signal of its own TestObservePurge_RecordsHowLongTheReapItselfTook (250 ms and 15 min), TestPurgeDurationBuckets_EndAtTheAttemptDeadline (the last boundary reads purgeAttemptTimeout rather than spelling it), TestPurgeWorker_Work_RecordsARealRepositoryPurgeInAllThreeSignals. TestObservePurge_RecordsHowLongTheReapItselfTook still carries "the reap" in its name, and the histogram times one purge attempt's drive of one tombstone row. The name predates that wording.
LAT-2 Purge latency and its bound: a tombstone's extra wait is a function of its position in the backlog, which the backlog count alone does not show TestObservePurge_RecordsTheTombstonesAgeWhenTheReapStarted, TestPurgeWorker_Work_TheLagHistogramSeparatesASeededAgeSpread
AC-36 A purge that fails permanently keeps its repository counted in the tombstone-count gauge and re-queued at the bounded cadence The gauge half is Step 17b's and the re-queue half Step 16's. What this step adds is the arm that names the permanent failure: the "a foreign-key stall on a parent delete, at a maven artifact level" case of TestPurgeWorker_Work_CountsEachOutcomeUnderItsOwnArm, and TestObservePurge_ClassifiesTheReapOutcome
AC-51 The destructive repository DELETE emits no counter delta at the tombstone write Step 19's. Not covered here.
AC-52 Δsize and Δcomponents decrements fire at the purger Step 19's. Not covered here.
AC-1..AC-35, AC-37..AC-50, AC-53..AC-59 Purger and discovery, per-format reap, the repository entry point, the sweep, tombstone visibility, schema and configuration Other steps. This step adds no scan, no reap and no route. It does widen one existing scan's projection: ScanTombstonedRepositories now selects repositories.format, and datastore.TombstoneRow gains a Format field to carry it. Its only other reach into internal/datastore is a comment paragraph in lifecycle_reap_repository.go. No DDL column, no index and no migration.
P-1 Plan Step 17a: the purge-outcome counter separates "nothing left to reap" from a foreign-key stall and from an ordering refusal TestObservePurge_ClassifiesTheReapOutcome, TestPurgeWorker_Work_CountsEachOutcomeUnderItsOwnArm, TestPurgeWorker_Work_CountsEveryTombstoneInThePage (the counter measures rows, not attempts). "Nothing left to reap" is two outcomes rather than one: drained for a reap that reported the subtree empty, and no_verdict for a walk that stopped on a chunk that removed nothing
P-2 Plan Step 17a: the purge-duration histogram is labeled by level TestObservePurge_LabelsEachScopeByItsOwnLevel, TestObservePurge_RecordsTheDocumentedFamiliesAndLabelSets, TestObservePurge_RecordsHowLongTheReapItselfTook
P-3 Plan Step 17a: the purge-lag histogram records the tombstone's age at the moment its purge ran, labeled by level TestObservePurge_RecordsTheTombstonesAgeWhenTheReapStarted, TestObservePurge_RecordsNoNegativeLagWhenTheClocksDisagree, TestObservePurge_LabelsEachScopeByItsOwnLevel, TestPurgeWorker_Work_TheLagHistogramSeparatesASeededAgeSpread
P-4 Plan Step 17a: label cardinality passes the internal/metrics audit, and no label carries a namespace or repository identifier TestObservePurge_PassesTheCardinalityAudit (every value of all three labels observed first, over 3 scopes × 4 formats × 5 outcome ends, and all three families required on the gather so the audit is not vacuous), TestObservePurge_RecordsTheDocumentedFamiliesAndLabelSets (exact label set per family)
P-5 Plan Step 17a: none of the three needs a lease, and all three register through lifecycle.RegisterMetrics TestRegisterMetrics_DuplicateRegistrationReturnsAnError, TestRegisterMetrics_RegistersOntoEachRegistryIndependently, TestObservePurge_RegistersNoFamilyBeyondTheThree
P-6 Plan Step 17a Tests: metrics_test.go covers label sets and the lag observation's value TestObservePurge_RecordsTheDocumentedFamiliesAndLabelSets, TestObservePurge_RecordsTheTombstonesAgeWhenTheReapStarted
P-7 Plan Step 17a Tests: metrics_integration_test.go covers the lag histogram across a seeded age spread and the outcome counter's three arms TestPurgeWorker_Work_TheLagHistogramSeparatesASeededAgeSpread (5 min, 6 h, 10 d), TestPurgeWorker_Work_CountsEachOutcomeUnderItsOwnArm. The suite drives four arms where the plan's Tests line says three. The fourth is no_verdict, which this step adds
B-1 An observation reaches all three families or none, so the counter stays the denominator an operator divides the histograms by TestObservePurge_MovesEveryFamilyOnceForOneTombstone, and the tail of assertOneObservation in every classification case
B-2 The observation points exist: a helper nothing calls records nothing, and no unit assertion detects that TestPurgeWorker_Work_RecordsARealRepositoryPurgeInAllThreeSignals (the package's own dispatch over a real tombstoned repository)

Error cases

# Condition Tests
E-9 Purge job: 23503 on a parent delete — the transaction aborts and the job fails Recorded as purge_outcome="parent_pinned": TestObservePurge_ClassifiesTheReapOutcome (wrapped, and through a join), TestPurgeWorker_Work_CountsEachOutcomeUnderItsOwnArm
E-10 Purge job: ContainerManifestDeleter refuses a manifest as still referenced Reaches the catch-all arm, since it is neither sentinel: the "a failure none of the named arms describes" case of TestObservePurge_ClassifiesTheReapOutcome
E-11 Purge job: the finalizer aborts because an artifact remains Recorded as purge_outcome="repository_not_empty": TestObservePurge_ClassifiesTheReapOutcome, TestPurgeWorker_Work_CountsEachOutcomeUnderItsOwnArm
E-12 Purge job: repository already hard-deleted when the job runs The drained arm: the "nothing left to reap, in an oci repository" case of TestPurgeWorker_Work_CountsEachOutcomeUnderItsOwnArm
E-13 Purge job: retention window changed between enqueue and run No tombstone is discovered, so the attempt makes no observation. Pinned by pre-existing TestPurgeWorker_Work_ALengthenedRetentionWindowLeavesTheTombstoneUnreaped.
E-14 Sweep: enumeration query fails Step 16's. Not covered here.
E-15 Sweep: interval elapses before enumeration completes Step 16's. Not covered here.
E-16 Emit API unavailable at a purge or tombstone site Step 19's. Not covered here.
E-1..E-8, E-17, E-18 The destructive parameter, the tombstoned-repository routes, the mount source, the parent gate, and coordinate reoccupation Steps 3, 4, 13 and 18. Not covered here.
E-19 Not a spec row: an attempt cut short by a shutdown, which is neither sentinel The catch-all arm: the "an attempt cut short by a shutdown" case of TestObservePurge_ClassifiesTheReapOutcome
E-20 Not a spec row: a walk that stops on a chunk that removed nothing, which is not a failure and not a drain purge_outcome="no_verdict": the "a walk that stopped on a chunk that removed nothing" case of TestObservePurge_ClassifiesTheReapOutcome, and the "a walk that stopped without a verdict, in a maven repository" case of TestPurgeWorker_Work_CountsEachOutcomeUnderItsOwnArm

Security considerations

# Concern Tests
S-6 Error messages must not become an existence oracle Adjacent surface: a metric label is a second oracle a 404 says nothing about. TestObservePurge_RecordsTheDocumentedFamiliesAndLabelSets asserts each family's whole label set, and TestObservePurge_PassesTheCardinalityAudit runs the disallowed-name check over every label the three families declare.
S-7 Purge failures are operationally visible rather than silent; a purge that cannot complete must never report success Partly this step. No reap failure is dropped for want of a label: TestObservePurge_ClassifiesTheReapOutcome (the catch-all arm), TestPurgeWorker_Work_CountsEachOutcomeUnderItsOwnArm (both failing arms still fail the attempt). A stop that reaped nothing and failed nothing is separated from a drain rather than folded into it, which is the no_verdict arm. The tombstone-count gauge the paragraph names is Step 17b's.
S-1 Authorization is unchanged and is not weakened Step 18's. This step adds no route and no authorization decision.
S-2 destructive is a confirmation, not an authorization control Step 18's. Not covered here.
S-3 Deletion is irreversible in closed beta and the API must say so Step 18's. Not covered here.
S-4 Input validation: no purger input is user-controlled Step 5's datastore.PurgeScope constructor and Step 15's decode. This step adds two folds that keep an unpinned value off a label: TestObservePurge_FoldsAScopeOutsideTheDefinedSetIntoOneSeries for an out-of-set purge scope, and TestObservePurge_LabelsEachFormatFamilyOnTheOutcomeCounter for a format the purger did not resolve.
S-5 No injection surface is added This step adds no SQL beyond one extra projected column on an existing statement. internal/lifecycle owns no SQL per ADR-023, which metrics.go keeps.

What changed against the test-author table

Row Was Now
OBS-2 "The format dimension is not covered" Covered. The counter carries purge_format, with five named tests and one datastore subtest.
OBS-2 "7 causes, each through the chunk envelope's wrap and half through a page's join" 8 cases. Two carry no error and differ only in the reap's verdict. Six failures arrive through the chunk envelope's wrap, and two of those carry an errors.Join around that wrap.
OBS-2, P-1, S-7 Four purge_outcome values, and three arms end to end Five values and four arms. no_verdict is the fifth, and the design note "The fifth purge_outcome value" states what it separates.
AC-1..AC-35 "This step adds no scan, no reap, no route and no column" The scan, reap and route half stands. The step widens one existing scan's projection, adds a struct field, and adds a comment paragraph in lifecycle_reap_repository.go. No DDL column.
P-4 "every value of both labels observed first" Three labels, not two. The audit loop emits 3 scopes × 4 formats × 5 outcome ends.
P-7 "the outcome counter's three arms" The suite drives four. The fourth is no_verdict.
S-6 "the disallowed-name check over both declared labels" Three declared labels on the counter, one on each histogram.
S-4 Named the scope fold only Two folds now. The format fold arrived with purge_format.
S-5 "This step adds no SQL" It adds one projected column to an existing statement. internal/lifecycle still owns no SQL.
AC-36 Cited a subtest path that no longer resolves The case is now "a foreign-key stall on a parent delete, at a maven artifact level".
E-12 Cited a subtest path that no longer resolves The case is now "nothing left to reap, in an oci repository".
E-20 Absent Added. It is not a spec row, and it names the stop the no_verdict value carries.

e2e scenario catalog

Guardrail 12 asks a feat or fix MR to update docs/testing/ or to say why no scenario is affected. This MR is chore, so the obligation does not fire. No scenario is added or affected either way.

go list -deps ./cmd/artifact-registry returns 55 internal packages, and internal/lifecycle is not one of them. cmd/artifact-registry is the only package under cmd/, so nothing this step adds to internal/lifecycle is compiled into a binary. Three of the ten changed files are compiled in. internal/datastore/lifecycle_scan.go adds one projected column and the field that carries it. internal/datastore/lifecycle_reap_repository.go adds comment lines and no statement. internal/metrics/cardinality.go adds three label pins, and AuditCardinality is the only reader of those tables. No production code calls AuditCardinality. Nothing a booted service serves changes.

Diff size

The diff is 10 files, +2676/-14, measured at cd2aac4b9 against the target branch tip 99830546c. That is past the 500-line review threshold in docs/dev/development-model.md, and past this plan's own ~900-line working ceiling and its ~480-line forecast for step 17a.

Group Files Added Removed Statements
Tests internal/lifecycle/metrics_test.go (991), internal/lifecycle/metrics_integration_test.go (626), internal/lifecycle/purge_internal_test.go (163), internal/datastore/lifecycle_scan_integration_test.go (66) 1846 2 1057
Production internal/lifecycle/metrics.go (580), internal/lifecycle/purge.go (140), internal/metrics/cardinality.go (69), internal/datastore/lifecycle_scan.go (27), internal/datastore/lifecycle_reap_repository.go (11) 827 12 217
Docs docs/dev/observability.md 3 0 3
Total 10 2676 14 1277

The "Statements" column counts added lines that are neither blank nor a comment. Tests are 69% of the added lines. The production group's 827 added lines are 217 statements, 572 comment lines and 38 blank lines. That ratio is what a metrics surface takes here. Each label value, each bucket boundary and each fold carries the rationale docs/dev/observability.md asks for.

A split does not help here, for three reasons.

  • The three families are one unit at registration and one unit at observation. RegisterMetrics registers them together and observePurge moves them together, and TestObservePurge_MovesEveryFamilyOnceForOneTombstone asserts that property. A split by family leaves each part with a partial helper and no way to state the coupling.
  • The label declaration and the cardinality pin must land together. AuditCardinality rejects a label name that is absent from expectedDistinctValues, so internal/metrics/cardinality.go and internal/lifecycle/metrics.go fail apart and pass together.
  • The purge_format label needs three edits at once: the scan projection, TombstoneRow.Format, and PurgeLevel.formatOf. Split them and formatOf reads a field nothing fills.

The remaining candidate is a test-only follow-up MR, which trades a reviewable unit for an unreviewed one.

Merge order

This MR targets prozlach/s20a-lifecycle-closed-beta-step-15, not main. It is the third level of a stack: main <- !1812 (merged) (step 14) <- !1838 (merged) (step 15) <- this MR. Neither predecessor is merged today. Merge !1812 (merged) first, then !1838 (merged), then this MR.

!1811 (merged)feat(datastore): reap the three uncovered container remote tables rewrites the doc on datastore.ErrReapParentPinned from two causes to three. This step describes that sentinel at the class level, with no count and no closed list. It does so at five sites:

  • the purge_outcome value doc in internal/lifecycle/metrics.go
  • the counter's Prometheus Help string, which ships on /-/metrics
  • the doc on TestObservePurge_ClassifiesTheReapOutcome
  • the doc on TestPurgeWorker_Work_CountsEachOutcomeUnderItsOwnArm
  • the counter's catalog row in docs/dev/observability.md

Each of the five says that parent_pinned names a class of refusal rather than any one cause. Each also says that persistence across re-queues separates the causes inside that class. That text stays true whether or not !1811 (merged) lands, so the two MRs carry no order between them. The plan requires !1811 (merged) before step 16's MR opens, and step 17a is independent of step 16.

The plan Status row. AGENTS.md gives the plan Status table a single writer, so a step MR does not edit the plan file. !1840 (merged), the standing S20-A docs(plans) MR, carries row 17a. While !1840 (merged) is open, the plan on this branch shows an empty cell for row 17a. That cell means "not yet recorded" rather than "not started". !1840 (merged) also carries the step 16 amendment the design note "lifecycle.RegisterMetrics has no production caller, by design" describes.

Files step 15a also works

Step 15a is !1868 (merged), chore(lifecycle): namespace write-serviceability gate on the purge path. It is a parallel sibling of this step. Both depend on step 15 and neither depends on the other. The operator ruled that the two steps keep disjoint regions in the two files they share. The operator also ruled that whichever MR opens second rebases on the other. !1868 (merged) opened after this MR, so under that rule the rebase is !1868 (merged)'s. The regions are stated at hunk grain below so a reviewer can check the ruling. Every claim about !1868 (merged) below names a symbol or a region rather than a commit. !1868 (merged) is still in progress and its head moves, so a SHA written here is out of date before a reader can check it.

internal/lifecycle/purge.go

This step touches five regions:

  • The PurgeLevel doc comment, and the new Format field on that struct.
  • The new PurgeFormat type, its value block, and the PurgeLevel.formatOf method.
  • The DefaultPurgeLevels doc comment, and the Format: value on each of the seven artifact levels.
  • One sentence in the DefaultPurgeChunkSize doc comment, which now names what purgeDurationSeconds times.
  • The purgeLevel doc comment, and its per-row loop, where startedAt goes before runPurgeChunks and observePurge after it.

PurgeWorker.Work and the purgeAttemptTimeout doc comment and declaration are byte-identical to the target branch. Both blocks were located by symbol rather than by line, and compared by md5 against 99830546c. PurgeWorker.Work with its doc comment is 104 lines, and it hashes to d28633c73b918b498ed341c095b8b0ac on both sides. The purgeAttemptTimeout doc comment and declaration are 24 lines, and they hash to 9b631c3b1f2cfb3f2b569f748e301eb8 on both sides.

!1868 (merged) changes this file in seven places. They are the import block, the logKey* const block with its doc comment, and two new package-level error values. The other four are the new PurgeNamespaceFinder interface, PurgeWorkerDeps, NewPurgeWorker, and PurgeWorker.Work with its doc comment. None of the seven is one of the five regions above. A region-by-region comparison of the two sides finds no overlap in this file, so !1868 (merged)'s rebase here is mechanical.

internal/lifecycle/purge_internal_test.go

This step touches three regions:

  • The first sentence of the file header comment. It now names the levels' format resolution beside the two subjects already there.
  • Five comment lines appended to the end of that same header block. They say why PurgeLevel.formatOf is tested from inside the package.
  • A pure append of 156 lines after the closing brace of TestCheckPurgeLevels_ReadsTheTopLevelValueOfEachField. No existing line is touched.

No import line is added, because postgres and require were already imported.

This file also overlaps with !1868 (merged) in two regions, and both resolve keep-both. The first is the header comment block. This step rewrites its opening sentence and appends a paragraph at the end, and !1868 (merged) rewrites the paragraph between the two. The second is the shared tail anchor, where each step appends its own tests. No test name and no symbol collides across the two sides.

The reason this step edits an internal test file at all: PurgeLevel.formatOf is unexported. Its verdict is unreachable from lifecycle_test, and a gathered metric conflates a wrong family with a wrong label rendering. The same file holds the totality check over datastore.RepositoryFormats(). That check turns a fifth repository format into a CI failure rather than a silent slide into unknown.

Design notes for the reviewer

The purge_format label and the fold

The label's domain is {maven, npm, container, unknown}. repositories.format holds four values, and this label names three families and one catch-all.

container_images and container_remote_manifests hold the rows of a Docker repository and an OCI one alike, and neither table carries a format column. So no row either scan returns belongs to one of the two. PurgeLevel.formatOf folds docker and oci onto container, and both purge levels then speak one vocabulary.

The two scopes resolve the value from different places. At purge_scope="artifact" it comes from the level, because each artifact level reads one table of one family. At purge_scope="repository" it comes from the row, read off repositories.format by ScanTombstonedRepositories, the one discovery scan that projects the column.

unknown folds a family the purger did not resolve, and it takes both halves of one condition rather than either half alone. The level must name no family, and the row that level's scan returned must give no resolvable format. PurgeLevel.formatOf answers with the level's own family first, so the rows of a level that names one never reach the fold. DefaultPurgeLevels reaches neither half. Its one family-less level is the repository level, and that level's scan projects repositories.format, which is NOT NULL and held to four values by check_repositories_format. What the fold covers is a caller that assembles a level list of its own.

The Docker and OCI distinction is not lost. It stays on the repositories.format column, which the fold does not touch. It does not survive in the structured-log key logKeyPurgeLevel either. That key names the table a scan read, and a container row is read from container_images or container_remote_manifests whether its repository is docker or oci.

The label is spelled purge_format rather than format for the reason its two siblings take a prefix. AuditCardinality keys closed sets and distinct-value budgets by label name across the whole service. A bare format therefore pins this set service-wide at a budget of four, with no headroom left. The next metric that wants docker as a format value then fails the audit on its first emission rather than at review.

The fifth purge_outcome value

The domain is {drained, parent_pinned, repository_not_empty, no_verdict, error}. Five values over three scopes and four formats put the whole counter surface at 60 series, and each histogram carries purge_scope alone for 3 series. internal/metrics/cardinality.go pins all three counts and all three value sets.

no_verdict exists because a nil error is two different ends. runPurgeChunks returns nil for a reap that reported the subtree empty, and also for a chunk that removed no row. Only the first is a verdict. A chunk that removed nothing has established nothing about the subtree. The tombstone can be standing in full, and the sweep hands it back on the next tick. Folded into drained, that stall reads as the steady state on every tick.

At purge_scope="repository" the value is a stall signal. The repository walk reports drained on the chunk that found every level empty and finalized. A stop with no verdict behind it there is therefore a purge that is not finishing. gitlab_artifact_registry_lifecycle_purge_lag_seconds carries the same news, more slowly and confounded. A stuck tombstone's wait and a genuine backlog's wait both grow by one sweep interval per tick. The counter names the stall on the tick it happens, and the lag histogram confounds it with a genuine backlog.

At purge_scope="artifact" the value is a route rather than a stall, and it is permanent. datastore.NpmRemoteVersionReaper and datastore.ContainerRemoteManifestReaper never assign ReapTotals.Drained on any path. The npm_remote_versions and container_remote_manifests levels therefore land in no_verdict on every successful drain. No label filters them out. Each shares its purge_scope and its purge_format with a sibling level that does report a verdict. The level itself travels on logKeyPurgeLevel rather than on a label. An alert on this value therefore reads purge_scope="repository" alone.

The value is spelled no_verdict rather than no_progress because the two remote levels above make progress and still land here. Carrying the verdict as far as the label costs two changes in the code. purgeOutcomeLabelValue now takes ReapTotals.Drained beside the error, and purgeLevel keeps the totals runPurgeChunks returns rather than discarding them.

The tenth file

internal/datastore/lifecycle_reap_repository.go gains a comment paragraph and no statement.

reapRepositoryLevelPage synthesizes a TombstoneRow for the rows a repository walk hands its sub-reapers. The paragraph above it records which fields that synthesis leaves at their zero value. Before this step the paragraph named SoftDeletedAt alone, which was the whole list. TombstoneRow.Format makes it a half-list, and AGENTS.md's "A Documented Absence Names Every Reading It Leaves Open" says half a list is worse than none.

The two absences are not the same shape, so the new text does not copy the old one. No correct soft_deleted_at exists for a synthesized row. A correct format does exist: readRepositoryReapDispatch has already read the walked repository's repositories.format, and Reap still holds it where it calls this function. The row does not carry it because no sub-reaper reads the field. PurgeLevel.formatOf is the only reader, and it runs on the rows a discovery scan returned rather than on these.

TombstoneRow.Format is a *int16

datastore.RepositoryFormatDocker is zero. With a plain int16, "no format was projected" and "the format is Docker" are the same state. Every artifact scan leaves the field nil, and the purger labels a metric with what it reads there. So the difference between the two states has to survive into the caller, and the pointer is what keeps them apart.

The extra column costs no extra read. index_repositories_on_ns_id_soft_deleted_at carries no INCLUDE and no id, so the scan already visits the heap for every row the index condition matches.

internal/lifecycle/chunk.go is in the plan's Files list and is not touched

This is deliberate, and the spec is the reason. The spec's Observability table has three rows and names no chunk grain. The plan's step 12 calls a per-chunk duration signal "a monitoring addition rather than a hole in this step's contract". The same paragraph names step 17a as the place for such a signal. The spec is the source of truth, so this step ships the three signals the spec names and no fourth.

One alternative was weighed and not chosen: move the existing observation down into runPurgeChunks. That is about 20 lines and adds no new series. It was rejected because it trades one grain for another rather than adding one. The move makes purge_duration_seconds time one chunk instead of one attempt's whole drive of a row. A chunk is finer than the grain the spec's latency row is written at.

lifecycle.RegisterMetrics has no production caller, by design

Step 17a ships the seam and step 16 ships the call. Step 16's Files entry already creates cmd/artifact-registry/wire_lifecycle.go, the tier that builds the purge worker. wire_glaz.go and wire_iam.go already register their own packages from their own tier file, so the destination matches the house pattern. !1840 (merged) amends the plan's step 16 bullet to name RegisterMetrics, so the obligation is written down where step 16's author reads it.

Until that call lands, the three families are absent from /-/metrics however many observations the purger makes. The RegisterMetrics doc comment says this, and each of the three catalog rows carries the same condition.

ADR check

ADR-023 governs where SQL lives, and internal/lifecycle still owns none of it, which the S-5 row above records. ADR-007 governs the schema surface this step reads, and the phase 6 database review recorded the conformance with line citations. The internal ADR set was checked at its source, and none of it governs this change.

Guardrail 11 exempts a chore MR from the Related to <issue URL> line, so this MR carries none. Nothing in the running service changes: internal/lifecycle is not in the binary's dependency graph.

Database Review Evidence

Note

Migration mode did not run. This merge request adds and modifies no file under internal/datastore/migrations/sql/, and the diff carries no DDL. Query mode ran on internal/datastore/lifecycle_scan.go, the one changed file that dispatches a statement. internal/lifecycle/metrics.go, internal/lifecycle/purge.go, and internal/metrics/cardinality.go carry no dispatch signature, so the content filter drops them. The diff base is 94531ee18, the merge base with the target branch prozlach/s20a-lifecycle-closed-beta-step-15 at the time of the review, and not origin/main. The branch has rebased since, and the merge base is now 99830546c. The hunks in internal/datastore/lifecycle_scan.go are unchanged at 27/6, so the evidence below transfers.

Queries

Note

Plans are from EXPLAIN (ANALYZE, BUFFERS) against an ephemeral PostgreSQL 17.10 container (matching GL_PG_CURR_VERSION from .gitlab-ci-other-versions.yml), with synthesized seed data rolled back per query and the container torn down at the end of the run. Numbers reflect moderate cardinality and do not capture production-scale effects. See Database review evidence for seed sizing, methodology, and the anomalies the skill flags. Expand each row's details for the seed shape, rendered SQL, bound args, and raw plan.

Method Plan node Index Rows (plan / actual) Cost Time Buffers (hit / read) Partitions
datastore.ScanTombstonedRepositories Limit over Index Scan Backward repositories_p40_namespace_id_soft_deleted_at_idx 25 / 25 2.39 0.018ms 3 / 0 1/64
datastore.ScanTombstonedRepositories

Summary: The plan matches the method's intent. PostgreSQL walks a partition of index_repositories_on_ns_id_soft_deleted_at backwards, and reads one partition of 64. The eligibility predicate sits inside the Index Cond, and no Sort node and no Filter node stand above the scan. The added format column moves one number: the row width goes from 40 bytes to 42. No anomalies.

Seed shape: namespaces=1, repositories=5000

Rendered SQL:

SELECT repositories.namespace_id AS "tombstone_row.namespace_id",
     repositories.id AS "tombstone_row.id",
     repositories.soft_deleted_at AS "tombstone_row.soft_deleted_at",
     repositories.format AS "tombstone_row.format"
FROM public.repositories
WHERE (repositories.namespace_id = $1::uuid) AND (repositories.soft_deleted_at < (NOW() - INTERVAL '0 MICROSECOND'))
ORDER BY repositories.soft_deleted_at ASC
LIMIT $2;

Bound args: [da080057-bc67-4268-9419-7057f6e5633e, 25]

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

 Limit  (cost=0.29..2.39 rows=25 width=42) (actual time=0.015..0.018 rows=25 loops=1)
   Buffers: shared hit=3
   ->  Index Scan Backward using repositories_p40_namespace_id_soft_deleted_at_idx on repositories_p40 repositories  (cost=0.29..420.41 rows=5000 width=42) (actual time=0.015..0.017 rows=25 loops=1)
         Index Cond: ((namespace_id = 'da080057-bc67-4268-9419-7057f6e5633e'::uuid) AND (soft_deleted_at < (now() - '00:00:00'::interval)))
         Buffers: shared hit=3
 Planning:
   Buffers: shared hit=10
 Planning Time: 0.123 ms
 Execution Time: 0.030 ms

Timings: planning 0.123ms, execution 0.030ms, total 0.153ms.

The added column against the merge base. The description claims that repositories.format costs no extra read. Both projections ran in one transaction, against one seed, after one warm-up run each. The merge-base statement is the same statement without the repositories.format item.

Projection Limit cost Scan cost Root width Buffers (hit / read) Plan shape
Merge base, three columns 0.29..2.39 0.29..420.41 40 3 / 0 Limit over Index Scan Backward, 1/64 partitions, no Sort, no Filter
This merge request, four columns 0.29..2.39 0.29..420.41 42 3 / 0 Limit over Index Scan Backward, 1/64 partitions, no Sort, no Filter

The measurement holds the claim. Cost, buffer counts, index choice, partition count, and node shape are the same on both sides. The row width is the one number that moves, and it moves by the two bytes of a smallint. The DDL is the reason: index_repositories_on_ns_id_soft_deleted_at is btree (namespace_id, soft_deleted_at DESC) WHERE (soft_deleted_at IS NOT NULL), with no INCLUDE and no id, so the scan already visits the heap for every matched row.

This is a bot message 🤖 — /smurfit

Edited by Pawel Rozlach

Merge request reports

Loading
Loading