use serde_json::Value;
#[derive(Debug)]
pub struct ObjectAccumulator {
buf: Vec<u8>,
rows: usize,
parted_bytes: usize,
max_rows: Option<usize>,
max_bytes: Option<usize>,
part_bytes: Option<usize>,
}
#[derive(Debug, PartialEq, Eq)]
pub struct CompletedObject {
pub body: Vec<u8>,
pub rows: usize,
}
#[derive(Debug, PartialEq, Eq)]
pub enum Emit {
Nothing,
Part(Vec<u8>),
Object(CompletedObject),
}
impl ObjectAccumulator {
pub fn new(max_rows: Option<usize>, max_bytes: Option<usize>) -> Self {
Self {
buf: Vec::new(),
rows: 0,
parted_bytes: 0,
max_rows: max_rows.filter(|n| *n > 0),
max_bytes: max_bytes.filter(|n| *n > 0),
part_bytes: None,
}
}
pub fn with_part_size(mut self, bytes: usize) -> Self {
self.part_bytes = (bytes > 0).then_some(bytes);
self
}
pub fn parted_bytes(&self) -> usize {
self.parted_bytes
}
pub fn has_parts(&self) -> bool {
self.parted_bytes > 0
}
pub fn rows(&self) -> usize {
self.rows
}
pub fn len(&self) -> usize {
self.buf.len()
}
pub fn is_empty(&self) -> bool {
self.rows == 0
}
pub fn push_encoded(&mut self, encoded: &[u8]) -> Emit {
self.buf.extend_from_slice(encoded);
self.rows += 1;
if self.max_rows.is_some_and(|m| self.rows >= m)
|| self
.max_bytes
.is_some_and(|m| self.parted_bytes + self.buf.len() >= m)
{
return Emit::Object(self.take());
}
if self.part_bytes.is_some_and(|m| self.buf.len() >= m) {
let part = std::mem::take(&mut self.buf);
self.parted_bytes += part.len();
return Emit::Part(part);
}
Emit::Nothing
}
pub fn push_record(&mut self, record: &Value) -> Result<Emit, crate::FaucetError> {
let mut line = serde_json::to_vec(record)
.map_err(|e| crate::FaucetError::Sink(format!("JSON serialization failed: {e}")))?;
line.push(b'\n');
Ok(self.push_encoded(&line))
}
pub fn finish(&mut self) -> Option<CompletedObject> {
(!self.is_empty()).then(|| self.take())
}
fn take(&mut self) -> CompletedObject {
self.parted_bytes = 0;
CompletedObject {
body: std::mem::take(&mut self.buf),
rows: std::mem::replace(&mut self.rows, 0),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn rec(i: u64) -> Value {
json!({ "id": i })
}
fn push_all(acc: &mut ObjectAccumulator, n: u64) -> (Vec<CompletedObject>, Vec<Vec<u8>>) {
let (mut objects, mut parts) = (Vec::new(), Vec::new());
for i in 0..n {
match acc.push_record(&rec(i)).unwrap() {
Emit::Nothing => {}
Emit::Part(p) => parts.push(p),
Emit::Object(o) => objects.push(o),
}
}
(objects, parts)
}
#[test]
fn nothing_rolls_without_a_threshold() {
let mut acc = ObjectAccumulator::new(None, None);
let (objects, parts) = push_all(&mut acc, 1000);
assert!(objects.is_empty() && parts.is_empty());
let done = acc.finish().expect("the remainder must be written");
assert_eq!(done.rows, 1000);
assert!(acc.finish().is_none(), "finish must not double-emit");
}
#[test]
fn rows_across_several_pages_land_in_one_object() {
let mut acc = ObjectAccumulator::new(Some(10), None);
let mut completed = Vec::new();
for page in 0..3 {
for i in 0..4 {
if let Emit::Object(o) = acc.push_record(&rec(page * 4 + i)).unwrap() {
completed.push(o);
}
}
}
assert_eq!(completed.len(), 1, "one rollover at 10 rows");
assert_eq!(completed[0].rows, 10);
let rest = acc.finish().expect("2 rows remain");
assert_eq!(rest.rows, 2);
assert_eq!(
completed[0].rows + rest.rows,
12,
"no row may be lost across a rollover"
);
}
#[test]
fn the_byte_threshold_rolls_independently_of_rows() {
let mut acc = ObjectAccumulator::new(None, Some(32));
let (objects, _) = push_all(&mut acc, 20);
assert!(!objects.is_empty(), "a byte cap must roll");
for obj in &objects {
assert!(
obj.body.len() >= 32,
"an object rolls at or past the threshold, not before: {}",
obj.body.len()
);
assert!(obj.body.len() < 32 + 64, "overshoot bounded by one record");
}
}
#[test]
fn whichever_threshold_hits_first_wins() {
let mut acc = ObjectAccumulator::new(Some(1000), Some(24));
let (objects, _) = push_all(&mut acc, 10);
assert!(
!objects.is_empty(),
"the byte cap must roll even though the row cap is far away"
);
}
#[test]
fn a_single_oversized_record_gets_its_own_object() {
let mut acc = ObjectAccumulator::new(None, Some(8));
let big = json!({ "blob": "x".repeat(500) });
let Emit::Object(obj) = acc.push_record(&big).unwrap() else {
panic!("an oversized record must complete an object immediately");
};
assert_eq!(obj.rows, 1);
assert!(obj.body.len() > 8);
}
#[test]
fn a_zero_threshold_means_no_limit_not_roll_every_record() {
let mut acc = ObjectAccumulator::new(Some(0), Some(0));
let (objects, parts) = push_all(&mut acc, 50);
assert!(objects.is_empty() && parts.is_empty());
assert_eq!(acc.finish().expect("remainder").rows, 50);
}
#[test]
fn bodies_are_ndjson_with_a_trailing_newline() {
let mut acc = ObjectAccumulator::new(Some(2), None);
acc.push_record(&rec(1)).unwrap();
let Emit::Object(obj) = acc.push_record(&rec(2)).unwrap() else {
panic!("rolled");
};
let text = String::from_utf8(obj.body).unwrap();
assert_eq!(text, "{\"id\":1}\n{\"id\":2}\n");
}
#[test]
fn counters_track_the_open_object() {
let mut acc = ObjectAccumulator::new(Some(10), None);
assert!(acc.is_empty());
acc.push_record(&rec(1)).unwrap();
assert_eq!(acc.rows(), 1);
assert!(!acc.is_empty());
acc.finish();
assert!(acc.is_empty(), "finish resets the open object");
assert_eq!(acc.rows(), 0);
assert_eq!(acc.len(), 0);
}
#[test]
fn parts_bound_peak_memory_for_an_uncapped_object() {
let mut acc = ObjectAccumulator::new(None, None).with_part_size(64);
let (objects, parts) = push_all(&mut acc, 200);
assert!(objects.is_empty(), "no object cap, so nothing rolls");
assert!(!parts.is_empty(), "parts must be emitted");
for p in &parts {
assert!(p.len() >= 64, "a part fills before it is emitted");
assert!(p.len() < 64 + 64, "and is not held far past the threshold");
}
assert!(
acc.len() < 64,
"the unflushed tail stays under one part: {}",
acc.len()
);
assert!(acc.has_parts());
let tail = acc.finish().expect("tail");
assert_eq!(tail.rows, 200, "rows count the whole object, not the tail");
}
#[test]
fn the_byte_cap_measures_the_whole_object_not_just_the_tail() {
let mut acc = ObjectAccumulator::new(None, Some(200)).with_part_size(64);
let (objects, parts) = push_all(&mut acc, 200);
assert!(!parts.is_empty(), "parts still stream");
assert!(
!objects.is_empty(),
"the object cap must still be reached once parts are counted"
);
}
#[test]
fn taking_an_object_resets_the_part_counter() {
let mut acc = ObjectAccumulator::new(Some(2), None).with_part_size(8);
acc.push_record(&rec(1)).unwrap();
let Emit::Object(_) = acc.push_record(&rec(2)).unwrap() else {
panic!("rolled at 2 rows");
};
assert_eq!(acc.parted_bytes(), 0);
assert!(!acc.has_parts());
}
#[test]
fn an_object_cap_closes_rather_than_parting_on_the_same_push() {
let mut acc = ObjectAccumulator::new(Some(1), None).with_part_size(1);
assert!(matches!(acc.push_record(&rec(1)).unwrap(), Emit::Object(_)));
}
}
#[derive(Debug)]
pub struct PageAccumulator {
rows: Vec<Value>,
bytes: usize,
max_rows: Option<usize>,
max_bytes: Option<usize>,
}
impl PageAccumulator {
pub fn new(max_rows: Option<usize>, max_bytes: Option<usize>) -> Self {
Self {
rows: Vec::new(),
bytes: 0,
max_rows: max_rows.filter(|n| *n > 0),
max_bytes: max_bytes.filter(|n| *n > 0),
}
}
pub fn len(&self) -> usize {
self.rows.len()
}
pub fn is_empty(&self) -> bool {
self.rows.is_empty()
}
pub fn bytes(&self) -> usize {
self.bytes
}
pub fn push_page(&mut self, page: &[Value]) -> Option<Vec<Value>> {
for r in page {
self.bytes += estimate_size(r);
self.rows.push(r.clone());
}
let full = self.max_rows.is_some_and(|m| self.rows.len() >= m)
|| self.max_bytes.is_some_and(|m| self.bytes >= m);
full.then(|| self.take())
}
pub fn finish(&mut self) -> Option<Vec<Value>> {
(!self.is_empty()).then(|| self.take())
}
fn take(&mut self) -> Vec<Value> {
self.bytes = 0;
std::mem::take(&mut self.rows)
}
}
fn estimate_size(v: &Value) -> usize {
match v {
Value::Null => 4,
Value::Bool(_) => 5,
Value::Number(_) => 8,
Value::String(s) => s.len() + 2,
Value::Array(a) => 2 + a.iter().map(estimate_size).sum::<usize>() + a.len(),
Value::Object(m) => {
2 + m
.iter()
.map(|(k, v)| k.len() + 3 + estimate_size(v))
.sum::<usize>()
}
}
}
#[cfg(test)]
mod page_accumulator_tests {
use super::*;
use serde_json::json;
fn page(n: usize) -> Vec<Value> {
(0..n).map(|i| json!({ "id": i })).collect()
}
#[test]
fn small_pages_merge_into_one_commit_group() {
let mut acc = PageAccumulator::new(Some(100), None);
let mut commits = Vec::new();
for _ in 0..10 {
if let Some(g) = acc.push_page(&page(10)) {
commits.push(g);
}
}
assert_eq!(commits.len(), 1, "ten small pages → one commit");
assert_eq!(commits[0].len(), 100);
assert!(acc.finish().is_none(), "nothing left over");
}
#[test]
fn a_group_overshoots_by_at_most_one_page() {
let mut acc = PageAccumulator::new(Some(10), None);
let g = acc
.push_page(&page(25))
.expect("one page over the cap commits");
assert_eq!(g.len(), 25);
}
#[test]
fn the_byte_cap_rolls_independently_of_rows() {
let mut acc = PageAccumulator::new(None, Some(200));
let mut commits = 0;
for _ in 0..20 {
if acc.push_page(&page(5)).is_some() {
commits += 1;
}
}
assert!(commits > 0, "a byte cap must roll");
}
#[test]
fn no_cap_means_one_commit_for_the_whole_run() {
let mut acc = PageAccumulator::new(None, None);
for _ in 0..50 {
assert!(acc.push_page(&page(10)).is_none());
}
assert_eq!(acc.finish().expect("flush commits").len(), 500);
}
#[test]
fn a_zero_cap_means_no_limit() {
let mut acc = PageAccumulator::new(Some(0), Some(0));
assert!(acc.push_page(&page(10)).is_none());
assert_eq!(acc.finish().expect("remainder").len(), 10);
}
#[test]
fn counters_track_the_open_group() {
let mut acc = PageAccumulator::new(Some(100), None);
assert!(acc.is_empty());
acc.push_page(&page(3));
assert_eq!(acc.len(), 3);
assert!(acc.bytes() > 0);
acc.finish();
assert!(acc.is_empty());
assert_eq!(acc.bytes(), 0, "finish resets the size estimate too");
}
#[test]
fn size_estimate_grows_with_real_content() {
let small = estimate_size(&json!({ "a": 1 }));
let big = estimate_size(&json!({ "a": 1, "b": "x".repeat(1000) }));
assert!(big > small + 900, "{small} vs {big}");
assert!(estimate_size(&json!([1, 2, 3])) > estimate_size(&json!([])));
}
}