Skip to main content

spate_s3/
split.rs

1//! Split descriptors, deterministic split identity, and listing-order
2//! packing. These are the shared vocabulary between the leader's planner and
3//! every split reader.
4//!
5//! A **split** is one leasable unit of work: a small batch of whole objects,
6//! or one byte range of a large object. The planner packs the sorted listing
7//! into splits; the [`SplitDescriptor`] carries each split's member objects
8//! (keys, sizes, ETags) and its range, if it has one, verbatim to whichever
9//! worker gains it, so workers never list.
10//!
11//! # Identity
12//!
13//! [`split_id_for`] digests the member set (keys **and** ETags) plus the
14//! packing-algorithm version into a stable [`SplitId`];
15//! [`split_id_for_range`] digests one object's key, ETag and range the same
16//! way. The consequences:
17//!
18//! - Replanning unchanged work reproduces the same ids, so re-submitting a
19//!   plan is a store-side create-if-absent no-op.
20//! - An overwritten object (new ETag) yields a **new** split id: the new
21//!   content is new work, never silently skipped against stale progress.
22//! - A change to the packing algorithm bumps the digested version, retiring
23//!   every old id as an explicit epoch instead of silently re-reading a
24//!   reshuffled listing against orphaned progress records.
25//!
26//! The digest is truncated SHA-256: split ids are persisted identity, and a
27//! collision would silently drop one member set, so the digest must hold up
28//! even for adversarially-named keys.
29//!
30//! # Packing
31//!
32//! [`pack`] walks the sorted listing in order and first-fits each object
33//! into one of a bounded window of open bins. It never reorders by size, so
34//! the output is a pure function of the listing order and objects sharing a
35//! key prefix stay in the same split. Each object costs at least
36//! `target / 16`, an open-cost floor that stops thousands of tiny objects
37//! coalescing into one split, so a split holds at most ~16 members. The planner
38//! validates each descriptor against its portable raw byte limit. An object above the
39//! target that [`Packing::delimiter_for`] accepts is cut into byte ranges of
40//! at most the target, one split each; any other object at or above the
41//! target lands alone in its own split.
42
43use crate::config::Compression;
44use crate::fetch::ObjectEntry;
45use crate::framer::Codec;
46use base64::Engine as _;
47use base64::engine::general_purpose::URL_SAFE_NO_PAD;
48use serde::{Deserialize, Serialize};
49use sha2::{Digest as _, Sha256};
50use spate_core::coordination::{CoordinationError, CoordinationErrorKind, SplitId};
51use std::collections::VecDeque;
52use std::rc::Rc;
53
54/// Version of the [`SplitDescriptor`] wire encoding. Bumped on any change
55/// to the descriptor's schema; a worker refuses a descriptor written by an
56/// incompatible release instead of misreading it.
57pub const DESCRIPTOR_VERSION: u32 = 2;
58
59/// Version of the packing algorithm, folded into every split id by
60/// [`split_id_for`]. Bumping it retires all previously planned ids as an
61/// explicit epoch (see the module docs).
62pub(crate) const PACKING_VERSION: u32 = 2;
63
64/// Largest object [`pack`] cuts into byte ranges: 50,000 GiB, the largest
65/// object S3 can hold (10,000 parts of 5 GiB). A listing reporting a larger
66/// size is read whole.
67pub(crate) const MAX_SUBDIVIDABLE_BYTES: u64 = 50_000 << 30;
68
69/// Maximum number of bins held open during packing. Bounds the open-bin
70/// window and how far out of listing order a member can land. The listing
71/// itself is resident for the whole plan, so planner memory scales with the
72/// listing rather than with this.
73pub(crate) const PACKING_LOOKBACK: usize = 10;
74
75/// Denominator of the per-object open-cost floor: each member costs at
76/// least `target / OPEN_COST_DIVISOR`, capping members per split at ~16.
77pub(crate) const OPEN_COST_DIVISOR: u64 = 16;
78
79/// One member object inside a [`SplitDescriptor`].
80///
81/// Mirrors what an object-store listing reports; everything a reader needs
82/// to fetch and pin the object without a HEAD request.
83#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
84pub struct DescriptorObject {
85    /// Full object key.
86    pub key: String,
87    /// Object size in bytes, from the listing.
88    pub size: u64,
89    /// ETag from the listing, if the store reports one. Readers pin every
90    /// GET to it (`If-Match`), so a concurrent overwrite surfaces as a
91    /// precondition failure instead of a silent content splice.
92    pub etag: Option<String>,
93    /// Last-modified time (ms since epoch), used as the records' event time.
94    pub last_modified_ms: i64,
95}
96
97/// A byte range `[start, end)` of one object, read as a split of its own.
98///
99/// The range owns the records whose first byte lies in `[start, end)`, where a
100/// record starts at byte 0 and after every `delimiter` byte, so each record of
101/// an object belongs to exactly one range of a tiling. The object must be
102/// uncompressed: a reader whose `compression` setting decodes the key fails
103/// the pipeline on a ranged split.
104#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
105#[non_exhaustive]
106pub struct SplitRange {
107    /// First byte of the range.
108    pub start: u64,
109    /// One past the last byte of the range.
110    pub end: u64,
111    /// The framer's [`resync_delimiter`](spate_core::framing::RecordFramer::resync_delimiter).
112    /// A reader whose framer declares a different one refuses the split.
113    pub delimiter: u8,
114}
115
116impl SplitRange {
117    /// The range `[start, end)` of an object framed on `delimiter`.
118    #[must_use]
119    pub fn new(start: u64, end: u64, delimiter: u8) -> SplitRange {
120        SplitRange {
121            start,
122            end,
123            delimiter,
124        }
125    }
126}
127
128/// The opaque payload carried in a
129/// [`SplitSpec::descriptor`](spate_core::coordination::SplitSpec): the
130/// split's member objects, in listing order.
131///
132/// The encoding is versioned JSON ([`DESCRIPTOR_VERSION`]); member order is
133/// meaningful (composite offsets index into it). Out-of-process producers
134/// (an event-notification planner, a single-shot invocation minting one
135/// split from an S3 event) construct via [`SplitDescriptor::new`] or
136/// [`SplitDescriptor::with_range`] (which stamp the version;
137/// [`encode`](SplitDescriptor::encode) refuses anything else) and mint ids
138/// with [`split_id_for`] or [`split_id_for_range`] respectively, which
139/// together are the whole cross-process contract. Fields are freely readable.
140#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
141pub struct SplitDescriptor {
142    /// Encoding version; always [`DESCRIPTOR_VERSION`] at write. Private to
143    /// construction ([`SplitDescriptor::new`]): a hand-written version
144    /// would ship a descriptor every leasing worker fails on, fleet-wide.
145    pub(crate) v: u32,
146    /// Member objects, in listing (and therefore read) order.
147    pub objects: Vec<DescriptorObject>,
148    /// The byte range of the one member this split reads, or `None` when it
149    /// reads every member whole.
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub range: Option<SplitRange>,
152}
153
154/// The version probe decoded before the full descriptor, so an
155/// incompatible version is reported as such rather than as a parse error.
156#[derive(Deserialize)]
157struct VersionProbe {
158    v: u32,
159}
160
161impl SplitDescriptor {
162    /// Build a descriptor over `objects` (listing order, since ordinals
163    /// index into it), stamped with the current [`DESCRIPTOR_VERSION`].
164    /// [`with_range`](SplitDescriptor::with_range) builds a ranged one, and
165    /// [`encode`](SplitDescriptor::encode) refuses any other version.
166    #[must_use]
167    pub fn new(objects: Vec<DescriptorObject>) -> SplitDescriptor {
168        SplitDescriptor {
169            v: DESCRIPTOR_VERSION,
170            objects,
171            range: None,
172        }
173    }
174
175    /// Build a descriptor over the byte `range` of `object`, stamped with the
176    /// current [`DESCRIPTOR_VERSION`]. [`encode`](SplitDescriptor::encode)
177    /// refuses it unless `object` has an ETag and
178    /// `range.start < range.end <= object.size`. `object` must be
179    /// uncompressed, as [`SplitRange`] states.
180    #[must_use]
181    pub fn with_range(object: DescriptorObject, range: SplitRange) -> SplitDescriptor {
182        SplitDescriptor {
183            v: DESCRIPTOR_VERSION,
184            objects: vec![object],
185            range: Some(range),
186        }
187    }
188
189    /// The encoding version this descriptor was constructed (or decoded)
190    /// under.
191    #[must_use]
192    pub fn version(&self) -> u32 {
193        self.v
194    }
195
196    /// Materialize the member objects as fetchable entries, preserving
197    /// descriptor order (ordinals index into it).
198    pub(crate) fn to_entries(&self) -> Vec<ObjectEntry> {
199        self.objects
200            .iter()
201            .map(|o| ObjectEntry {
202                key: o.key.clone(),
203                size: o.size,
204                etag: o.etag.clone(),
205                last_modified_ms: o.last_modified_ms,
206            })
207            .collect()
208    }
209
210    /// Build a descriptor from listed entries, preserving their order.
211    pub(crate) fn from_entries(entries: &[ObjectEntry]) -> SplitDescriptor {
212        SplitDescriptor::new(
213            entries
214                .iter()
215                .map(|e| DescriptorObject {
216                    key: e.key.clone(),
217                    size: e.size,
218                    etag: e.etag.clone(),
219                    last_modified_ms: e.last_modified_ms,
220                })
221                .collect(),
222        )
223    }
224
225    /// Encode to the versioned wire form.
226    ///
227    /// # Errors
228    ///
229    /// [`Fatal`](CoordinationErrorKind::Fatal) when the descriptor's
230    /// version is not [`DESCRIPTOR_VERSION`], or when it has a range and
231    /// not exactly one member, a member without an ETag, or a range that is
232    /// empty or extends past the member's size. A descriptor written under a
233    /// wrong version fails pipeline-fatal on every worker that leases it.
234    pub fn encode(&self) -> Result<Vec<u8>, CoordinationError> {
235        if self.v != DESCRIPTOR_VERSION {
236            return Err(CoordinationError::new(
237                CoordinationErrorKind::Fatal,
238                format!(
239                    "descriptor version {} is not the supported {DESCRIPTOR_VERSION}; \
240                     construct via SplitDescriptor::new",
241                    self.v
242                ),
243            ));
244        }
245        self.validate_range()?;
246        Ok(serde_json::to_vec(self).expect("descriptor serialization is infallible: no non-string map keys, no fallible Serialize impls"))
247    }
248
249    /// Decode a descriptor, probing the version first.
250    ///
251    /// # Errors
252    ///
253    /// [`Fatal`](CoordinationErrorKind::Fatal) when the bytes do not parse,
254    /// were written under a different [`DESCRIPTOR_VERSION`], or carry a range
255    /// [`encode`](SplitDescriptor::encode) would refuse. A worker must never
256    /// guess at an incompatible descriptor.
257    pub fn decode(bytes: &[u8]) -> Result<SplitDescriptor, CoordinationError> {
258        let fatal = |reason: String| CoordinationError::new(CoordinationErrorKind::Fatal, reason);
259        let probe: VersionProbe = serde_json::from_slice(bytes)
260            .map_err(|e| fatal(format!("split descriptor is not valid JSON: {e}")))?;
261        if probe.v != DESCRIPTOR_VERSION {
262            return Err(fatal(format!(
263                "split descriptor version {} is not this release's version \
264                 {DESCRIPTOR_VERSION}; the split was planned by an incompatible release",
265                probe.v
266            )));
267        }
268        let descriptor: SplitDescriptor = serde_json::from_slice(bytes)
269            .map_err(|e| fatal(format!("split descriptor failed to decode: {e}")))?;
270        descriptor.validate_range()?;
271        Ok(descriptor)
272    }
273
274    /// Refuse a ranged descriptor unless it has exactly one member, that
275    /// member has an ETag, and `start < end <= size`.
276    fn validate_range(&self) -> Result<(), CoordinationError> {
277        let Some(range) = self.range else {
278            return Ok(());
279        };
280        let fatal = |reason: String| CoordinationError::new(CoordinationErrorKind::Fatal, reason);
281        let [object] = self.objects.as_slice() else {
282            return Err(fatal(format!(
283                "a ranged split descriptor has exactly one member, this one has {}",
284                self.objects.len()
285            )));
286        };
287        if object.etag.is_none() {
288            return Err(fatal(format!(
289                "ranged split descriptor over \"{}\" has no ETag to pin its reads to",
290                object.key
291            )));
292        }
293        if range.start >= range.end || range.end > object.size {
294            return Err(fatal(format!(
295                "ranged split descriptor over \"{}\" has range [{}, {}), which is empty or \
296                 extends past the object's {} bytes",
297                object.key, range.start, range.end, object.size
298            )));
299        }
300        Ok(())
301    }
302}
303
304/// Mint the deterministic split id for a member set.
305///
306/// `members` are `(key, etag)` pairs; order does not matter (they are
307/// sorted by key before digesting). The id digests keys, ETags, and the
308/// packing version; see the module docs for why each is included. The
309/// result is always a valid [`SplitId`]: 25 bytes of `[A-Za-z0-9_-]`
310/// regardless of what the keys contain.
311///
312/// Public so out-of-process producers mint byte-identical ids for the same
313/// members. The digest preimage is wire format, precise enough to
314/// reimplement in any language:
315///
316/// 1. Sort the members ascending by key (byte-wise comparison of the
317///    UTF-8 key bytes).
318/// 2. Feed SHA-256 with, in order:
319///    - the domain tag: the 15 ASCII bytes `spate-s3-split\n`;
320///    - the packing version as a little-endian `u32` (currently `2`, the
321///      crate's `PACKING_VERSION`);
322///    - for each member, in sorted order:
323///      - the key's byte length as a little-endian `u32`, then the key's
324///        UTF-8 bytes;
325///      - the ETag presence byte: `0x01` followed by the ETag's byte
326///        length as a little-endian `u32` and its UTF-8 bytes when
327///        present, the single byte `0x00` when absent.
328/// 3. Truncate the 32-byte digest to its first 16 bytes, encode them as
329///    base64url without padding (RFC 4648 §5), and prefix `s3-`, giving a
330///    25-character id over `[A-Za-z0-9_-]`.
331///
332/// ```
333/// use spate_s3::split_id_for;
334///
335/// let id = split_id_for([
336///     ("exports/2026/part-000.ndjson", Some("\"9b2cf5\"")),
337///     ("exports/2026/part-001.ndjson", None),
338/// ])
339/// .expect("non-empty member set");
340/// assert!(id.as_str().starts_with("s3-"));
341/// assert_eq!(id.as_str().len(), 25);
342/// ```
343///
344/// # Errors
345///
346/// [`Fatal`](CoordinationErrorKind::Fatal) for an empty member set; a
347/// split with no members is meaningless.
348pub fn split_id_for<'a, I>(members: I) -> Result<SplitId, CoordinationError>
349where
350    I: IntoIterator<Item = (&'a str, Option<&'a str>)>,
351{
352    split_id_with_version(members, PACKING_VERSION)
353}
354
355/// [`split_id_for`] with an explicit packing version. The seam lets tests
356/// pin version sensitivity.
357fn split_id_with_version<'a, I>(members: I, version: u32) -> Result<SplitId, CoordinationError>
358where
359    I: IntoIterator<Item = (&'a str, Option<&'a str>)>,
360{
361    let mut members: Vec<(&str, Option<&str>)> = members.into_iter().collect();
362    if members.is_empty() {
363        return Err(CoordinationError::new(
364            CoordinationErrorKind::Fatal,
365            "cannot mint a split id for an empty member set",
366        ));
367    }
368    members.sort_unstable_by_key(|(key, _)| *key);
369
370    let mut hasher = Sha256::new();
371    hasher.update(b"spate-s3-split\n");
372    hasher.update(version.to_le_bytes());
373    for (key, etag) in members {
374        digest_member(&mut hasher, key, etag);
375    }
376    id_from_digest(hasher)
377}
378
379/// Mint the deterministic split id for the byte `range` of one object.
380///
381/// The id digests the key, the ETag, the range and the packing version, and
382/// never equals an id [`split_id_for`] mints. The digest preimage is wire
383/// format:
384///
385/// 1. Feed SHA-256 with, in order:
386///    - the domain tag: the 15 ASCII bytes `spate-s3-range\n`;
387///    - the packing version as a little-endian `u32` (currently `2`, the
388///      crate's `PACKING_VERSION`);
389///    - the key's byte length as a little-endian `u32`, then the key's UTF-8
390///      bytes;
391///    - the byte `0x01`, the ETag's byte length as a little-endian `u32`,
392///      then its UTF-8 bytes;
393///    - `range.start` and `range.end`, each a little-endian `u64`, then the
394///      single byte `range.delimiter`.
395/// 2. Truncate and encode the digest as step 3 of [`split_id_for`] does.
396///
397/// ```
398/// use spate_s3::{SplitRange, split_id_for_range};
399///
400/// let id = split_id_for_range(
401///     "exports/2026/part-000.ndjson",
402///     "\"9b2cf5\"",
403///     SplitRange::new(0, 64 << 20, b'\n'),
404/// )
405/// .expect("non-empty range");
406/// assert_eq!(id.as_str().len(), 25);
407/// ```
408///
409/// # Errors
410///
411/// [`Fatal`](CoordinationErrorKind::Fatal) when `range.start >= range.end`.
412pub fn split_id_for_range(
413    key: &str,
414    etag: &str,
415    range: SplitRange,
416) -> Result<SplitId, CoordinationError> {
417    if range.start >= range.end {
418        return Err(CoordinationError::new(
419            CoordinationErrorKind::Fatal,
420            format!(
421                "cannot mint a split id for the empty range [{}, {})",
422                range.start, range.end
423            ),
424        ));
425    }
426    let mut hasher = Sha256::new();
427    hasher.update(b"spate-s3-range\n");
428    hasher.update(PACKING_VERSION.to_le_bytes());
429    digest_member(&mut hasher, key, Some(etag));
430    hasher.update(range.start.to_le_bytes());
431    hasher.update(range.end.to_le_bytes());
432    hasher.update([range.delimiter]);
433    id_from_digest(hasher)
434}
435
436/// Feed one member's key and ETag in the length-prefixed form both id
437/// preimages share.
438fn digest_member(hasher: &mut Sha256, key: &str, etag: Option<&str>) {
439    hasher.update(u32::try_from(key.len()).unwrap_or(u32::MAX).to_le_bytes());
440    hasher.update(key.as_bytes());
441    match etag {
442        Some(etag) => {
443            hasher.update([0x01]);
444            hasher.update(u32::try_from(etag.len()).unwrap_or(u32::MAX).to_le_bytes());
445            hasher.update(etag.as_bytes());
446        }
447        None => hasher.update([0x00]),
448    }
449}
450
451/// The `s3-` id over the first 16 bytes of the digest, base64url unpadded.
452fn id_from_digest(hasher: Sha256) -> Result<SplitId, CoordinationError> {
453    let digest = hasher.finalize();
454    SplitId::new(format!("s3-{}", URL_SAFE_NO_PAD.encode(&digest[..16])))
455}
456
457/// What [`pack`] needs besides the listing.
458#[derive(Clone, Copy, Debug)]
459pub(crate) struct Packing {
460    /// The split target, in bytes. Never zero.
461    pub(crate) target_bytes: u64,
462    /// The configured compression, which decides each object's codec.
463    pub(crate) compression: Compression,
464    /// The framer's resync delimiter, or `None` to read every object whole.
465    pub(crate) delimiter: Option<u8>,
466}
467
468impl Packing {
469    /// The delimiter to cut `entry` on, or `None` when it is read whole.
470    ///
471    /// An object is cut when it is above the target, at most
472    /// [`MAX_SUBDIVIDABLE_BYTES`], uncompressed, and has an ETag.
473    pub(crate) fn delimiter_for(&self, entry: &ObjectEntry) -> Option<u8> {
474        let delimiter = self.delimiter?;
475        let cut = entry.size > self.target_bytes
476            && entry.size <= MAX_SUBDIVIDABLE_BYTES
477            && entry.etag.is_some()
478            && Codec::resolve(self.compression, &entry.key) == Codec::Plain;
479        cut.then_some(delimiter)
480    }
481}
482
483/// One split [`pack`] produces.
484#[derive(Clone, Debug, PartialEq, Eq)]
485pub(crate) enum Packed {
486    /// Whole objects, in listing order.
487    Objects(Vec<ObjectEntry>),
488    /// One byte range of one object, which has an ETag. The object's ranges
489    /// share one entry.
490    Range(Rc<ObjectEntry>, SplitRange),
491}
492
493impl Packed {
494    /// The split's deterministic id.
495    pub(crate) fn id(&self) -> Result<SplitId, CoordinationError> {
496        match self {
497            Packed::Objects(members) => {
498                split_id_for(members.iter().map(|m| (m.key.as_str(), m.etag.as_deref())))
499            }
500            Packed::Range(entry, range) => split_id_for_range(
501                &entry.key,
502                entry
503                    .etag
504                    .as_deref()
505                    .expect("pack cuts only an object with an ETag"),
506                *range,
507            ),
508        }
509    }
510
511    /// The split's descriptor.
512    pub(crate) fn descriptor(&self) -> SplitDescriptor {
513        match self {
514            Packed::Objects(members) => SplitDescriptor::from_entries(members),
515            Packed::Range(entry, range) => SplitDescriptor {
516                range: Some(*range),
517                ..SplitDescriptor::from_entries(std::slice::from_ref(&**entry))
518            },
519        }
520    }
521
522    /// The split's weight: its objects' summed sizes, or its range's length,
523    /// at least 1.
524    pub(crate) fn weight(&self) -> u64 {
525        match self {
526            // Saturating: sizes are remote listing data.
527            Packed::Objects(members) => members
528                .iter()
529                .fold(0u64, |acc, m| acc.saturating_add(m.size)),
530            Packed::Range(_, range) => range.end - range.start,
531        }
532        .max(1)
533    }
534}
535
536/// Cut `[0, size)` into `ceil(size / target)` contiguous ranges, each
537/// between `target / 2` and `target` bytes long when `size > target`.
538fn tile(size: u64, target: u64, delimiter: u8) -> impl Iterator<Item = SplitRange> {
539    let n = size.div_ceil(target);
540    // u128: `i * size` reaches about 2^71 for the largest object at the 1 MiB
541    // target floor.
542    let boundary = move |i: u64| (u128::from(i) * u128::from(size) / u128::from(n)) as u64;
543    (0..n).map(move |i| SplitRange::new(boundary(i), boundary(i + 1), delimiter))
544}
545
546/// Pack the sorted listing into splits of roughly `packing.target_bytes`
547/// each.
548///
549/// A pure function of `(entries, packing)`: walking the listing in order,
550/// each object costs `max(size, target_bytes / 16)` and first-fits into the
551/// oldest of at most [`PACKING_LOOKBACK`] open bins with room; a bin at or
552/// above the target closes, so an object costing the whole target lands
553/// alone in its own split. An object [`Packing::delimiter_for`] accepts is
554/// instead cut into `ceil(size / target)` byte ranges, emitted at its listing
555/// position in ascending order. It evicts an open bin as an oversized object
556/// does, so the other objects' bins are the same whether or not it is cut.
557/// Returned splits preserve listing order both across splits (by first
558/// member) and within each split.
559pub(crate) fn pack(entries: Vec<ObjectEntry>, packing: &Packing) -> Vec<Packed> {
560    let target_bytes = packing.target_bytes;
561    debug_assert!(
562        target_bytes > 0,
563        "config validation rejects a zero split target"
564    );
565    struct Bin {
566        members: Vec<ObjectEntry>,
567        cost: u64,
568        /// Set on a byte-range bin, which has no members and is never open.
569        range: Option<(Rc<ObjectEntry>, SplitRange)>,
570    }
571    let floor = (target_bytes / OPEN_COST_DIVISOR).max(1);
572    let mut bins: Vec<Bin> = Vec::new();
573    // Indexes into `bins` still accepting members, oldest first.
574    let mut open: VecDeque<usize> = VecDeque::new();
575    for entry in entries {
576        if let Some(delimiter) = packing.delimiter_for(&entry) {
577            if open.len() == PACKING_LOOKBACK {
578                open.pop_front();
579            }
580            let entry = Rc::new(entry);
581            bins.extend(tile(entry.size, target_bytes, delimiter).map(|range| Bin {
582                members: Vec::new(),
583                cost: range.end - range.start,
584                range: Some((Rc::clone(&entry), range)),
585            }));
586            continue;
587        }
588        let cost = entry.size.max(floor);
589        // Saturating: sizes are remote listing data and may be
590        // adversarially close to u64::MAX; the fit test must not overflow.
591        let idx = match open
592            .iter()
593            .position(|&i| bins[i].cost.saturating_add(cost) <= target_bytes)
594        {
595            Some(pos) => open[pos],
596            None => {
597                if open.len() == PACKING_LOOKBACK {
598                    open.pop_front();
599                }
600                bins.push(Bin {
601                    members: Vec::new(),
602                    cost: 0,
603                    range: None,
604                });
605                let idx = bins.len() - 1;
606                open.push_back(idx);
607                idx
608            }
609        };
610        bins[idx].members.push(entry);
611        bins[idx].cost = bins[idx].cost.saturating_add(cost);
612        if bins[idx].cost >= target_bytes
613            && let Some(pos) = open.iter().position(|&i| i == idx)
614        {
615            open.remove(pos);
616        }
617    }
618    bins.into_iter()
619        .map(|Bin { members, range, .. }| match range {
620            Some((entry, range)) => Packed::Range(entry, range),
621            None => Packed::Objects(members),
622        })
623        .collect()
624}
625
626#[cfg(test)]
627mod tests {
628    use super::*;
629    use proptest::prelude::*;
630
631    fn entry(key: &str, size: u64) -> ObjectEntry {
632        ObjectEntry {
633            key: key.to_string(),
634            size,
635            etag: Some(format!("\"etag-{key}\"")),
636            last_modified_ms: 1_760_000_000_000,
637        }
638    }
639
640    const MB: u64 = 1024 * 1024;
641
642    /// A target that is not a multiple of 16 rounds the open-cost floor down.
643    #[test]
644    fn the_open_cost_floor_rounds_down() {
645        let target = "1.1MiB".parse::<bytesize::ByteSize>().unwrap().as_u64();
646        assert_ne!(target % 16, 0);
647        let listing: Vec<ObjectEntry> = (0..32).map(|i| entry(&format!("k{i:02}"), 1)).collect();
648        let bins: Vec<usize> = object_bins(&pack(listing, &whole(target)))
649            .iter()
650            .map(Vec::len)
651            .collect();
652        assert_eq!(bins, [16, 16]);
653    }
654
655    /// Whole objects land in the same bins whether or not cutting is on,
656    /// pinned with literal bins and lookback counts.
657    #[test]
658    fn whole_object_bins_are_pinned() {
659        let keys = |packed: Vec<Packed>| -> Vec<Vec<String>> {
660            object_bins(&packed)
661                .into_iter()
662                .map(|b| b.into_iter().map(|e| e.key).collect())
663                .collect()
664        };
665        for packing in [whole(64 * MB), cut(64 * MB)] {
666            // First fit: `c` joins the oldest bin with room.
667            let first_fit = vec![
668                entry("a", 40 * MB),
669                entry("b", 50 * MB),
670                entry("c", 10 * MB),
671            ];
672            assert_eq!(keys(pack(first_fit, &packing)), [vec!["a", "c"], vec!["b"]]);
673
674            // Ten open bins: the eleventh evicts `a`, so `z` cannot join it.
675            let mut evicted = vec![entry("a", 40 * MB)];
676            evicted.extend((0..10).map(|i| entry(&format!("m{i}"), 60 * MB)));
677            evicted.push(entry("z", 20 * MB));
678            assert_eq!(keys(pack(evicted, &packing)).len(), 12, "{packing:?}");
679
680            // A bin exactly at the target closes, so it does not hold a
681            // lookback slot.
682            let mut exact = vec![entry("a", 40 * MB), entry("b", 64 * MB)];
683            exact.extend((0..9).map(|i| entry(&format!("m{i}"), 60 * MB)));
684            exact.push(entry("z", 20 * MB));
685            let bins = keys(pack(exact, &packing));
686            assert_eq!(bins[0], ["a", "z"], "{packing:?}");
687        }
688    }
689
690    /// The whole-object packing this crate shipped before byte-range
691    /// subdivision, as the reference [`pack`] must reproduce with cutting off.
692    fn reference_pack(entries: Vec<ObjectEntry>, target_bytes: u64) -> Vec<Vec<ObjectEntry>> {
693        struct Bin {
694            members: Vec<ObjectEntry>,
695            cost: u64,
696        }
697        let floor = (target_bytes / 16).max(1);
698        let mut bins: Vec<Bin> = Vec::new();
699        let mut open: VecDeque<usize> = VecDeque::new();
700        for entry in entries {
701            let cost = entry.size.max(floor);
702            let idx = match open
703                .iter()
704                .position(|&i| bins[i].cost.saturating_add(cost) <= target_bytes)
705            {
706                Some(pos) => open[pos],
707                None => {
708                    if open.len() == 10 {
709                        open.pop_front();
710                    }
711                    bins.push(Bin {
712                        members: Vec::new(),
713                        cost: 0,
714                    });
715                    open.push_back(bins.len() - 1);
716                    bins.len() - 1
717                }
718            };
719            bins[idx].members.push(entry);
720            bins[idx].cost = bins[idx].cost.saturating_add(cost);
721            if bins[idx].cost >= target_bytes
722                && let Some(pos) = open.iter().position(|&i| i == idx)
723            {
724                open.remove(pos);
725            }
726        }
727        bins.into_iter().map(|b| b.members).collect()
728    }
729
730    /// Packing that reads every object whole.
731    fn whole(target: u64) -> Packing {
732        Packing {
733            target_bytes: target,
734            compression: Compression::Auto,
735            delimiter: None,
736        }
737    }
738
739    /// Packing that cuts a large plain object on `\n`.
740    fn cut(target: u64) -> Packing {
741        Packing {
742            delimiter: Some(b'\n'),
743            ..whole(target)
744        }
745    }
746
747    /// The members of every whole-object split, in order.
748    fn object_bins(packed: &[Packed]) -> Vec<Vec<ObjectEntry>> {
749        packed
750            .iter()
751            .filter_map(|p| match p {
752                Packed::Objects(members) => Some(members.clone()),
753                Packed::Range(..) => None,
754            })
755            .collect()
756    }
757
758    // --- identity ---
759
760    #[test]
761    fn digest_id_is_pinned() {
762        // The id is persisted identity: accidental drift of the digest
763        // algorithm, preimage layout, or encoding must fail this test, and
764        // a deliberate change must bump PACKING_VERSION.
765        let id = split_id_for([
766            ("exports/2026/part-000.ndjson", Some("\"9b2cf5\"")),
767            ("exports/2026/part-001.ndjson", None),
768        ])
769        .unwrap();
770        assert_eq!(id.as_str(), "s3-xje_MfL-wSNqMCft5ellCg");
771    }
772
773    #[test]
774    fn digest_is_order_insensitive_but_content_sensitive() {
775        let forward = split_id_for([("a", Some("1")), ("b", Some("2"))]).unwrap();
776        let reversed = split_id_for([("b", Some("2")), ("a", Some("1"))]).unwrap();
777        assert_eq!(forward, reversed);
778
779        let other_key = split_id_for([("a", Some("1")), ("c", Some("2"))]).unwrap();
780        assert_ne!(forward, other_key);
781    }
782
783    #[test]
784    fn digest_changes_when_an_etag_changes() {
785        let before = split_id_for([("a", Some("v1")), ("b", Some("x"))]).unwrap();
786        let overwritten = split_id_for([("a", Some("v2")), ("b", Some("x"))]).unwrap();
787        let dropped = split_id_for([("a", None), ("b", Some("x"))]).unwrap();
788        assert_ne!(before, overwritten);
789        assert_ne!(before, dropped);
790    }
791
792    #[test]
793    fn digest_folds_in_the_packing_version() {
794        let v1 = split_id_with_version([("a", Some("1"))], 1).unwrap();
795        let v2 = split_id_with_version([("a", Some("1"))], 2).unwrap();
796        assert_ne!(v1, v2);
797    }
798
799    #[test]
800    fn empty_member_set_is_rejected() {
801        assert!(split_id_for(std::iter::empty()).is_err());
802    }
803
804    #[test]
805    fn ambiguous_concatenations_do_not_collide() {
806        // Length prefixes and etag presence tags keep distinct member sets
807        // from concatenating to one preimage.
808        let a = split_id_for([("ab", Some("c"))]).unwrap();
809        let b = split_id_for([("a", Some("bc"))]).unwrap();
810        let c = split_id_for([("abc", None)]).unwrap();
811        assert_ne!(a, b);
812        assert_ne!(a, c);
813        assert_ne!(b, c);
814    }
815
816    // --- descriptor ---
817
818    #[test]
819    fn descriptor_round_trips() {
820        let desc = SplitDescriptor::from_entries(&[
821            entry("exports/part-000.ndjson.gz", 52 * MB),
822            ObjectEntry {
823                key: "exports/part-001.ndjson.gz".to_string(),
824                size: 9,
825                etag: None,
826                last_modified_ms: 1,
827            },
828        ]);
829        let decoded = SplitDescriptor::decode(&desc.encode().unwrap()).unwrap();
830        assert_eq!(decoded, desc);
831        assert_eq!(decoded.v, DESCRIPTOR_VERSION);
832    }
833
834    #[test]
835    fn descriptor_encoding_is_pinned() {
836        // The descriptor is a persisted document; its field names and
837        // shape are wire format. A deliberate change bumps
838        // DESCRIPTOR_VERSION and updates this pin.
839        let desc = SplitDescriptor::from_entries(&[entry("k", 5)]);
840        assert_eq!(
841            String::from_utf8(desc.encode().unwrap()).unwrap(),
842            r#"{"v":2,"objects":[{"key":"k","size":5,"etag":"\"etag-k\"","last_modified_ms":1760000000000}]}"#,
843        );
844    }
845
846    #[test]
847    fn encode_refuses_a_descriptor_not_built_by_new() {
848        // A hand-written version would ship a descriptor every leasing
849        // worker fails pipeline-fatal on; refuse at the producer instead.
850        let rogue = SplitDescriptor {
851            v: 0,
852            objects: vec![],
853            range: None,
854        };
855        let err = rogue.encode().unwrap_err();
856        assert_eq!(err.kind, CoordinationErrorKind::Fatal);
857        assert!(
858            err.reason.contains("SplitDescriptor::new"),
859            "reason: {}",
860            err.reason
861        );
862        assert_eq!(SplitDescriptor::new(vec![]).version(), DESCRIPTOR_VERSION);
863    }
864
865    /// A version-1 descriptor, written before descriptors could carry a
866    /// range, is refused.
867    #[test]
868    fn a_version_1_descriptor_is_refused() {
869        let err = SplitDescriptor::decode(
870            br#"{"v":1,"objects":[{"key":"k","size":5,"etag":null,"last_modified_ms":1}]}"#,
871        )
872        .unwrap_err();
873        assert_eq!(err.kind, CoordinationErrorKind::Fatal);
874        assert!(err.reason.contains("version 1"), "reason: {}", err.reason);
875    }
876
877    #[test]
878    fn unknown_descriptor_version_is_rejected_actionably() {
879        let err = SplitDescriptor::decode(br#"{"v":999,"objects":[]}"#).unwrap_err();
880        assert_eq!(err.kind, CoordinationErrorKind::Fatal);
881        assert!(err.reason.contains("version 999"), "reason: {}", err.reason);
882        assert!(
883            err.reason.contains("incompatible release"),
884            "reason: {}",
885            err.reason
886        );
887
888        let garbage = SplitDescriptor::decode(b"not json").unwrap_err();
889        assert_eq!(garbage.kind, CoordinationErrorKind::Fatal);
890    }
891
892    fn ranged_object(size: u64, etag: Option<&str>) -> DescriptorObject {
893        DescriptorObject {
894            key: "big.ndjson".to_string(),
895            size,
896            etag: etag.map(str::to_owned),
897            last_modified_ms: 7,
898        }
899    }
900
901    #[test]
902    fn ranged_descriptor_round_trips_and_its_encoding_is_pinned() {
903        let desc = SplitDescriptor::with_range(
904            ranged_object(100, Some("\"e\"")),
905            SplitRange::new(40, 80, b'\n'),
906        );
907        let encoded = desc.encode().unwrap();
908        assert_eq!(
909            String::from_utf8(encoded.clone()).unwrap(),
910            r#"{"v":2,"objects":[{"key":"big.ndjson","size":100,"etag":"\"e\"","last_modified_ms":7}],"range":{"start":40,"end":80,"delimiter":10}}"#,
911        );
912        assert_eq!(SplitDescriptor::decode(&encoded).unwrap(), desc);
913    }
914
915    /// `encode` and `decode` refuse a ranged descriptor with other than one
916    /// member, without an ETag, or with an empty or out-of-bounds range.
917    #[test]
918    fn invalid_ranged_descriptors_are_refused_on_both_sides() {
919        let pinned = || ranged_object(100, Some("\"e\""));
920        let two_members = SplitDescriptor {
921            objects: vec![pinned(), pinned()],
922            ..SplitDescriptor::with_range(pinned(), SplitRange::new(0, 10, b'\n'))
923        };
924        let no_members = SplitDescriptor {
925            objects: vec![],
926            ..SplitDescriptor::with_range(pinned(), SplitRange::new(0, 10, b'\n'))
927        };
928        let cases = [
929            ("member", two_members),
930            ("member", no_members),
931            (
932                "ETag",
933                SplitDescriptor::with_range(
934                    ranged_object(100, None),
935                    SplitRange::new(0, 10, b'\n'),
936                ),
937            ),
938            (
939                "empty",
940                SplitDescriptor::with_range(pinned(), SplitRange::new(10, 10, b'\n')),
941            ),
942            (
943                "empty",
944                SplitDescriptor::with_range(pinned(), SplitRange::new(11, 10, b'\n')),
945            ),
946            (
947                "past the object",
948                SplitDescriptor::with_range(pinned(), SplitRange::new(0, 101, b'\n')),
949            ),
950        ];
951        for (needle, desc) in cases {
952            let err = desc.encode().unwrap_err();
953            assert_eq!(err.kind, CoordinationErrorKind::Fatal);
954            assert!(err.reason.contains(needle), "encode: {}", err.reason);
955            // The same bytes written by a producer that skipped validation.
956            let raw = serde_json::to_vec(&desc).unwrap();
957            let err = SplitDescriptor::decode(&raw).unwrap_err();
958            assert_eq!(err.kind, CoordinationErrorKind::Fatal);
959            assert!(err.reason.contains(needle), "decode: {}", err.reason);
960        }
961        // A range covering the whole object is valid.
962        let whole = SplitDescriptor::with_range(pinned(), SplitRange::new(0, 100, b'\n'));
963        SplitDescriptor::decode(&whole.encode().unwrap()).unwrap();
964    }
965
966    #[test]
967    fn ranged_id_is_pinned() {
968        // Persisted identity, like `digest_id_is_pinned`; the value was
969        // recomputed from the documented preimage outside Rust.
970        let id = split_id_for_range(
971            "exports/2026/part-000.ndjson",
972            "\"9b2cf5\"",
973            SplitRange::new(0, 64 * MB, b'\n'),
974        )
975        .unwrap();
976        assert_eq!(id.as_str(), "s3-fA0wNRW2MyDxquwFvNy0JA");
977    }
978
979    /// Every input of a ranged id moves it, and no ranged id equals the
980    /// member-set id of the same object.
981    #[test]
982    fn ranged_ids_digest_every_input_and_never_equal_member_set_ids() {
983        let base = split_id_for_range("k", "e", SplitRange::new(0, 10, b'\n')).unwrap();
984        for other in [
985            split_id_for_range("k2", "e", SplitRange::new(0, 10, b'\n')),
986            split_id_for_range("k", "e2", SplitRange::new(0, 10, b'\n')),
987            split_id_for_range("k", "e", SplitRange::new(1, 10, b'\n')),
988            split_id_for_range("k", "e", SplitRange::new(0, 11, b'\n')),
989            split_id_for_range("k", "e", SplitRange::new(0, 10, b';')),
990        ] {
991            assert_ne!(other.unwrap(), base);
992        }
993        assert_ne!(split_id_for([("k", Some("e"))]).unwrap(), base);
994        assert!(split_id_for_range("k", "e", SplitRange::new(10, 10, b'\n')).is_err());
995    }
996
997    // --- packing ---
998
999    /// A listing entry whose shape `kind` picks: plain, gzip, zstd, or plain
1000    /// without an ETag.
1001    fn shaped(i: usize, size: u64, kind: u8) -> ObjectEntry {
1002        let key = format!(
1003            "k{i:04}.ndjson{}",
1004            ["", ".gz", ".zst", ""][kind as usize % 4]
1005        );
1006        ObjectEntry {
1007            etag: (kind % 4 != 3).then(|| format!("\"etag-{i}\"")),
1008            ..entry(&key, size)
1009        }
1010    }
1011
1012    #[test]
1013    fn packing_is_deterministic_for_a_fixed_listing() {
1014        let listing: Vec<ObjectEntry> = (0..200)
1015            .map(|i| entry(&format!("k{i:04}"), (i % 90) * MB))
1016            .collect();
1017        let a = pack(listing.clone(), &cut(64 * MB));
1018        let b = pack(listing, &cut(64 * MB));
1019        assert_eq!(a, b);
1020    }
1021
1022    #[test]
1023    fn tiny_objects_coalesce_under_the_open_cost_floor() {
1024        // 64 tiny objects at a 64 MB target: each costs the 4 MB floor, so
1025        // exactly 16 fill a bin.
1026        let listing: Vec<ObjectEntry> = (0..64).map(|i| entry(&format!("k{i:02}"), 1)).collect();
1027        let bins = object_bins(&pack(listing, &whole(64 * MB)));
1028        assert_eq!(bins.len(), 4);
1029        assert!(bins.iter().all(|b| b.len() == 16));
1030    }
1031
1032    #[test]
1033    fn adversarial_listing_sizes_do_not_overflow_the_fit_test() {
1034        // Sizes come from remote listing metadata; a u64::MAX entry must
1035        // neither panic in debug nor share a bin, and is read whole.
1036        let listing = vec![
1037            entry("a", 10 * MB),
1038            entry("huge", u64::MAX),
1039            entry("z", 10 * MB),
1040        ];
1041        let packed = pack(listing, &cut(64 * MB));
1042        let bins = object_bins(&packed);
1043        assert_eq!(bins.len(), packed.len(), "nothing is cut");
1044        let huge_bin = bins
1045            .iter()
1046            .find(|b| b.iter().any(|e| e.key == "huge"))
1047            .unwrap();
1048        assert_eq!(huge_bin.len(), 1, "the oversized object lands alone");
1049        let total: usize = bins.iter().map(Vec::len).sum();
1050        assert_eq!(total, 3, "nothing lost, nothing duplicated");
1051    }
1052
1053    /// An object above [`MAX_SUBDIVIDABLE_BYTES`] is read whole, and one at
1054    /// the limit, or of 6 TiB, is cut.
1055    #[test]
1056    fn an_object_above_the_subdivision_limit_stays_whole() {
1057        let target = 1 << 30;
1058        let six_tib = pack(vec![entry("6tib", 6 << 40)], &cut(target));
1059        assert_eq!(six_tib.len(), 6 << 10);
1060        let over = entry("over", MAX_SUBDIVIDABLE_BYTES + 1);
1061        assert_eq!(
1062            pack(vec![over.clone()], &cut(target)),
1063            [Packed::Objects(vec![over])]
1064        );
1065        let at = pack(vec![entry("at", MAX_SUBDIVIDABLE_BYTES)], &cut(target));
1066        assert_eq!(at.len() as u64, MAX_SUBDIVIDABLE_BYTES / target);
1067        assert!(at.iter().all(|p| matches!(p, Packed::Range(..))));
1068    }
1069
1070    /// An object is read whole when it is compressed by extension or by
1071    /// configuration, has no ETag, meets a framer with no delimiter, or sits
1072    /// exactly at the target.
1073    #[test]
1074    fn objects_that_cannot_be_cut_stay_whole() {
1075        let target = 64 * MB;
1076        let plain = entry("plain.ndjson", 3 * target);
1077        let gzip_config = Packing {
1078            compression: Compression::Gzip,
1079            ..cut(target)
1080        };
1081        let no_etag = ObjectEntry {
1082            etag: None,
1083            ..plain.clone()
1084        };
1085        for (packing, object) in [
1086            (cut(target), entry("gz.ndjson.gz", 3 * target)),
1087            (cut(target), entry("zst.ndjson.zst", 3 * target)),
1088            (gzip_config, plain.clone()),
1089            (cut(target), no_etag),
1090            (whole(target), plain.clone()),
1091            (cut(target), entry("at.ndjson", target)),
1092        ] {
1093            assert_eq!(
1094                pack(vec![object.clone()], &packing),
1095                [Packed::Objects(vec![object.clone()])],
1096                "{packing:?} {object:?}"
1097            );
1098        }
1099        assert_eq!(
1100            pack(vec![entry("over.ndjson", target + 1)], &cut(target)).len(),
1101            2,
1102            "one byte above the target is cut"
1103        );
1104    }
1105
1106    #[test]
1107    fn oversized_object_gets_its_own_split() {
1108        let listing = vec![
1109            entry("a", 10 * MB),
1110            entry("huge", 500 * MB),
1111            entry("b", 10 * MB),
1112        ];
1113        let bins = object_bins(&pack(listing, &whole(64 * MB)));
1114        let huge_bin = bins
1115            .iter()
1116            .find(|b| b.iter().any(|e| e.key == "huge"))
1117            .unwrap();
1118        assert_eq!(
1119            huge_bin.len(),
1120            1,
1121            "an oversized object never shares a split"
1122        );
1123    }
1124
1125    /// A large plain object is cut into ranges emitted at its listing
1126    /// position, between its neighbors' splits.
1127    #[test]
1128    fn a_large_plain_object_is_cut_in_place() {
1129        let listing = vec![
1130            entry("a", 60 * MB),
1131            entry("huge", 500 * MB),
1132            entry("z", 60 * MB),
1133        ];
1134        let packed = pack(listing, &cut(64 * MB));
1135        let keys: Vec<&str> = packed
1136            .iter()
1137            .map(|p| match p {
1138                Packed::Objects(members) => members[0].key.as_str(),
1139                Packed::Range(entry, _) => entry.key.as_str(),
1140            })
1141            .collect();
1142        let mut want = vec!["a"];
1143        want.extend(["huge"; 8]);
1144        want.push("z");
1145        assert_eq!(keys, want);
1146    }
1147
1148    /// Every size from one byte above the target to eight times it, and the
1149    /// largest object at the smallest target, tiles `[0, size)` with ranges
1150    /// of `target / 2` to `target` bytes.
1151    #[test]
1152    fn ranges_tile_the_object_within_half_to_one_target() {
1153        for target in 1..=64u64 {
1154            for size in target + 1..=8 * target {
1155                assert_tiles(size, target);
1156            }
1157        }
1158        assert_tiles(MAX_SUBDIVIDABLE_BYTES, MB);
1159        assert_tiles(MAX_SUBDIVIDABLE_BYTES - 1, MB);
1160    }
1161
1162    fn assert_tiles(size: u64, target: u64) {
1163        let mut count = 0;
1164        let mut next = 0;
1165        for range in tile(size, target, b'\n') {
1166            count += 1;
1167            assert_eq!(range.start, next, "size {size} target {target}");
1168            let len = range.end - range.start;
1169            assert!(
1170                target / 2 <= len && len <= target,
1171                "size {size} target {target}: range {range:?} is {len} bytes"
1172            );
1173            next = range.end;
1174        }
1175        assert_eq!(count, size.div_ceil(target), "size {size} target {target}");
1176        assert_eq!(next, size, "size {size} target {target}");
1177    }
1178
1179    #[test]
1180    fn packing_preserves_listing_order_within_and_across_bins() {
1181        let listing: Vec<ObjectEntry> = (0..50)
1182            .map(|i| {
1183                entry(
1184                    &format!("k{i:02}"),
1185                    if i % 7 == 0 { 60 * MB } else { 3 * MB },
1186                )
1187            })
1188            .collect();
1189        let bins = object_bins(&pack(listing, &whole(64 * MB)));
1190        for bin in &bins {
1191            let keys: Vec<&str> = bin.iter().map(|e| e.key.as_str()).collect();
1192            let mut sorted = keys.clone();
1193            sorted.sort_unstable();
1194            assert_eq!(keys, sorted, "members stay in listing order within a bin");
1195        }
1196        let firsts: Vec<&str> = bins.iter().map(|b| b[0].key.as_str()).collect();
1197        let mut sorted = firsts.clone();
1198        sorted.sort_unstable();
1199        assert_eq!(
1200            firsts, sorted,
1201            "bins emerge in listing order of their first member"
1202        );
1203    }
1204
1205    #[test]
1206    fn lookback_bounds_how_long_a_bin_stays_open() {
1207        // A bin that never fills is force-closed once PACKING_LOOKBACK
1208        // newer bins have opened: no unbounded open-bin growth.
1209        let mut listing = vec![entry("a-half-full", 40 * MB)];
1210        // Each of these fills a fresh bin exactly (nothing fits alongside
1211        // 40 MB in a 64 MB bin except <= 24 MB; use 60 MB so none fit).
1212        for i in 0..PACKING_LOOKBACK + 2 {
1213            listing.push(entry(&format!("b{i:02}"), 60 * MB));
1214        }
1215        listing.sort_unstable_by(|a, b| a.key.cmp(&b.key));
1216        let bins = object_bins(&pack(listing, &whole(64 * MB)));
1217        // The half-full bin closed with its single member; every 60 MB
1218        // object got its own bin.
1219        assert_eq!(bins.len(), PACKING_LOOKBACK + 3);
1220        assert!(bins.iter().all(|b| b.len() == 1));
1221    }
1222
1223    proptest! {
1224        /// Every object lands whole in exactly one split, or is tiled in
1225        /// order by ranged splits and appears in no whole-object split.
1226        #[test]
1227        fn prop_packing_partitions_the_listing_exactly(
1228            objects in proptest::collection::vec((0u64..300 * MB, 0u8..4), 0..120),
1229            target_mb in 1u64..129,
1230        ) {
1231            let listing: Vec<ObjectEntry> = objects
1232                .iter()
1233                .enumerate()
1234                .map(|(i, &(size, kind))| shaped(i, size, kind))
1235                .collect();
1236            let packed = pack(listing.clone(), &cut(target_mb * MB));
1237            for object in &listing {
1238                let whole_count = object_bins(&packed)
1239                    .iter()
1240                    .flatten()
1241                    .filter(|e| *e == object)
1242                    .count();
1243                let ranges: Vec<SplitRange> = packed
1244                    .iter()
1245                    .filter_map(|p| match p {
1246                        Packed::Range(e, range) if **e == *object => Some(*range),
1247                        _ => None,
1248                    })
1249                    .collect();
1250                if ranges.is_empty() {
1251                    prop_assert_eq!(whole_count, 1, "{:?}", object);
1252                } else {
1253                    prop_assert_eq!(whole_count, 0, "{:?}", object);
1254                    let mut next = 0;
1255                    for range in ranges {
1256                        prop_assert_eq!(range.start, next);
1257                        next = range.end;
1258                    }
1259                    prop_assert_eq!(next, object.size);
1260                }
1261            }
1262            prop_assert!(object_bins(&packed).iter().all(|b| !b.is_empty()));
1263        }
1264
1265        /// Cutting an object replaces its whole-object split with its ranges
1266        /// and changes no other split.
1267        #[test]
1268        fn prop_small_object_bins_do_not_depend_on_cutting(
1269            objects in proptest::collection::vec((0u64..300 * MB, 0u8..4), 0..120),
1270            target_mb in 1u64..129,
1271        ) {
1272            let listing: Vec<ObjectEntry> = objects
1273                .iter()
1274                .enumerate()
1275                .map(|(i, &(size, kind))| shaped(i, size, kind))
1276                .collect();
1277            let packing = cut(target_mb * MB);
1278            let expanded: Vec<Packed> = pack(listing.clone(), &whole(target_mb * MB))
1279                .into_iter()
1280                .flat_map(|split| match &split {
1281                    Packed::Objects(members) if members.len() == 1 => {
1282                        match packing.delimiter_for(&members[0]) {
1283                            Some(delimiter) => tile(members[0].size, packing.target_bytes, delimiter)
1284                                .map(|range| Packed::Range(Rc::new(members[0].clone()), range))
1285                                .collect(),
1286                            None => vec![split],
1287                        }
1288                    }
1289                    _ => vec![split],
1290                })
1291                .collect();
1292            prop_assert_eq!(pack(listing, &packing), expanded);
1293        }
1294
1295        /// With cutting off, every split is the one the reference packing
1296        /// makes. Sizes are whole MiB so bins often fill exactly.
1297        #[test]
1298        fn prop_whole_object_packing_matches_the_reference(
1299            objects in proptest::collection::vec((0u64..300, 0u8..4), 0..120),
1300            target_mb in 1u64..129,
1301        ) {
1302            let listing: Vec<ObjectEntry> = objects
1303                .iter()
1304                .enumerate()
1305                .map(|(i, &(size_mb, kind))| shaped(i, size_mb * MB, kind))
1306                .collect();
1307            let packed = pack(listing.clone(), &whole(target_mb * MB));
1308            prop_assert_eq!(object_bins(&packed).len(), packed.len());
1309            prop_assert_eq!(object_bins(&packed), reference_pack(listing, target_mb * MB));
1310        }
1311
1312        #[test]
1313        fn prop_ranges_tile_large_objects(
1314            target in 1u64..=1 << 32,
1315            n in 2u64..=1 << 12,
1316            extra in any::<u64>(),
1317        ) {
1318            // A size in `((n - 1) * target, n * target]`, and its upper end.
1319            assert_tiles((n - 1) * target + 1 + extra % target, target);
1320            assert_tiles(n * target, target);
1321        }
1322
1323        #[test]
1324        fn prop_member_count_is_bounded_by_the_floor(
1325            sizes in proptest::collection::vec(0u64..300 * MB, 0..120),
1326        ) {
1327            let target = 64 * MB;
1328            let listing: Vec<ObjectEntry> = sizes
1329                .iter()
1330                .enumerate()
1331                .map(|(i, &s)| entry(&format!("k{i:04}"), s))
1332                .collect();
1333            let bins = object_bins(&pack(listing, &cut(target)));
1334            // floor = target/16 divides target exactly, so a bin never
1335            // holds more than 16 members, the structural descriptor bound.
1336            prop_assert!(bins.iter().all(|b| b.len() <= 16));
1337        }
1338
1339        #[test]
1340        fn prop_split_ids_are_valid_for_arbitrary_keys(
1341            keys in proptest::collection::btree_set("[ -~]{1,64}", 1..8),
1342            etag in proptest::option::of("[ -~]{1,16}"),
1343        ) {
1344            // S3 keys contain '/', '.', spaces, '%', anything printable —
1345            // none of it may leak into the id (charset [A-Za-z0-9_-]).
1346            let members: Vec<(&str, Option<&str>)> =
1347                keys.iter().map(|k| (k.as_str(), etag.as_deref())).collect();
1348            let id = split_id_for(members).unwrap();
1349            prop_assert!(id.as_str().starts_with("s3-"));
1350            prop_assert_eq!(id.as_str().len(), 25);
1351        }
1352
1353        #[test]
1354        fn prop_packing_and_ids_are_deterministic(
1355            sizes in proptest::collection::vec(0u64..200 * MB, 1..60),
1356        ) {
1357            let listing: Vec<ObjectEntry> = sizes
1358                .iter()
1359                .enumerate()
1360                .map(|(i, &s)| entry(&format!("k{i:04}"), s))
1361                .collect();
1362            let ids = |packed: &[Packed]| -> Vec<SplitId> {
1363                packed.iter().map(|p| p.id().unwrap()).collect()
1364            };
1365            let a = pack(listing.clone(), &cut(32 * MB));
1366            let b = pack(listing, &cut(32 * MB));
1367            prop_assert_eq!(ids(&a), ids(&b));
1368        }
1369    }
1370}