Skip to main content

weir_sink_sdk/
lib.rs

1//! Sink trait and error contract for building sinks for the [weir] daemon.
2//!
3//! [weir]: https://github.com/miki-przygoda/weir
4//!
5//! A weir **sink** is a downstream commit target. The daemon's drain reads
6//! batches of records out of sealed write-ahead-buffer segments and hands them to
7//! a sink to commit to a database, HTTP endpoint, object store, etc. Implement
8//! [`Sink`] (and [`SinkError`] for your error type); the drain retries transient
9//! failures with backoff and dead-letters permanent ones.
10//!
11//! A sink whose `commit` can never fail picks `type Error = std::convert::Infallible`
12//! — no error type to hand-roll, no `is_transient` to write:
13//!
14//! ```
15//! use weir_sink_sdk::{CommitResult, Payload, Sink, SinkBatch, SinkHealth};
16//!
17//! struct StdoutSink;
18//!
19//! impl Sink for StdoutSink {
20//!     type Error = std::convert::Infallible; // this commit never returns Err
21//!     async fn commit(&self, batch: SinkBatch) -> Result<CommitResult, Self::Error> {
22//!         let batch = batch.into_records();
23//!         for r in &batch {
24//!             println!("{} bytes", r.len());
25//!         }
26//!         Ok(CommitResult::new(batch, Vec::new()))
27//!     }
28//!     async fn health(&self) -> SinkHealth {
29//!         SinkHealth::Healthy
30//!     }
31//! }
32//! ```
33//!
34//! A sink that *can* fail returns a [`SinkError`] that classifies the failure as
35//! transient (retried) or permanent (dead-lettered). Reach for the ready-made
36//! [`BasicSinkError`] before writing your own error type.
37//!
38//! # Idempotency contract
39//!
40//! The drain guarantees **at-least-once delivery per segment**, not per record.
41//! If the daemon crashes after a partial commit but before recording the segment
42//! as confirmed, `commit` is called again with the full segment — including
43//! records already committed. The same whole-segment replay also occurs when a
44//! segment is *stranded* (its transient-retry budget is exhausted while the sink
45//! is unavailable) and later auto-resumed once the sink recovers: the resume
46//! reprocesses from the start, not from the last durably-committed sub-batch.
47//! Implementations **must** handle duplicates gracefully — upsert,
48//! `INSERT IGNORE`, an `Idempotency-Key`, whatever the downstream offers. This
49//! is the explicit durability trade-off, not a protocol weakness.
50//!
51//! ## Which key to dedup on
52//!
53//! Two handles arrive on every [`SinkBatch`], and they are not
54//! interchangeable:
55//!
56//! - [`SinkBatch::record_ids`] — one [`RecordId`] per record, derived from its
57//!   WAB coordinate. Distinct for two records carrying identical bytes, and
58//!   **unchanged when `sink_max_batch_size` changes between two delivery
59//!   attempts**. Use this when a duplicate is expensive.
60//! - [`SinkBatch::dedup_token`] — one [`DedupToken`] for the whole batch,
61//!   derived from its bytes. Simpler, but it names the batch, so it moves when
62//!   the drain re-splits a segment at different boundaries. Fine for an
63//!   archive; not for a billing row.
64//!
65//! **Do not dedup on a hash of the payload.** It is not a weaker version of the
66//! two above, it is a different thing that loses data: a producer emitting
67//! repetitive records — a heartbeat, a fixed-shape metering event — hands the
68//! downstream the same key for genuinely distinct events, and a correct
69//! idempotent endpoint keeps the first and discards the rest. weir acked those
70//! records and delivered them; the key threw them away.
71//!
72//! # Running your sink in the daemon
73//!
74//! This crate lets you **implement and test** a sink against a stable trait,
75//! independent of the daemon's internals. *Running* it is a separate matter: the
76//! released `weir-server` binary wires only the built-in sinks selected by the
77//! `sink_type` config. There is **no dynamic plugin or registration path yet** —
78//! to run a custom sink today you build a `weir-server` with your sink compiled
79//! into the sink-selection path (effectively a small fork). A first-class
80//! entry-point for downstream sinks is a candidate for a future minor release;
81//! because it is purely additive it would be a SemVer-compatible change.
82
83// The drain is always generic over `S: Sink` and stores `Arc<S>` — it never uses
84// `dyn Sink`. So the Send-bound ergonomics the `async_fn_in_trait` lint warns
85// about do not apply here, and sink authors get clean `async fn` signatures.
86#![allow(async_fn_in_trait)]
87#![deny(missing_docs)]
88
89/// Opaque record payload bytes (re-exported from `weir-core`). A newtype over
90/// ref-counted `bytes::Bytes` that derefs to `[u8]`, so clones through the drain
91/// are O(1). Sinks normally *receive* `Payload`s from the drain; to build one
92/// yourself (e.g. in a unit test) use `Payload::copy_from_slice(bytes)`,
93/// `Payload::from(&b"..."[..])`, or `Payload::from(vec_of_u8)`.
94pub use weir_core::Payload;
95
96/// An error returned by [`Sink::commit`].
97///
98/// Implementations must classify every error as transient or permanent:
99/// - **Transient**: the drain retries the whole segment with exponential backoff.
100/// - **Permanent**: the affected records are dead-lettered and the segment is confirmed.
101pub trait SinkError: Send + Sync + std::error::Error + 'static {
102    /// Whether the drain should retry the segment (`true`) or dead-letter it (`false`).
103    fn is_transient(&self) -> bool;
104
105    /// Hint for how long to wait before retrying, e.g. parsed from an HTTP
106    /// `Retry-After` header on a 429 / 503. The drain uses this in place of its
107    /// exponential-backoff delay when present (subject to a sanity cap).
108    fn retry_after(&self) -> Option<std::time::Duration> {
109        None
110    }
111}
112
113/// [`SinkError`] for a sink whose `commit` can never fail: use
114/// `type Error = std::convert::Infallible` instead of hand-rolling a never-type.
115/// `is_transient` is unreachable (an `Infallible` value cannot exist).
116impl SinkError for std::convert::Infallible {
117    fn is_transient(&self) -> bool {
118        match *self {}
119    }
120}
121
122/// A ready-made [`SinkError`] for sinks that don't need a bespoke error type —
123/// a message plus a transient/permanent classification. Construct it with
124/// [`BasicSinkError::transient`] (the drain retries the segment) or
125/// [`BasicSinkError::permanent`] (the records are dead-lettered).
126#[derive(Debug, Clone)]
127pub struct BasicSinkError {
128    message: String,
129    transient: bool,
130}
131
132impl BasicSinkError {
133    /// A transient failure — the drain retries the whole segment with backoff.
134    pub fn transient(message: impl Into<String>) -> Self {
135        Self {
136            message: message.into(),
137            transient: true,
138        }
139    }
140
141    /// A permanent failure — the affected records are dead-lettered.
142    pub fn permanent(message: impl Into<String>) -> Self {
143        Self {
144            message: message.into(),
145            transient: false,
146        }
147    }
148}
149
150impl std::fmt::Display for BasicSinkError {
151    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152        write!(f, "{}", self.message)
153    }
154}
155
156impl std::error::Error for BasicSinkError {}
157
158impl SinkError for BasicSinkError {
159    fn is_transient(&self) -> bool {
160        self.transient
161    }
162}
163
164/// The result of a successful [`Sink::commit`].
165///
166/// Build one with [`CommitResult::new`]. The fields are public for reading, but
167/// the type is `#[non_exhaustive]`, so a future release can add a field (or a
168/// constructor variant) without a breaking change — construct it through `new`
169/// rather than a struct literal.
170///
171/// Conceptually, every record handed to [`Sink::commit`] should appear in
172/// exactly one of `committed` or `dead_lettered`. **Nothing enforces that set
173/// partition.** This type does not (a [`Payload`] carries no identity the
174/// constructor could check), and the drain only validates total **count**
175/// coverage: it refuses to confirm a segment unless
176/// `committed.len() + dead_lettered.len()` equals the batch length. That count
177/// check catches a sink that simply drops a record, but it does **not** catch a
178/// sink that emits the same record in *both* vectors while dropping a different
179/// one — the counts still add up, so the segment is confirmed and the dropped
180/// record is silently false-acked (never delivered, never dead-lettered).
181///
182/// **Author warning:** do not emit a record in both partitions, and do not drop
183/// or duplicate records — a count-correct but identity-incorrect partition can
184/// still cause a lost record. Built-in sinks always partition cleanly; the
185/// burden is on custom sinks.
186///
187/// # Reading a `CommitResult`
188///
189/// Both fields are public, so inspect them directly. `committed` is a
190/// `Vec<Payload>` of the accepted records; `dead_lettered` is a
191/// `Vec<(Payload, String)>`
192/// pairing each permanently-rejected record with its human-readable reason.
193///
194/// ```
195/// use weir_sink_sdk::{CommitResult, Payload};
196///
197/// // The drain hands you back a CommitResult; here we build one to read it.
198/// let result = CommitResult::new(
199///     vec![Payload::from(b"keep-1".as_ref()), Payload::from(b"keep-2".as_ref())],
200///     vec![(Payload::from(b"reject".as_ref()), "400 bad request".to_string())],
201/// );
202///
203/// // `committed`: the records the sink accepted.
204/// assert_eq!(result.committed.len(), 2);
205/// for record in &result.committed {
206///     println!("committed {} bytes", record.len());
207/// }
208///
209/// // `dead_lettered`: each rejected record paired with WHY it was rejected.
210/// assert_eq!(result.dead_lettered.len(), 1);
211/// for (record, reason) in &result.dead_lettered {
212///     println!("dead-lettered {} bytes: {reason}", record.len());
213/// }
214/// let (rejected, reason) = &result.dead_lettered[0];
215/// assert_eq!(&rejected[..], b"reject");
216/// assert_eq!(reason, "400 bad request");
217/// ```
218///
219/// # Driving an async `commit` from a sync test (no runtime)
220///
221/// `Sink::commit` is `async`, but you can unit-test a sink whose commit is
222/// immediately ready (no real I/O await points) without pulling in a runtime,
223/// by polling the future to completion with a no-op waker. This is the same
224/// `block_on` helper the SDK's own tests use — copy it into your sink's tests:
225///
226/// <div class="warning">
227///
228/// **Only for sinks with no real I/O await points.** This `block_on` busy-polls
229/// with a *no-op* waker — it never sleeps and never wakes. If the future ever
230/// returns `Poll::Pending` (any real `await` that yields: a socket read, a timer,
231/// `tokio::fs`, `sqlx`, `reqwest`), this loop spins the CPU at 100% forever and
232/// **never makes progress** — the no-op waker can't reschedule it. It is fine
233/// only when `commit`/`health` complete synchronously (in-memory work, a `Vec`
234/// push, no yield point). For a sink that does real I/O, use a real runtime —
235/// write a `#[tokio::test]` and `.await` the commit directly (see
236/// `docs/getting-started/integrating.md` → "Unit-testing a real-I/O sink").
237///
238/// </div>
239///
240/// ```
241/// use weir_sink_sdk::{CommitResult, DedupToken, Payload, Sink, SinkBatch, SinkError, SinkHealth};
242///
243/// /// Minimal std-only executor: drives a future to completion by polling with
244/// /// a no-op waker. ONLY for a sink whose `commit`/`health` are immediately
245/// /// ready (no real I/O await points) — it busy-spins forever on any future
246/// /// that returns `Poll::Pending`. For real I/O use a `#[tokio::test]` instead.
247/// fn block_on<F: std::future::Future>(fut: F) -> F::Output {
248///     use std::task::{Context, Poll};
249///     let mut fut = std::pin::pin!(fut);
250///     let waker = std::task::Waker::noop();
251///     let mut cx = Context::from_waker(waker);
252///     loop {
253///         if let Poll::Ready(v) = fut.as_mut().poll(&mut cx) {
254///             return v;
255///         }
256///     }
257/// }
258///
259/// // A sink that dead-letters anything equal to `b"reject"` and commits the rest.
260/// struct MySink;
261/// impl Sink for MySink {
262///     type Error = std::convert::Infallible;
263///     async fn commit(&self, batch: SinkBatch) -> Result<CommitResult, Self::Error> {
264///         let batch = batch.into_records();
265///         let (mut ok, mut dead) = (Vec::new(), Vec::new());
266///         for r in batch {
267///             if r.as_ref() == b"reject" {
268///                 dead.push((r, "rejected by MySink".to_string()));
269///             } else {
270///                 ok.push(r);
271///             }
272///         }
273///         Ok(CommitResult::new(ok, dead))
274///     }
275///     async fn health(&self) -> SinkHealth {
276///         SinkHealth::Healthy
277///     }
278/// }
279///
280/// let records = vec![
281///     Payload::from(b"keep".as_ref()),
282///     Payload::from(b"reject".as_ref()),
283/// ];
284/// let batch = SinkBatch::new(records.clone(), DedupToken::for_payloads(records.iter()));
285/// // Drive the async commit to completion synchronously, then read the result.
286/// let result = block_on(MySink.commit(batch)).unwrap();
287/// assert_eq!(result.committed.len(), 1);
288/// assert_eq!(result.dead_lettered.len(), 1);
289/// assert_eq!(result.dead_lettered[0].1, "rejected by MySink");
290/// ```
291#[derive(Debug)]
292#[non_exhaustive]
293pub struct CommitResult {
294    /// Records the sink accepted.
295    pub committed: Vec<Payload>,
296    /// Records the sink permanently rejected, each with a human-readable reason.
297    pub dead_lettered: Vec<(Payload, String)>,
298}
299
300impl CommitResult {
301    /// Builds a commit result from the accepted and permanently-rejected records.
302    ///
303    /// Every record passed to [`Sink::commit`] should appear in exactly one of the
304    /// two lists. Neither this constructor nor the drain enforces that partition
305    /// by identity (only total count coverage is checked) — see the type-level
306    /// note for the failure mode and the author warning.
307    #[must_use]
308    pub fn new(committed: Vec<Payload>, dead_lettered: Vec<(Payload, String)>) -> Self {
309        Self {
310            committed,
311            dead_lettered,
312        }
313    }
314}
315
316/// Coarse health signal from [`Sink::health`].
317///
318/// `#[non_exhaustive]`: a finer health taxonomy may be added post-1.0, so
319/// downstream matches must include a wildcard arm.
320#[derive(Clone, Debug)]
321#[non_exhaustive]
322pub enum SinkHealth {
323    /// The downstream is fully available.
324    Healthy,
325    /// The downstream is partially available / degraded; the reason is for operators.
326    Degraded(String),
327    /// The downstream is unavailable; the reason is for operators.
328    Down(String),
329}
330
331/// A content-derived, batch-scoped idempotency handle for one [`Sink::commit`]
332/// call.
333///
334/// # What it is
335///
336/// `sha256(len(p₀) ++ p₀ ++ len(p₁) ++ p₁ ++ …)`, each length an 8-byte
337/// little-endian prefix, over the batch's payload bytes in delivery order.
338///
339/// The length prefix is load-bearing, not decoration. Concatenating payloads
340/// without a delimiter makes `["ab", "c"]` and `["a", "bc"]` hash identically —
341/// a dedup-capable sink would then drop the second, genuinely distinct batch as
342/// a duplicate and **lose data**. The prefix restores a prefix-free framing.
343///
344/// # What it guarantees
345///
346/// A crash-replayed, byte-identical batch produces a byte-identical token, so a
347/// downstream that deduplicates on it (ClickHouse's `insert_deduplication_token`,
348/// an HTTP `Idempotency-Key`, a Postgres `ON CONFLICT` key) collapses the
349/// re-delivery that weir's at-least-once contract permits.
350///
351/// # Precondition — keep `sink_max_batch_size` stable
352///
353/// The token covers exactly the sub-batch handed to `commit`, which the drain
354/// sizes by `sink_max_batch_size`. If that config changes across a restart, a
355/// replayed segment re-splits into differently-sized sub-batches whose tokens
356/// differ from the originals, the downstream does not recognise them as
357/// duplicates, and at-least-once becomes a double-insert. **The guarantee above
358/// holds only while that setting is stable.**
359///
360/// This is not a weakness in the digest that a better one would fix. A
361/// batch-level key is a function of the batch; re-batching changes the batch;
362/// so the key changes. Deriving it from the batch's WAB coordinate *range*
363/// instead does not help, because the range moves with the boundaries too, and
364/// making it segment-scoped is actively unsafe: every sub-batch of a segment
365/// would present the same value and a dedup-capable sink would discard all but
366/// the first, turning a duplicate-prevention feature into data loss.
367///
368/// **If your downstream cannot accept that precondition, do not use this
369/// token.** Use [`SinkBatch::record_ids`] instead: a [`RecordId`] names the
370/// record's position in the buffer rather than the batch it arrived in, so it
371/// is unchanged by a re-split. For an archive the precondition is cheap; for a
372/// billing or metering record a silent double-charge is not.
373///
374/// # Stability
375///
376/// The digest is byte-identical to the token weir 1.x's ClickHouse sink computed
377/// internally, so an operator upgrading mid-outage keeps deduplicating. It is
378/// pinned by a known-answer test and must not change without a major version.
379#[derive(Clone, Copy, PartialEq, Eq, Hash)]
380pub struct DedupToken([u8; 32]);
381
382impl DedupToken {
383    /// Derives the token from a batch's payloads, in delivery order.
384    pub fn for_payloads<'a>(payloads: impl IntoIterator<Item = &'a Payload>) -> Self {
385        use sha2::{Digest, Sha256};
386        let mut hasher = Sha256::new();
387        for p in payloads {
388            hasher.update((p.len() as u64).to_le_bytes());
389            hasher.update(p.as_ref());
390        }
391        Self(hasher.finalize().into())
392    }
393
394    /// Rebuilds a token from a previously captured digest. Intended for sink
395    /// authors' tests; the drain always uses [`DedupToken::for_payloads`].
396    pub fn from_bytes(bytes: [u8; 32]) -> Self {
397        Self(bytes)
398    }
399
400    /// The raw 32-byte digest.
401    pub fn as_bytes(&self) -> &[u8; 32] {
402        &self.0
403    }
404
405    /// Lower-hex, 64 characters, no prefix.
406    pub fn to_hex(&self) -> String {
407        use std::fmt::Write as _;
408        let mut out = String::with_capacity(64);
409        for b in self.0 {
410            // Writing to a String is infallible.
411            let _ = write!(out, "{b:02x}");
412        }
413        out
414    }
415
416    /// `sha256:<lower-hex>` — the form an HTTP `Idempotency-Key` wants, where
417    /// the prefix tells the endpoint which digest it is looking at.
418    pub fn to_prefixed_hex(&self) -> String {
419        format!("sha256:{}", self.to_hex())
420    }
421}
422
423impl std::fmt::Debug for DedupToken {
424    /// Prints the hex digest rather than 32 raw bytes — a token shows up in
425    /// drain error logs, where `[228, 91, ...]` is useless for correlating
426    /// against a downstream's dedup table.
427    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
428        write!(f, "DedupToken({})", self.to_hex())
429    }
430}
431
432/// A per-record identity: unique even across records carrying identical bytes.
433///
434/// # Why this exists separately from [`DedupToken`]
435///
436/// The two answer different questions and pull in opposite directions.
437///
438/// A `DedupToken` must be **stable across a retry**: the same batch, re-sent
439/// after a transient failure, has to present the same value or the retry
440/// deduplicates as new data. It is therefore a pure function of the payload
441/// bytes, and two batches carrying identical bytes are — correctly — the same
442/// token.
443///
444/// A per-record idempotency key must be **unique across records**. Derived from
445/// payload bytes alone, a producer emitting repetitive records (a heartbeat, a
446/// status ping, any fixed-shape event) hands the downstream the same key for
447/// genuinely distinct events, and a correctly-implemented idempotent endpoint
448/// keeps the first and discards the rest. weir acked those records and wrote
449/// them to disk; they are then dropped downstream by weir's own header.
450///
451/// `RecordId` mixes the record's WAB coordinate — the segment it was read from
452/// and its index within that segment — into the digest, so identical bytes at
453/// different coordinates are different ids. The coordinate is stable across a
454/// re-read of the same segment, so a retried delivery still presents the same
455/// id and per-record retry dedup keeps working.
456///
457/// # It survives a changed `sink_max_batch_size`
458///
459/// The index is the record's ordinal **within its segment**, not within the
460/// batch it was delivered in. The drain re-reads `sink_max_batch_size` on every
461/// call, so an operator who edits it — or a redeployed pod carrying a different
462/// config — re-splits an unconfirmed segment at different boundaries on replay.
463/// A [`DedupToken`] moves with those boundaries and a downstream keyed on it
464/// double-inserts; a `RecordId` does not move, so a downstream keyed on it
465/// still recognises the record.
466///
467/// **This is the identity to use when a duplicate is expensive** — a billing
468/// row, a metering event, anything an operator would be asked to refund. The
469/// invariant is pinned by `record_ids_survive_a_changed_sink_max_batch_size` in
470/// the daemon's drain tests.
471///
472/// The one thing it does not survive is a change of coordinate. `weir-ctl dl
473/// requeue` and `quarantine requeue` re-push records through the daemon socket
474/// into a *new* segment, so they arrive with new ids and a dedup-capable sink
475/// correctly treats them as new records. Both commands say so before asking for
476/// confirmation.
477///
478/// # Format
479///
480/// SHA-256 over `segment_len ++ segment ++ index ++ payload_len ++ payload`,
481/// every length a little-endian `u64`. The lengths are what stop
482/// `("ab", 1)` and `("a", 0xb...)` from colliding.
483#[derive(Clone, Copy, PartialEq, Eq, Hash)]
484pub struct RecordId([u8; 32]);
485
486impl RecordId {
487    /// Derives the id from a record's WAB coordinate and bytes.
488    ///
489    /// `segment` is the segment's file name and `index` the record's ordinal
490    /// within it — together the record's address in the buffer, which is what
491    /// makes this unique where a content hash is not.
492    pub fn for_record(segment: &str, index: u64, payload: &Payload) -> Self {
493        use sha2::{Digest, Sha256};
494        let mut hasher = Sha256::new();
495        hasher.update((segment.len() as u64).to_le_bytes());
496        hasher.update(segment.as_bytes());
497        hasher.update(index.to_le_bytes());
498        hasher.update((payload.len() as u64).to_le_bytes());
499        hasher.update(payload.as_ref());
500        Self(hasher.finalize().into())
501    }
502
503    /// Rebuilds an id from a previously captured digest. Intended for sink
504    /// authors' tests.
505    pub fn from_bytes(bytes: [u8; 32]) -> Self {
506        Self(bytes)
507    }
508
509    /// The raw 32-byte digest.
510    pub fn as_bytes(&self) -> &[u8; 32] {
511        &self.0
512    }
513
514    /// Lower-hex, 64 characters, no prefix.
515    pub fn to_hex(&self) -> String {
516        use std::fmt::Write as _;
517        let mut out = String::with_capacity(64);
518        for b in self.0 {
519            // Writing to a String is infallible.
520            let _ = write!(out, "{b:02x}");
521        }
522        out
523    }
524
525    /// `sha256:<lower-hex>` — the form an HTTP `Idempotency-Key` wants.
526    pub fn to_prefixed_hex(&self) -> String {
527        format!("sha256:{}", self.to_hex())
528    }
529}
530
531impl std::fmt::Debug for RecordId {
532    /// Prints the hex digest rather than 32 raw bytes, for the same reason
533    /// [`DedupToken`] does: an id shows up in drain logs, where a byte array is
534    /// useless for correlating against a downstream's dedup table.
535    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
536        write!(f, "RecordId({})", self.to_hex())
537    }
538}
539
540/// One batch of records on its way to [`Sink::commit`], with the
541/// [`DedupToken`] the drain derived from it.
542///
543/// The token is **batch-scoped, not segment-scoped**. The drain splits each
544/// sealed segment into `sink_max_batch_size` chunks and calls `commit` once per
545/// chunk; a token shared across those chunks would make a dedup-capable sink
546/// discard every chunk after the first. See [`DedupToken`] for the stability
547/// precondition that comes with it.
548///
549/// That precondition is why the batch also carries a [`RecordId`] per record
550/// ([`SinkBatch::record_ids`]). Those name the records' positions in the
551/// buffer rather than the batch, so they are the key to use when the downstream
552/// must not double-count — see [`RecordId`] for the two-way comparison.
553///
554/// Records are [`Payload`] — opaque bytes. weir 1.x let a sink pick its own
555/// record type via `Sink::Record`, but the only implementation was ever the
556/// identity on `Payload`, so 2.0 drops the generic and its no-op conversion.
557#[derive(Debug, Clone, PartialEq, Eq)]
558pub struct SinkBatch {
559    records: Vec<Payload>,
560    dedup_token: DedupToken,
561    /// Parallel to `records` when present. `None` from [`SinkBatch::new`], so a
562    /// batch built by a 1.x-era caller or a sink author's test is unchanged.
563    record_ids: Option<Vec<RecordId>>,
564    /// Creation time of the owning WAB segment, unix nanoseconds. `None` from
565    /// every constructor except [`SinkBatch::with_segment_context`], which only
566    /// the drain calls.
567    segment_created_at: Option<i64>,
568}
569
570impl SinkBatch {
571    /// Builds a batch. The drain calls this; sink authors need it to construct
572    /// realistic input in their own tests.
573    pub fn new(records: Vec<Payload>, dedup_token: DedupToken) -> Self {
574        Self {
575            records,
576            dedup_token,
577            record_ids: None,
578            segment_created_at: None,
579        }
580    }
581
582    /// Builds a batch that also carries a [`RecordId`] per record, in the same
583    /// order. [`SinkBatch::new`] remains the plain form.
584    ///
585    /// The drain no longer calls this — it uses
586    /// [`SinkBatch::with_segment_context`], which additionally carries the
587    /// owning segment's creation time. This constructor remains the right one
588    /// for a sink author's tests, and is not deprecated.
589    ///
590    /// Note for tests that compare batches: `SinkBatch` derives `PartialEq`, so
591    /// a batch from this constructor does **not** equal one from
592    /// `with_segment_context` even with identical records and ids.
593    ///
594    /// # Panics
595    ///
596    /// If `record_ids.len() != records.len()`. They are read positionally, so a
597    /// length mismatch would silently pair records with other records' ids —
598    /// worse than the bug this type exists to fix.
599    pub fn with_record_ids(
600        records: Vec<Payload>,
601        dedup_token: DedupToken,
602        record_ids: Vec<RecordId>,
603    ) -> Self {
604        assert_eq!(
605            records.len(),
606            record_ids.len(),
607            "SinkBatch::with_record_ids: {} records but {} ids",
608            records.len(),
609            record_ids.len()
610        );
611        Self {
612            records,
613            dedup_token,
614            record_ids: Some(record_ids),
615            segment_created_at: None,
616        }
617    }
618
619    /// Builds a batch carrying per-record ids **and** the owning segment's
620    /// creation time. The drain calls this; it is the constructor that lets a
621    /// sink derive a stable object key or partition path.
622    ///
623    /// `segment_created_at` is the WAB segment header's `created_at` field
624    /// (unix nanoseconds). Unlike wall-clock, it is written once and read back
625    /// unchanged after a crash-replay — so a sink may derive a partition path
626    /// from it and have the replayed batch land in the same place. Deriving one
627    /// from `SystemTime::now()` instead would place a replayed batch somewhere
628    /// new and duplicate it downstream.
629    ///
630    /// Note that this timestamp identifies the *segment*, not the batch: many
631    /// batches share one. A sink needing a unique per-batch name must use
632    /// [`SinkBatch::record_ids`], whose WAB coordinate is unique — a
633    /// [`DedupToken`] is **not** unique across batches carrying identical bytes.
634    ///
635    /// # Panics
636    ///
637    /// If `record_ids.len() != records.len()`, for the reason given on
638    /// [`SinkBatch::with_record_ids`].
639    #[must_use]
640    pub fn with_segment_context(
641        records: Vec<Payload>,
642        dedup_token: DedupToken,
643        record_ids: Vec<RecordId>,
644        segment_created_at: i64,
645    ) -> Self {
646        assert_eq!(
647            records.len(),
648            record_ids.len(),
649            "with_segment_context: record_ids must be parallel to records"
650        );
651        Self {
652            records,
653            dedup_token,
654            record_ids: Some(record_ids),
655            segment_created_at: Some(segment_created_at),
656        }
657    }
658
659    /// Creation time of the owning WAB segment in unix nanoseconds, when the
660    /// batch came from the drain. See [`SinkBatch::with_segment_context`] for
661    /// why this and not wall-clock.
662    #[must_use]
663    pub fn segment_created_at(&self) -> Option<i64> {
664        self.segment_created_at
665    }
666
667    /// The per-record ids, in record order — the idempotency key to use when a
668    /// duplicate is expensive.
669    ///
670    /// **The drain always supplies these**, one per record, on every `commit`.
671    /// A [`RecordId`] names the record's WAB coordinate, so unlike the batch's
672    /// [`DedupToken`] it does not move when `sink_max_batch_size` changes
673    /// between two delivery attempts: a sink keyed on these still recognises a
674    /// record it has already committed after such a change, and one keyed on
675    /// the token does not.
676    ///
677    /// `None` means the batch was **not** built by the drain — a hand-built
678    /// [`SinkBatch::new`] or `From<Vec<Payload>>`, i.e. a sink author's test.
679    /// A sink whose correctness rests on these ids should treat `None` as a
680    /// programming error and fail loudly rather than invent a substitute key;
681    /// one for which they are an optimisation should fall back to whatever key
682    /// it used before.
683    pub fn record_ids(&self) -> Option<&[RecordId]> {
684        self.record_ids.as_deref()
685    }
686
687    /// Consumes the batch for its records paired with their ids, where present.
688    pub fn into_records_with_ids(self) -> (Vec<Payload>, Option<Vec<RecordId>>) {
689        (self.records, self.record_ids)
690    }
691
692    /// The records, borrowed.
693    pub fn records(&self) -> &[Payload] {
694        &self.records
695    }
696
697    /// Consumes the batch for its records, discarding the token. This is the
698    /// one-line migration for a sink written against weir 1.x:
699    /// `let batch = batch.into_records();` at the top of `commit`.
700    pub fn into_records(self) -> Vec<Payload> {
701        self.records
702    }
703
704    /// Consumes the batch for both halves.
705    pub fn into_parts(self) -> (Vec<Payload>, DedupToken) {
706        (self.records, self.dedup_token)
707    }
708
709    /// The batch's idempotency handle.
710    pub fn dedup_token(&self) -> &DedupToken {
711        &self.dedup_token
712    }
713
714    /// Number of records in the batch.
715    pub fn len(&self) -> usize {
716        self.records.len()
717    }
718
719    /// Whether the batch carries no records.
720    pub fn is_empty(&self) -> bool {
721        self.records.is_empty()
722    }
723}
724
725/// Builds a batch whose token is derived from its own records — the same thing
726/// the drain does, and the convenient form for a sink author's tests.
727///
728/// Use [`SinkBatch::new`] instead when you need to pin a specific token, e.g. to
729/// assert that your sink forwards it unchanged.
730impl From<Vec<Payload>> for SinkBatch {
731    fn from(records: Vec<Payload>) -> Self {
732        let dedup_token = DedupToken::for_payloads(records.iter());
733        Self {
734            records,
735            dedup_token,
736            record_ids: None,
737            segment_created_at: None,
738        }
739    }
740}
741
742/// A downstream commit target for weir records.
743///
744/// The drain calls [`commit`](Sink::commit) with batches of records read from
745/// sealed segments. Implementations may be async (tokio, sqlx, reqwest, …); they
746/// run on a dedicated single-threaded tokio runtime in the drain thread.
747pub trait Sink: Send + Sync + 'static {
748    /// The error type this sink returns; must classify transient vs permanent.
749    type Error: SinkError;
750
751    /// Commit a batch of records. Returns the committed records and any
752    /// permanently rejected ones. Return `Err(e)` with `e.is_transient() == true`
753    /// to have the drain retry the whole segment.
754    ///
755    /// # Migrating from weir 1.x
756    ///
757    /// Two changes. `Sink::Record` is gone — records are always [`Payload`], as
758    /// they always were in practice — and the parameter is now a [`SinkBatch`]
759    /// carrying the batch's [`DedupToken`]. A sink that does not want the token
760    /// needs one deleted line and one added line:
761    ///
762    /// ```ignore
763    /// impl Sink for MySink {
764    ///     // type Record = Payload;          <- delete this
765    ///     type Error = MyError;
766    ///
767    ///     async fn commit(&self, batch: SinkBatch) -> Result<CommitResult, MyError> {
768    ///         let batch = batch.into_records();   // <- add this
769    ///         // ... the rest of your 1.x body, unchanged
770    ///     }
771    /// }
772    /// ```
773    ///
774    /// A sink whose downstream can deduplicate should instead read
775    /// [`SinkBatch::dedup_token`] and pass it along — see [`DedupToken`].
776    async fn commit(&self, batch: SinkBatch) -> Result<CommitResult, Self::Error>;
777
778    /// Maximum number of records per `commit` call. The drain splits larger
779    /// segments into sub-batches of this size.
780    fn max_batch_size(&self) -> usize {
781        1000
782    }
783
784    /// Health probe feeding the daemon's `weir_sink_health` gauge. The drain
785    /// calls it after a segment is processed in the Draining state, and on a
786    /// wall-clock interval while idle or blocked on a full dead-letter dir — so
787    /// the gauge keeps moving even when no segments are flowing. It is NOT called
788    /// after every individual commit (retries don't re-probe). Keep it cheap (a
789    /// single ping / HEAD) — it runs under a timeout backstop on the drain thread.
790    async fn health(&self) -> SinkHealth;
791}
792
793#[cfg(test)]
794mod tests {
795    use super::*;
796
797    /// The defect this type exists to fix: two distinct records that happen to
798    /// carry identical bytes must not present the same idempotency key.
799    #[test]
800    fn identical_payloads_at_different_coordinates_get_different_ids() {
801        let p = Payload::from(b"heartbeat".as_ref());
802        let a = RecordId::for_record("seg-000042.wab", 7, &p);
803        let b = RecordId::for_record("seg-000042.wab", 8, &p);
804        let c = RecordId::for_record("seg-000043.wab", 7, &p);
805        assert_ne!(a, b, "same segment, different index must differ");
806        assert_ne!(a, c, "same index, different segment must differ");
807        assert_ne!(b, c);
808    }
809
810    /// The property that lets a sink recognise a record it has already
811    /// committed after `sink_max_batch_size` changed: the index is the record's
812    /// ordinal within its SEGMENT, so re-splitting the segment into different
813    /// sub-batches leaves every id alone.
814    ///
815    /// Stated here as well as in the daemon because it is a promise of this
816    /// crate's public API — a third-party sink builds its dedup on it, and this
817    /// crate is what it depends on.
818    #[test]
819    fn ids_do_not_move_when_the_records_are_re_split_into_other_batches() {
820        let segment = "shard_00/seg_00000001.wab.sealed";
821        let payloads: Vec<Payload> = (1..=10)
822            .map(|i| Payload::copy_from_slice(format!("record-{i:02}").as_bytes()))
823            .collect();
824
825        // The drain's own numbering: absolute within the segment, independent
826        // of how the records are grouped for delivery.
827        let ids: Vec<RecordId> = payloads
828            .iter()
829            .enumerate()
830            .map(|(i, p)| RecordId::for_record(segment, i as u64 + 1, p))
831            .collect();
832
833        // Two deliveries of the same segment at two batch sizes. `chunks` is
834        // exactly what the drain does with `sink_max_batch_size`.
835        let at = |n: usize| -> Vec<RecordId> { ids.chunks(n).flat_map(|c| c.to_vec()).collect() };
836        assert_eq!(at(3).len(), 10);
837        assert_eq!(at(3), at(7), "a re-split must not change any id");
838
839        // And the counterfactual that makes the assertion non-vacuous: numbering
840        // each record within its BATCH does move them.
841        let batch_relative = |n: usize| -> Vec<RecordId> {
842            payloads
843                .chunks(n)
844                .flat_map(|c| {
845                    c.iter()
846                        .enumerate()
847                        .map(|(i, p)| RecordId::for_record(segment, i as u64 + 1, p))
848                        .collect::<Vec<_>>()
849                })
850                .collect()
851        };
852        assert_ne!(
853            batch_relative(3),
854            batch_relative(7),
855            "precondition: a batch-relative index is what would break this"
856        );
857    }
858
859    /// And the property that keeps per-record retry dedup working: a re-read of
860    /// the same record from the same place is the same id.
861    #[test]
862    fn the_same_record_reread_gets_the_same_id() {
863        let p = Payload::from(b"heartbeat".as_ref());
864        assert_eq!(
865            RecordId::for_record("seg-000042.wab", 7, &p),
866            RecordId::for_record("seg-000042.wab", 7, &p)
867        );
868    }
869
870    /// The length prefixes are load-bearing: without them a segment name and an
871    /// index could be re-split to produce the same byte stream.
872    #[test]
873    fn field_boundaries_cannot_be_confused() {
874        let p = Payload::from(b"x".as_ref());
875        assert_ne!(
876            RecordId::for_record("ab", 1, &p),
877            RecordId::for_record("a", 1, &p),
878            "a shorter segment name must not collide with a longer one"
879        );
880    }
881
882    /// Payload bytes still participate, so a corrupted re-read is a different id
883    /// rather than silently reusing the original record's key.
884    #[test]
885    fn payload_bytes_still_participate() {
886        let a = RecordId::for_record("seg.wab", 0, &Payload::from(b"one".as_ref()));
887        let b = RecordId::for_record("seg.wab", 0, &Payload::from(b"two".as_ref()));
888        assert_ne!(a, b);
889    }
890
891    #[test]
892    fn record_id_hex_forms_are_well_shaped() {
893        let id = RecordId::for_record("seg.wab", 0, &Payload::from(b"x".as_ref()));
894        assert_eq!(id.to_hex().len(), 64);
895        assert!(id.to_hex().chars().all(|c| c.is_ascii_hexdigit()));
896        assert_eq!(id.to_prefixed_hex(), format!("sha256:{}", id.to_hex()));
897        // Debug prints the digest, not a byte array — it lands in drain logs.
898        assert!(format!("{id:?}").contains(&id.to_hex()));
899    }
900
901    #[test]
902    fn with_record_ids_round_trips() {
903        let records = vec![Payload::from(b"a".as_ref()), Payload::from(b"b".as_ref())];
904        let ids: Vec<_> = records
905            .iter()
906            .enumerate()
907            .map(|(i, p)| RecordId::for_record("seg.wab", i as u64, p))
908            .collect();
909        let batch = SinkBatch::with_record_ids(
910            records.clone(),
911            DedupToken::for_payloads(records.iter()),
912            ids.clone(),
913        );
914        assert_eq!(batch.record_ids(), Some(&ids[..]));
915        let (r, got) = batch.into_records_with_ids();
916        assert_eq!(r, records);
917        assert_eq!(got, Some(ids));
918    }
919
920    /// A batch built the plain way carries no ids, and a sink must be able to
921    /// tell that apart from "ids that happen to be empty".
922    #[test]
923    fn plain_batches_carry_no_record_ids() {
924        let records = vec![Payload::from(b"a".as_ref())];
925        let batch = SinkBatch::new(records.clone(), DedupToken::for_payloads(records.iter()));
926        assert_eq!(batch.record_ids(), None);
927        assert_eq!(SinkBatch::from(records).record_ids(), None);
928    }
929
930    #[test]
931    #[should_panic(expected = "2 records but 1 ids")]
932    fn mismatched_id_count_panics_rather_than_mispairing() {
933        let records = vec![Payload::from(b"a".as_ref()), Payload::from(b"b".as_ref())];
934        let token = DedupToken::for_payloads(records.iter());
935        SinkBatch::with_record_ids(records, token, vec![RecordId::from_bytes([0u8; 32])]);
936    }
937
938    /// Minimal std-only executor: drives a future to completion by polling with a
939    /// no-op waker. Enough to test a sink whose `commit`/`health` are immediately
940    /// ready (no real I/O await points), without pulling a runtime into the SDK.
941    fn block_on<F: std::future::Future>(fut: F) -> F::Output {
942        use std::task::{Context, Poll};
943        let mut fut = std::pin::pin!(fut);
944        let waker = std::task::Waker::noop();
945        let mut cx = Context::from_waker(waker);
946        loop {
947            if let Poll::Ready(v) = fut.as_mut().poll(&mut cx) {
948                return v;
949            }
950        }
951    }
952
953    /// A trivial sink that counts committed records and dead-letters anything
954    /// equal to `b"reject"`. Shows the pattern sink authors use to unit-test a
955    /// `Sink` impl against the stable contract (no daemon, no runtime).
956    #[test]
957    fn a_custom_sink_can_be_driven_and_unit_tested() {
958        use std::sync::atomic::{AtomicUsize, Ordering};
959
960        #[derive(Debug)]
961        struct Never;
962        impl std::fmt::Display for Never {
963            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
964                write!(f, "never")
965            }
966        }
967        impl std::error::Error for Never {}
968        impl SinkError for Never {
969            fn is_transient(&self) -> bool {
970                true
971            }
972        }
973
974        struct CountingSink {
975            committed: AtomicUsize,
976        }
977        impl Sink for CountingSink {
978            type Error = Never;
979            async fn commit(&self, batch: SinkBatch) -> Result<CommitResult, Never> {
980                let batch = batch.into_records();
981                let (mut ok, mut dead) = (Vec::new(), Vec::new());
982                for r in batch {
983                    if r.as_ref() == b"reject" {
984                        dead.push((r, "rejected by CountingSink".to_string()));
985                    } else {
986                        ok.push(r);
987                    }
988                }
989                self.committed.fetch_add(ok.len(), Ordering::Relaxed);
990                Ok(CommitResult::new(ok, dead))
991            }
992            async fn health(&self) -> SinkHealth {
993                SinkHealth::Healthy
994            }
995        }
996
997        let sink = CountingSink {
998            committed: AtomicUsize::new(0),
999        };
1000        let records = vec![
1001            Payload::copy_from_slice(b"keep-1"),
1002            Payload::copy_from_slice(b"reject"),
1003            Payload::copy_from_slice(b"keep-2"),
1004        ];
1005        let batch = SinkBatch::new(records.clone(), DedupToken::for_payloads(records.iter()));
1006        let result = block_on(sink.commit(batch)).unwrap();
1007        assert_eq!(result.committed.len(), 2);
1008        assert_eq!(result.dead_lettered.len(), 1);
1009        assert_eq!(&result.dead_lettered[0].0[..], b"reject");
1010        assert_eq!(sink.committed.load(Ordering::Relaxed), 2);
1011        assert!(matches!(block_on(sink.health()), SinkHealth::Healthy));
1012    }
1013
1014    #[test]
1015    fn basic_sink_error_classifies_and_displays() {
1016        let t = BasicSinkError::transient("503 from upstream");
1017        assert!(t.is_transient());
1018        assert_eq!(t.to_string(), "503 from upstream");
1019        let p = BasicSinkError::permanent("400 bad request");
1020        assert!(!p.is_transient());
1021        // Usable as a SinkError trait object / std error.
1022        let _: &dyn SinkError = &p;
1023        let _: &dyn std::error::Error = &p;
1024    }
1025
1026    #[test]
1027    fn commit_result_new_keeps_both_partitions() {
1028        let r = CommitResult::new(
1029            vec![Payload::from(b"a".as_ref())],
1030            vec![(Payload::from(b"b".as_ref()), "rejected".to_string())],
1031        );
1032        assert_eq!(r.committed.len(), 1);
1033        assert_eq!(r.dead_lettered.len(), 1);
1034        assert_eq!(&r.committed[0][..], b"a");
1035        assert_eq!(&r.dead_lettered[0].0[..], b"b");
1036        assert_eq!(r.dead_lettered[0].1, "rejected");
1037    }
1038
1039    // ── DedupToken ────────────────────────────────────────────────────────────
1040
1041    fn p(bytes: &[u8]) -> Payload {
1042        Payload::from(bytes)
1043    }
1044
1045    /// Captured by running weir 1.3.1's `clickhouse::dedup_token` on
1046    /// `[b"alpha", b"beta", b"gamma"]`. Changing this constant means breaking
1047    /// compatibility with every deployment that deduplicates on the token.
1048    /// Don't. See also the second, deliberately independent copy in
1049    /// `weir-server`'s clickhouse sink tests.
1050    const KNOWN_ANSWER_1_X: &str =
1051        "5bc1fe58cc34db881c67b2acd898651311f6dfc576285c906c8f97e049b15342";
1052
1053    #[test]
1054    fn dedup_token_is_deterministic() {
1055        let batch = [p(b"a"), p(b"b")];
1056        assert_eq!(
1057            DedupToken::for_payloads(&batch).to_hex(),
1058            DedupToken::for_payloads(&batch).to_hex()
1059        );
1060    }
1061
1062    #[test]
1063    fn dedup_token_changes_on_reorder() {
1064        let a = [p(b"a"), p(b"b")];
1065        let b = [p(b"b"), p(b"a")];
1066        assert_ne!(
1067            DedupToken::for_payloads(&a).to_hex(),
1068            DedupToken::for_payloads(&b).to_hex()
1069        );
1070    }
1071
1072    #[test]
1073    fn dedup_token_distinguishes_different_batch_boundaries() {
1074        // Without the length prefix these two hash identically, and a
1075        // dedup-capable sink would drop the second as a duplicate — losing data.
1076        let a = [p(b"ab"), p(b"c")];
1077        let b = [p(b"a"), p(b"bc")];
1078        assert_ne!(
1079            DedupToken::for_payloads(&a).to_hex(),
1080            DedupToken::for_payloads(&b).to_hex()
1081        );
1082    }
1083
1084    #[test]
1085    fn dedup_token_empty_batch_is_the_empty_sha256() {
1086        // sha256 of zero bytes. An empty batch never reaches a sink in practice
1087        // (the drain skips it), but the function must be total.
1088        assert_eq!(
1089            DedupToken::for_payloads(&[] as &[Payload]).to_hex(),
1090            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
1091        );
1092    }
1093
1094    #[test]
1095    fn dedup_token_hex_forms_agree() {
1096        let t = DedupToken::for_payloads(&[p(b"x")]);
1097        assert_eq!(t.to_prefixed_hex(), format!("sha256:{}", t.to_hex()));
1098        assert_eq!(t.to_hex().len(), 64);
1099        assert!(
1100            t.to_hex()
1101                .chars()
1102                .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
1103        );
1104    }
1105
1106    #[test]
1107    fn dedup_token_round_trips_through_bytes() {
1108        let t = DedupToken::for_payloads(&[p(b"x")]);
1109        assert_eq!(DedupToken::from_bytes(*t.as_bytes()).to_hex(), t.to_hex());
1110    }
1111
1112    #[test]
1113    fn dedup_token_debug_is_hex_not_a_byte_array() {
1114        let t = DedupToken::for_payloads(&[p(b"x")]);
1115        let d = format!("{t:?}");
1116        assert!(
1117            d.contains(&t.to_hex()),
1118            "Debug should show the hex digest: {d}"
1119        );
1120        assert!(
1121            !d.contains('['),
1122            "Debug should not dump the byte array: {d}"
1123        );
1124    }
1125
1126    /// Known-answer vector. This exact digest is what weir 1.x's ClickHouse sink
1127    /// sent as `insert_deduplication_token` for this batch. If this test fails,
1128    /// the algorithm changed and every deployment that deduplicates on the token
1129    /// will stop recognising replays across the upgrade. Do not re-bless it —
1130    /// fix the code.
1131    #[test]
1132    fn dedup_token_matches_the_weir_1_x_digest() {
1133        let batch = [p(b"alpha"), p(b"beta"), p(b"gamma")];
1134        assert_eq!(
1135            DedupToken::for_payloads(&batch).to_hex(),
1136            KNOWN_ANSWER_1_X,
1137            "the dedup digest changed — see the doc comment"
1138        );
1139    }
1140
1141    // ── SinkBatch ─────────────────────────────────────────────────────────────
1142
1143    #[test]
1144    fn sink_batch_exposes_records_and_token() {
1145        let token = DedupToken::for_payloads(&[p(b"a")]);
1146        let batch = SinkBatch::new(vec![p(b"a"), p(b"b")], token);
1147
1148        assert_eq!(batch.len(), 2);
1149        assert!(!batch.is_empty());
1150        assert_eq!(batch.records(), &[p(b"a"), p(b"b")][..]);
1151        assert_eq!(batch.dedup_token().to_hex(), token.to_hex());
1152    }
1153
1154    #[test]
1155    fn sink_batch_into_records_is_the_migration_path() {
1156        // The whole third-party migration: one line at the top of commit().
1157        let batch = SinkBatch::new(vec![p(b"a")], DedupToken::for_payloads(&[p(b"a")]));
1158        let records: Vec<Payload> = batch.into_records();
1159        assert_eq!(records, vec![p(b"a")]);
1160    }
1161
1162    #[test]
1163    fn sink_batch_into_parts_yields_both() {
1164        let token = DedupToken::for_payloads(&[p(b"a")]);
1165        let (records, t) = SinkBatch::new(vec![p(b"a")], token).into_parts();
1166        assert_eq!(records, vec![p(b"a")]);
1167        assert_eq!(t.to_hex(), token.to_hex());
1168    }
1169
1170    #[test]
1171    fn sink_batch_empty_is_empty() {
1172        let batch = SinkBatch::new(vec![], DedupToken::for_payloads(&[] as &[Payload]));
1173        assert!(batch.is_empty());
1174        assert_eq!(batch.len(), 0);
1175    }
1176
1177    // ── segment_created_at (2.1.0) ──────────────────────────────────────────
1178
1179    #[test]
1180    fn segment_created_at_is_none_from_the_plain_constructors() {
1181        // A 1.x-era caller, or a sink author's test, must be unaffected by the
1182        // field's existence.
1183        let token = DedupToken::for_payloads(&[p(b"a")]);
1184        assert_eq!(
1185            SinkBatch::new(vec![p(b"a")], token).segment_created_at(),
1186            None
1187        );
1188        assert_eq!(SinkBatch::from(vec![p(b"a")]).segment_created_at(), None);
1189        assert_eq!(
1190            SinkBatch::with_record_ids(
1191                vec![p(b"a")],
1192                token,
1193                vec![RecordId::for_record(
1194                    "shard_00/seg_00000001.wab",
1195                    1,
1196                    &p(b"a")
1197                )],
1198            )
1199            .segment_created_at(),
1200            None
1201        );
1202    }
1203
1204    #[test]
1205    fn segment_created_at_round_trips_through_with_segment_context() {
1206        let payload = p(b"a");
1207        let batch = SinkBatch::with_segment_context(
1208            vec![payload.clone()],
1209            DedupToken::for_payloads(std::slice::from_ref(&payload)),
1210            vec![RecordId::for_record(
1211                "shard_00/seg_00000001.wab",
1212                1,
1213                &payload,
1214            )],
1215            1_788_704_730_000_000_000,
1216        );
1217        assert_eq!(batch.segment_created_at(), Some(1_788_704_730_000_000_000));
1218        assert_eq!(batch.record_ids().map(<[RecordId]>::len), Some(1));
1219    }
1220
1221    #[test]
1222    #[should_panic(expected = "with_segment_context: record_ids must be parallel")]
1223    fn with_segment_context_keeps_the_length_mismatch_assertion() {
1224        // The assertion is why with_record_ids exists; a new constructor that
1225        // dropped it would silently pair records with other records' ids.
1226        let a = p(b"a");
1227        let _ = SinkBatch::with_segment_context(
1228            vec![a.clone(), p(b"b")],
1229            DedupToken::for_payloads(std::slice::from_ref(&a)),
1230            vec![RecordId::for_record("s", 1, &a)],
1231            0,
1232        );
1233    }
1234}