Skip to main content

spate_s3/
split.rs

1//! Unit descriptors and deterministic unit identity.
2//!
3//! A unit of work is one object, one byte range of one object, or several
4//! objects read whole in turn. The
5//! [`SplitDescriptor`] carries the unit's member objects (keys, sizes, ETags)
6//! and its range, if it has one, verbatim to whichever worker receives the
7//! unit, so workers never list.
8//!
9//! # Identity
10//!
11//! [`split_id_for`] digests the member set (keys **and** ETags) plus a
12//! version into a stable id; [`split_id_for_range`] digests one object's key,
13//! ETag and range the same way. The consequences:
14//!
15//! - Publishing unchanged work again reproduces the same ids.
16//! - An overwritten object (new ETag) yields a **new** id: the new content is
17//!   new work, never silently skipped against stale progress.
18//! - A change to the version retires every old id as an explicit epoch.
19//!
20//! The digest is truncated SHA-256: ids are persisted identity, and a
21//! collision would silently drop one member set, so the digest must hold up
22//! even for adversarially-named keys.
23//!
24//! # Cutting
25//!
26//! An object above the target that [`Packing::delimiter_for`] accepts is cut
27//! by [`tile`] into byte ranges of at most the target, one unit each; any
28//! other object is read whole, and the lister packs it into a unit with the
29//! objects listed beside it while their bytes stay within the target.
30
31use crate::config::Compression;
32use crate::fetch::ObjectEntry;
33use crate::framer::Codec;
34use base64::Engine as _;
35use base64::engine::general_purpose::URL_SAFE_NO_PAD;
36use serde::{Deserialize, Serialize};
37use sha2::{Digest as _, Sha256};
38
39/// Version of the [`SplitDescriptor`] wire encoding. Bumped on any change
40/// to the descriptor's schema; a worker refuses a descriptor written by an
41/// incompatible release instead of misreading it.
42pub const DESCRIPTOR_VERSION: u32 = 2;
43
44/// The most member objects a descriptor may name for the source to read it.
45/// A unit naming more is failed back. Far below the composite offset's
46/// ordinal space, so no unit's ordinal overflows it.
47pub const MAX_UNIT_MEMBERS: usize = 4096;
48
49/// The most objects one unit the lister publishes packs together.
50pub(crate) const PACK_MEMBERS: usize = 16;
51
52/// The longest key the lister packs with other objects. A longer key is
53/// published alone, so a packed unit's body stays under
54/// [`PACKED_BODY_MAX_BYTES`] on a store that allows keys longer than S3's
55/// 1,024 bytes.
56pub(crate) const PACK_KEY_MAX_BYTES: usize = 1024;
57
58/// The longest ETag the lister packs with other objects. A longer ETag is
59/// published alone, so a packed unit's body stays under
60/// [`PACKED_BODY_MAX_BYTES`].
61pub(crate) const PACK_ETAG_MAX_BYTES: usize = 256;
62
63/// The largest body a packed unit carries: [`PACK_MEMBERS`] members whose
64/// keys of [`PACK_KEY_MAX_BYTES`] and ETags of [`PACK_ETAG_MAX_BYTES`] each
65/// escape to two bytes a byte in JSON, with a size and a time each, come to
66/// about 43 KB. It sits under
67/// librdkafka's default `message.max.bytes` of 1,000,000 less 36 bytes of
68/// record overhead, and under the SQS feed's 1 MiB message less its
69/// attribute name and type, with room for a 25-byte id and a dead-letter
70/// copy's headers.
71pub(crate) const PACKED_BODY_MAX_BYTES: usize = 48 * 1024;
72
73const _: () = assert!(PACK_MEMBERS <= MAX_UNIT_MEMBERS);
74const _: () = assert!(PACKED_BODY_MAX_BYTES + 1024 + 25 <= 1_000_000 - 36);
75const _: () =
76    assert!(PACKED_BODY_MAX_BYTES + 1024 + 25 <= 1_048_576 - "spate.unit".len() - "String".len());
77
78/// Version folded into every id by [`split_id_for`] and
79/// [`split_id_for_range`]. It is part of the persisted identity, so a change
80/// retires every id already minted.
81pub(crate) const PACKING_VERSION: u32 = 2;
82
83/// Largest object a unit is cut into byte ranges: 50,000 GiB, the largest
84/// object S3 can hold (10,000 parts of 5 GiB). A listing reporting a larger
85/// size is read whole.
86pub(crate) const MAX_SUBDIVIDABLE_BYTES: u64 = 50_000 << 30;
87
88/// Why a unit descriptor or id could not be built or decoded.
89#[derive(Clone, Debug, PartialEq, Eq)]
90#[non_exhaustive]
91pub struct SplitError {
92    /// What was wrong, naming the object or range where there is one.
93    pub reason: String,
94}
95
96impl SplitError {
97    fn new(reason: impl Into<String>) -> SplitError {
98        SplitError {
99            reason: reason.into(),
100        }
101    }
102}
103
104impl std::fmt::Display for SplitError {
105    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106        f.write_str(&self.reason)
107    }
108}
109
110impl std::error::Error for SplitError {}
111
112/// One member object inside a [`SplitDescriptor`].
113///
114/// Mirrors what an object-store listing reports; everything a reader needs
115/// to fetch and pin the object without a HEAD request.
116#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
117pub struct DescriptorObject {
118    /// Full object key.
119    pub key: String,
120    /// Object size in bytes, from the listing.
121    pub size: u64,
122    /// ETag from the listing, if the store reports one. Readers pin every
123    /// GET to it (`If-Match`), so a concurrent overwrite surfaces as a
124    /// precondition failure instead of a silent content splice.
125    pub etag: Option<String>,
126    /// Last-modified time (ms since epoch), used as the records' event time.
127    pub last_modified_ms: i64,
128}
129
130/// A byte range `[start, end)` of one object, read as a unit of its own.
131///
132/// The range owns the records whose first byte lies in `[start, end)`, where a
133/// record starts at byte 0 and after every `delimiter` byte, so each record of
134/// an object belongs to exactly one range of a tiling. The object must be
135/// uncompressed. When the reader's `compression` setting decodes the key, the
136/// reader fails the unit back to the feed.
137#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
138#[non_exhaustive]
139pub struct SplitRange {
140    /// First byte of the range.
141    pub start: u64,
142    /// One past the last byte of the range.
143    pub end: u64,
144    /// The framer's [`resync_delimiter`](spate_core::framing::RecordFramer::resync_delimiter).
145    /// A reader whose framer declares a different one refuses the unit.
146    pub delimiter: u8,
147}
148
149impl SplitRange {
150    /// The range `[start, end)` of an object framed on `delimiter`.
151    #[must_use]
152    pub fn new(start: u64, end: u64, delimiter: u8) -> SplitRange {
153        SplitRange {
154            start,
155            end,
156            delimiter,
157        }
158    }
159}
160
161/// The body of a feed unit that names objects to read: the unit's member
162/// objects, in listing order.
163///
164/// The encoding is versioned JSON ([`DESCRIPTOR_VERSION`]); member order is
165/// meaningful (composite offsets index into it). Out-of-process producers
166/// (an event-notification handler, a single-shot invocation minting one
167/// unit from an S3 event) construct via [`SplitDescriptor::new`] or
168/// [`SplitDescriptor::with_range`] (which stamp the version;
169/// [`encode`](SplitDescriptor::encode) refuses anything else) and mint ids
170/// with [`split_id_for`] or [`split_id_for_range`] respectively, which
171/// together are the whole cross-process contract. Fields are freely readable.
172///
173/// The source reads a descriptor of one member whole, in byte ranges when the
174/// member qualifies for cutting, or as the byte range the descriptor names. It
175/// reads a descriptor of two or more members whole, member by member in order,
176/// on one lane, and a member named twice is read once per mention. It fails
177/// back a descriptor that names no member or more than [`MAX_UNIT_MEMBERS`],
178/// carries an ETag `If-Match` cannot send, or names a byte range cut on a
179/// delimiter other than the framer's or of an object the source decompresses.
180#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
181pub struct SplitDescriptor {
182    /// Encoding version; always [`DESCRIPTOR_VERSION`] at write. Private to
183    /// construction ([`SplitDescriptor::new`]): a hand-written version
184    /// would ship a descriptor every leasing worker fails on, fleet-wide.
185    pub(crate) v: u32,
186    /// Member objects, in listing (and therefore read) order.
187    pub objects: Vec<DescriptorObject>,
188    /// The byte range of the one member this unit reads, or `None` when it
189    /// reads every member whole.
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub range: Option<SplitRange>,
192}
193
194/// The version probe decoded before the full descriptor, so an
195/// incompatible version is reported as such rather than as a parse error.
196#[derive(Deserialize)]
197struct VersionProbe {
198    v: u32,
199}
200
201impl SplitDescriptor {
202    /// Build a descriptor over `objects` (listing order, since ordinals
203    /// index into it), stamped with the current [`DESCRIPTOR_VERSION`].
204    /// [`with_range`](SplitDescriptor::with_range) builds a ranged one, and
205    /// [`encode`](SplitDescriptor::encode) refuses any other version.
206    #[must_use]
207    pub fn new(objects: Vec<DescriptorObject>) -> SplitDescriptor {
208        SplitDescriptor {
209            v: DESCRIPTOR_VERSION,
210            objects,
211            range: None,
212        }
213    }
214
215    /// Build a descriptor over the byte `range` of `object`, stamped with the
216    /// current [`DESCRIPTOR_VERSION`]. [`encode`](SplitDescriptor::encode)
217    /// refuses it unless `object` has an ETag and
218    /// `range.start < range.end <= object.size`. `object` must be
219    /// uncompressed, as [`SplitRange`] states.
220    #[must_use]
221    pub fn with_range(object: DescriptorObject, range: SplitRange) -> SplitDescriptor {
222        SplitDescriptor {
223            v: DESCRIPTOR_VERSION,
224            objects: vec![object],
225            range: Some(range),
226        }
227    }
228
229    /// The encoding version this descriptor was constructed (or decoded)
230    /// under.
231    #[must_use]
232    pub fn version(&self) -> u32 {
233        self.v
234    }
235
236    /// Materialize the member objects as fetchable entries, preserving
237    /// descriptor order (ordinals index into it).
238    pub(crate) fn to_entries(&self) -> Vec<ObjectEntry> {
239        self.objects
240            .iter()
241            .map(|o| ObjectEntry {
242                key: o.key.clone(),
243                size: o.size,
244                etag: o.etag.clone(),
245                last_modified_ms: o.last_modified_ms,
246            })
247            .collect()
248    }
249
250    /// Build a descriptor from listed entries, preserving their order.
251    #[cfg(test)]
252    pub(crate) fn from_entries(entries: &[ObjectEntry]) -> SplitDescriptor {
253        SplitDescriptor::new(
254            entries
255                .iter()
256                .map(|e| DescriptorObject {
257                    key: e.key.clone(),
258                    size: e.size,
259                    etag: e.etag.clone(),
260                    last_modified_ms: e.last_modified_ms,
261                })
262                .collect(),
263        )
264    }
265
266    /// Encode to the versioned wire form.
267    ///
268    /// # Errors
269    ///
270    /// When the descriptor's
271    /// version is not [`DESCRIPTOR_VERSION`], or when it has a range and
272    /// not exactly one member, a member without an ETag, or a range that is
273    /// empty or extends past the member's size.
274    pub fn encode(&self) -> Result<Vec<u8>, SplitError> {
275        if self.v != DESCRIPTOR_VERSION {
276            return Err(SplitError::new(format!(
277                "descriptor version {} is not the supported {DESCRIPTOR_VERSION}; \
278                     construct via SplitDescriptor::new",
279                self.v
280            )));
281        }
282        self.validate_range()?;
283        Ok(serde_json::to_vec(self).expect("descriptor serialization is infallible: no non-string map keys, no fallible Serialize impls"))
284    }
285
286    /// Decode a descriptor, probing the version first.
287    ///
288    /// # Errors
289    ///
290    /// When the bytes do not parse,
291    /// were written under a different [`DESCRIPTOR_VERSION`], or carry a range
292    /// [`encode`](SplitDescriptor::encode) would refuse. A worker must never
293    /// guess at an incompatible descriptor.
294    pub fn decode(bytes: &[u8]) -> Result<SplitDescriptor, SplitError> {
295        let fatal = SplitError::new;
296        let probe: VersionProbe = serde_json::from_slice(bytes)
297            .map_err(|e| fatal(format!("unit descriptor is not valid JSON: {e}")))?;
298        if probe.v != DESCRIPTOR_VERSION {
299            return Err(fatal(format!(
300                "unit descriptor version {} is not this release's version \
301                 {DESCRIPTOR_VERSION}; the unit was written by an incompatible release",
302                probe.v
303            )));
304        }
305        let descriptor: SplitDescriptor = serde_json::from_slice(bytes)
306            .map_err(|e| fatal(format!("unit descriptor failed to decode: {e}")))?;
307        descriptor.validate_range()?;
308        Ok(descriptor)
309    }
310
311    /// Refuse a ranged descriptor unless it has exactly one member, that
312    /// member has an ETag, and `start < end <= size`.
313    fn validate_range(&self) -> Result<(), SplitError> {
314        let Some(range) = self.range else {
315            return Ok(());
316        };
317        let fatal = SplitError::new;
318        let [object] = self.objects.as_slice() else {
319            return Err(fatal(format!(
320                "a ranged unit descriptor has exactly one member, this one has {}",
321                self.objects.len()
322            )));
323        };
324        if object.etag.is_none() {
325            return Err(fatal(format!(
326                "ranged unit descriptor over \"{}\" has no ETag to pin its reads to",
327                object.key
328            )));
329        }
330        if range.start >= range.end || range.end > object.size {
331            return Err(fatal(format!(
332                "ranged unit descriptor over \"{}\" has range [{}, {}), which is empty or \
333                 extends past the object's {} bytes",
334                object.key, range.start, range.end, object.size
335            )));
336        }
337        Ok(())
338    }
339}
340
341/// Mint the deterministic unit id for a member set.
342///
343/// `members` are `(key, etag)` pairs; order does not matter (they are
344/// sorted by key before digesting). The id digests keys, ETags, and the
345/// version; see the module docs for why each is included. The
346/// result is always a valid id: 25 bytes of `[A-Za-z0-9_-]`
347/// regardless of what the keys contain.
348///
349/// Public so out-of-process producers mint byte-identical ids for the same
350/// members. The digest preimage is wire format, precise enough to
351/// reimplement in any language:
352///
353/// 1. Sort the members ascending by key (byte-wise comparison of the
354///    UTF-8 key bytes).
355/// 2. Feed SHA-256 with, in order:
356///    - the domain tag: the 15 ASCII bytes `spate-s3-split\n`;
357///    - the packing version as a little-endian `u32` (currently `2`, the
358///      crate's `PACKING_VERSION`);
359///    - for each member, in sorted order:
360///      - the key's byte length as a little-endian `u32`, then the key's
361///        UTF-8 bytes;
362///      - the ETag presence byte: `0x01` followed by the ETag's byte
363///        length as a little-endian `u32` and its UTF-8 bytes when
364///        present, the single byte `0x00` when absent.
365/// 3. Truncate the 32-byte digest to its first 16 bytes, encode them as
366///    base64url without padding (RFC 4648 §5), and prefix `s3-`, giving a
367///    25-character id over `[A-Za-z0-9_-]`.
368///
369/// ```
370/// use spate_s3::split_id_for;
371///
372/// let id = split_id_for([
373///     ("exports/2026/part-000.ndjson", Some("\"9b2cf5\"")),
374///     ("exports/2026/part-001.ndjson", None),
375/// ])
376/// .expect("non-empty member set");
377/// assert!(id.starts_with("s3-"));
378/// assert_eq!(id.len(), 25);
379/// ```
380///
381/// # Errors
382///
383/// When the member set is empty; a unit with no members is meaningless.
384pub fn split_id_for<'a, I>(members: I) -> Result<String, SplitError>
385where
386    I: IntoIterator<Item = (&'a str, Option<&'a str>)>,
387{
388    split_id_with_version(members, PACKING_VERSION)
389}
390
391/// [`split_id_for`] with an explicit packing version. The seam lets tests
392/// pin version sensitivity.
393fn split_id_with_version<'a, I>(members: I, version: u32) -> Result<String, SplitError>
394where
395    I: IntoIterator<Item = (&'a str, Option<&'a str>)>,
396{
397    let mut members: Vec<(&str, Option<&str>)> = members.into_iter().collect();
398    if members.is_empty() {
399        return Err(SplitError::new(
400            "cannot mint a unit id for an empty member set",
401        ));
402    }
403    members.sort_unstable_by_key(|(key, _)| *key);
404
405    let mut hasher = Sha256::new();
406    hasher.update(b"spate-s3-split\n");
407    hasher.update(version.to_le_bytes());
408    for (key, etag) in members {
409        digest_member(&mut hasher, key, etag);
410    }
411    id_from_digest(hasher)
412}
413
414/// Mint the deterministic unit id for the byte `range` of one object.
415///
416/// The id digests the key, the ETag, the range and the packing version, and
417/// never equals an id [`split_id_for`] mints. The digest preimage is wire
418/// format:
419///
420/// 1. Feed SHA-256 with, in order:
421///    - the domain tag: the 15 ASCII bytes `spate-s3-range\n`;
422///    - the packing version as a little-endian `u32` (currently `2`, the
423///      crate's `PACKING_VERSION`);
424///    - the key's byte length as a little-endian `u32`, then the key's UTF-8
425///      bytes;
426///    - the byte `0x01`, the ETag's byte length as a little-endian `u32`,
427///      then its UTF-8 bytes;
428///    - `range.start` and `range.end`, each a little-endian `u64`, then the
429///      single byte `range.delimiter`.
430/// 2. Truncate and encode the digest as step 3 of [`split_id_for`] does.
431///
432/// ```
433/// use spate_s3::{SplitRange, split_id_for_range};
434///
435/// let id = split_id_for_range(
436///     "exports/2026/part-000.ndjson",
437///     "\"9b2cf5\"",
438///     SplitRange::new(0, 64 << 20, b'\n'),
439/// )
440/// .expect("non-empty range");
441/// assert_eq!(id.len(), 25);
442/// ```
443///
444/// # Errors
445///
446/// When `range.start >= range.end`.
447pub fn split_id_for_range(key: &str, etag: &str, range: SplitRange) -> Result<String, SplitError> {
448    if range.start >= range.end {
449        return Err(SplitError::new(format!(
450            "cannot mint a unit id for the empty range [{}, {})",
451            range.start, range.end
452        )));
453    }
454    let mut hasher = Sha256::new();
455    hasher.update(b"spate-s3-range\n");
456    hasher.update(PACKING_VERSION.to_le_bytes());
457    digest_member(&mut hasher, key, Some(etag));
458    hasher.update(range.start.to_le_bytes());
459    hasher.update(range.end.to_le_bytes());
460    hasher.update([range.delimiter]);
461    id_from_digest(hasher)
462}
463
464/// Feed one member's key and ETag in the length-prefixed form both id
465/// preimages share.
466fn digest_member(hasher: &mut Sha256, key: &str, etag: Option<&str>) {
467    hasher.update(u32::try_from(key.len()).unwrap_or(u32::MAX).to_le_bytes());
468    hasher.update(key.as_bytes());
469    match etag {
470        Some(etag) => {
471            hasher.update([0x01]);
472            hasher.update(u32::try_from(etag.len()).unwrap_or(u32::MAX).to_le_bytes());
473            hasher.update(etag.as_bytes());
474        }
475        None => hasher.update([0x00]),
476    }
477}
478
479/// The `s3-` id over the first 16 bytes of the digest, base64url unpadded.
480fn id_from_digest(hasher: Sha256) -> Result<String, SplitError> {
481    let digest = hasher.finalize();
482    Ok(format!("s3-{}", URL_SAFE_NO_PAD.encode(&digest[..16])))
483}
484
485/// What cutting a unit into ranges needs besides the object.
486#[derive(Clone, Copy, Debug)]
487pub(crate) struct Packing {
488    /// The split target, in bytes. Never zero.
489    pub(crate) target_bytes: u64,
490    /// The configured compression, which decides each object's codec.
491    pub(crate) compression: Compression,
492    /// The framer's resync delimiter, or `None` to read every object whole.
493    pub(crate) delimiter: Option<u8>,
494}
495
496impl Packing {
497    /// The delimiter to cut `entry` on, or `None` when it is read whole.
498    ///
499    /// An object is cut when it is above the target, at most
500    /// [`MAX_SUBDIVIDABLE_BYTES`], uncompressed, and has an ETag.
501    pub(crate) fn delimiter_for(&self, entry: &ObjectEntry) -> Option<u8> {
502        let delimiter = self.delimiter?;
503        let cut = entry.size > self.target_bytes
504            && entry.size <= MAX_SUBDIVIDABLE_BYTES
505            && entry.etag.is_some()
506            && Codec::resolve(self.compression, &entry.key) == Codec::Plain;
507        cut.then_some(delimiter)
508    }
509}
510
511/// Cut `[0, size)` into `ceil(size / target)` contiguous ranges, each
512/// between `target / 2` and `target` bytes long when `size > target`.
513pub(crate) fn tile(size: u64, target: u64, delimiter: u8) -> impl Iterator<Item = SplitRange> {
514    let n = size.div_ceil(target);
515    // u128: `i * size` reaches about 2^71 for the largest object at the 1 MiB
516    // target floor.
517    let boundary = move |i: u64| (u128::from(i) * u128::from(size) / u128::from(n)) as u64;
518    (0..n).map(move |i| SplitRange::new(boundary(i), boundary(i + 1), delimiter))
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524    use proptest::prelude::*;
525
526    fn entry(key: &str, size: u64) -> ObjectEntry {
527        ObjectEntry {
528            key: key.to_string(),
529            size,
530            etag: Some(format!("\"etag-{key}\"")),
531            last_modified_ms: 1_760_000_000_000,
532        }
533    }
534
535    const MB: u64 = 1024 * 1024;
536
537    /// Packing that reads every object whole.
538    fn whole(target: u64) -> Packing {
539        Packing {
540            target_bytes: target,
541            compression: Compression::Auto,
542            delimiter: None,
543        }
544    }
545
546    /// Packing that cuts a large plain object on `\n`.
547    fn cut(target: u64) -> Packing {
548        Packing {
549            delimiter: Some(b'\n'),
550            ..whole(target)
551        }
552    }
553
554    // --- identity ---
555
556    #[test]
557    fn digest_id_is_pinned() {
558        // The id is persisted identity: accidental drift of the digest
559        // algorithm, preimage layout, or encoding must fail this test, and
560        // a deliberate change must bump PACKING_VERSION.
561        let id = split_id_for([
562            ("exports/2026/part-000.ndjson", Some("\"9b2cf5\"")),
563            ("exports/2026/part-001.ndjson", None),
564        ])
565        .unwrap();
566        assert_eq!(id, "s3-xje_MfL-wSNqMCft5ellCg");
567    }
568
569    #[test]
570    fn digest_is_order_insensitive_but_content_sensitive() {
571        let forward = split_id_for([("a", Some("1")), ("b", Some("2"))]).unwrap();
572        let reversed = split_id_for([("b", Some("2")), ("a", Some("1"))]).unwrap();
573        assert_eq!(forward, reversed);
574
575        let other_key = split_id_for([("a", Some("1")), ("c", Some("2"))]).unwrap();
576        assert_ne!(forward, other_key);
577    }
578
579    #[test]
580    fn digest_changes_when_an_etag_changes() {
581        let before = split_id_for([("a", Some("v1")), ("b", Some("x"))]).unwrap();
582        let overwritten = split_id_for([("a", Some("v2")), ("b", Some("x"))]).unwrap();
583        let dropped = split_id_for([("a", None), ("b", Some("x"))]).unwrap();
584        assert_ne!(before, overwritten);
585        assert_ne!(before, dropped);
586    }
587
588    #[test]
589    fn digest_folds_in_the_packing_version() {
590        let v1 = split_id_with_version([("a", Some("1"))], 1).unwrap();
591        let v2 = split_id_with_version([("a", Some("1"))], 2).unwrap();
592        assert_ne!(v1, v2);
593    }
594
595    #[test]
596    fn empty_member_set_is_rejected() {
597        let err = split_id_for(std::iter::empty()).unwrap_err();
598        assert_eq!(err.to_string(), err.reason);
599        assert!(err.reason.contains("empty member set"), "{}", err.reason);
600    }
601
602    #[test]
603    fn ambiguous_concatenations_do_not_collide() {
604        // Length prefixes and etag presence tags keep distinct member sets
605        // from concatenating to one preimage.
606        let a = split_id_for([("ab", Some("c"))]).unwrap();
607        let b = split_id_for([("a", Some("bc"))]).unwrap();
608        let c = split_id_for([("abc", None)]).unwrap();
609        assert_ne!(a, b);
610        assert_ne!(a, c);
611        assert_ne!(b, c);
612    }
613
614    // --- descriptor ---
615
616    #[test]
617    fn descriptor_round_trips() {
618        let desc = SplitDescriptor::from_entries(&[
619            entry("exports/part-000.ndjson.gz", 52 * MB),
620            ObjectEntry {
621                key: "exports/part-001.ndjson.gz".to_string(),
622                size: 9,
623                etag: None,
624                last_modified_ms: 1,
625            },
626        ]);
627        let decoded = SplitDescriptor::decode(&desc.encode().unwrap()).unwrap();
628        assert_eq!(decoded, desc);
629        assert_eq!(decoded.v, DESCRIPTOR_VERSION);
630    }
631
632    #[test]
633    fn descriptor_encoding_is_pinned() {
634        // The descriptor is a persisted document; its field names and
635        // shape are wire format. A deliberate change bumps
636        // DESCRIPTOR_VERSION and updates this pin.
637        let desc = SplitDescriptor::from_entries(&[entry("k", 5)]);
638        assert_eq!(
639            String::from_utf8(desc.encode().unwrap()).unwrap(),
640            r#"{"v":2,"objects":[{"key":"k","size":5,"etag":"\"etag-k\"","last_modified_ms":1760000000000}]}"#,
641        );
642    }
643
644    #[test]
645    fn encode_refuses_a_descriptor_not_built_by_new() {
646        // A hand-written version would ship a descriptor every leasing
647        // worker fails pipeline-fatal on; refuse at the producer instead.
648        let rogue = SplitDescriptor {
649            v: 0,
650            objects: vec![],
651            range: None,
652        };
653        let err = rogue.encode().unwrap_err();
654        assert!(
655            err.reason.contains("SplitDescriptor::new"),
656            "reason: {}",
657            err.reason
658        );
659        assert_eq!(SplitDescriptor::new(vec![]).version(), DESCRIPTOR_VERSION);
660    }
661
662    /// A version-1 descriptor, written before descriptors could carry a
663    /// range, is refused.
664    #[test]
665    fn a_version_1_descriptor_is_refused() {
666        let err = SplitDescriptor::decode(
667            br#"{"v":1,"objects":[{"key":"k","size":5,"etag":null,"last_modified_ms":1}]}"#,
668        )
669        .unwrap_err();
670        assert!(err.reason.contains("version 1"), "reason: {}", err.reason);
671    }
672
673    #[test]
674    fn unknown_descriptor_version_is_rejected_actionably() {
675        let err = SplitDescriptor::decode(br#"{"v":999,"objects":[]}"#).unwrap_err();
676        assert!(err.reason.contains("version 999"), "reason: {}", err.reason);
677        assert!(
678            err.reason.contains("incompatible release"),
679            "reason: {}",
680            err.reason
681        );
682
683        let garbage = SplitDescriptor::decode(b"not json").unwrap_err();
684        assert!(
685            garbage.reason.contains("not valid JSON"),
686            "{}",
687            garbage.reason
688        );
689    }
690
691    fn ranged_object(size: u64, etag: Option<&str>) -> DescriptorObject {
692        DescriptorObject {
693            key: "big.ndjson".to_string(),
694            size,
695            etag: etag.map(str::to_owned),
696            last_modified_ms: 7,
697        }
698    }
699
700    #[test]
701    fn ranged_descriptor_round_trips_and_its_encoding_is_pinned() {
702        let desc = SplitDescriptor::with_range(
703            ranged_object(100, Some("\"e\"")),
704            SplitRange::new(40, 80, b'\n'),
705        );
706        let encoded = desc.encode().unwrap();
707        assert_eq!(
708            String::from_utf8(encoded.clone()).unwrap(),
709            r#"{"v":2,"objects":[{"key":"big.ndjson","size":100,"etag":"\"e\"","last_modified_ms":7}],"range":{"start":40,"end":80,"delimiter":10}}"#,
710        );
711        assert_eq!(SplitDescriptor::decode(&encoded).unwrap(), desc);
712    }
713
714    /// `encode` and `decode` refuse a ranged descriptor with other than one
715    /// member, without an ETag, or with an empty or out-of-bounds range.
716    #[test]
717    fn invalid_ranged_descriptors_are_refused_on_both_sides() {
718        let pinned = || ranged_object(100, Some("\"e\""));
719        let two_members = SplitDescriptor {
720            objects: vec![pinned(), pinned()],
721            ..SplitDescriptor::with_range(pinned(), SplitRange::new(0, 10, b'\n'))
722        };
723        let no_members = SplitDescriptor {
724            objects: vec![],
725            ..SplitDescriptor::with_range(pinned(), SplitRange::new(0, 10, b'\n'))
726        };
727        let cases = [
728            ("member", two_members),
729            ("member", no_members),
730            (
731                "ETag",
732                SplitDescriptor::with_range(
733                    ranged_object(100, None),
734                    SplitRange::new(0, 10, b'\n'),
735                ),
736            ),
737            (
738                "empty",
739                SplitDescriptor::with_range(pinned(), SplitRange::new(10, 10, b'\n')),
740            ),
741            (
742                "empty",
743                SplitDescriptor::with_range(pinned(), SplitRange::new(11, 10, b'\n')),
744            ),
745            (
746                "past the object",
747                SplitDescriptor::with_range(pinned(), SplitRange::new(0, 101, b'\n')),
748            ),
749        ];
750        for (needle, desc) in cases {
751            let err = desc.encode().unwrap_err();
752            assert!(err.reason.contains(needle), "encode: {}", err.reason);
753            // The same bytes written by a producer that skipped validation.
754            let raw = serde_json::to_vec(&desc).unwrap();
755            let err = SplitDescriptor::decode(&raw).unwrap_err();
756            assert!(err.reason.contains(needle), "decode: {}", err.reason);
757        }
758        // A range covering the whole object is valid.
759        let whole = SplitDescriptor::with_range(pinned(), SplitRange::new(0, 100, b'\n'));
760        SplitDescriptor::decode(&whole.encode().unwrap()).unwrap();
761    }
762
763    #[test]
764    fn ranged_id_is_pinned() {
765        // Persisted identity, like `digest_id_is_pinned`; the value was
766        // recomputed from the documented preimage outside Rust.
767        let id = split_id_for_range(
768            "exports/2026/part-000.ndjson",
769            "\"9b2cf5\"",
770            SplitRange::new(0, 64 * MB, b'\n'),
771        )
772        .unwrap();
773        assert_eq!(id, "s3-fA0wNRW2MyDxquwFvNy0JA");
774    }
775
776    /// Every input of a ranged id moves it, and no ranged id equals the
777    /// member-set id of the same object.
778    #[test]
779    fn ranged_ids_digest_every_input_and_never_equal_member_set_ids() {
780        let base = split_id_for_range("k", "e", SplitRange::new(0, 10, b'\n')).unwrap();
781        for other in [
782            split_id_for_range("k2", "e", SplitRange::new(0, 10, b'\n')),
783            split_id_for_range("k", "e2", SplitRange::new(0, 10, b'\n')),
784            split_id_for_range("k", "e", SplitRange::new(1, 10, b'\n')),
785            split_id_for_range("k", "e", SplitRange::new(0, 11, b'\n')),
786            split_id_for_range("k", "e", SplitRange::new(0, 10, b';')),
787        ] {
788            assert_ne!(other.unwrap(), base);
789        }
790        assert_ne!(split_id_for([("k", Some("e"))]).unwrap(), base);
791        assert!(split_id_for_range("k", "e", SplitRange::new(10, 10, b'\n')).is_err());
792    }
793
794    // --- packing ---
795
796    /// An object above [`MAX_SUBDIVIDABLE_BYTES`] is read whole, and one at
797    /// the limit, or of 6 TiB, is cut.
798    #[test]
799    fn an_object_above_the_subdivision_limit_stays_whole() {
800        let packing = cut(1 << 30);
801        assert_eq!(packing.delimiter_for(&entry("6tib", 6 << 40)), Some(b'\n'));
802        let at = entry("at", MAX_SUBDIVIDABLE_BYTES);
803        assert_eq!(packing.delimiter_for(&at), Some(b'\n'));
804        let over = entry("over", MAX_SUBDIVIDABLE_BYTES + 1);
805        assert_eq!(packing.delimiter_for(&over), None);
806    }
807
808    /// An object is read whole when it is compressed by extension or by
809    /// configuration, has no ETag, meets a framer with no delimiter, or sits
810    /// exactly at the target.
811    #[test]
812    fn objects_that_cannot_be_cut_stay_whole() {
813        let target = 64 * MB;
814        let plain = entry("plain.ndjson", 3 * target);
815        let gzip_config = Packing {
816            compression: Compression::Gzip,
817            ..cut(target)
818        };
819        let no_etag = ObjectEntry {
820            etag: None,
821            ..plain.clone()
822        };
823        for (packing, object) in [
824            (cut(target), entry("gz.ndjson.gz", 3 * target)),
825            (cut(target), entry("zst.ndjson.zst", 3 * target)),
826            (gzip_config, plain.clone()),
827            (cut(target), no_etag),
828            (whole(target), plain.clone()),
829            (cut(target), entry("at.ndjson", target)),
830        ] {
831            assert_eq!(
832                packing.delimiter_for(&object),
833                None,
834                "{packing:?} {object:?}"
835            );
836        }
837        assert_eq!(
838            cut(target).delimiter_for(&entry("over.ndjson", target + 1)),
839            Some(b'\n'),
840            "one byte above the target is cut"
841        );
842    }
843
844    /// Every size from one byte above the target to eight times it, and the
845    /// largest object at the smallest target, tiles `[0, size)` with ranges
846    /// of `target / 2` to `target` bytes.
847    #[test]
848    fn ranges_tile_the_object_within_half_to_one_target() {
849        for target in 1..=64u64 {
850            for size in target + 1..=8 * target {
851                assert_tiles(size, target);
852            }
853        }
854        assert_tiles(MAX_SUBDIVIDABLE_BYTES, MB);
855        assert_tiles(MAX_SUBDIVIDABLE_BYTES - 1, MB);
856    }
857
858    fn assert_tiles(size: u64, target: u64) {
859        let mut count = 0;
860        let mut next = 0;
861        for range in tile(size, target, b'\n') {
862            count += 1;
863            assert_eq!(range.start, next, "size {size} target {target}");
864            let len = range.end - range.start;
865            assert!(
866                target / 2 <= len && len <= target,
867                "size {size} target {target}: range {range:?} is {len} bytes"
868            );
869            next = range.end;
870        }
871        assert_eq!(count, size.div_ceil(target), "size {size} target {target}");
872        assert_eq!(next, size, "size {size} target {target}");
873    }
874
875    proptest! {
876        #[test]
877        fn prop_ranges_tile_large_objects(
878            target in 1u64..=1 << 32,
879            n in 2u64..=1 << 12,
880            extra in any::<u64>(),
881        ) {
882            // A size in `((n - 1) * target, n * target]`, and its upper end.
883            assert_tiles((n - 1) * target + 1 + extra % target, target);
884            assert_tiles(n * target, target);
885        }
886
887        #[test]
888        fn prop_split_ids_are_valid_for_arbitrary_keys(
889            keys in proptest::collection::btree_set("[ -~]{1,64}", 1..8),
890            etag in proptest::option::of("[ -~]{1,16}"),
891        ) {
892            // S3 keys contain '/', '.', spaces, '%', anything printable —
893            // none of it may leak into the id (charset [A-Za-z0-9_-]).
894            let members: Vec<(&str, Option<&str>)> =
895                keys.iter().map(|k| (k.as_str(), etag.as_deref())).collect();
896            let id = split_id_for(members).unwrap();
897            prop_assert!(id.starts_with("s3-"));
898            prop_assert_eq!(id.len(), 25);
899        }
900
901    }
902}