Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

ADR-0004: Destination Write Contracts

Status: Accepted
Date: 2026-04
Context: Rivet writes exported data to four backends — local filesystem, S3, GCS, and stdout. Their failure modes, commit boundaries, and write guarantees differ. The planning and recovery layers must be able to reason about these differences without inspecting backend internals.


Problem

State and manifest writes (ADR-0001 invariants I2–I4) must happen only after the destination write is durably committed. But “committed” means different things for different backends:

  • Local writes stage into a dot-prefixed temp file in the target directory and commit with an atomic same-filesystem rename (OPT-6), so a failure leaves nothing at the final path — retry-safe, no partial-write risk.
  • S3 and GCS object writes are not committed until the writer handle is closed (dst.close()); a mid-upload failure leaves nothing at the destination.
  • stdout streams data immediately with no atomic commit point; a retry produces duplicate or corrupt output.

Without an explicit contract, the pipeline has no safe way to determine: when is it safe to advance the cursor? when is it safe to record a manifest entry? is a failed write safe to retry automatically?


Decision

Introduce two types in src/destination/mod.rs:

  • WriteCommitProtocol — when a write becomes durably committed and visible to readers.
  • DestinationCapabilities — the full set of operational guarantees for a backend.

Add a capabilities() method to the Destination trait so each backend declares its own contract. The pipeline can inspect capabilities without downcasting.


Per-Backend Capability Table

Backendcommit_protocolidempotent_overwriteretry_safepartial_write_risk
LocalDestinationAtomictruetruefalse
S3DestinationFinalizeOnClosetruetruefalse
GcsDestinationFinalizeOnClosetruetruefalse
AzureDestinationFinalizeOnClosetruetruefalse
StdoutDestinationStreamingfalsefalsetrue

S3Destination / GcsDestination / AzureDestination are all type aliases for CloudDestination<B> (src/destination/{s3,gcs,azure}.rs); they share one capabilities() body in cloud.rs, so the three cloud rows are identical by construction, not by coincidence.

WriteCommitProtocol semantics

write() returns Ok(WriteOutcome) (not Ok(())): on success the file is present per the commit protocol below, and the outcome carries the store’s own content checksum when the upload reported one (GCS/Azure single Put Blob MD5, S3 single PutObject ETag), which the commit path compares to the locally computed MD5 for a fail-fast, no-download transit-integrity check. None for backends/paths that report none (local FS, streamed multipart).

  • Atomic: a successful write() means the full file is present at the destination. The only Atomic backend (LocalDestination) stages into a temp file and commits via atomic same-filesystem rename, so a failure leaves nothing at the final path (partial_write_risk = false, retry_safe = true); an Atomic backend that could leave a partial artifact would declare partial_write_risk = true, in which case the caller would need to clean up before retrying.
  • FinalizeOnClose: The object is committed only when the internal writer handle is closed. A mid-upload failure leaves nothing at the destination — the object is never partially visible to readers. retry_safe = true because a failed upload can be retried from scratch with no cleanup needed.
  • Streaming: Data is written to an unbuffered output with no atomic commit boundary. Partial output may be observable before write() returns. Retrying after failure produces duplicate or corrupt output. There is no safe commit moment.

Alignment with ADR-0001 Invariants I2–I4

ADR-0001 requires that state writes (manifest, cursor, schema) happen only after the destination write succeeds. This ADR makes the commit boundary explicit:

  • I2 (Write Before Manifest): record_file is called after dest.write() returns Ok(()). For Atomic and FinalizeOnClose backends, this is the commit boundary.
  • I3 (Write Before Cursor): st.update() is called after the file-writing loop. For Atomic and FinalizeOnClose backends, all files are committed before the cursor advances.
  • I4 (Metric After Verdict): Unchanged — metrics are recorded at the terminal state of the run.

The ordering is made explicit in the source at the shared commit seam: pipeline/commit.rs::{write_part_file, record_part} (which every runner, including pipeline/single.rs:run_single_export at its record_part call, goes through) documents that state writes happen only after destination.write() returns Ok.


Runtime Capability Inspection

pipeline/single.rs:run_single_export inspects dest.capabilities() at runtime and logs the commit protocol for every run:

export 'orders': destination commit_protocol=Atomic idempotent=true retry_safe=true partial_risk=false

When a destination that is not retry-safe (retry_safe = false) is configured with automatic retries (max_retries > 0), a WARN is emitted once per export at capability-logging time, before any retry occurs:

export 'orders': stdout destination is not retry-safe (max_retries=2); partial artifacts may exist at destination on failure — manual cleanup may be needed

This surfaces retry-safety mismatches without blocking the run. With current backends this can fire only for stdout (Streaming); local, S3, GCS and Azure all declare retry_safe: true.


Known Gap: stdout state writes

StdoutDestination has commit_protocol: Streaming. There is no safe moment to advance state after a streaming write — any output may have been partially consumed by the reader before write() returns.

Current behavior: The pipeline does not special-case stdout for state writes. If stdout is used as a destination, cursor and manifest writes proceed as normal after write() returns. This is safe only because stdout is used exclusively in development/piping scenarios where state persistence is not meaningful. The plan validation layer rejects stdout + chunked and stdout + max_file_size combinations via Rejected diagnostics before execution starts.

If stdout is ever used in a production pipeline with cursor or manifest state, this gap must be addressed. The fix is for the pipeline to inspect capabilities().commit_protocol and skip or warn on state writes when Streaming.


Consequences

  • Each backend’s operational contract is now machine-readable and located with the implementation.
  • The planning and recovery layers can inspect capabilities() without coupling to backend types.
  • The stdout gap is documented rather than hidden; future callers are warned.
  • No breaking changes — capabilities() is a new trait method with a defined contract.