Files
buzz/docs/data-backfills.md
Luke Tornquist 9263cda845 docs: specify durable data backfills (#7326)
## 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>
2026-09-25 13:05:41 -04:00

512 lines
27 KiB
Markdown

# 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:
1. The application registers the stable ID.
2. Starting the backfill captures a finite upper bound.
3. A worker takes an exclusive claim.
4. Each transaction changes target rows and advances the checkpoint together.
5. Workers repeat bounded batches until they reach the upper bound.
6. 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:
1. **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.
2. **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.
3. **Rollback.** Failure after mutation but before commit, including checkpoint
failure, leaves target data and checkpoint unchanged.
4. **Restart.** Termination before and after batch commit, and during validation,
rebuilds progress from PostgreSQL without recapturing the bound.
5. **Stale owner.** Pause, expiry, or takeover while a worker is in flight
rejects its mutation, checkpoint, validation, and completion at commit.
6. **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.
7. **Validation.** Reaching the bound cannot complete without validation.
Failure is durable and repeatable; only success reaches immutable completion.
8. **Bounded failure.** Persistent errors reach `failed` or `blocked` instead of
retrying forever. Operator retry preserves the bound and checkpoint.
9. **Admin authorization.** Unauthorized reads and controls return no protected
state and perform no mutation. Community roles grant no authority.
10. **Admin idempotency.** Duplicate, concurrent, and lost-response requests do
not duplicate starts, reuse generations, or recapture bounds. Conflicts
return current state.
11. **Explicit validation.** Start with a `completed` row 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-`pending` rows. Unauthorized, duplicate, concurrent, and
lost-response requests follow the contract above. Removing serialization,
read-only behavior, or an immutable-state guard fails the test.
12. **Client projection.** The UI preserves unknown and failure states and sends
only supported controls.
13. **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`,
and `failed` through 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.