Skip to content

Commit e44988c

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 da3fff4 commit e44988c

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
@@ -849,44 +849,116 @@ where
849849
}
850850
}
851851

852-
/// Read the next entry package from `buf`, pulling more chunks from
853-
/// the underlying stream until the package parses or the stream ends.
854-
/// See [`ParseOutcome`] for the four cases.
852+
/// Pull chunks until at least `n` bytes are buffered. Returns `Ok(true)`
853+
/// once `n` bytes are available, or `Ok(false)` if the stream ended first.
854+
/// Nothing is consumed; the caller parses the now-buffered bytes.
855+
async fn fill_at_least<S>(buf: &mut StreamBuffer<S>, n: usize) -> ApiResult<bool>
856+
where
857+
S: futures_util::Stream<Item = std::result::Result<Vec<u8>, BodyError>> + Unpin,
858+
{
859+
while buf.len() < n {
860+
if !buf.pull_one().await? {
861+
return Ok(false);
862+
}
863+
}
864+
Ok(true)
865+
}
866+
867+
/// Read one length-prefixed entry (`u16 len || len bytes`) from the front
868+
/// of `buf`, consuming exactly the bytes read. Returns `Ok(None)` if the
869+
/// stream ends before a complete entry is buffered (truncation). Unlike a
870+
/// whole-package reparse, each call consumes what it reads, so pulling more
871+
/// bytes for a later entry never re-copies the entries already taken.
872+
async fn read_one_entry<S>(buf: &mut StreamBuffer<S>) -> ApiResult<Option<Vec<u8>>>
873+
where
874+
S: futures_util::Stream<Item = std::result::Result<Vec<u8>, BodyError>> + Unpin,
875+
{
876+
if !fill_at_least(buf, 2).await? {
877+
return Ok(None);
878+
}
879+
let bytes = buf.buffered();
880+
let len = usize::from(u16::from_be_bytes([bytes[0], bytes[1]]));
881+
let total = 2 + len;
882+
if !fill_at_least(buf, total).await? {
883+
return Ok(None);
884+
}
885+
let entry = buf.buffered()[2..total].to_vec();
886+
buf.consume(total);
887+
Ok(Some(entry))
888+
}
889+
890+
/// Read the next entry package from `buf`, pulling more chunks from the
891+
/// underlying stream until the package parses or the stream ends. See
892+
/// [`ParseOutcome`] for the four cases.
893+
///
894+
/// The package is parsed incrementally, consuming each entry and the proof
895+
/// from `buf` as soon as it is fully buffered. A short read pulls one more
896+
/// chunk and resumes from the next unread unit, so a large package
897+
/// arriving in small chunks is parsed and copied once, not re-parsed from
898+
/// byte zero on every chunk.
855899
async fn parse_next_package<S>(
856900
buf: &mut StreamBuffer<S>,
857901
num_entries: u64,
858902
) -> ApiResult<ParseOutcome>
859903
where
860904
S: futures_util::Stream<Item = std::result::Result<Vec<u8>, BodyError>> + Unpin,
861905
{
906+
// Reject oversized counts before allocating, matching
907+
// EntryPackage::read_from so the two parse paths agree on limits.
908+
if num_entries > PACKAGE_ALIGNMENT {
909+
return Ok(ParseOutcome::Err(ParseError::TooManyEntries(num_entries)));
910+
}
862911
// EOF with an empty buffer: clean truncation between packages.
863912
if buf.is_eof() && buf.len() == 0 {
864913
return Ok(ParseOutcome::CleanEof);
865914
}
866-
loop {
867-
let mut cursor = Cursor::new(buf.buffered());
868-
match EntryPackage::read_from(&mut cursor, num_entries) {
869-
Ok(pkg) => {
870-
let consumed = usize::try_from(cursor.position()).unwrap_or(usize::MAX);
871-
buf.consume(consumed);
872-
return Ok(ParseOutcome::Ok(pkg));
873-
}
874-
Err(ParseError::Io(ref e)) if e.kind() == ErrorKind::UnexpectedEof => {
875-
if !buf.pull_one().await? {
876-
// Stream ended mid-parse. An empty buffer means the
877-
// previous package consumed exactly all buffered bytes
878-
// and this call started a fresh (never-arriving)
879-
// package: a clean between-package truncation. A
880-
// non-empty buffer holds a partial package.
881-
if buf.len() == 0 {
882-
return Ok(ParseOutcome::CleanEof);
883-
}
884-
return Ok(ParseOutcome::MidPackageEof);
885-
}
915+
916+
let num_entries = usize::try_from(num_entries).unwrap_or(usize::MAX);
917+
let mut entries = Vec::with_capacity(num_entries);
918+
for _ in 0..num_entries {
919+
match read_one_entry(buf).await? {
920+
Some(entry) => entries.push(entry),
921+
None => {
922+
// Stream ended before this entry completed. Nothing buffered
923+
// and no partial bytes means a clean between-package
924+
// truncation; otherwise a partial package was left behind.
925+
return Ok(package_eof(entries.is_empty(), buf.len() > 0));
886926
}
887-
Err(e) => return Ok(ParseOutcome::Err(e)),
888927
}
889928
}
929+
930+
// Proof: `u8 num_hashes || num_hashes * HASH_SIZE bytes`.
931+
if !fill_at_least(buf, 1).await? {
932+
return Ok(package_eof(entries.is_empty(), buf.len() > 0));
933+
}
934+
let num_hashes = buf.buffered()[0];
935+
if num_hashes > tlog_mirror::MAX_HASHES_PER_PROOF {
936+
return Ok(ParseOutcome::Err(ParseError::TooManyHashes(num_hashes)));
937+
}
938+
let proof_bytes = 1 + usize::from(num_hashes) * tlog_core::HASH_SIZE;
939+
if !fill_at_least(buf, proof_bytes).await? {
940+
return Ok(package_eof(entries.is_empty(), buf.len() > 0));
941+
}
942+
let mut proof = Vec::with_capacity(usize::from(num_hashes));
943+
for i in 0..usize::from(num_hashes) {
944+
let off = 1 + i * tlog_core::HASH_SIZE;
945+
let mut hash = [0u8; tlog_core::HASH_SIZE];
946+
hash.copy_from_slice(&buf.buffered()[off..off + tlog_core::HASH_SIZE]);
947+
proof.push(Hash(hash));
948+
}
949+
buf.consume(proof_bytes);
950+
Ok(ParseOutcome::Ok(EntryPackage { entries, proof }))
951+
}
952+
953+
/// Classify a stream end reached partway through [`parse_next_package`]:
954+
/// a clean between-package truncation when nothing of this package had
955+
/// been read, otherwise a mid-package truncation.
956+
fn package_eof(no_entries_yet: bool, saw_partial: bool) -> ParseOutcome {
957+
if no_entries_yet && !saw_partial {
958+
ParseOutcome::CleanEof
959+
} else {
960+
ParseOutcome::MidPackageEof
961+
}
890962
}
891963

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

0 commit comments

Comments
 (0)