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}