Skip to main content

spate_s3/
lib.rs

1//! Coordinated object-storage (S3) backfill source for Spate.
2//!
3//! Point the source at a bucket/prefix and it streams every object's
4//! records through the pipeline: a **bounded** job that self-terminates
5//! (the pipeline exits [`Completed`](spate_core::pipeline::ExitState)) once
6//! the prefix is exhausted. Delivery is at-least-once: a resume replays
7//! from the last committed position, so duplicates are possible and loss
8//! is not.
9//!
10//! # Shape
11//!
12//! - **Work is planned as splits.** The fleet's elected leader lists the
13//!   prefix once and packs the sorted listing into **splits** — small
14//!   batches of whole objects, ~64 MiB by default — each with a
15//!   deterministic identity ([`split_id_for`]) and a self-contained
16//!   [`SplitDescriptor`] carrying its member keys, sizes, and ETags.
17//!   Workers lease splits through the coordination store and read them
18//!   straight from the descriptors: **workers never list**.
19//! - **One lane per in-flight split.** A gained split materializes one
20//!   data lane (one framework partition, one monotonic offset stream); a
21//!   record's `i64` offset packs (member ordinal within the split, record
22//!   index within the object). `coordination.max_in_flight` bounds the
23//!   working set and therefore read parallelism.
24//! - **Progress lives in the coordination store — nowhere else.** Commits
25//!   are fenced per-split writes; a lost or stolen split resumes on its
26//!   next owner from the acked watermark, drift-checked against the
27//!   descriptor's ETag pins. The coordinator is **assembly wiring**: hand
28//!   one in via [`S3Source::with_coordinator`] (e.g. `spate-coordination`'s
29//!   `StoreCoordinator` over its NATS JetStream store) and every replica
30//!   of the pipeline shares the backfill, takes a leader-assigned share, and
31//!   takes over from the dead. Without one the source runs solo over an
32//!   in-process store: correct, but a restart replays the prefix (a
33//!   startup WARN says so). This crate names no concrete backend — which
34//!   store a deployment uses is the deployer's choice, not a connector
35//!   compile-time feature.
36//! - **Bad objects poison their split, not the pipeline.** An object
37//!   deleted after planning, overwritten under its ETag pin, corrupt, or
38//!   unreadable past the retry budget hands its split back (surfaced in
39//!   `spate_s3_source_objects_failed_total{reason}`); at the attempt cap
40//!   the split is quarantined. A bounded job with quarantined splits ends
41//!   **failed**, never silently incomplete. Credentials/configuration
42//!   errors are still immediately fatal.
43//! - **Records are framed, not decoded, here.** The source is
44//!   format-agnostic: it streams object bytes (after gzip/zstd decompression)
45//!   through a [`RecordFramer`](spate_core::framing::RecordFramer) *you supply*
46//!   for the objects' format via
47//!   [`S3Source::with_framer`](crate::S3Source::with_framer) — e.g. `spate-json`'s
48//!   `NdjsonFramer` for NDJSON — emitting one raw payload per framed record.
49//!   Deserialization then stays in the operator chain (`spate-json` etc.),
50//!   exactly as with the Kafka source.
51//!
52//! # Split identity and drift
53//!
54//! A split's id digests its sorted member keys **and ETags** (plus the
55//! packing-algorithm version), and every GET is pinned `If-Match` to the
56//! descriptor's ETag. The consequences:
57//!
58//! - Replanning an unchanged prefix reproduces identical ids — replans
59//!   are create-if-absent no-ops, and completed splits stay completed.
60//! - An **overwritten** object shows up as a new split (new id) on the
61//!   next plan; under an in-flight split it trips the `If-Match` pin and
62//!   poisons the split. Either way it can never silently splice into
63//!   committed progress.
64//! - A **deleted** object poisons only the splits that still needed it.
65//!
66//! Prefer an append-only prefix for the lifetime of a backfill; by
67//! default the listing is taken once (`refresh_listing: false`), so
68//! late-arriving keys are simply not part of the job.
69//!
70//! No `object_store` types appear in this crate's public API (the same
71//! dependency policy that keeps rdkafka out of `spate-kafka`'s). The one
72//! seam that would break it, `S3Source::with_store`, is gated behind the
73//! off-by-default `testing` feature so tests can inject wrapped or
74//! fault-injecting stores without that type reaching a real consumer.
75
76mod config;
77mod error;
78mod fetch;
79mod framer;
80mod lane;
81mod metrics;
82mod offset;
83mod planner;
84mod source;
85mod split;
86mod split_ctx;
87#[cfg(test)]
88mod testutil;
89
90pub use config::{Compression, S3SourceConfig};
91pub use lane::{S3Batch, S3Lane};
92pub use source::S3Source;
93pub use split::{DESCRIPTOR_VERSION, DescriptorObject, SplitDescriptor, split_id_for};