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}