## Why Data backfills need a durable contract outside schema migrations. The contract must survive crashes and support automatic or manual operation without adding version-selection machinery. ## What - Define one PostgreSQL-backed lifecycle for finite data repair - Treat the stable backfill ID as the only durable execution and readiness identity - Use eventually consistent, idempotent registration; compatible workers compete for one exclusive claim - Require bounded batches, atomic mutation and checkpoint commits, generation fencing, and validation before completion - Define all four migration and automatic-backfill configurations - Define the authorized admin API and honest client projection ## How A backfill captures one immutable upper bound, processes finite batches, and commits each target mutation with its checkpoint. PostgreSQL owns lifecycle state, claims, progress, and completion. Registration order and build metadata do not choose a definition. Compatible processes are interchangeable. A material behavior change requires a new stable ID. The orchestrator does not resolve different behavior registered under one active ID. Diagnostic validation on completed rows records only its bounded outcome and audit; it does not create an execution claim. The spec now starts with a six-step mental model and follows the operator flow. It removes repeated requirements and cuts the document from 614 lines and 4,835 words to 474 lines and 3,656 words. ## Risk Low. This PR changes documentation only. It commits Buzz to a design contract, but it does not add runtime, schema, configuration, or protocol behavior. ## Testing No manual runtime testing. A fresh full-spec review checked the lifecycle, safety rules, four configuration cells, admin contract, and production-seam conformance requirements. `git diff --check` and the repository file-size policy pass at `13681664674d4667b181a6211f012090fa40a2cf`. ## Bigger picture The design keeps the relay as orchestrator, a focused backfill crate as domain owner, and `buzz-db` as narrow transaction infrastructure. It removes deployment generations and definition precedence from the implementation plan. Generated with Codex --------- Signed-off-by: tornquist <tornquist@squareup.com> Co-authored-by: Codex <noreply@openai.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
27 KiB
Durable data backfills: a specification
draft
Overview
This section is non-normative. It gives the mental model for the requirements that follow.
A Buzz data backfill repairs existing PostgreSQL rows after a compatible schema is available. It may start automatically with the relay or manually while the relay serves traffic. If a worker stops, another worker resumes from committed progress.
Think of each backfill as one durable PostgreSQL row keyed by a stable ID:
- The application registers the stable ID.
- Starting the backfill captures a finite upper bound.
- A worker takes an exclusive claim.
- Each transaction changes target rows and advances the checkpoint together.
- Workers repeat bounded batches until they reach the upper bound.
- Validation proves the result before the row becomes
completed.
PostgreSQL is the authority throughout this flow. Queues and timers may wake a worker, but they do not own work or progress. The admin API and client display the PostgreSQL state; they do not keep another copy of the lifecycle.
The key words MUST, MUST NOT, REQUIRED, SHOULD, SHOULD NOT, and MAY are to be interpreted as described in BCP 14 when they appear in all capitals.
Purpose and scope
This specification covers:
- durable registration and execution of bounded data backfills;
- exclusive claims, crash recovery, retry, pause, and validation;
- atomic target mutations and checkpoint progress;
- independent schema-migration and automatic-backfill controls;
- readiness behavior in automatic and manual modes;
- an authorized, auditable admin API and client; and
- conformance tests at production transaction, startup, and admin seams.
It does not define:
- schema migration mechanics or a generic migration framework;
- a Nostr event kind or other wire protocol;
- a distributed queue as durable state;
- exactly-once worker code outside a PostgreSQL transaction;
- one universal batch size, timeout, or retry schedule;
- a way for a backfill to make an incompatible application schema safe; or
- destructive controls that erase, rewind, or force completion.
Backfill definitions are application code, not runtime-authored migrations.
Core model
Terms
| Term | Meaning |
|---|---|
| Definition | Application code that declares an ID, ordering key, batch bound, mutation, and validation. |
| Stable ID | The only identity for execution, administration, validation, completion, and readiness. |
| Upper bound | The inclusive, definition-typed end of the finite source set. It is captured once. |
| Checkpoint | The greatest source position whose required mutation is durably committed or was deterministically unnecessary. |
| Claim | Temporary, exclusive PostgreSQL authority for one worker to advance a backfill. |
| Generation | A monotonically increasing value that fences work from a stale claim. |
| Validation | Definition-specific proof that work through the upper bound has the required postcondition. |
| Required automatic backfill | A stable ID that the deployed application includes in automatic startup readiness. |
PostgreSQL authority
PostgreSQL MUST be the sole authority for lifecycle, ownership, progress, retry eligibility, diagnostics, validation, and completion. A queue, timer, cache, notification, or in-process registry MAY wake a worker. Loss, delay, duplication, or replay of that signal MUST NOT affect correctness. A worker MUST re-read PostgreSQL before acting.
The row for a stable ID MUST remain compact. It records the lifecycle, immutable bound, monotonic checkpoint, claim and generation, bounded retry and diagnostic state, validation, timestamps, and audit correlation. Bounded definition metadata MAY be stored for diagnostics. Detailed history belongs in the audit facility, not an unbounded per-batch journal. Telemetry is a projection, not another state store.
Stable identity and registration
Each definition MUST declare a stable ID. A stable ID MUST map to exactly one durable row and at most one readiness obligation across builds and deployments. Validated completion MUST be permanent for that ID.
Registration and reconciliation MUST be idempotent upserts by stable ID. Repeated registration MUST NOT create another execution. Registration signals may arrive late, more than once, or out of order; the durable state MUST still converge on one row and one readiness obligation. For registration-owned diagnostic metadata, the last registration wins.
Registration order, revision strings, build metadata, and version metadata MUST NOT select a runnable definition or establish execution precedence. They MUST NOT create another row, checkpoint, completion, or readiness obligation.
Every process allowed to work on an active ID MUST implement the same behavior: the same source set, ordering and checkpoint meaning, mutation, and validation contract. A material change to any of those semantics MUST use a new stable ID. Registering different behavior under an active ID is operator error; the orchestrator MUST NOT compare or choose between definitions.
Rolling deployments
A process MUST reconcile its definitions by stable ID before claiming work. Any process with a compatible local definition MAY claim eligible work, regardless of registration order or diagnostic metadata. A process MUST fail closed if it does not know the definition or cannot safely interpret the row's bound, checkpoint, lifecycle, and completion contract.
Deploying another compatible process does not revoke an existing claim. Its owner MAY commit while the claim and generation remain valid. If the deployment cannot accept that commit, its semantics are materially different and require a new stable ID. Deployment order, binary age, and metadata MUST NOT act as fences.
An exclusive claim authorizes one compatible worker. Generations reject stale owners. These mechanisms MUST NOT establish definition precedence.
Before starting a backfill, deploy and fully roll out the normal write path that maintains the desired postcondition. Operators MUST ensure old writers and in-flight old behavior no longer produce actionable rows, then start the bounded historical repair. This ordering is an operator and developer responsibility, not a condition the backfill framework verifies. If it is violated, the existing checkpoint may have advanced past writes that were never repaired, and no rerun under the same stable ID can revisit them. Operators must run a new repair under a new stable ID, and may need to repeat that process until the normal write path is safe.
Lifecycle and operator actions
One state machine governs automatic and manual execution:
| State | Meaning | Next states |
|---|---|---|
pending |
Registered but not started; no bound exists. | running |
running |
Started and eligible for a worker claim, subject to retry backoff. | paused, blocked, failed, validating |
paused |
Operator intent prevents claims and fences current work. | running, validating |
blocked |
A prerequisite or data condition needs operator remediation. | running, validating |
failed |
Execution exhausted bounded retries, or validation failed. | running, validating |
validating |
Mutation reached the bound and is eligible for validation. | paused, completed, failed, blocked |
completed |
Validation succeeded. This state is terminal. | none |
The supported operator actions are:
| Action | Contract |
|---|---|
start |
From pending, atomically capture the upper bound and enter running before dispatching work. |
pause |
From running or validating, atomically enter paused, advance the generation, and invalidate the claim. |
resume |
From paused, enter running, or validating when no admitted work remains. |
retry |
From blocked or failed, enter running, or validating when no admitted work remains, at the existing bound and checkpoint. |
validate |
For pending, return current-state. For any other row, use the read-only validation contract below. |
Repeated pause and resume requests MUST converge on the requested state.
Neither action changes the bound or checkpoint. There is no separate retrying
or cancellation state. Retry eligibility and delay are attributes of running;
pause is the safe interruption command.
An in-flight batch either commits before pause or fails the generation fence
and rolls back. Previously committed batches remain committed.
Retry, blockage, and failure
The engine MUST durably classify an execution error before reporting it as
handled. Retryable errors remain running without a claim until bounded
backoff permits another claim. Automatic retries MUST be bounded and
end in a terminal failed or blocked disposition instead of a hot loop.
An unmet prerequisite or data invariant enters blocked with a bounded,
actionable diagnostic. Exhausted execution errors enter failed. Validation
failure enters failed, unless it identifies an unmet prerequisite and enters
blocked instead.
retry is fresh operator intent. It MAY clear bounded attempt accounting and
stale diagnostics. It MUST preserve history, the bound, and the checkpoint, and
MUST NOT bypass validation.
Validation and completion
Reaching the upper bound MUST enter validating, not completed. Validation
MUST read authoritative PostgreSQL state, cover the postcondition through the
bound, and be safe to repeat after a crash.
Only successful validation MAY transition the row to completed. The
completion transaction MUST verify the lifecycle and generation so a stale
validator cannot complete a paused or retried run. Operators cannot select
completed, and completed state MUST remain immutable.
Execution safety
Finite source set and checkpoint
Before the first mutation, start MUST capture an upper bound in the same
transaction that initiates execution. The definition MUST state how the bound
represents an empty source set. Once captured, the bound MUST NOT be changed or
reinterpreted for that stable ID.
The definition MUST use a checkpoint type with a total, stable ordering. The checkpoint MUST be inclusive. Before the first source item commits, the checkpoint MAY be absent; absence means a position before all admitted work, not a worker-supplied text sentinel.
The checkpoint MUST NOT move backward or beyond the upper bound. A worker MUST select positions strictly after the checkpoint and no later than the bound. If the ordering key is not unique, the definition MUST use a stable compound key that cannot ambiguously skip or repeat tied rows.
Rows created beyond the upper bound MUST be handled by normal write paths or a new backfill ID. Application reads and writes MUST remain correct for both repaired and unrepaired rows during execution.
Rationale: the upper bound makes historical repair finite and separates it from live writes.
Bounded batches
Each definition MUST declare a finite maximum amount of work per batch. The production selection and mutation path MUST enforce it. Any work that could grow with the source set MUST have that bound or a stricter finite bound.
Batch transactions and lock-holding intervals MUST have bounded duration. Workers MUST observe cancellation, timeout, and pause intent at transaction and batch boundaries. They MUST NOT start another batch after observing that intent, and an interrupted transaction MUST roll back.
If a work, transaction, lock, or interruption bound cannot be honored, the engine MUST commit neither partial mutations nor a checkpoint. It MUST apply the bounded failure policy instead of widening the batch or continuing without a bound.
Atomic mutation and progress
Every batch MUST commit its target mutations and checkpoint advance in one PostgreSQL transaction. A mutation, checkpoint, ownership, commit, cancellation, or timeout failure MUST roll back both.
Rationale: this transaction keeps committed mutations from being hidden behind an older checkpoint and keeps the checkpoint from claiming rolled-back work. Definitions SHOULD still make mutations idempotent as defense in depth.
Side effects that cannot join the transaction are outside the backfill commit. They MUST NOT be required for the checkpoint to mean complete.
Exclusive claims and generation fencing
A backfill MUST NOT have more than one current claim. Acquisition, renewal, release, and takeover MUST be PostgreSQL state transitions. Each new claim or takeover MUST advance the generation. An admin transition that invalidates in-flight work MUST also advance it.
Every transaction that changes target data or progress, or commits a validation or lifecycle result that can advance execution, MUST verify before commit that:
- the lifecycle permits the operation; and
- the presented generation equals the current generation.
A claim-authorized worker execution commit MUST additionally verify that:
- the presented owner holds the current claim; and
- the claim remains valid.
Operator transitions and diagnostic validation do not require a claim. Operator actions remain lifecycle- and generation-fenced, as do validation or completion results that advance execution.
Failure of any applicable check MUST reject the entire transaction. For a claim-authorized worker, checking only before work starts is insufficient because a paused, expired, or replaced owner may finish after takeover.
Claims MUST have bounded validity and require renewal. Takeover MUST eventually be possible after renewal stops. PostgreSQL time and state decide validity; worker-local clocks do not grant authority.
Release, crash, and takeover
A worker MAY release a claim while the row remains running. A crash leaves
either a committed batch or no batch effect. After the abandoned claim expires,
another worker advances the generation and resumes strictly after the committed
checkpoint.
The old worker may keep computing, but its later transaction MUST fail the generation check. Takeover MUST NOT rewind the checkpoint, recapture the bound, or infer progress from worker memory.
Configuration and readiness
Schema auto-migration and automatic backfills are independent controls. All four configurations are valid:
| Schema auto-migration | Automatic backfills | Required behavior |
|---|---|---|
| off | off | The relay MUST run no migrations, MUST NOT start pending backfills, and MUST NOT add a backfill readiness gate. Already-started manual work MUST remain recoverable. The installed schema and application MUST serve safely with incomplete data. |
| off | on | The relay MUST run no migrations. With a compatible installed schema, it MUST start required backfills and remain unready until they reach completed. Missing required schema is a schema-compatibility failure; a backfill MUST NOT replace migration. |
| on | off | The relay MUST run migrations, but MUST NOT start pending backfills or add a backfill readiness gate. Operators run them manually while the application serves mixed repaired and unrepaired data. Already-started work MUST remain recoverable. |
| on | on | The relay MUST run migrations, then required backfills, and remain unready until every required backfill reaches completed. |
Automatic mode assumes the rollout prerequisite above is already satisfied. A required automatic backfill MUST ship in a release after its prerequisite write path is fully rolled out and old writer behavior is drained. A definition that ships with its prerequisite write-path change MUST NOT auto-start in that release; an operator MUST start it manually only after rollout completion.
In automatic mode, every required pending, running, paused, blocked,
failed, or validating row MUST keep the serving gate closed. The relay MUST
register the complete set of required stable IDs before evaluating that gate.
A restart MUST reconstruct the gate from PostgreSQL; late discovery MUST NOT
create a ready interval.
The authorized deployment-admin boundary MUST remain reachable while the ordinary serving readiness gate is closed so operators can inspect and recover incomplete backfills.
In manual mode, incomplete backfills MUST NOT make the relay unready. This is safe only when application reads and writes work correctly before, during, and after the repair. A schema change that cannot serve safely until its backfill finishes does not conform to manual mode.
Turning automatic backfills off prevents automatic initiation of pending
rows. It does not abandon running or validating work. Workers MUST recover
or take over already-started work after failure without adding a readiness gate;
an operator MAY pause it.
Backfill readiness is only one input to the serving decision. Database, cache, deletion-fence, shutdown, and schema-safety checks remain independent.
Admin and client contract
The relay admin API is the external boundary. The client and operator tools MUST project its PostgreSQL-backed state and invoke the lifecycle actions above; they MUST NOT keep another lifecycle or synthesize outcomes.
Read model and honest projection
Authorized list and detail reads MUST expose:
- stable IDs and bounded informational definition metadata;
- lifecycle, bound, checkpoint-derived progress, ownership, retry eligibility, and timestamps;
- bounded execution and validation diagnostics;
- the latest explicit validation disposition, outcome, and time; and
- participation in the automatic readiness gate.
Unknown totals, unavailable diagnostics, and failed or stale reads MUST remain unknown. The UI MUST NOT render them as zero, complete, healthy, or offline. The server MUST derive current status from PostgreSQL on each read. A client MAY cache for presentation, but a failed refresh does not make cached data current.
Control idempotency
The API and client MUST support exactly the lifecycle actions listed above. They MUST be safe under retries, duplicate delivery, concurrent operators, and a response lost after commit. Repeating one logical request MUST return its original result or the converged state without duplicating a transition, recapturing a bound, reusing a generation, or starting another owner. Conflicting requests MUST return a typed conflict with current server state.
In manual mode, start is the durable intent that separates recoverable work
from registered pending work that automatic configuration MUST leave alone.
The normal API MUST NOT expose reset, checkpoint rewind, bound changes, record deletion, force completion, unfenced claim release, or generic cancellation. Incompatible work uses a new stable ID. Destructive recovery requires a separate specification.
Explicit validation
validate requests definition-owned, read-only validation against PostgreSQL.
It is not a second lifecycle or an operator-selected completion transition.
The API MUST return exactly one of these typed outcomes:
| Outcome | Meaning |
|---|---|
success |
Validation ran and the postcondition held. |
failure |
Validation ran and rejected the state, with a bounded diagnostic. |
current-state |
Validation did not run or has no result yet; the response includes the lifecycle and reason without implying success or failure. |
A pending row MUST return current-state without running validation. Any
other row MAY be validated. When the row is validating and
completion preconditions hold, the request MAY drive the same generation-fenced
completion transition used by the lifecycle. In every other state, validation
is diagnostic and MUST NOT change the lifecycle, bound, checkpoint, generation,
or target data.
Revalidating a completed row MUST report a current failure as failure while
leaving immutable completion and progress unchanged.
Only one validation invocation per stable ID MAY run at a time. Duplicate
delivery of one logical request MUST return its original bounded outcome or a
correlated current-state result. Concurrent requests MUST join the active
invocation or return current-state. After a lost response, retrying the same
request MUST recover the outcome. The client MUST retry or refresh; it MUST NOT
infer the outcome.
Diagnostic validation MUST persist only its bounded outcome, request correlation, and audit record. It MUST NOT require or create an execution claim. It MUST remain serialized, operator-authorized, read-only, and unable to change immutable execution state.
Accepted, rejected, coalesced, and completed validation requests MUST be audited with request correlation, actor, stable ID, evaluated lifecycle, and bounded outcome. Audit recording MUST NOT turn validation into a target-data write.
Authorization and audit
Backfills are deployment-wide operational state. Reads and controls MUST require an explicit deployment-operator capability at the relay admin boundary. Community ownership, channel administration, and membership MUST NOT grant this authority. A protected network boundary MAY supply equivalent authority, but the deployment MUST NOT claim individual attribution it does not have.
Every accepted or rejected control attempt MUST be audited with the actor or authority source, stable ID, request correlation, prior and resulting state, claim generation when relevant, time, and a bounded outcome. An accepted state change and its audit record, or durable intent to append that record, MUST commit atomically. An unaudited change is not success.
Diagnostics in the API, audit, and logs MUST be bounded and redacted. They MUST NOT expose raw source rows, credentials, unbounded database errors, or query text. Detailed diagnostic reads SHOULD be observable without logging row data.
Observability
Operators MUST be able to distinguish:
- lifecycle counts and readiness-gating disposition;
- claim acquisition, renewal, loss, takeover, and stale-owner rejection;
- attempted and committed work, checkpoint movement, and lack of progress;
- retryable errors, blockage, exhausted failure, and validation outcomes; and
- execution and validation duration.
Metric labels MUST use bounded vocabularies. Stable IDs, checkpoints, row values, diagnostics, and actor IDs belong in authorized views and structured logs, not labels. Missing telemetry is unknown. Metrics and logs MUST reconcile with PostgreSQL state and MUST NOT authorize transitions.
Conformance tests
Tests MUST exercise production transaction, startup, admin, and client seams. They MUST be falsifiable: removing the relevant bound, atomic write, fence, authorization check, or readiness input MUST fail a test. Test-only lifecycle helpers do not prove conformance.
At minimum, the suite covers:
- Claim race. Concurrent workers contend for one row. Only one worker owns the current claim and commits under its generation; eventual takeover preserves the checkpoint.
- Bounded batch. A source set larger than one batch advances only within the declared batch bound before releasing the transaction. Cancellation or timeout rolls back target mutations and checkpoint. Removing production bound enforcement fails the test.
- Rollback. Failure after mutation but before commit, including checkpoint failure, leaves target data and checkpoint unchanged.
- Restart. Termination before and after batch commit, and during validation, rebuilds progress from PostgreSQL without recapturing the bound.
- Stale owner. Pause, expiry, or takeover while a worker is in flight rejects its mutation, checkpoint, validation, and completion at commit.
- Rolling deployment. Two compatible processes register the same stable ID in either order and retain one row and readiness obligation. Either MAY win an exclusive claim. After takeover, generation fencing rejects the prior owner's writes. A process that does not know the stable ID cannot claim. The test MUST NOT choose by registration order or metadata, or assert definition precedence.
- Validation. Reaching the bound cannot complete without validation. Failure is durable and repeatable; only success reaches immutable completion.
- Bounded failure. Persistent errors reach
failedorblockedinstead of retrying forever. Operator retry preserves the bound and checkpoint. - Admin authorization. Unauthorized reads and controls return no protected state and perform no mutation. Community roles grant no authority.
- Admin idempotency. Duplicate, concurrent, and lost-response requests do not duplicate starts, reuse generations, or recapture bounds. Conflicts return current state.
- Explicit validation. Start with a
completedrow and no execution claim. Through the production API and client, run validation, persist and display its current typed outcome, and write its audit record. It creates no claim and does not change the generation, target data, lifecycle, bound, checkpoint, or completion. The API and client also render all typed outcomes for non-pendingrows. Unauthorized, duplicate, concurrent, and lost-response requests follow the contract above. Removing serialization, read-only behavior, or an immutable-state guard fails the test. - Client projection. The UI preserves unknown and failure states and sends only supported controls.
- Configuration matrix. All four configurations run through real startup
and readiness paths. Automatic mode gates until validation, manual mode does
not add the gate, and schema safety remains independent. Automatic mode
also exercises authorized inspection and recovery from
paused,blocked, andfailedthrough the production admin boundary while ordinary serving remains gated.
PostgreSQL concurrency tests SHOULD use controlled transaction barriers so losing and stale commits are observed rather than inferred from timing.
Component mapping
| Responsibility | Buzz component |
|---|---|
| Stable-ID registration, lifecycle, durable store, claims, fencing, checkpoints, retry, and validation | The new buzz-backfill crate owns the domain and persistence. |
| Database and transaction primitives for atomic mutation and checkpoint progress | buzz-db provides narrow primitives. It does not own backfill records, policy, or orchestration, and it does not expose its connection pool. |
| Discovery, automatic execution, configuration, startup ordering, and readiness | buzz-relay composes the engine with startup and serving coordination. |
| Authorized reads, lifecycle actions, validation, audit, conflicts, and diagnostics | Relay deployment-admin interfaces expose the contract; tools use them instead of accessing tables. |
| List, detail, progress, diagnostics, readiness, and controls | The desktop/admin client projects relay state and never stores another lifecycle. |
This keeps the repository's focused-crate architecture: the relay orchestrates, the backfill crate owns its domain, and the database crate provides general transaction infrastructure. Backfill administration remains an operator admin concern; it does not add a Nostr event or NIP.