Skip to content

Commit 9e14a9a

Browse files
committed
mirror_worker: parse entry packages incrementally
parse_next_package rebuilt a Cursor at byte zero and re-ran EntryPackage::read_from on every pull, re-parsing and re-allocating all already-read entries; a large package arriving in small chunks was O(n^2) in copies. Parse entry-by-entry instead, consuming each entry and the proof from the StreamBuffer as soon as it is fully buffered, so a short read resumes from the next unread unit. Adds tests for byte-by-byte reassembly, back-to-back packages, both truncation classes, and the num_hashes limit.
1 parent 8ad4521 commit 9e14a9a

1 file changed

Lines changed: 214 additions & 26 deletions

File tree

crates/mirror_worker/src/add_entries.rs

Lines changed: 214 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -833,44 +833,116 @@ where
833833
}
834834
}
835835

836-
/// Read the next entry package from `buf`, pulling more chunks from
837-
/// the underlying stream until the package parses or the stream ends.
838-
/// See [`ParseOutcome`] for the four cases.
836+
/// Pull chunks until at least `n` bytes are buffered. Returns `Ok(true)`
837+
/// once `n` bytes are available, or `Ok(false)` if the stream ended first.
838+
/// Nothing is consumed; the caller parses the now-buffered bytes.
839+
async fn fill_at_least<S>(buf: &mut StreamBuffer<S>, n: usize) -> ApiResult<bool>
840+
where
841+
S: futures_util::Stream<Item = std::result::Result<Vec<u8>, BodyError>> + Unpin,
842+
{
843+
while buf.len() < n {
844+
if !buf.pull_one().await? {
845+
return Ok(false);
846+
}
847+
}
848+
Ok(true)
849+
}
850+
851+
/// Read one length-prefixed entry (`u16 len || len bytes`) from the front
852+
/// of `buf`, consuming exactly the bytes read. Returns `Ok(None)` if the
853+
/// stream ends before a complete entry is buffered (truncation). Unlike a
854+
/// whole-package reparse, each call consumes what it reads, so pulling more
855+
/// bytes for a later entry never re-copies the entries already taken.
856+
async fn read_one_entry<S>(buf: &mut StreamBuffer<S>) -> ApiResult<Option<Vec<u8>>>
857+
where
858+
S: futures_util::Stream<Item = std::result::Result<Vec<u8>, BodyError>> + Unpin,
859+
{
860+
if !fill_at_least(buf, 2).await? {
861+
return Ok(None);
862+
}
863+
let bytes = buf.buffered();
864+
let len = usize::from(u16::from_be_bytes([bytes[0], bytes[1]]));
865+
let total = 2 + len;
866+
if !fill_at_least(buf, total).await? {
867+
return Ok(None);
868+
}
869+
let entry = buf.buffered()[2..total].to_vec();
870+
buf.consume(total);
871+
Ok(Some(entry))
872+
}
873+
874+
/// Read the next entry package from `buf`, pulling more chunks from the
875+
/// underlying stream until the package parses or the stream ends. See
876+
/// [`ParseOutcome`] for the four cases.
877+
///
878+
/// The package is parsed incrementally, consuming each entry and the proof
879+
/// from `buf` as soon as it is fully buffered. A short read pulls one more
880+
/// chunk and resumes from the next unread unit, so a large package
881+
/// arriving in small chunks is parsed and copied once, not re-parsed from
882+
/// byte zero on every chunk.
839883
async fn parse_next_package<S>(
840884
buf: &mut StreamBuffer<S>,
841885
num_entries: u64,
842886
) -> ApiResult<ParseOutcome>
843887
where
844888
S: futures_util::Stream<Item = std::result::Result<Vec<u8>, BodyError>> + Unpin,
845889
{
890+
// Reject oversized counts before allocating, matching
891+
// EntryPackage::read_from so the two parse paths agree on limits.
892+
if num_entries > PACKAGE_ALIGNMENT {
893+
return Ok(ParseOutcome::Err(ParseError::TooManyEntries(num_entries)));
894+
}
846895
// EOF with an empty buffer: clean truncation between packages.
847896
if buf.is_eof() && buf.len() == 0 {
848897
return Ok(ParseOutcome::CleanEof);
849898
}
850-
loop {
851-
let mut cursor = Cursor::new(buf.buffered());
852-
match EntryPackage::read_from(&mut cursor, num_entries) {
853-
Ok(pkg) => {
854-
let consumed = usize::try_from(cursor.position()).unwrap_or(usize::MAX);
855-
buf.consume(consumed);
856-
return Ok(ParseOutcome::Ok(pkg));
857-
}
858-
Err(ParseError::Io(ref e)) if e.kind() == ErrorKind::UnexpectedEof => {
859-
if !buf.pull_one().await? {
860-
// Stream ended mid-parse. An empty buffer means the
861-
// previous package consumed exactly all buffered bytes
862-
// and this call started a fresh (never-arriving)
863-
// package: a clean between-package truncation. A
864-
// non-empty buffer holds a partial package.
865-
if buf.len() == 0 {
866-
return Ok(ParseOutcome::CleanEof);
867-
}
868-
return Ok(ParseOutcome::MidPackageEof);
869-
}
899+
900+
let num_entries = usize::try_from(num_entries).unwrap_or(usize::MAX);
901+
let mut entries = Vec::with_capacity(num_entries);
902+
for _ in 0..num_entries {
903+
match read_one_entry(buf).await? {
904+
Some(entry) => entries.push(entry),
905+
None => {
906+
// Stream ended before this entry completed. Nothing buffered
907+
// and no partial bytes means a clean between-package
908+
// truncation; otherwise a partial package was left behind.
909+
return Ok(package_eof(entries.is_empty(), buf.len() > 0));
870910
}
871-
Err(e) => return Ok(ParseOutcome::Err(e)),
872911
}
873912
}
913+
914+
// Proof: `u8 num_hashes || num_hashes * HASH_SIZE bytes`.
915+
if !fill_at_least(buf, 1).await? {
916+
return Ok(package_eof(entries.is_empty(), buf.len() > 0));
917+
}
918+
let num_hashes = buf.buffered()[0];
919+
if num_hashes > tlog_mirror::MAX_HASHES_PER_PROOF {
920+
return Ok(ParseOutcome::Err(ParseError::TooManyHashes(num_hashes)));
921+
}
922+
let proof_bytes = 1 + usize::from(num_hashes) * tlog_core::HASH_SIZE;
923+
if !fill_at_least(buf, proof_bytes).await? {
924+
return Ok(package_eof(entries.is_empty(), buf.len() > 0));
925+
}
926+
let mut proof = Vec::with_capacity(usize::from(num_hashes));
927+
for i in 0..usize::from(num_hashes) {
928+
let off = 1 + i * tlog_core::HASH_SIZE;
929+
let mut hash = [0u8; tlog_core::HASH_SIZE];
930+
hash.copy_from_slice(&buf.buffered()[off..off + tlog_core::HASH_SIZE]);
931+
proof.push(Hash(hash));
932+
}
933+
buf.consume(proof_bytes);
934+
Ok(ParseOutcome::Ok(EntryPackage { entries, proof }))
935+
}
936+
937+
/// Classify a stream end reached partway through [`parse_next_package`]:
938+
/// a clean between-package truncation when nothing of this package had
939+
/// been read, otherwise a mid-package truncation.
940+
fn package_eof(no_entries_yet: bool, saw_partial: bool) -> ParseOutcome {
941+
if no_entries_yet && !saw_partial {
942+
ParseOutcome::CleanEof
943+
} else {
944+
ParseOutcome::MidPackageEof
945+
}
874946
}
875947

876948
/// Read the per-origin DO state snapshot. A non-200 status or RPC failure
@@ -1186,8 +1258,8 @@ impl HashReader for MapReader<'_> {
11861258
#[cfg(test)]
11871259
mod tests {
11881260
use super::{
1189-
CONTENT_TYPE, MapReader, content_type_is_octet_stream, excess_entries, parse_header,
1190-
verify_package,
1261+
CONTENT_TYPE, MapReader, ParseOutcome, content_type_is_octet_stream, excess_entries,
1262+
parse_header, parse_next_package, verify_package,
11911263
};
11921264
use crate::body::BodyError;
11931265
use crate::mirror_state_do::PendingCheckpoint;
@@ -1442,4 +1514,120 @@ mod tests {
14421514
Err(super::AppError::BadRequest(_))
14431515
));
14441516
}
1517+
1518+
fn sample_package() -> EntryPackage {
1519+
EntryPackage {
1520+
entries: vec![
1521+
b"first-entry".to_vec(),
1522+
Vec::new(),
1523+
b"a-much-longer-third-entry-with-more-bytes".to_vec(),
1524+
vec![0xab; 300],
1525+
],
1526+
proof: vec![
1527+
Hash([0x11; tlog_core::HASH_SIZE]),
1528+
Hash([0x22; tlog_core::HASH_SIZE]),
1529+
],
1530+
}
1531+
}
1532+
1533+
fn package_bytes(pkg: &EntryPackage) -> Vec<u8> {
1534+
let mut buf = Vec::new();
1535+
pkg.write_to(&mut buf).unwrap();
1536+
buf
1537+
}
1538+
1539+
// parse_next_package returns ApiResult, whose Err (AppError) is not
1540+
// Debug, so unwrap the transport layer by hand for the tests.
1541+
fn ok_outcome(res: super::ApiResult<ParseOutcome>) -> ParseOutcome {
1542+
let Ok(outcome) = res else {
1543+
panic!("unexpected transport error from parse_next_package");
1544+
};
1545+
outcome
1546+
}
1547+
1548+
// The incremental parser must reconstruct a package identical to a
1549+
// one-shot read regardless of how the wire bytes are chunked, including
1550+
// single-byte chunks that split every length prefix, entry, and proof
1551+
// hash. This is the regression for re-parsing from byte zero.
1552+
#[tokio::test(flavor = "current_thread")]
1553+
async fn parse_next_package_reassembles_across_chunk_sizes() {
1554+
let pkg = sample_package();
1555+
let bytes = package_bytes(&pkg);
1556+
let num_entries = pkg.entries.len() as u64;
1557+
for size in [1usize, 2, 3, 7, 33, bytes.len()] {
1558+
let chunks: Vec<Vec<u8>> = bytes.chunks(size).map(<[u8]>::to_vec).collect();
1559+
let mut buf = stream_buffer(chunks);
1560+
let ParseOutcome::Ok(parsed) =
1561+
ok_outcome(parse_next_package(&mut buf, num_entries).await)
1562+
else {
1563+
panic!("chunk size {size} should parse Ok");
1564+
};
1565+
assert_eq!(parsed.entries, pkg.entries, "chunk size {size} entries");
1566+
assert_eq!(parsed.proof, pkg.proof, "chunk size {size} proof");
1567+
}
1568+
}
1569+
1570+
// Two packages back to back: the first parse must consume exactly its
1571+
// bytes, leaving the second intact for the next call.
1572+
#[tokio::test(flavor = "current_thread")]
1573+
async fn parse_next_package_leaves_following_package_intact() {
1574+
let pkg = sample_package();
1575+
let mut bytes = package_bytes(&pkg);
1576+
bytes.extend(package_bytes(&pkg));
1577+
let num_entries = pkg.entries.len() as u64;
1578+
// 5-byte chunks so package boundaries fall mid-chunk.
1579+
let chunks: Vec<Vec<u8>> = bytes.chunks(5).map(<[u8]>::to_vec).collect();
1580+
let mut buf = stream_buffer(chunks);
1581+
for which in ["first", "second"] {
1582+
let ParseOutcome::Ok(parsed) =
1583+
ok_outcome(parse_next_package(&mut buf, num_entries).await)
1584+
else {
1585+
panic!("{which} package should parse Ok");
1586+
};
1587+
assert_eq!(parsed.entries, pkg.entries, "{which} entries");
1588+
assert_eq!(parsed.proof, pkg.proof, "{which} proof");
1589+
}
1590+
}
1591+
1592+
// Stream ending exactly on a package boundary is a clean truncation.
1593+
#[tokio::test(flavor = "current_thread")]
1594+
async fn parse_next_package_clean_eof_between_packages() {
1595+
let pkg = sample_package();
1596+
let mut buf = stream_buffer(vec![package_bytes(&pkg)]);
1597+
let num_entries = pkg.entries.len() as u64;
1598+
assert!(matches!(
1599+
ok_outcome(parse_next_package(&mut buf, num_entries).await),
1600+
ParseOutcome::Ok(_)
1601+
));
1602+
// Buffer now empty and stream ended: next call is a clean EOF.
1603+
assert!(matches!(
1604+
ok_outcome(parse_next_package(&mut buf, num_entries).await),
1605+
ParseOutcome::CleanEof
1606+
));
1607+
}
1608+
1609+
// Stream ending partway through a package is a mid-package truncation.
1610+
#[tokio::test(flavor = "current_thread")]
1611+
async fn parse_next_package_mid_package_eof() {
1612+
let pkg = sample_package();
1613+
let bytes = package_bytes(&pkg);
1614+
let mut buf = stream_buffer(vec![bytes[..10].to_vec()]);
1615+
let num_entries = pkg.entries.len() as u64;
1616+
assert!(matches!(
1617+
ok_outcome(parse_next_package(&mut buf, num_entries).await),
1618+
ParseOutcome::MidPackageEof
1619+
));
1620+
}
1621+
1622+
// An oversized num_hashes must be an Err, matching read_from's limit.
1623+
#[tokio::test(flavor = "current_thread")]
1624+
async fn parse_next_package_rejects_too_many_hashes() {
1625+
// One zero-length entry, then num_hashes = 64 (> spec max 63).
1626+
let bytes = vec![0x00, 0x00, 64];
1627+
let mut buf = stream_buffer(vec![bytes]);
1628+
assert!(matches!(
1629+
ok_outcome(parse_next_package(&mut buf, 1).await),
1630+
ParseOutcome::Err(super::ParseError::TooManyHashes(64))
1631+
));
1632+
}
14451633
}

0 commit comments

Comments
 (0)