Repository navigation
feat(reporting-storage): fence the orphan collector against a cloned or restored ledger database - #3593
Conversation
…or restored ledger database
A clone or point-in-time restore shares the live database's installation
identity, namespace key and configuration (including
acknowledgeExclusiveNamespace), so its collector would delete the live
database's newer row objects. A real collectOrphanedRowObjects pass now claims
the namespace first: a create-only chain of epoch objects under
{prefix}/{namespace_key}/_collector-fence/ plus a row in the new
adcp_reporting_orphan_collector_fences table (migration 0009). It deletes only
while the highest epoch in the bucket is exactly the claim the database
recorded, re-checks before every deleting page, and reports a refused binding
as fenced with a cause. dryRun checks without claiming. takeOver
OrphanCollectorFence is the deliberate takeover.
Closes #3292
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claim under a session lock held through the object write, finish an unfinished claim under a fresh token, recheck the fence every 25 deletes, read the bucket before the row in read-only checks, scope a fence by provider kind, and widen takeover to a damaged chain. Adds non-database tests for the decision table. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…e fence limits A takeover now supersedes every object and version it saw. Documents that a fence that advances mid-pass stops the pass at the next check (at most 25 deletes), that a takeover does not stop a running collector, and that list must be read-after-write consistent. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Ladon verdict: Approve
Approve.
Fences the orphan collector against a cloned/restored ledger DB (#3292) in the experimental @adcp/reporting-storage package. No blocking or Medium findings.
Checked:
orphan-fence.tsdecideOrphanFenceis a pure, fail-closed decision table (fence_regressedcatch-all) matching the unit matrix.postgres.tsclaimOrphanFencecommits the (epoch,token) intent before the create-only object write; a crash is finished next pass viacomplete, which rewrites under a fresh token so a mid-claim copy cannot adopt the original token. Advisory lock serializes claims;lock_timeout=120s; object I/O bounded by read/write deadlines.row-storage.tslistObjectsskips fence keys so the collector never deletes its own chain;listCollectorFencepage cap fails closed.- 929-line PG test covers clone before/after, restore, takeover, token_mismatch, claim_lost, mid-pass recheck, migration_required, concurrent same-DB hosts, provider-failure→failed.
- Changeset is
minorwith migration note — correct for this@experimentalpackage. Migration 0009 is additive/idempotent and wired into MIGRATIONS + optionalTables.
Gate checks: gated_paths false; high_risk false; no no-auto-approve team match. Note review_decision: REVIEW_REQUIRED — but with gated_paths false and no blocking findings, no gate fires. Rows 1–8 do not match; falls through to row 9 → approve.
… the orphan collector fence The fence adds about 22 KB packed (a new module, the claim and takeover, the row storage fence I/O and their declarations), over the 15 KB per-PR allowance. The ceiling goes from 490,000 to 520,000 so two further PRs still fit. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Ladon verdict: Approve
Approve — clean delta since prior approve.
The only substantive change since the prior clean approve is a single line in scripts/package-size-budget.json raising the @adcp/reporting-storage packed-size ceiling 490000 → 520000 to accommodate the orphan-fence code this PR adds. The bump is proportionate (~6%), unpackedBytes/files ceilings are untouched, and a correct minor changeset is present for this experimental package. No wire-shape or code-behavior change.
Checked: no critical/high findings; no medium findings; witness-not-translator not implicated (no response reshaping); changeset present and correctly typed; no gated paths; no high-risk files; author is not on a no-auto-approve team. Decision table rows 1–8 do not fire → row 9 approve.
Resolves the migrations list, README table and migration test against the row_binding_id bound (0010), keeping 0009 ahead of it. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Ladon verdict: Approve
Approve.
Subsequent pass following a prior clean approve. The delta (12 files in packages/reporting-storage) widens row_binding_id/credentialRef from 128→255 via a single-source constant, adds a guarded idempotent migration 0010, and maps 23514→STATE_UNAVAILABLE. Reviewer confirmed: migration no-op/idempotency correct on fresh and current schemas, TS validator and DDL CHECK share one bound, the changeset is a correctly-typed minor for the experimental @adcp/reporting-storage, and PG tests cover fresh/legacy/un-migrated/no-table paths.
No critical, high, or medium findings. high_risk is false, gated_paths is false, and no no-auto-approve team match. Decision table rows 1–8 do not fire; falls through to row 9.
Note: review_decision is REVIEW_REQUIRED, but since gated_paths is false, row 2 does not apply and does not block approval.
Closes #3292
Problem
collectOrphanedRowObjectsdeletes objects under{prefix}/{namespace_key}/that no ledger record names. A clone or point-in-time restore of the ledger database carries the same installation identity, namespace key and configuration (includingacknowledgeExclusiveNamespace: true), sees an older set of references, and would delete the live database's newer objects. The age threshold does not help once those objects are old enough.Design: a create-only epoch chain in the bucket, recorded in the database
{prefix}/{namespace_key}/_collector-fence/{epoch:016d}.json, one create-only object per epoch:{ v, namespace_key, epoch, token, previous_token, claimed_at }. The highest epoch is the current claim. The database records its last(epoch, token, previous_token)inadcp_reporting_orphan_collector_fences(new migration0009), keyed by a hash of provider kind, location, prefix and namespace key, so bindings that reach the same bucket and prefix share one fence.status: 'fenced'andfence: { cause, detail, fenceEpoch?, recordedEpoch? }; the result gets a requiredfenced: string[]; the maintenance task warns. Causes:fence_advanced,claimed_elsewhere,token_mismatch,fence_missing,fence_regressed,fence_unreadable,claim_lost,migration_required. Everything unexpected fails closed (refuse), never open. A provider or database error isfailed, notfenced.dryRunreads the fence without claiming it (no object, no row) and reports fenced bindings too.store.takeOverOrphanCollectorFence({ acknowledgePreviousOwnerGone: true, bindings?, includeReadOnly? })claims max(epoch)+1 and supersedes every older object and version, including a damaged chain. It refuses without the acknowledgement. On a namespace nobody has claimed it is a claim and nothing more, which is how an operator claims from the live database first.Why this and not the alternatives
putIfAbsent, exact-versionget,list, exact-versiondelete). A chain of create-only epoch objects gives atomic arbitration on the provider contract we already require, with no new provider method.Known limits, documented
minAgeSecondsold. Rollout step: claim from the live database first (one real pass, ortakeOverOrphanCollectorFence).readOnlybindings collected withincludeReadOnly), a lifecycle rule must not expire_collector-fence/, andlistmust be read-after-write consistent (the bundled providers are; the conformance suite already exercises create-only).Migration (minor changeset for
@adcp/reporting-storage)npx adcp-reporting-storage migrate(0009, additive). Until applied, every pass,dryRunincluded, reportsfenced/migration_requiredand deletes nothing.collectOrphanedRowObjectsmust returnfenced: [];statusgains'fenced'.See the changeset,
docs/guides/REPORTING-OPERATIONS.mdanddocs/migration-reporting-storage-package.md.Tests
test/reporting-orphan-collector-fence-pg.test.js(PostgreSQL + filesystem provider; a clone is a point-in-time copy of every table in the live schema, a restore resets the fence row): clone fenced with a control showing the same clone deletes the live object once it has taken over; clone that claims first fences the original; restore fenced until takeover; takeover recovers every cause; dry run does not claim; shared-fence bindings; mid-pass claim stops the next delete; provider failure isfailednotfenced; crash recovery with a real failing put; copy taken during an unfinished claim cannot adopt the original's token; sibling waits on an in-flight claim; migration absent; malformed, duplicated and foreign fence objects fail closed.test/reporting-orphan-fence.test.js: the decision table, strict parsing and key helpers without a database.@adcp/reporting-storagesuite against PostgreSQL 5433: 467 + 206 pass. Also:npm run build, root and package typecheck,lint:boundaries,format:check,ci:doc-links,check:package-size(note: reporting-storage has about 19.8 KB of headroom left under its 490,000 byte ceiling).Expert review
Reviewed read-only by
security-reviewer,code-reviewer-deep,nodejs-testing-expertand Codex (review:codex --all, diff piped on stdin because its sandbox cannot read the repo).Fixed:
fenced(now bucket first, then row); fence scope used the host's registry key rather than the provider kind; README prettier; stale0001–0008;@internaltags and a namedReportingOrphanFenceTakeoverV1; a fenced binding no longer rests a whole interval in the maintenance cycle (looks again within the hour).deletedcount);listread-after-write requirement documented.freshClone; non-database tests for the decision table; failed-vs-fenced, crash-recovery, sibling and takeover coverage; dead counters removed; a deterministic lock test replaces the loose concurrency range.Declined:
🤖 Generated with Claude Code