chore(lifecycle): purge worker and chunk driver (S20-A plan: 15/21)
What this step delivers
This step creates the internal/lifecycle package.
The package holds the tombstone purge job: its arguments, its River worker, and the chunk driver that bounds every transaction.
It also enrolls the package in every CI list that decides whether its tests run.
PurgeArgscarries a namespace id and adatastore.PurgeScope.UnmarshalJSONrejects a scope outside the defined set, and it rejects a payload with noscopefield at all. An absent field decodes to the zero value, so the decode is the boundary where a malformed payload must stop.PurgeWorker.Workruns the discovery scans whose level matches the job's scope. It then reaps the subtree of each row a scan returns. Eligibility is evaluated when the attempt runs, and never carried forward from the enqueue.PurgeWorker.Timeoutbounds one attempt at 30 minutes, becausedatastore.Reaperrequires every reap to have a deadline.runPurgeChunksandrunPurgeChunkput each chunk in its own transaction. A failed chunk rolls back whole, and the drained verdict is read off the committed chunks only.PurgeMaxAttemptsandPurgeEnqueueOptionsstate the kind's retry policy and its uniqueness option. The section "SPEC-03 — the retry policy, and why this step widened" records why the budget is one.- CI enrollment covers five places.
Two are in the
test:integrationjob of.gitlab-ci.yml: itsgo listargument list, and its&integration-test-fileschanges anchor. The third is thedepguardno-reverse-dependencyglob list in.golangci.yaml, with the rationale text beside it. The fourth is thetest:lifecycle-failpointsjob. That job now runsgofail enableoverinternal/datastoreandinternal/lifecycle, and it carriesinternal/lifecycle/**/*in its&lifecycle-failpoint-filesanchor. The fifth is that job's local mirror, thetest:lifecycle-failpointstask in.mise.toml. Each package declares its own armer:armRepoWalkSeamininternal/datastore/lifecycle_reap_repository_integration_test.go, andarmPurgeSeamininternal/lifecycle/purge_integration_test.go. The.gitignorecomment that names the trees with*.fail.gobindings now names both. docs/dev/background-jobs.mdgains a section on structurally idempotent workers, with this job as the worked example.- The
lifecycleconfiguration comments now say which knob has a reader.retention_windowhas one, andsweep_intervalhas none.
Nothing registers the worker with River, and nothing enqueues the lifecycle:purge kind.
A running server therefore behaves exactly as it does today.
Spec coverage
Spec: docs/specs/S20-a-lifecycle-closed-beta.md
Rows are numbered positionally inside each of the spec's own groups: the spec
writes every criterion as 1. and renders the number from its position, so
there is no id to copy.
Acceptance criteria
Purger and discovery:
| # | Criterion | Tests |
|---|---|---|
| AC-P1 | A tombstone older than retention_window is discovered and reaped; a newer one is not |
Step 5's scan suites own the discovery half. The worker half -- the window evaluated at run time -- is TestPurgeWorker_Work_ALengthenedRetentionWindowLeavesTheTombstoneUnreaped |
| AC-P2 | A row with soft_deleted_at IS NULL is never a purge root, for every table the purger reaps |
Steps 5-7 (scans) and Step 14 (walk). Worker half: TestPurgeWorker_Work_ReapsATombstonedRepositoryAndLeavesALiveNeighbour, TestPurgeWorker_Work_ArtifactScopeReapsATombstonedVersionUnderALiveRepository |
| AC-P3 | EXPLAIN on the repositories, npm_packages and container_images scans |
Steps 5 and 7, in internal/datastore/lifecycle_explain_integration_test.go. Not re-asserted here |
| AC-P4 | EXPLAIN on the five version-level scans |
Step 6, same file. Not re-asserted here |
| AC-P5 | Duplicate fire is a no-op reporting success; a mid-walk abort leaves strictly fewer rows and no marker | TestPurgeWorker_Work_DuplicateFireOnAReapedNamespaceReportsSuccess, TestPurgeWorker_Work_AnInterruptedWalkLeavesFewerRowsAndNoMarker |
| AC-P6 | The npm repository walk is state-blind: live and already-marked rows go in one pass | Step 14. Carried through the worker by the marked-version fixture in TestPurgeWorker_Work_ReapsATombstonedRepositoryAndLeavesALiveNeighbour |
| AC-P7 | Every purge transaction is bounded, over a repository holding more artifacts than one chunk | TestPurgeWorker_Work_BoundsEveryChunkInItsOwnTransaction; TestPurgeWorker_Work_AFailedChunkRollsBackWhole pins the envelope's other half |
| AC-P8 | A frozen namespace refuses retryably before any row is reaped, per lifecycle column | Step 15a. Not covered here: the gate is that step's whole subject |
Per-format reap logic:
| # | Criterion | Tests |
|---|---|---|
| AC-F1 | A tombstoned npm version, its files and their attachments are reaped under npm's own reference check | Step 8 (internal/datastore/lifecycle_reap_npm_integration_test.go) |
| AC-F2 | Unpublish-then-republish leaves no orphan at the same coordinate | Step 8 |
| AC-F3 | A tombstoned npm package is reaped with its tags, metadata files and their attachments | Step 8 |
| AC-F4 | DeleteNpmMetadataFiles removes the attachment at the whole-package unpublish call site |
Step 8 |
| AC-F5 | A tombstoned Maven version and package are reaped with their files and attachments | Step 9 |
| AC-F6 | A container repository is reaped through the existing per-artifact deleters, observed running | Step 13 |
| AC-F7 | An index and its referenced manifests are reaped parent-first with no MANIFEST_REFERENCED refusal |
Step 13 |
| AC-F8 | A tombstoned container_images row under a live repository is reached by the image scan |
Step 13 |
| AC-F9 | The container repository walk is state-blind | Steps 13 and 14 |
| AC-F10 | Every remote-cache table carrying the column is reaped, a positive hit per table | Steps 10, 11 and 12 |
| AC-F11 | container_remote_manifests and container_remote_blobs reaped; tags and relationships cleared |
Step 12, with the three-table follow-on the plan's Dependencies section records |
| AC-F12 | After a reap every referenced blob has zero attachments | Steps 8-13 |
Repository entry point:
| # | Criterion | Tests |
|---|---|---|
| AC-R1 | DELETE without destructive, or with a non-boolean value, returns 400 |
Step 18 |
| AC-R2 | destructive=false on an empty repository returns 204 and frees the name |
Step 18 |
| AC-R3 | destructive=false on a non-empty repository returns 409 and changes nothing |
Step 18 |
| AC-R4 | That 409's message names the remedy the caller has, per format and kind |
Step 18 |
| AC-R5 | destructive=true on a non-empty repository returns 202, tombstones and enqueues in-tx |
Step 18 |
| AC-R6 | destructive=true on an empty repository returns 204 with no tombstone and no job |
Step 18 |
| AC-R7 | Every client route naming the repository in its path returns 404 after the 202 |
Steps 3, 4 and 18 |
| AC-R8 | A create at the tombstoned name is 409 while the purge is outstanding and 201 after |
Step 18 |
| AC-R9 | The finalizer cannot succeed early, verified through a gofail seam |
Step 14 (TestRepositoryReaper_Reap_FinalizerRefusesWhileAnArtifactRemains) |
| AC-R10 | A finalizing purge removes the repositories row, its format child and every collection link | Step 14 |
Sweep:
| # | Criterion | Tests |
|---|---|---|
| AC-S1 | A tombstone with no live purge job is re-queued within one sweep_interval |
Step 16 |
| AC-S2 | The sweep reads oldest-first, asserted behaviorally and by EXPLAIN |
Steps 5-7 own the EXPLAIN half; Step 16 the tick half |
| AC-S3 | A tombstoned npm_packages row whose versions are all reaped is enumerated and reaped |
Step 7 (the scan) and Step 16 (the re-queue) |
| AC-S4 | A repository that already has a live purge job is not re-queued | Step 16 |
| AC-S5 | A repository whose prior job reached a terminal state is re-queued | Step 16 |
| AC-S6 | A permanently failing purge stays counted and re-queued at the bounded cadence | Step 16 for the cadence, Step 17b for the gauge |
| AC-S7 | The sweep registers a worker for its periodic kind | Step 16 |
| AC-S8 | A namespace that does not serve writes has nothing enqueued, and recovers on the next tick | Step 16, with Step 15a's job-side refusal |
Tombstone visibility:
| # | Criterion | Tests |
|---|---|---|
| AC-V1 | ContainerRepositoryStore.FindByNameInNamespace hides it, for docker and oci |
Step 3 |
| AC-V2 | RepositoryStore.FindByID returns ErrNotFound |
Step 3 |
| AC-V3 | RepositoryStore.Update returns ErrNotFound |
Step 3 |
| AC-V4 | Delete, the three counter movers and Missing still act on a tombstoned row |
Step 3 |
| AC-V5 | parentRepositoryIsActive refuses a parent tombstoned mid-transaction |
Step 4 |
| AC-V6 | Each of the four container_images gates hides a tombstoned image |
S17 Phase 4 Step 6 |
| AC-V7 | Every /v2/ route resolving through FindByName answers 404 NAME_UNKNOWN |
S17 Phase 4 Step 6 |
| AC-V8 | The blob mount route answers 202, not 404, on a tombstoned source |
S17 Phase 4 Step 6 |
| AC-V9 | A tombstoned image named in from= still mounts and answers 201 |
S17 Phase 4 Step 6 |
| AC-V10 | A mount into a tombstoned destination name answers 201 |
S17 Phase 4 Step 6 |
| AC-V11 | UpsertContainerImage does not resurrect a tombstoned image |
S17 Phase 4 Step 6 |
| AC-V12 | ContainerImageStore.Delete still acts on a tombstoned row |
S17 Phase 4 Step 6 |
Accounting call sites:
| # | Criterion | Tests |
|---|---|---|
| AC-A1 | The tombstone write emits no counter delta | Step 19 |
| AC-A2 | The purger is the emit site, and the delta set depends on the level | Step 19 |
| AC-A3 | Every emit site commits its source rows before issuing the delta | Step 19. The chunk's half of the premise -- a failed chunk commits nothing -- is TestPurgeWorker_Work_AFailedChunkRollsBackWhole |
| AC-A4 | A chunk emits from its own committed affected-row aggregate | Step 19. Same premise, same test |
| AC-A5 | A raw foreign-key cascade emits nothing and is not the reaping path | Steps 13 and 19 |
Schema and configuration:
| # | Criterion | Tests |
|---|---|---|
| AC-C1 | All three discovery-index migrations apply and roll back | Step 1, and S17 Phase 4's marker migration |
| AC-C2 | No column added or dropped, and no CHECK pins a soft_deleted_at |
Step 1 |
| AC-C3 | unique_container_images_ns_id_cr_id_name is partial everywhere |
S17 Phase 4 Step 7 |
| AC-C4 | Config load accepts 0s, rejects a negative window and a bad interval |
Step 2 (internal/config/lifecycle_test.go) |
Error cases
| # | Condition | Tests |
|---|---|---|
| E-1 | DELETE omits destructive: 400, nothing written |
Step 18 |
| E-2 | destructive carries a value other than true or false: 400 |
Step 18 |
| E-3 | destructive=false on a non-empty repository: 409, message per kind |
Step 18 |
| E-4 | An artifact published between the DELETE and its statement: 23503 branch |
Step 18 |
| E-5 | DELETE on a missing or already-tombstoned repository: 404 |
Steps 3 and 18 |
| E-6 | Create at a name a tombstone holds: 409 |
Step 18 |
| E-7 | Any client route naming a tombstoned repository: 404 in its own vocabulary |
Steps 3, 4, 18 and S17 Phase 4 Step 6 |
| E-8 | A blob mount whose from= names a tombstoned repository: 202 |
S17 Phase 4 Step 6 |
| E-9 | Purge job: 23503 on a parent delete fails the job, never ErrRepositoryNotEmpty |
TestPurgeWorker_Work_APinnedParentFailsTheJobRatherThanReportingNotEmpty |
| E-10 | Purge job: ContainerManifestDeleter refuses a referenced manifest, retried |
Step 13 |
| E-11 | Purge job: the finalizer aborts because an artifact remains, the job fails | Step 14 owns the refusal. The worker's propagation of a reap failure is the same path E-9 asserts; no separate case here |
| E-12 | Purge job: repository already hard-deleted when the job runs, reported as success | TestPurgeWorker_Work_DuplicateFireOnAReapedNamespaceReportsSuccess |
| E-13 | Purge job: retention window changed between enqueue and run | TestPurgeWorker_Work_ALengthenedRetentionWindowLeavesTheTombstoneUnreaped |
| E-14 | Sweep: enumeration query fails, the tick errors and River retries | Step 16 |
| E-15 | Sweep: the interval elapses before enumeration completes | Step 16 |
| E-16 | Emit API unavailable at a purge or tombstone site: the delta is dropped and logged | Step 19 |
| E-17 | Parent gate: parent tombstoned between the gate and the child INSERT |
Step 4 |
| E-18 | A tombstoned coordinate reoccupied by a republished row before the reap | Steps 8 and 13 |
Security considerations
| # | Concern | Tests |
|---|---|---|
| S-1 | The purger runs with no request context and no caller identity | By construction: PurgeWorker.Work takes the job's arguments and nothing else, and PurgeWorkerDeps carries no identity. No test asserts an absence |
| S-2 | destructive is a confirmation, not an authorization control |
Step 18 |
| S-3 | Deletion is irreversible in closed beta and the API says so | Step 18, in the OpenAPI contract rather than in a test |
| S-4 | Input validation: no purger input is user-controlled; the job carries UUID identifiers | TestPurgeArgs_DecodeRejectsAScopeOutsideTheDefinedSet, TestPurgeArgs_DecodeRejectsAPayloadOutsideTheArgumentShape, TestPurgeArgs_RoundTripsEachDefinedScope. destructive's own validation is Step 18 |
| S-5 | No injection surface is added: all purger SQL lives in internal/datastore |
Owned by internal/datastore under ADR-023. internal/lifecycle adds no statement of its own, and no test in this MR asserts that absence |
| S-6 | Error messages must not become an existence oracle | Steps 3, 4 and 18 |
| S-7 | A purge that cannot complete must never report success | TestPurgeWorker_Work_APinnedParentFailsTheJobRatherThanReportingNotEmpty, TestPurgeWorker_Work_AnInterruptedWalkLeavesFewerRowsAndNoMarker, TestPurgeWorker_Work_AFailedChunkRollsBackWhole, TestPurgeWorker_Work_ACancelledLevelWithNoRowToReportOnStillFailsTheAttempt. The tombstone-count gauge that surfaces a stall is Step 17b |
Notes on the spec-coverage table
The table above is pasted unedited. Four things about it need a note.
- The branch carries tests that no criterion names.
They are
TestPurgeArgs_KindIsTheDurableRoutingKey,TestNewPurgeWorker_ChunkSizeTakesTheDefaultBelowOneAndClampsAboveTheMaximum,TestNewPurgeWorker_ANilLevelListTakesTheDispatchAndAnEmptyOneStaysEmpty,TestPurgeWorker_Timeout_EndsAnAttemptBeforeRiversStuckJobRescueHorizon,TestPurgeWorker_Work_RefusesAnAttemptWithNoDatabase,TestPurgeWorker_Work_RefusesAScopeTheDecodeNeverSaw,TestPurgeWorker_Work_AReapWithNoVerdictStopsAtTheChunkBudget,TestDefaultPurgeLevels_CoverEveryScanLevelOfBothScopes,TestPurgeWorker_Work_APageContinuesPastATombstoneItCannotReap,TestPurgeWorker_Work_ADiscoveryScanFailureLogsTheNamespaceAndTheLevel, andTestPurgeWorker_Work_GivesEveryReapADeadlineOnADeadlineFreeContext. - AC-S4 and AC-S5 were reworded by the spec amendment that the section "SPEC-01 — the purge job's addressing unit" describes. Their subject is now a namespace and a scope rather than a repository. Both rows still point at Step 16.
- AC-S6's "never retried hot" half is served by
PurgeMaxAttemptsin this MR. The cadence half stays with Step 16, and the gauge stays with Step 17b. - AC-P8's "refuses retryably" wording is superseded by the retry policy that the section "SPEC-03 — the retry policy, and why this step widened" describes.
PurgeMaxAttemptsis 1, so the job backend discards a refused attempt and runs no second one. The row still points at Step 15a.
e2e scenario catalogs
docs/testing/ gains nothing in this change set.
git diff --name-only origin/prozlach/s20a-lifecycle-closed-beta-step-14 -- docs/testing returns no path.
No scenario is affected, and the reason is the same one that empties the runtime-behavior list. Nothing registers the worker, and nothing enqueues the kind, so there is no behavior an end-to-end scenario can drive. A purge scenario becomes writable at Step 16, which registers the worker and the sweep.
Diff size, and why a split does not help
The diff is past the 500 reviewable-LOC ceiling that docs/dev/development-model.md names, so it owes a justification with the split.
These numbers were re-measured at 63ccb79f4, with one command:
git diff --numstat origin/prozlach/s20a-lifecycle-closed-beta-step-14| Group | Files | Added | Removed |
|---|---|---|---|
| Test Go | 5 | 2125 | 5 |
| Production Go | 9 | 875 | 44 |
| Documentation, CI and configuration | 9 | 157 | 88 |
Generated: gen/artifactregistry/config/v1/config.pb.go |
1 | 8 | 6 |
| Total | 24 | 3165 | 143 |
| Reviewable, total minus generated | 23 | 3157 | 137 |
Tests are 2125 of the 3165 added lines, which is 67 percent of them.
internal/lifecycle holds 2120 of the added test lines and 825 of the added production lines.
The other 50 added and 44 removed production lines are the comment-only corrections in internal/datastore and internal/config, which the section "Disclosures" lists.
A split does not help here, for three reasons. The tests, the worker and the chunk driver are one deliverable, and the CI enrollment cannot be reviewed apart from them. An enrollment MR that adds this package to two job lists before the package has a test is green either way, and it proves nothing. The spec amendment is the operator's answer to a finding raised against this diff, so it belongs with the diff that raised it. The comment-only files correct text that this MR's own code makes false.
Merge order
This MR targets prozlach/s20a-lifecycle-closed-beta-step-14, not main.
That branch is !1812 (merged).
- !1812 (merged) must merge first.
- After !1812 (merged) merges, this MR is retargeted to
main. - The base branch was force-pushed three times on 2026-08-21. No base commit id is quoted here for that reason. Re-read the base before you compare anything against it.
!1811 (merged) rewrites the same text this MR rewrites.
Both edit the doc comment on the Reaper interface in internal/datastore/lifecycle_scan.go.
!1811 (merged) is open, it is unapproved, and it has unresolved discussions.
It targets main, where this MR targets step 14's branch.
Whichever lands second rebases over the other.
Both MRs also edit internal/datastore/lifecycle_reap_container_remote.go, in hunks that do not overlap.
This step widens no predicate ahead of the code that serves it.
PurgeEnqueueOptions has no caller and no enqueue site exists, so nothing reaches the worker until Step 16 registers it.
The plan file is not edited here
AGENTS.md on main states that a step MR does not edit the plan file, the Status table included.
Row 15 of the S20-A Status table is therefore empty in this change set.
An empty cell means "not yet recorded", not "not started".
!1840 (merged), a separate docs(plans) merge request, fills row 15, states the merge order, and records a plan correction for the addressing-unit change that the section "SPEC-01 — the purge job's addressing unit" describes.
Operator decisions recorded on this branch
SPEC-01 — the purge job's addressing unit
The spec and the branch disagreed. The spec made one purge job address one tombstone. The branch makes one purge job address one namespace at one scope.
The operator kept the branch's shape and amended the spec to match.
The amendment is +11 −6 in docs/specs/S20-a-lifecycle-closed-beta.md, in commit 233a1ae94.
The whole-branch diff of that file is larger, because commit d5de89305 corrected six more claims in it.
The section "Spec-coherence items answered on this branch" covers those.
It covers the five places that named the old unit:
- the job-kind row
- the sweep enumeration sentence
- the frozen-namespace row
- the sequence diagram
- Sweep criteria 4 and 5
It also adds one sentence to the uniqueness paragraph, because the uniqueness subject follows from the arguments.
The same decision changed purgeLevel in this MR.
A row whose reap fails no longer ends the page.
The failure is collected, and the rows behind it are reaped.
The collected failures return together, so the attempt still fails over any one of them.
Without that change one permanently failing tombstone stands at the head of every later scan of its level in its namespace.
The scans read oldest first, so that one row holds back every row behind it, with no bound.
TestPurgeWorker_Work_APageContinuesPastATombstoneItCannotReap covers the new behavior.
SPEC-03 — the retry policy, and why this step widened
Spec line 928 requires a permanently failing purge to be re-queued at the bounded once-per-interval cadence, never retried hot and never silently abandoned. No step claimed that criterion. The operator widened step 15 to carry the retry policy.
The policy has three parts.
PurgeMaxAttemptsis 1. River discards a failed attempt instead of retrying it, and the re-queue comes from the periodic sweep at thesweep_intervalcadence. River's own default is 25 attempts on a fourth-power backoff. That default lands four attempts inside the five-minute default interval, and then waits days between the last ones.- The worker adds no backoff of its own. The budget is spent on the first failure, so River discards the job before it consults any per-worker schedule.
uniqueStatesWhileLive()ininternal/jobsriveris not edited. At a budget of one the job never reaches the retryable state, so it cannot deduplicate a sweep enqueue behind it.
Disclosures
internal/datastore/lifecycle_scan.go is in the diff and in no step-15 Files bullet.
The change is comment-only, +35 −32.
The comment guardrail compels it: those doc comments described the purge worker as work that had not landed, and this MR lands it.
!1811 (merged) also edits that file, and it edits the same doc comment.
The section "Merge order" records what that means.
Six more files are in the diff and in no step-15 Files bullet.
Every one of them is comment-only, and each corrects text that this MR's own code makes false.
internal/datastore/lifecycle_reap_maven.go,internal/datastore/lifecycle_reap_maven_remote.go,internal/datastore/lifecycle_reap_npm_remote.goandinternal/datastore/lifecycle_reap_container_remote.go. Their comments said the chunk driver had not landed, or thatReaperhad no caller outsideinternal/datastore. This MR lands that caller.internal/datastore/lifecycle_scan_test.go, which carried the same claim aboutReaper..gitignore, whose comment lists the trees thattest:lifecycle-failpointsrewrites. That job now rewrites two.
The planned file count was 18. These six take it to 24, which is the count the section "Diff size, and why a split does not help" reports.
purgeLevel logs the cause and returns the error.
docs/dev/go-style.md normally forbids that shape.
docs/dev/database-query-patterns.md prescribes it for a call site outside the management-API writers, and this is such a call site.
The store errors carry no identifiers by that same rule, and the worker must still return the error, because River records the attempt from it.
internal/managementapi/bulk_container_worker.go follows the same shape.
Sweep references are future-tense in the new files, and the merged precedent is left alone.
grep -in sweep over internal/lifecycle/chunk.go and internal/lifecycle/purge.go matches ten lines.
Six of them state the relationship to the sweep, which lands with Step 16.
All six are in the future tense, as the forward-reference guardrail requires: chunk.go:37, chunk.go:49, chunk.go:97, purge.go:149, purge.go:348 and purge.go:562.
The other four state no relationship.
purge.go:150 names the SweepInterval field, inside the sentence that starts at purge.go:149.
purge.go:339 and purge.go:343 name the configuration knob.
purge.go:181 states a conditional consequence inside the doc comment on PurgeEnqueueOptions.
Every line number here is read at 63ccb79f4.
internal/datastore/lifecycle_reap_repository.go:698 and internal/datastore/lifecycle_reap_npm.go:682 carry the same claim in the present tense, and this MR does not touch them.
Those files are merged and outside this step's scope, so the difference is deliberate rather than an oversight.
One acceptance criterion is discharged after this MR opens, not before.
The step's acceptance text asks that the test:integration job's package list resolve internal/lifecycle.
The assertion is a run of that job on this MR, read from its per-test output rather than from the diff.
A green branch pipeline is interim evidence and does not close it.
Spec-coherence items answered on this branch
Two items were open when this MR opened, and commit d5de89305 answers both.
That commit corrects four more claims in the same spec, found by the same review pass.
Its diff is +9 −7 in docs/specs/S20-a-lifecycle-closed-beta.md.
The two that were open:
- The freeze table said "A retry completes the work when the condition lifts".
PurgeMaxAttemptsis 1, so River discards a refused attempt rather than retrying it. The cell now says the sweep's next tick completes the work after the condition lifts. - "Never re-swept" overclaimed, in the uniqueness paragraph and in Sweep criterion 5. River retains a terminal job row for a bounded window, so the deduplication lifts when that row is cleaned. Both sites now name River's retention of the terminal row as the bound, in place of "never".
The four found with them:
- The freeze table's stated reason for a refusal over a skip did not hold.
It said a skip discards a job the sweep has no reason to enqueue again.
A skipped job completes,
UniqueByArgsWhileLivecounts no terminal state as live, and the next tick therefore re-enqueues either way. The cell now states what a refusal does buy: a failed attempt an operator can read. - The uniqueness sentence still reasoned about a repository. The SPEC-01 amendment moved the uniqueness subject to the namespace and scope pair, and the sentence three lines under it did not move.
- Sweep criterion 4 said a second eligible tombstone "is served by the job already live". A purge attempt takes one page per level and carries no cursor, so a tombstone beyond that page is not served by the live job. The load-bearing half — no second job at that scope — is unchanged.
- "Doubles that probe count" failed at the weak end of its own range. An attempt re-runs only its own scope's scans, so a namespace holding only repository-scoped tombstones pays nine probes and not sixteen. The claim is now "at most doubles", with both ends stated.
Recorded, not acted on
ADR-025's attachment lock is not taken on the reap path.
ADR-025 requires an attachment lock taken with FOR UPDATE, and the reap path does not take it.
Nothing in this change set can act on that.
The reapers that delete blob_storage_attachments rows landed in earlier steps, and this MR does not touch them.
runPurgeChunk is the seam a later step locks.
This is recorded here so the gap is read as known rather than as a defect of this MR.
!1811 (merged)'s row-budget obligation is already met, so this MR owes no code change for it.
That obligation is that a driver either sizes a row budget against the arm it dispatches, or treats Rows as an after-the-fact count.
runPurgeChunks takes the second option.
It passes deps.ChunkSize unchanged as every reap's limit, and it reads Rows only to accumulate a total and to test chunk.Rows == 0.
No budget is derived from limit.
The real in-tree contradiction was elsewhere.
DefaultPurgeChunkSize's doc claimed the budget bounds the rows one reap writes.
The Reaper contract bounds what a reap selects, and it says a call can delete past the limit.
Commit ff0fb9977 corrects that doc.
Review notes declined without a round
A review round read the confidential AppSec review thread on this MR in full, and the operator declined it. No reply goes in that thread, and this MR gets no calibration label. This entry names the thread only, because the thread is confidential.
The GitLabDuo review note on this MR reports no findings. It carries nothing to act on, so it gets no reply and no resolve.
Issue link
The step card gives this step the type chore.
The issue-link guardrail exempts chore work from the Related to <issue URL> line, so this description carries none.
The exemption is stated rather than left silent.
This is a bot message