use crate::fetch::{ObjectEntry, Overwrite};
use crate::framer::Codec;
use crate::listing::addressable;
use crate::split::{
DescriptorObject, MAX_UNIT_MEMBERS, PACK_ETAG_MAX_BYTES, PACK_KEY_MAX_BYTES, PACK_MEMBERS,
Packing, SplitDescriptor, SplitError, SplitRange, split_id_for, split_id_for_range, tile,
};
use object_store::path::Path;
use serde::Deserialize;
use spate_core::feed::Unit;
use std::iter::Peekable;
use std::sync::Arc;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum Origin {
Lister,
Notification {
version: Option<String>,
re_enqueues: u32,
},
}
pub(crate) struct Part {
pub(crate) object: Arc<Vec<ObjectEntry>>,
pub(crate) range: Option<SplitRange>,
pub(crate) origin: Origin,
}
pub(crate) struct Cursor {
pub(crate) object: Arc<Vec<ObjectEntry>>,
ranges: Option<Peekable<Box<dyn Iterator<Item = SplitRange> + Send>>>,
whole: bool,
origin: Origin,
}
impl Cursor {
fn whole(object: Arc<Vec<ObjectEntry>>, origin: Origin) -> Cursor {
Cursor {
object,
ranges: None,
whole: true,
origin,
}
}
fn range(object: Arc<Vec<ObjectEntry>>, range: SplitRange, origin: Origin) -> Cursor {
Cursor::ranges(object, Box::new(std::iter::once(range)), origin)
}
fn ranges(
object: Arc<Vec<ObjectEntry>>,
ranges: Box<dyn Iterator<Item = SplitRange> + Send>,
origin: Origin,
) -> Cursor {
Cursor {
object,
ranges: Some(ranges.peekable()),
whole: false,
origin,
}
}
pub(crate) fn key(&self) -> &str {
&self.object[0].key
}
pub(crate) fn is_exhausted(&mut self) -> bool {
match &mut self.ranges {
Some(ranges) => ranges.peek().is_none(),
None => !self.whole,
}
}
}
impl Iterator for Cursor {
type Item = Part;
fn next(&mut self) -> Option<Part> {
let range = match &mut self.ranges {
Some(ranges) => Some(ranges.next()?),
None => {
if !std::mem::take(&mut self.whole) {
return None;
}
None
}
};
Some(Part {
object: Arc::clone(&self.object),
range,
origin: self.origin.clone(),
})
}
}
pub(crate) fn parts_of(object: Arc<Vec<ObjectEntry>>, packing: Packing, origin: Origin) -> Cursor {
debug_assert_eq!(object.len(), 1, "a part names one object");
let entry = &object[0];
match packing
.delimiter_for(entry)
.filter(|_| addressable(&entry.key))
{
Some(delimiter) => {
let ranges = tile(entry.size, packing.target_bytes, delimiter);
Cursor::ranges(object, Box::new(ranges), origin)
}
None => Cursor::whole(object, origin),
}
}
pub(crate) fn units(
entry: ObjectEntry,
packing: Packing,
) -> impl Iterator<Item = Result<Unit, SplitError>> {
let object = DescriptorObject {
key: entry.key.clone(),
size: entry.size,
etag: entry.etag.clone(),
last_modified_ms: entry.last_modified_ms,
};
parts_of(Arc::new(vec![entry]), packing, Origin::Lister).map(move |part| {
let (id, descriptor) = match part.range {
None => (
split_id_for([(object.key.as_str(), object.etag.as_deref())])?,
SplitDescriptor::new(vec![object.clone()]),
),
Some(range) => (
split_id_for_range(
&object.key,
object
.etag
.as_deref()
.expect("an object is cut only when it has an ETag"),
range,
)?,
SplitDescriptor::with_range(object.clone(), range),
),
};
Ok(Unit::new(id, descriptor.encode()?))
})
}
#[derive(Debug)]
pub(crate) struct Minted {
pub(crate) unit: Unit,
pub(crate) first_key: String,
pub(crate) objects: usize,
pub(crate) listed: usize,
}
pub(crate) struct Packer {
packing: Packing,
open: Vec<ObjectEntry>,
open_bytes: u64,
}
impl Packer {
pub(crate) fn new(packing: Packing) -> Packer {
Packer {
packing,
open: Vec::new(),
open_bytes: 0,
}
}
pub(crate) fn push(
&mut self,
entry: ObjectEntry,
) -> impl Iterator<Item = Result<Minted, SplitError>> + use<> {
let shared = self.shares(&entry);
let before =
if !shared || self.open_bytes.saturating_add(entry.size) > self.packing.target_bytes {
self.close()
} else {
None
};
let (alone, after) = if shared {
self.open_bytes = self.open_bytes.saturating_add(entry.size);
self.open.push(entry);
let full = if self.open.len() == PACK_MEMBERS {
self.close()
} else {
None
};
(None, full)
} else {
(Some(entry), None)
};
let packing = self.packing;
before.into_iter().chain(after).chain(
alone
.into_iter()
.flat_map(move |entry| alone_units(entry, packing)),
)
}
pub(crate) fn close(&mut self) -> Option<Result<Minted, SplitError>> {
if self.open.is_empty() {
return None;
}
let mut members = std::mem::take(&mut self.open);
self.open_bytes = 0;
if members.len() == 1 {
let entry = members.pop().expect("one member");
return alone_units(entry, self.packing).next();
}
Some(packed_unit(&members))
}
fn shares(&self, entry: &ObjectEntry) -> bool {
self.packing.delimiter_for(entry).is_none()
&& addressable(&entry.key)
&& entry.key.len() <= PACK_KEY_MAX_BYTES
&& entry
.etag
.as_deref()
.is_none_or(|etag| etag.len() <= PACK_ETAG_MAX_BYTES && sendable(etag))
}
}
pub(crate) fn alone_units(
entry: ObjectEntry,
packing: Packing,
) -> impl Iterator<Item = Result<Minted, SplitError>> {
let first_key = entry.key.clone();
units(entry, packing).enumerate().map(move |(i, unit)| {
unit.map(|unit| Minted {
unit,
first_key: first_key.clone(),
objects: 1,
listed: usize::from(i == 0),
})
})
}
fn packed_unit(members: &[ObjectEntry]) -> Result<Minted, SplitError> {
let id = split_id_for(members.iter().map(|e| (e.key.as_str(), e.etag.as_deref())))?;
let descriptor = SplitDescriptor::new(
members
.iter()
.map(|e| DescriptorObject {
key: e.key.clone(),
size: e.size,
etag: e.etag.clone(),
last_modified_ms: e.last_modified_ms,
})
.collect(),
);
Ok(Minted {
unit: Unit::new(id, descriptor.encode()?),
first_key: members[0].key.clone(),
objects: members.len(),
listed: members.len(),
})
}
#[derive(Clone, Debug)]
pub(crate) struct Scope {
pub(crate) bucket: Option<String>,
pub(crate) prefix: Path,
}
pub(crate) enum Decoded {
Read(Vec<Cursor>),
Acknowledge(&'static str),
Fail(String),
}
pub(crate) fn decode(body: &[u8], scope: &Scope, packing: Packing) -> Decoded {
let Ok(value) = serde_json::from_slice::<serde_json::Value>(body) else {
return Decoded::Fail(
"the unit's body is neither a descriptor nor a storage notification".to_owned(),
);
};
if value.get("v").is_some() {
return match descriptor_parts(body, packing) {
Ok(cursor) => Decoded::Read(vec![cursor]),
Err(reason) => Decoded::Fail(reason),
};
}
if value.get("Event").and_then(serde_json::Value::as_str) == Some("s3:TestEvent") {
return Decoded::Acknowledge("a storage test event");
}
let Some(records) = value.get("Records").and_then(serde_json::Value::as_array) else {
return Decoded::Fail(
"the unit's body is neither a descriptor nor a storage notification".to_owned(),
);
};
let mut parts = Vec::with_capacity(records.len());
for (i, record) in records.iter().enumerate() {
match record_part(record, scope, packing) {
Ok(Some(cursor)) => parts.push(cursor),
Ok(None) => {}
Err(reason) => return Decoded::Fail(format!("notification record {i}: {reason}")),
}
}
if parts.is_empty() {
Decoded::Acknowledge("a notification that names no object to read")
} else {
Decoded::Read(parts)
}
}
pub(crate) const MAX_VERSION_BYTES: usize = 1024;
const CEPH_SOURCE: &str = "ceph:s3";
const RE_ENQUEUES: &str = "spateReEnqueues";
#[derive(Deserialize)]
struct Record {
#[serde(rename = "eventSource", default)]
event_source: Option<String>,
#[serde(rename = "eventTime", default)]
event_time: Option<String>,
#[serde(rename = "spateReEnqueues", default)]
re_enqueues: Option<u32>,
s3: RecordS3,
}
#[derive(Deserialize)]
struct RecordS3 {
bucket: RecordBucket,
object: RecordObject,
}
#[derive(Deserialize)]
struct RecordBucket {
name: String,
}
#[derive(Deserialize)]
struct RecordObject {
key: String,
#[serde(default)]
size: Option<u64>,
#[serde(rename = "eTag", default)]
etag: Option<String>,
#[serde(rename = "versionId", default)]
version_id: Option<String>,
}
fn record_part(
record: &serde_json::Value,
scope: &Scope,
packing: Packing,
) -> Result<Option<Cursor>, String> {
let Some(event) = record.get("eventName").and_then(serde_json::Value::as_str) else {
return Err("the record's `eventName` is missing or not a string".to_owned());
};
if !event
.strip_prefix("s3:")
.unwrap_or(event)
.starts_with("ObjectCreated:")
{
return Ok(None);
}
let record =
Record::deserialize(record).map_err(|e| format!("the record is unreadable: {e}"))?;
let bucket = record.s3.bucket.name;
if scope.bucket.as_deref() != Some(bucket.as_str()) {
return Err(format!(
"the record names the bucket \"{bucket}\", which this source does not read"
));
}
let key = if record.event_source.as_deref() == Some(CEPH_SOURCE) {
record.s3.object.key
} else {
form_decoded(&record.s3.object.key)?
};
let prefix = scope.prefix.as_ref();
if !prefix.is_empty()
&& !key
.strip_prefix(prefix)
.is_some_and(|rest| rest.starts_with('/'))
{
return Err(format!(
"the key \"{key}\" is outside the source's prefix \"{prefix}/\""
));
}
let Some(size) = record.s3.object.size else {
return Err(format!("the record for \"{key}\" has no size"));
};
if size == 0 {
return Ok(None);
}
if !addressable(&key) {
return Err(format!(
"the key \"{key}\" cannot be read under its own name through `object_store`"
));
}
if record
.s3
.object
.etag
.as_deref()
.is_some_and(|etag| !sendable(etag))
{
return Err(format!(
"the record for \"{key}\" has an ETag that cannot be sent in `If-Match`"
));
}
let version = match record.s3.object.version_id {
Some(version) if version.is_empty() || version == "null" => None,
Some(version) if version.len() > MAX_VERSION_BYTES => {
return Err(format!(
"the record for \"{key}\" has a `versionId` longer than {MAX_VERSION_BYTES} bytes"
));
}
Some(version) if version.bytes().any(|b| b == b' ' || b.is_ascii_control()) => {
return Err(format!(
"the record for \"{key}\" has a `versionId` holding a space or a control byte"
));
}
version => version,
};
let last_modified_ms = record
.event_time
.as_deref()
.and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok())
.map_or(0, |t| t.timestamp_millis());
let entry = ObjectEntry {
key,
size,
etag: record.s3.object.etag.map(quoted),
last_modified_ms,
};
let origin = Origin::Notification {
version,
re_enqueues: record.re_enqueues.unwrap_or(0),
};
Ok(Some(parts_of(Arc::new(vec![entry]), packing, origin)))
}
pub(crate) fn re_enqueue_body(bucket: &str, overwrite: &Overwrite, re_enqueues: u32) -> Vec<u8> {
let mut object = serde_json::json!({
"key": form_encoded(&overwrite.key),
"size": overwrite.size,
});
if let Some(etag) = &overwrite.etag {
object["eTag"] = etag.as_str().into();
}
let mut record = serde_json::json!({
"eventName": "ObjectCreated:Put",
RE_ENQUEUES: re_enqueues,
"s3": { "bucket": { "name": bucket }, "object": object },
});
if let Some(time) = chrono::DateTime::from_timestamp_millis(overwrite.last_modified_ms) {
record["eventTime"] = time
.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
.into();
}
let body = serde_json::json!({ "Records": [record] });
serde_json::to_vec(&body).expect("a JSON value always serialises")
}
fn form_encoded(key: &str) -> String {
let mut out = String::with_capacity(key.len());
for b in key.bytes() {
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~' | b'/') {
out.push(char::from(b));
} else {
out.push_str(&format!("%{b:02X}"));
}
}
out
}
pub(crate) fn quoted(etag: String) -> String {
if etag.len() >= 2 && etag.starts_with('"') && etag.ends_with('"') {
etag
} else {
format!("\"{etag}\"")
}
}
fn sendable(etag: &str) -> bool {
etag.bytes().all(|b| b == b'\t' || (b >= 0x20 && b != 0x7f))
}
fn form_decoded(key: &str) -> Result<String, String> {
let hex = |b: u8| char::from(b).to_digit(16);
let bytes = key.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'+' => out.push(b' '),
b'%' => {
let byte = match (
bytes.get(i + 1).copied().and_then(hex),
bytes.get(i + 2).copied().and_then(hex),
) {
(Some(high), Some(low)) => (high * 16 + low) as u8,
_ => return Err(format!("the key \"{key}\" has a malformed `%` escape")),
};
out.push(byte);
i += 2;
}
b => out.push(b),
}
debug_assert!(out.len() <= bytes.len());
i += 1;
}
String::from_utf8(out).map_err(|_| format!("the key \"{key}\" does not decode as UTF-8"))
}
fn descriptor_parts(body: &[u8], packing: Packing) -> Result<Cursor, String> {
let descriptor = SplitDescriptor::decode(body)
.map_err(|e| format!("the unit's body is not a descriptor: {}", e.reason))?;
let entries = descriptor.to_entries();
if entries.is_empty() || entries.len() > MAX_UNIT_MEMBERS {
return Err(format!(
"the unit names {} objects; this source reads 1 to {MAX_UNIT_MEMBERS} per unit",
entries.len()
));
}
if let Some(object) = entries
.iter()
.find(|e| e.etag.as_deref().is_some_and(|etag| !sendable(etag)))
{
return Err(format!(
"the unit's ETag for \"{}\" cannot be sent in `If-Match`",
object.key
));
}
let object = match <[ObjectEntry; 1]>::try_from(entries) {
Ok([object]) => object,
Err(entries) => return Ok(Cursor::whole(Arc::new(entries), Origin::Lister)),
};
let Some(range) = descriptor.range else {
return Ok(parts_of(Arc::new(vec![object]), packing, Origin::Lister));
};
if packing.delimiter != Some(range.delimiter) {
return Err(format!(
"the unit splits its object on byte {:#04x}, but the source's framer {}",
range.delimiter,
match packing.delimiter {
Some(d) => format!("resyncs on byte {d:#04x}"),
None => "declares no resync delimiter".to_owned(),
}
));
}
let codec = Codec::resolve(packing.compression, &object.key);
if codec != Codec::Plain {
return Err(format!(
"the unit names a byte range of \"{}\", which the source decodes as {}; a byte \
range needs an uncompressed object",
object.key,
format!("{codec:?}").to_lowercase()
));
}
Ok(Cursor::range(Arc::new(vec![object]), range, Origin::Lister))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Compression;
use crate::split::MAX_SUBDIVIDABLE_BYTES;
use proptest::prelude::*;
const MIB: u64 = 1 << 20;
const GIB: u64 = 1 << 30;
const EXTENSIONS: [&str; 6] = ["", ".ndjson", ".gz", ".gzip", ".zst", ".zstd"];
fn key(i: usize, shape: u8, ext: &str) -> (String, bool) {
match shape {
0 => (format!("a/k{i}{ext}"), false),
1 => (format!("a//k{i}{ext}"), true),
2 => (format!("a/../k{i}{ext}"), true),
3 => (format!("a/k\u{1}{i}{ext}"), true),
4 => (format!("/a/k{i}{ext}"), true),
_ => (format!("a/k{i}{ext}/"), true),
}
}
fn compression(n: u8) -> Compression {
match n {
0 => Compression::Auto,
1 => Compression::None,
2 => Compression::Gzip,
_ => Compression::Zstd,
}
}
fn plain(compression: Compression, key: &str) -> bool {
match compression {
Compression::None => true,
Compression::Auto => ![".gz", ".gzip", ".zst", ".zstd"]
.iter()
.any(|ext| key.ends_with(ext)),
_ => false,
}
}
fn size(target: u64, shape: u8, random: u64) -> u64 {
match shape {
0 => target,
1 => target + 1,
2 if target >= GIB => MAX_SUBDIVIDABLE_BYTES - 1,
3 if target >= GIB => MAX_SUBDIVIDABLE_BYTES,
4 if target >= GIB => MAX_SUBDIVIDABLE_BYTES + 1,
5 => 0,
_ => random % (4 * target),
}
}
fn target() -> impl Strategy<Value = u64> {
prop_oneof![MIB..=64 * MIB, 16 * GIB..=64 * GIB]
}
#[test]
fn the_cut_yields_one_range_at_a_time() {
let packing = Packing {
target_bytes: 1,
compression: Compression::None,
delimiter: Some(b'\n'),
};
let entry = ObjectEntry {
key: "a.ndjson".into(),
size: MAX_SUBDIVIDABLE_BYTES,
etag: Some("\"etag\"".into()),
last_modified_ms: 1,
};
let mut cursor = parts_of(Arc::new(vec![entry]), packing, Origin::Lister);
let first: Vec<(u64, u64)> = cursor
.by_ref()
.take(2)
.map(|part| {
let range = part.range.expect("the object is cut");
(range.start, range.end)
})
.collect();
assert_eq!(first, [(0, 1), (1, 2)]);
assert!(!cursor.is_exhausted());
}
#[test]
fn the_cut_marks_each_range_with_the_framers_delimiter() {
let packing = Packing {
target_bytes: MIB,
compression: Compression::None,
delimiter: Some(b'\r'),
};
let entry = ObjectEntry {
key: "a.csv".into(),
size: 3 * MIB,
etag: Some("\"etag\"".into()),
last_modified_ms: 1,
};
let delimiters: Vec<u8> = parts_of(Arc::new(vec![entry]), packing, Origin::Lister)
.map(|part| part.range.expect("the object is cut").delimiter)
.collect();
assert_eq!(delimiters, [b'\r'; 3]);
}
fn scope() -> Scope {
Scope {
bucket: Some("bucket".to_owned()),
prefix: Path::from("data"),
}
}
fn whole_bucket() -> Scope {
Scope {
bucket: Some("bucket".to_owned()),
prefix: Path::from(""),
}
}
fn packing() -> Packing {
Packing {
target_bytes: 100,
compression: Compression::Auto,
delimiter: Some(b'\n'),
}
}
fn record(event: &str, bucket: &str, key: &str, size: Option<u64>) -> serde_json::Value {
serde_json::json!({
"eventVersion": "2.1",
"eventSource": "aws:s3",
"eventTime": "2026-10-19T12:00:00.000Z",
"eventName": event,
"s3": {
"bucket": { "name": bucket },
"object": { "key": key, "size": size, "eTag": "0123abcd", "sequencer": "0A1B" },
},
})
}
fn created(key: &str, size: u64) -> serde_json::Value {
record("ObjectCreated:Put", "bucket", key, Some(size))
}
fn message(records: &[serde_json::Value]) -> Vec<u8> {
serde_json::to_vec(&serde_json::json!({ "Records": records })).unwrap()
}
type Read = (String, Option<String>, Option<(u64, u64)>);
fn read(decoded: Decoded) -> Vec<Read> {
match decoded {
Decoded::Read(cursors) => cursors
.into_iter()
.flatten()
.map(|part| {
let entry = &part.object[0];
(
entry.key.clone(),
entry.etag.clone(),
part.range.map(|r| (r.start, r.end)),
)
})
.collect(),
Decoded::Acknowledge(reason) => panic!("acknowledged: {reason}"),
Decoded::Fail(reason) => panic!("failed: {reason}"),
}
}
fn keys(decoded: Decoded) -> Vec<String> {
read(decoded).into_iter().map(|(key, _, _)| key).collect()
}
fn failed(decoded: Decoded) -> String {
match decoded {
Decoded::Fail(reason) => reason,
Decoded::Read(cursors) => panic!("read {} parts", cursors.len()),
Decoded::Acknowledge(reason) => panic!("acknowledged: {reason}"),
}
}
fn acknowledged(decoded: Decoded) -> &'static str {
match decoded {
Decoded::Acknowledge(reason) => reason,
Decoded::Read(cursors) => panic!("read {} parts", cursors.len()),
Decoded::Fail(reason) => panic!("failed: {reason}"),
}
}
fn decoded(records: &[serde_json::Value]) -> Decoded {
decode(&message(records), &scope(), packing())
}
#[test]
fn a_notification_key_is_form_decoded() {
for key in [
"data/dir%2Fhello+world.ndjson",
"data/dir/hello+world.ndjson",
"data%2Fdir%2Fhello%20world.ndjson",
] {
assert_eq!(
keys(decoded(&[created(key, 10)])),
["data/dir/hello world.ndjson"],
"{key}"
);
}
assert_eq!(
keys(decoded(&[created("data/caf%C3%A9+%2B.ndjson", 10)])),
["data/café +.ndjson"]
);
for key in [
"data/a%2",
"data/a%zz.ndjson",
"data/a%+1.ndjson",
"data/a%",
] {
let reason = failed(decoded(&[created(key, 10)]));
assert!(reason.contains("malformed `%` escape"), "{key}: {reason}");
}
let reason = failed(decoded(&[created("data/%FF.ndjson", 10)]));
assert!(reason.contains("does not decode as UTF-8"), "{reason}");
}
fn from_source(mut record: serde_json::Value, source: Option<&str>) -> serde_json::Value {
match source {
Some(source) => record["eventSource"] = source.into(),
None => {
record
.as_object_mut()
.expect("a record is an object")
.remove("eventSource");
}
}
record
}
fn ceph(key: &str, size: u64) -> serde_json::Value {
from_source(created(key, size), Some("ceph:s3"))
}
#[test]
fn a_ceph_notification_key_is_read_as_written() {
for key in [
"data/a+b c%25.ndjson",
"data/a%2Fb.ndjson",
"data/a%41.ndjson",
"data/a%zz.ndjson",
"data/c%",
"data/é x.ndjson",
] {
assert_eq!(keys(decoded(&[ceph(key, 10)])), [key], "{key}");
}
for source in [
Some("aws:s3"),
Some("rustfs:s3"),
None,
Some("Ceph:s3"),
Some("ceph:s3 "),
Some("ceph"),
] {
let record = |key| from_source(created(key, 10), source);
assert_eq!(
keys(decoded(&[record("data/a+b c%25.ndjson")])),
["data/a b c%.ndjson"],
"{source:?}"
);
assert_eq!(
keys(decoded(&[record("data/a%41.ndjson")])),
["data/aA.ndjson"],
"{source:?}"
);
let reason = failed(decoded(&[record("data/a%zz.ndjson")]));
assert!(
reason.contains("malformed `%` escape"),
"{source:?}: {reason}"
);
}
let mut null = created("data/a+b.ndjson", 10);
null["eventSource"] = serde_json::Value::Null;
assert_eq!(keys(decoded(&[null])), ["data/a b.ndjson"]);
let mut numbered = created("data/a.ndjson", 10);
numbered["eventSource"] = 1.into();
let reason = failed(decoded(&[numbered]));
assert!(reason.contains("the record is unreadable"), "{reason}");
}
#[test]
fn a_ceph_key_meets_the_checks_that_follow_decoding() {
let reason = failed(decoded(&[ceph("data%2Fx.ndjson", 10)]));
assert!(
reason.contains("outside the source's prefix \"data/\""),
"{reason}"
);
for key in ["data//a.ndjson", "data/../x"] {
let reason = failed(decoded(&[ceph(key, 10)]));
assert!(
reason.contains("cannot be read under its own name"),
"{key}: {reason}"
);
}
let body = message(&[ceph("a/%2Fb.ndjson", 10)]);
assert_eq!(
keys(decode(&body, &whole_bucket(), packing())),
["a/%2Fb.ndjson"]
);
acknowledged(decoded(&[ceph("data/a%zz", 0)]));
assert_eq!(
keys(decoded(&[
ceph("data/a+b.ndjson", 10),
created("data/a+b.ndjson", 10)
])),
["data/a+b.ndjson", "data/a b.ndjson"]
);
}
#[test]
fn a_re_enqueued_ceph_unit_reads_the_key_rgw_announced() {
let key = "data/a+b%2Fc d%25.ndjson";
let Decoded::Read(cursors) = decoded(&[ceph(key, 10)]) else {
panic!("not read");
};
let parts: Vec<Part> = cursors.into_iter().flatten().collect();
let entry = &parts[0].object[0];
assert_eq!(entry.key, key);
let overwrite = Overwrite {
key: entry.key.clone(),
etag: entry.etag.clone(),
size: entry.size,
last_modified_ms: entry.last_modified_ms,
};
let body = re_enqueue_body("bucket", &overwrite, 1);
assert_eq!(keys(decode(&body, &scope(), packing())), [key]);
}
#[test]
fn an_event_name_with_or_without_the_s3_prefix_is_accepted() {
for event in [
"s3:ObjectCreated:Put",
"ObjectCreated:Copy",
"ObjectCreated:CompleteMultipartUpload",
] {
let message = [record(event, "bucket", "data/a.ndjson", Some(10))];
assert_eq!(keys(decoded(&message)), ["data/a.ndjson"], "{event}");
}
let envelope = serde_json::json!({
"EventName": "s3:ObjectCreated:Put",
"Key": "bucket/data/a.ndjson",
"Records": [record("s3:ObjectCreated:Put", "bucket", "data%2Fa.ndjson", Some(10))],
});
let body = serde_json::to_vec(&envelope).unwrap();
assert_eq!(keys(decode(&body, &scope(), packing())), ["data/a.ndjson"]);
}
#[test]
fn a_record_for_another_bucket_or_prefix_is_dead_lettered() {
let other = record("ObjectCreated:Put", "elsewhere", "data/a.ndjson", Some(10));
let reason = failed(decoded(&[created("data/a.ndjson", 10), other]));
assert!(reason.contains("notification record 1"), "{reason}");
assert!(reason.contains("the bucket \"elsewhere\""), "{reason}");
for key in ["other/a.ndjson", "database/a.ndjson", "data"] {
let reason = failed(decoded(&[created(key, 10)]));
assert!(
reason.contains("outside the source's prefix \"data/\""),
"{key}: {reason}"
);
}
let nameless = Scope {
bucket: None,
prefix: Path::from("data"),
};
let reason = failed(decode(
&message(&[created("data/a.ndjson", 10)]),
&nameless,
packing(),
));
assert!(
reason.contains("which this source does not read"),
"{reason}"
);
let body = message(&[created("any/where.ndjson", 10)]);
assert_eq!(
keys(decode(&body, &whole_bucket(), packing())),
["any/where.ndjson"]
);
}
#[test]
fn a_size_zero_record_is_acknowledged_without_a_unit() {
for key in ["data/empty.ndjson", "data//empty.ndjson", "data/folder/"] {
acknowledged(decoded(&[created(key, 0)]));
}
let reason = failed(decoded(&[created("data/a%zz", 0)]));
assert!(reason.contains("malformed `%` escape"), "{reason}");
let reason = failed(decoded(&[record(
"ObjectCreated:Put",
"elsewhere",
"data/empty.ndjson",
Some(0),
)]));
assert!(reason.contains("the bucket \"elsewhere\""), "{reason}");
let reason = failed(decoded(&[created("other/empty.ndjson", 0)]));
assert!(reason.contains("outside the source's prefix"), "{reason}");
assert_eq!(
keys(decoded(&[created("data/one.ndjson", 1)])),
["data/one.ndjson"]
);
let reason = failed(decoded(&[record(
"ObjectCreated:Put",
"bucket",
"data/a",
None,
)]));
assert!(reason.contains("has no size"), "{reason}");
}
#[test]
fn a_test_event_and_a_name_that_creates_nothing_are_acknowledged_without_a_unit() {
let test_event = serde_json::json!({
"Service": "Amazon S3",
"Event": "s3:TestEvent",
"Time": "2026-10-19T12:00:00.000Z",
"Bucket": "bucket",
"RequestId": "5582815E1AEA5ADF",
"HostId": "8cLeGAmw098X5cv4Zkwcmo8vvZa3eH3eKxsPzbB9wrR+YstdA6Knx4Ip8EXAMPLE",
});
acknowledged(decode(
&serde_json::to_vec(&test_event).unwrap(),
&scope(),
packing(),
));
for event in [
"ObjectRemoved:Delete",
"s3:ObjectRemoved:DeleteMarkerCreated",
"ObjectCreatedButNot",
"xObjectCreated:Put",
"s3:s3:ObjectCreated:Put",
"ObjectRemoved:ObjectCreated:Put",
] {
acknowledged(decoded(&[record(
event,
"elsewhere",
"other/a.ndjson",
None,
)]));
}
acknowledged(decoded(&[]));
}
#[test]
fn a_key_path_parse_rejects_or_rewrites_is_dead_lettered() {
for key in [
"a//b.ndjson",
"/a.ndjson",
"a/",
"a/%2Fb.ndjson",
"a/./b.ndjson",
] {
let body = message(&[created(key, 10)]);
let reason = failed(decode(&body, &whole_bucket(), packing()));
assert!(
reason.contains("cannot be read under its own name"),
"{key}: {reason}"
);
}
}
#[test]
fn an_unknown_notification_shape_is_dead_lettered() {
for body in [
b"not json".to_vec(),
b"{}".to_vec(),
br#"{"Records":{}}"#.to_vec(),
br#"["Records"]"#.to_vec(),
] {
let reason = failed(decode(&body, &scope(), packing()));
assert!(
reason.contains("neither a descriptor nor a storage notification"),
"{reason}"
);
}
let no_s3 = serde_json::json!({ "eventName": "ObjectCreated:Put" });
let no_name = serde_json::json!({ "s3": { "bucket": { "name": "bucket" } } });
let mut negative = created("data/a.ndjson", 10);
negative["s3"]["object"]["size"] = serde_json::json!(-1);
let mut fractional = created("data/a.ndjson", 10);
fractional["s3"]["object"]["size"] = serde_json::json!(1.5);
for record in [no_s3, no_name, negative, fractional] {
failed(decoded(&[record]));
}
for body in [br#"{"v":1,"objects":[]}"#.to_vec(), br#"{"v":2}"#.to_vec()] {
let reason = failed(decode(&body, &scope(), packing()));
assert!(reason.contains("not a descriptor"), "{reason}");
}
let object = DescriptorObject {
key: "data/a.ndjson".into(),
size: 10,
etag: None,
last_modified_ms: 1,
};
let body = SplitDescriptor::new(vec![object]).encode().unwrap();
assert_eq!(keys(decode(&body, &scope(), packing())), ["data/a.ndjson"]);
}
#[test]
fn an_unquoted_event_etag_is_sent_quoted() {
let etag = |value: serde_json::Value| {
let mut created = created("data/a.ndjson", 10);
created["s3"]["object"]["eTag"] = value;
read(decoded(&[created])).remove(0).1
};
assert_eq!(etag(serde_json::json!("abc")), Some("\"abc\"".to_owned()));
assert_eq!(
etag(serde_json::json!("\"abc\"")),
Some("\"abc\"".to_owned())
);
assert_eq!(etag(serde_json::json!("\"")), Some("\"\"\"".to_owned()));
assert_eq!(
etag(serde_json::json!("\"abc")),
Some("\"\"abc\"".to_owned())
);
assert_eq!(etag(serde_json::json!("\"\"")), Some("\"\"".to_owned()));
assert_eq!(etag(serde_json::Value::Null), None);
}
#[test]
fn an_etag_that_cannot_be_a_header_value_is_dead_lettered() {
let with_etag = |etag: &str| {
let mut created = created("data/a.ndjson", 10);
created["s3"]["object"]["eTag"] = serde_json::json!(etag);
decoded(&[created])
};
for etag in ["abc\u{1}", "abc\u{1f}", "abc\u{7f}", "a\nb"] {
let reason = failed(with_etag(etag));
assert!(reason.contains("cannot be sent in `If-Match`"), "{reason}");
}
for etag in ["a\tb", "a b", "a~b", "a\u{e9}b"] {
assert_eq!(keys(with_etag(etag)), ["data/a.ndjson"], "{etag:?}");
}
let object = |etag: &str| DescriptorObject {
key: "data/a.ndjson".into(),
size: 10,
etag: Some(etag.to_owned()),
last_modified_ms: 1,
};
let body = SplitDescriptor::new(vec![object("\"abc\u{1}\"")])
.encode()
.unwrap();
let reason = failed(decode(&body, &scope(), packing()));
assert!(reason.contains("cannot be sent in `If-Match`"), "{reason}");
let body = SplitDescriptor::new(vec![object("\"abc\"")])
.encode()
.unwrap();
assert_eq!(keys(decode(&body, &scope(), packing())), ["data/a.ndjson"]);
}
fn member(key: &str, size: u64, etag: &str) -> DescriptorObject {
DescriptorObject {
key: key.into(),
size,
etag: Some(etag.to_owned()),
last_modified_ms: 1,
}
}
#[test]
fn a_multi_member_descriptor_is_one_whole_part() {
let body = SplitDescriptor::new(vec![
member("data/big.ndjson", 3 * packing().target_bytes, "\"a\""),
member("data/small.ndjson", 10, "\"b\""),
])
.encode()
.unwrap();
let Decoded::Read(cursors) = decode(&body, &scope(), packing()) else {
panic!("the unit is read");
};
let parts: Vec<Part> = cursors.into_iter().flatten().collect();
assert_eq!(parts.len(), 1);
assert!(parts[0].range.is_none());
let keys: Vec<&str> = parts[0].object.iter().map(|o| o.key.as_str()).collect();
assert_eq!(keys, ["data/big.ndjson", "data/small.ndjson"]);
}
#[test]
fn a_descriptor_with_an_unsendable_second_etag_fails() {
let body = SplitDescriptor::new(vec![
member("data/a.ndjson", 10, "\"abc\""),
member("data/b.ndjson", 10, "\"abc\u{1}\""),
])
.encode()
.unwrap();
let reason = failed(decode(&body, &scope(), packing()));
assert!(
reason.contains("\"data/b.ndjson\" cannot be sent in `If-Match`"),
"{reason}"
);
}
#[test]
fn a_descriptor_of_no_members_or_past_the_bound_fails() {
let body = |n: usize| {
SplitDescriptor::new(
(0..n)
.map(|i| member(&format!("data/{i}.ndjson"), 10, "\"e\""))
.collect(),
)
.encode()
.unwrap()
};
for n in [0, MAX_UNIT_MEMBERS + 1] {
let reason = failed(decode(&body(n), &scope(), packing()));
assert!(
reason.contains(&format!("names {n} objects")),
"{n}: {reason}"
);
}
assert_eq!(
keys(decode(&body(MAX_UNIT_MEMBERS), &scope(), packing())).len(),
1,
"one whole part"
);
}
#[test]
fn an_empty_object_with_an_unsendable_etag_is_dropped() {
let mut empty = created("data/empty.ndjson", 0);
empty["s3"]["object"]["eTag"] = serde_json::json!("abc\u{1}");
assert_eq!(
keys(decoded(&[empty, created("data/a.ndjson", 10)])),
["data/a.ndjson"]
);
}
#[test]
fn a_ranged_descriptor_with_an_unsendable_etag_fails() {
let object = |etag: &str| DescriptorObject {
key: "data/a.ndjson".into(),
size: 300,
etag: Some(etag.to_owned()),
last_modified_ms: 1,
};
let range = SplitRange::new(0, 100, b'\n');
let body = SplitDescriptor::with_range(object("\"abc\u{1}\""), range)
.encode()
.unwrap();
let reason = failed(decode(&body, &scope(), packing()));
assert!(reason.contains("cannot be sent in `If-Match`"), "{reason}");
let body = SplitDescriptor::with_range(object("\"abc\""), range)
.encode()
.unwrap();
assert_eq!(keys(decode(&body, &scope(), packing())), ["data/a.ndjson"]);
}
#[test]
fn a_record_without_an_event_name_fails_its_message() {
for name in [None, Some(serde_json::json!(1))] {
let mut record = created("data/a.ndjson", 10);
match name {
None => {
record.as_object_mut().unwrap().remove("eventName");
}
Some(value) => record["eventName"] = value,
}
let reason = failed(decoded(&[created("data/b.ndjson", 10), record]));
assert!(reason.contains("notification record 1"), "{reason}");
assert!(
reason.contains("`eventName` is missing or not a string"),
"{reason}"
);
}
}
#[test]
fn a_dropped_record_leaves_the_records_after_it() {
for dropped in [
record("ObjectRemoved:Delete", "bucket", "data/gone.ndjson", None),
record(
"ObjectRestore:Completed",
"elsewhere",
"other/a.ndjson",
Some(10),
),
created("data/empty.ndjson", 0),
] {
assert_eq!(
keys(decoded(&[dropped, created("data/a.ndjson", 10)])),
["data/a.ndjson"]
);
}
}
#[test]
fn a_message_with_several_records_gives_one_part_each() {
let parts = read(decoded(&[
created("data/small.ndjson", 10),
created("data/large.ndjson", 300),
]));
assert_eq!(
parts,
[
(
"data/small.ndjson".to_owned(),
Some("\"0123abcd\"".to_owned()),
None
),
(
"data/large.ndjson".to_owned(),
Some("\"0123abcd\"".to_owned()),
Some((0, 100))
),
(
"data/large.ndjson".to_owned(),
Some("\"0123abcd\"".to_owned()),
Some((100, 200))
),
(
"data/large.ndjson".to_owned(),
Some("\"0123abcd\"".to_owned()),
Some((200, 300))
),
]
);
let Decoded::Read(cursors) = decoded(&[created("data/a.ndjson", 10)]) else {
panic!("not read");
};
let expected = chrono::DateTime::parse_from_rfc3339("2026-10-19T12:00:00.000Z")
.unwrap()
.timestamp_millis();
assert_eq!(cursors[0].object[0].last_modified_ms, expected);
for time in [serde_json::Value::Null, serde_json::json!("yesterday")] {
let mut record = created("data/a.ndjson", 10);
record["eventTime"] = time;
let Decoded::Read(cursors) = decoded(&[record]) else {
panic!("not read");
};
assert_eq!(cursors[0].object[0].last_modified_ms, 0);
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(64))]
#[test]
fn prop_units_tile_the_listing_one_object_or_range_each(
objects in proptest::collection::vec(
(0u8..7, any::<u64>(), 0usize..6, 0u8..6, any::<bool>()),
0..12,
),
target in target(),
codec in 0u8..4,
delimiter in proptest::option::of(Just(b'\n')),
) {
let compression = compression(codec);
let packing = Packing { target_bytes: target, compression, delimiter };
let listing: Vec<(ObjectEntry, bool)> = objects
.iter()
.enumerate()
.map(|(i, &(size_shape, random, ext, key_shape, etag))| {
let (key, unaddressable) = key(i, key_shape, EXTENSIONS[ext]);
let entry = ObjectEntry {
etag: etag.then(|| format!("\"etag-{i}\"")),
key,
size: size(target, size_shape, random),
last_modified_ms: 1,
};
(entry, unaddressable)
})
.collect();
let entries: Vec<ObjectEntry> = listing.iter().map(|(e, _)| e.clone()).collect();
let units_of = |entries: &[ObjectEntry]| {
entries
.iter()
.cloned()
.flat_map(|e| units(e, packing))
.collect::<Result<Vec<_>, _>>()
.unwrap()
};
let got = units_of(&entries);
let again = units_of(&entries);
prop_assert_eq!(&got, &again, "units differ between two calls");
let mut seen = 0;
for (entry, unaddressable) in &listing {
let mine: Vec<(String, SplitDescriptor)> = got
.iter()
.map(|u| (u.id.clone(), SplitDescriptor::decode(&u.body).unwrap()))
.filter(|(_, d)| d.objects[0].key == entry.key)
.collect();
seen += mine.len();
for (_, d) in &mine {
prop_assert_eq!(d.objects.len(), 1);
prop_assert_eq!(&d.to_entries()[0], entry);
}
let cut = delimiter.is_some()
&& entry.size > target
&& entry.size <= MAX_SUBDIVIDABLE_BYTES
&& entry.etag.is_some()
&& plain(compression, &entry.key)
&& !unaddressable;
if cut {
let mut next = 0;
for (id, d) in &mine {
let range = d.range.expect("a cut object yields only ranges");
prop_assert_eq!(range.start, next);
let len = range.end - range.start;
prop_assert!(target / 2 <= len && len <= target, "{:?}", range);
prop_assert_eq!(Some(range.delimiter), delimiter);
let want = split_id_for_range(
&entry.key,
entry.etag.as_deref().unwrap(),
range,
)
.unwrap();
prop_assert_eq!(id, &want);
next = range.end;
}
prop_assert_eq!(next, entry.size, "the ranges do not tile the object");
} else {
prop_assert_eq!(mine.len(), 1, "{:?} is not one whole unit", entry);
let (id, d) = &mine[0];
prop_assert_eq!(d.range, None);
let want =
split_id_for([(entry.key.as_str(), entry.etag.as_deref())]).unwrap();
prop_assert_eq!(id, &want);
}
}
prop_assert_eq!(seen, got.len(), "a unit names no listed object");
let retagged: Vec<ObjectEntry> = entries
.iter()
.map(|e| ObjectEntry {
etag: e.etag.as_ref().map(|t| format!("{t}-2")),
..e.clone()
})
.collect();
let other = units_of(&retagged);
for (a, b) in got.iter().zip(&other) {
let tagged = SplitDescriptor::decode(&a.body).unwrap().objects[0].etag.is_some();
if tagged {
prop_assert_ne!(&a.id, &b.id, "the id ignores the ETag");
}
}
}
}
fn pack(entries: &[ObjectEntry], packing: Packing) -> Vec<Minted> {
let mut packer = Packer::new(packing);
let mut out: Vec<Minted> = Vec::new();
for entry in entries {
for minted in packer.push(entry.clone()) {
out.push(minted.unwrap());
}
}
out.extend(packer.close().map(Result::unwrap));
out
}
fn member_keys(minted: &[Minted]) -> Vec<Vec<String>> {
minted
.iter()
.map(|m| {
SplitDescriptor::decode(&m.unit.body)
.unwrap()
.objects
.into_iter()
.map(|o| o.key)
.collect()
})
.collect()
}
fn entry_tagged(key: &str, etag: Option<&str>) -> ObjectEntry {
ObjectEntry {
key: key.into(),
size: 10,
etag: etag.map(str::to_owned),
last_modified_ms: 1,
}
}
#[test]
fn an_object_whose_etag_cannot_be_sent_is_packed_alone() {
let entries = [
entry_tagged("a/x", Some("\"x\"")),
entry_tagged("a/bad", Some("\"a\u{1}b\"")),
entry_tagged("a/y", Some("\"y\"")),
];
assert_eq!(
member_keys(&pack(&entries, packing())),
[["a/x"], ["a/bad"], ["a/y"]]
);
}
#[test]
fn a_small_object_without_an_etag_shares_a_unit() {
let entries = [
entry_tagged("a/x", Some("\"x\"")),
entry_tagged("a/none", None),
entry_tagged("a/y", Some("\"y\"")),
];
let minted = pack(&entries, packing());
assert_eq!(member_keys(&minted), [["a/x", "a/none", "a/y"]]);
assert_eq!(minted[0].first_key, "a/x");
assert_eq!(minted[0].objects, 3);
}
#[test]
fn a_listed_size_near_u64_max_closes_the_open_unit() {
let mut big = entry_tagged("a/big", Some("\"b\""));
big.size = u64::MAX - 5;
let minted = pack(&[entry_tagged("a/x", Some("\"x\"")), big], packing());
assert_eq!(member_keys(&minted), [["a/x"], ["a/big"]]);
}
#[test]
fn a_unit_at_every_packing_cap_stays_under_the_body_bound() {
let etag = format!("\"{}\"", "\"".repeat(crate::split::PACK_ETAG_MAX_BYTES - 2));
let entries: Vec<ObjectEntry> = (0..PACK_MEMBERS)
.map(|i| {
let key = format!("a/{i:02}{}", "\"".repeat(PACK_KEY_MAX_BYTES - 4));
entry_tagged(&key, Some(&etag))
})
.collect();
let minted = pack(
&entries,
Packing {
target_bytes: MIB,
..packing()
},
);
assert_eq!(minted.len(), 1, "one unit of {PACK_MEMBERS}");
let body = minted[0].unit.body.len() + minted[0].unit.id.len();
assert!(
body <= crate::split::PACKED_BODY_MAX_BYTES,
"{body} bytes with the id"
);
}
#[test]
fn a_packed_body_stays_under_its_bound_with_long_etags() {
let long = format!("\"{}\"", "e".repeat(70_000));
let entries: Vec<ObjectEntry> = (0..PACK_MEMBERS)
.map(|i| entry_tagged(&format!("a/{i:02}"), Some(&long)))
.chain([
entry_tagged("a/x", Some("\"x\"")),
entry_tagged("a/y", Some("\"y\"")),
])
.collect();
let minted = pack(&entries, packing());
for m in &minted {
if m.objects > 1 {
assert!(m.unit.body.len() <= crate::split::PACKED_BODY_MAX_BYTES);
}
}
let sizes: Vec<usize> = minted.iter().map(|m| m.objects).collect();
let mut want = vec![1; PACK_MEMBERS];
want.push(2);
assert_eq!(sizes, want);
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(64))]
#[test]
fn prop_packed_units_tile_the_listing(
objects in proptest::collection::vec(
(0u8..7, any::<u64>(), 0usize..6, 0u8..6, 0u8..3, any::<bool>()),
0..40,
),
target in target(),
codec in 0u8..4,
delimiter in proptest::option::of(Just(b'\n')),
) {
let compression = compression(codec);
let packing = Packing { target_bytes: target, compression, delimiter };
let listing: Vec<(ObjectEntry, bool)> = objects
.iter()
.enumerate()
.map(|(i, &(size_shape, random, ext, key_shape, etag, long))| {
let (mut key, unaddressable) = key(i, key_shape, EXTENSIONS[ext]);
if long && key_shape == 0 {
key = format!("{}{key}", "x".repeat(1025 - key.len()));
}
let etag = match etag {
0 => None,
1 => Some(format!("\"etag-{i}\"")),
_ => Some(format!("\"bad\u{1}{i}\"")),
};
let entry = ObjectEntry {
key,
size: size(target, size_shape, random),
etag,
last_modified_ms: 1,
};
(entry, unaddressable)
})
.collect();
let entries: Vec<ObjectEntry> = listing.iter().map(|(e, _)| e.clone()).collect();
let got = pack(&entries, packing);
let again = pack(&entries, packing);
let units: Vec<&Unit> = got.iter().map(|m| &m.unit).collect();
prop_assert_eq!(units, again.iter().map(|m| &m.unit).collect::<Vec<_>>());
let cut = |(e, unaddressable): &(ObjectEntry, bool)| {
delimiter.is_some()
&& e.size > target
&& e.size <= MAX_SUBDIVIDABLE_BYTES
&& e.etag.is_some()
&& plain(compression, &e.key)
&& !unaddressable
};
let shares = |item: &(ObjectEntry, bool)| {
let (e, unaddressable) = item;
!cut(item)
&& !unaddressable
&& e.key.len() <= 1024
&& !e.etag.as_deref().is_some_and(|t| t.contains('\u{1}') || t.len() > 256)
};
let mut want: Vec<(Vec<String>, bool)> = Vec::new();
let mut open: Vec<String> = Vec::new();
let mut open_bytes = 0u64;
for item in &listing {
let (e, _) = item;
if shares(item) {
if !open.is_empty() && open_bytes + e.size > target {
want.push((std::mem::take(&mut open), false));
open_bytes = 0;
}
open.push(e.key.clone());
open_bytes += e.size;
if open.len() == 16 {
want.push((std::mem::take(&mut open), false));
open_bytes = 0;
}
} else {
if !open.is_empty() {
want.push((std::mem::take(&mut open), false));
open_bytes = 0;
}
want.push((vec![e.key.clone()], cut(item)));
}
}
if !open.is_empty() {
want.push((open, false));
}
let mut at = 0;
for (keys, is_cut) in &want {
if *is_cut {
let entry = &entries.iter().find(|e| e.key == keys[0]).unwrap();
let mut next = 0;
while next < entry.size {
let d = SplitDescriptor::decode(&got[at].unit.body).unwrap();
let range = d.range.expect("a cut object yields ranges");
prop_assert_eq!(&d.objects[0].key, &keys[0]);
prop_assert_eq!(range.start, next);
next = range.end;
at += 1;
}
continue;
}
let minted = &got[at];
let d = SplitDescriptor::decode(&minted.unit.body).unwrap();
let got_keys: Vec<&String> = d.objects.iter().map(|o| &o.key).collect();
prop_assert_eq!(got_keys, keys.iter().collect::<Vec<_>>());
prop_assert_eq!(d.range, None);
prop_assert!(keys.len() <= PACK_MEMBERS);
if keys.len() > 1 {
let bytes: u64 = d.objects.iter().map(|o| o.size).sum();
prop_assert!(bytes <= target, "{} bytes over a {} target", bytes, target);
}
let id = split_id_for(
d.objects.iter().map(|o| (o.key.as_str(), o.etag.as_deref())),
)
.unwrap();
prop_assert_eq!(&minted.unit.id, &id);
prop_assert_eq!(&minted.first_key, &keys[0]);
prop_assert_eq!(minted.objects, keys.len());
at += 1;
}
prop_assert_eq!(at, got.len(), "a unit the rule does not account for");
}
}
fn origins(decoded: Decoded) -> Vec<Origin> {
match decoded {
Decoded::Read(cursors) => cursors.into_iter().flatten().map(|p| p.origin).collect(),
Decoded::Acknowledge(reason) => panic!("acknowledged: {reason}"),
Decoded::Fail(reason) => panic!("failed: {reason}"),
}
}
fn with_field(field: &str, value: serde_json::Value) -> serde_json::Value {
let mut record = created("data/a.ndjson", 10);
match field {
"versionId" => record["s3"]["object"]["versionId"] = value,
_ => record[field] = value,
}
record
}
#[test]
fn a_version_id_decodes_by_the_notification_rule() {
let version = |origins: Vec<Origin>| match &origins[..] {
[Origin::Notification { version, .. }] => version.clone(),
other => panic!("{other:?}"),
};
assert_eq!(
version(origins(decoded(&[with_field(
"versionId",
"3HL4kqtJ".into()
)]))),
Some("3HL4kqtJ".to_owned())
);
assert_eq!(
version(origins(decoded(&[created("data/a.ndjson", 10)]))),
None
);
for absent in [serde_json::Value::Null, "".into(), "null".into()] {
let record = with_field("versionId", absent.clone());
assert_eq!(version(origins(decoded(&[record]))), None, "{absent}");
}
let longest = "v".repeat(MAX_VERSION_BYTES);
assert_eq!(
version(origins(decoded(&[with_field(
"versionId",
longest.clone().into()
)]))),
Some(longest)
);
for refused in [
serde_json::json!(7),
"a b".into(),
"a\u{1}b".into(),
"a\u{7f}b".into(),
"v".repeat(MAX_VERSION_BYTES + 1).into(),
] {
let reason = failed(decoded(&[with_field("versionId", refused.clone())]));
assert!(
reason.starts_with("notification record 0: "),
"{refused}: {reason}"
);
}
}
#[test]
fn a_re_enqueue_count_decodes_by_the_notification_rule() {
let count = |origins: Vec<Origin>| match &origins[..] {
[Origin::Notification { re_enqueues, .. }] => *re_enqueues,
other => panic!("{other:?}"),
};
assert_eq!(count(origins(decoded(&[created("data/a.ndjson", 10)]))), 0);
assert_eq!(
count(origins(decoded(&[with_field(
RE_ENQUEUES,
serde_json::Value::Null
)]))),
0
);
for n in [0, 7, u32::MAX] {
assert_eq!(
count(origins(decoded(&[with_field(RE_ENQUEUES, n.into())]))),
n
);
}
for refused in [
serde_json::json!(-1),
serde_json::json!(1.5),
serde_json::json!("3"),
serde_json::json!(4_294_967_296_u64),
] {
let reason = failed(decoded(&[with_field(RE_ENQUEUES, refused.clone())]));
assert!(
reason.contains("the record is unreadable"),
"{refused}: {reason}"
);
}
}
#[test]
fn a_re_enqueued_unit_decodes_to_the_key_and_the_heads_etag_and_size() {
for key in [
"data/a b.ndjson",
"data/a+b.ndjson",
"data/a%b.ndjson",
"data/é.ndjson",
"data/\u{FFFE}.ndjson",
"data/a~b-c_d.e",
] {
let overwrite = Overwrite {
key: key.to_owned(),
etag: Some("\"0123abcd\"".to_owned()),
size: 1234,
last_modified_ms: 1_760_000_000_123,
};
let body = re_enqueue_body("bucket", &overwrite, 4);
assert!(
body.iter().all(|&b| (0x20..0x7f).contains(&b)),
"{key}: {}",
String::from_utf8_lossy(&body)
);
let decoded = decode(&body, &scope(), packing());
let Decoded::Read(cursors) = decoded else {
panic!("{key}: not read");
};
let parts: Vec<Part> = cursors.into_iter().flatten().collect();
let entry = &parts[0].object[0];
assert_eq!(entry.key, key);
assert_eq!(entry.etag.as_deref(), Some("\"0123abcd\""));
assert_eq!(entry.size, 1234);
assert_eq!(entry.last_modified_ms, 1_760_000_000_123);
assert_eq!(
parts[0].origin,
Origin::Notification {
version: None,
re_enqueues: 4
},
"{key}"
);
}
}
#[test]
fn a_re_enqueued_unit_without_an_etag_reads_unpinned() {
let overwrite = Overwrite {
key: "data/a.ndjson".to_owned(),
etag: None,
size: 10,
last_modified_ms: 0,
};
let body = re_enqueue_body("bucket", &overwrite, 1);
assert_eq!(
read(decode(&body, &scope(), packing())),
[("data/a.ndjson".to_owned(), None, None)]
);
}
}