Skip to content

Commit d345c9c

Browse files
committed
mirror_worker: serve cut tiles when cosigning below the frontier
The frontier>upload_end case was mishandled: when a prior upload (or a racing client with an old ticket) had already persisted past upload_end, an add-entries whose packages were all already-persisted would cosign a checkpoint at upload_end even though the edge tiles were only written at the larger frontier. The narrower "cut" tiles a tree of size upload_end requires could be missing, so the cosignature was unverifiable against the mirror's own tiles. The spec mandates a 200 cosignature here (upload_end >= committed, all packages received) and that the tree at upload_end be servable first, so synthesize the cut tiles before cosigning, matching Sunlight's ensureCutTiles. This applies whether or not upload_end is tile-aligned: an aligned size below the frontier still needs upper-level partial hash tiles the frontier never wrote. ensure_cut_tiles re-persists [base, cut_size) on top of the aligned prefix via persist_entries, rewriting exactly the edge tiles at upload_end; re-uploads are idempotent. The frontier-below-upload_end guard is unchanged (still a 202 to resume).
1 parent 5bdaba9 commit d345c9c

2 files changed

Lines changed: 278 additions & 18 deletions

File tree

crates/mirror_worker/src/add_entries.rs

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -350,19 +350,13 @@ async fn verify_and_persist(
350350
));
351351
}
352352

353-
// Spec: the mirror updates its checkpoint to `upload_end` only once
354-
// "the next entry will be greater or equal to `upload_end`", i.e. all
355-
// entries up to `upload_end` are durably persisted. A request that
356-
// persists nothing (e.g. an empty body, or one whose packages are all
357-
// already-persisted) must not let us cosign past our frontier: without
358-
// this guard `upload_end` above `next_entry` would sign a checkpoint at
359-
// a size we never wrote tiles for. When the frontier has not reached
360-
// `upload_end`, treat it like a truncated upload and 202 so the client
361-
// resumes from the advertised next entry.
353+
// Frontier below `upload_end`: not every entry up to `upload_end` is
354+
// persisted yet (a truncated body, or an already-persisted request that
355+
// stops short). This is the spec's "not yet received all packages" case,
356+
// so 202 with the advanced frontier for the client to resume from.
362357
if frontier_size < header.upload_end {
363358
log::info!(
364-
"add-entries: frontier {frontier_size} below upload_end {}; nothing to persist \
365-
this request, returning 202 to resume",
359+
"add-entries: frontier {frontier_size} below upload_end {}; returning 202 to resume",
366360
header.upload_end,
367361
);
368362
return Ok(mirror_info_202(
@@ -400,6 +394,17 @@ async fn verify_and_persist(
400394
));
401395
}
402396

397+
// Frontier ahead of `upload_end` (a prior upload persisted past it, or a
398+
// racing client): the edge tiles were written at the larger frontier, so
399+
// the narrower "cut" tiles a tree of size `upload_end` needs may be
400+
// missing and a cosignature at `upload_end` would be unverifiable against
401+
// our own tiles. Spec requires the tree at `upload_end` be servable before
402+
// we cosign, so synthesize those cut tiles first.
403+
if frontier_size > header.upload_end {
404+
let bucket = load_origin_bucket(env, &header.log_origin)?;
405+
commit::ensure_cut_tiles(&bucket, header.upload_end, frontier_size, frontier_hash).await?;
406+
}
407+
403408
cosign_and_serve(env, header, target, snapshot).await
404409
}
405410

crates/mirror_worker/src/commit.rs

Lines changed: 262 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ use generic_log_worker::{
3939
};
4040
use length_prefixed::{ReadLengthPrefixedBytesExt as _, WriteLengthPrefixedBytesExt as _};
4141
use tlog_core::{
42-
Hash, HashReader as _, TlogError, record_hash, stored_hash_index, stored_hashes_for_record_hash,
42+
HASH_SIZE, Hash, HashReader, TlogError, record_hash, stored_hash_index,
43+
stored_hashes_for_record_hash,
4344
};
4445
use tlog_tiles::{PathElem, PreloadedTlogTileReader, TileHashReader, TlogTile, TlogTileRecorder};
4546
#[allow(clippy::wildcard_imports)]
@@ -162,6 +163,26 @@ async fn authenticated_leaf_hashes(
162163
tree_hash: Hash,
163164
indexes: &[u64],
164165
) -> Result<Vec<Hash>> {
166+
let tiles = fetch_authenticated_tiles(object, tree_size, indexes).await?;
167+
let reader = PreloadedTlogTileReader(tiles);
168+
TileHashReader::new(tree_size, tree_hash, &reader)
169+
.read_hashes(indexes)
170+
.map_err(|e| Error::from(format!("authenticate persisted leaf hashes: {e:?}")))
171+
}
172+
173+
/// Fetch (from storage) the hash tiles needed to prove `indexes` against
174+
/// `(tree_size, tree_hash)`. The returned map feeds a [`TileHashReader`] whose
175+
/// verifying pass authenticates them; a missing or stale tile surfaces as an
176+
/// error rather than a downstream client rejection.
177+
///
178+
/// # Errors
179+
///
180+
/// Returns an error if a required hash tile is missing from storage.
181+
async fn fetch_authenticated_tiles(
182+
object: &impl ObjectBackend,
183+
tree_size: u64,
184+
indexes: &[u64],
185+
) -> Result<HashMap<TlogTile, Vec<u8>>> {
165186
// Recording pass: `TlogTileRecorder` short-circuits `read_hashes` with
166187
// `RecordedTilesOnly` after collecting the tiles it would need.
167188
let recorder = TlogTileRecorder::default();
@@ -184,12 +205,7 @@ async fn authenticated_leaf_hashes(
184205
Ok::<(TlogTile, Vec<u8>), Error>((tile, bytes))
185206
}
186207
});
187-
let tiles: HashMap<TlogTile, Vec<u8>> = try_join_all(tile_futures).await?.into_iter().collect();
188-
189-
let reader = PreloadedTlogTileReader(tiles);
190-
TileHashReader::new(tree_size, tree_hash, &reader)
191-
.read_hashes(indexes)
192-
.map_err(|e| Error::from(format!("authenticate persisted leaf hashes: {e:?}")))
208+
Ok(try_join_all(tile_futures).await?.into_iter().collect())
193209
}
194210

195211
/// Authenticate a reloaded partial entry bundle before it is extended and
@@ -435,6 +451,105 @@ pub(crate) async fn write_checkpoint(object: &impl ObjectBackend, bytes: Vec<u8>
435451
.await
436452
}
437453

454+
/// Ensure the partial "cut" tiles for a tree of size `cut_size` exist, so a
455+
/// checkpoint at `cut_size` is servable per [tlog-tiles][tiles].
456+
///
457+
/// Needed when the persisted frontier advanced past `cut_size` (a prior
458+
/// upload, or a racing client): the frontier wrote wider partial tiles, so the
459+
/// narrower ones `cut_size` requires (the level-0 data bundle when mid-tile,
460+
/// and the partial hash tile at every level) may be missing.
461+
///
462+
/// A hash tile stores fixed-size hashes of complete subtrees, so its contents
463+
/// do not depend on the tree size and the narrow tile is a byte prefix of the
464+
/// wider stored one. Each level is therefore produced by truncating the widest
465+
/// stored tile, located with [`TlogTile::parent`] at `frontier_size` (the
466+
/// upper bound on everything persisted). The data bundle holds variable-length
467+
/// entries instead, so it is reframed from the authenticated leaves.
468+
///
469+
/// The wide hash tiles are authenticated against `frontier_hash` before being
470+
/// truncated, so a corrupted tile is not propagated into a cut a cosignature
471+
/// then vouches for. Re-uploads are idempotent, so racing a concurrent commit
472+
/// is safe.
473+
///
474+
/// # Errors
475+
///
476+
/// Returns an error on a storage fault, if a tile the cut needs has no stored
477+
/// counterpart, or if the stored tiles fail authentication.
478+
pub(crate) async fn ensure_cut_tiles(
479+
object: &impl ObjectBackend,
480+
cut_size: u64,
481+
frontier_size: u64,
482+
frontier_hash: Hash,
483+
) -> Result<()> {
484+
if cut_size == 0 || cut_size >= frontier_size {
485+
return Ok(());
486+
}
487+
let last_leaf = stored_hash_index(0, cut_size - 1);
488+
489+
// Authenticate the stored tiles along the cut's right edge against the
490+
// frontier root. These are the same (level, index) pairs the cut needs,
491+
// just at the frontier's wider widths.
492+
let wide = fetch_authenticated_tiles(object, frontier_size, &[last_leaf]).await?;
493+
let reader = PreloadedTlogTileReader(wide.clone());
494+
TileHashReader::new(frontier_size, frontier_hash, &reader)
495+
.read_hashes(&[last_leaf])
496+
.map_err(|e| Error::from(format!("authenticate frontier tiles for cut: {e:?}")))?;
497+
498+
let leaf_tile = TlogTile::from_index(last_leaf);
499+
let opts = immutable_tile_opts();
500+
let mut uploads: Vec<(String, Vec<u8>)> = Vec::new();
501+
502+
for level in 0.. {
503+
let Some(need) = leaf_tile.parent(level, cut_size) else {
504+
break;
505+
};
506+
let have = leaf_tile.parent(level, frontier_size).ok_or_else(|| {
507+
Error::from(format!(
508+
"no stored tile at level {level} for frontier {frontier_size}"
509+
))
510+
})?;
511+
// Same width at both sizes: the tile the cut needs is the stored one.
512+
if need.path() == have.path() {
513+
continue;
514+
}
515+
let bytes = wide.get(&have).ok_or_else(|| {
516+
Error::from(format!("frontier tile missing for cut: {}", have.path()))
517+
})?;
518+
let want = need.width() as usize * HASH_SIZE;
519+
if bytes.len() < want {
520+
return Err(Error::from(format!(
521+
"stored tile {} is {} bytes, need {want} to cut",
522+
have.path(),
523+
bytes.len()
524+
)));
525+
}
526+
uploads.push((need.path(), bytes[..want].to_vec()));
527+
528+
// The level-0 data bundle is not fixed-stride, so reframe it from the
529+
// leaves rather than truncating bytes.
530+
if level == 0 {
531+
let base = (cut_size - 1) / TILE_WIDTH * TILE_WIDTH;
532+
let leaves =
533+
read_persisted_leaves(object, base, cut_size - base, frontier_size, frontier_hash)
534+
.await?;
535+
let mut bundle = Vec::new();
536+
for entry in &leaves {
537+
push_tile_leaf(&mut bundle, entry)?;
538+
}
539+
uploads.push((need.with_data_path(PathElem::Entries).path(), bundle));
540+
}
541+
}
542+
543+
stream_iter(
544+
uploads
545+
.into_iter()
546+
.map(|(path, bytes)| object.upload(path, bytes, &opts)),
547+
)
548+
.buffer_unordered(UPLOAD_CONCURRENCY)
549+
.try_collect::<()>()
550+
.await
551+
}
552+
438553
/// Upload one entry bundle (data tile). `n` is the tree size after the
439554
/// bundle's last entry, so the bundle covers leaves ending at `n - 1`.
440555
async fn upload_entry_bundle(object: &impl ObjectBackend, n: u64, bytes: Vec<u8>) -> Result<()> {
@@ -711,6 +826,146 @@ mod tests {
711826
);
712827
}
713828

829+
/// Assert a checkpoint at `size` is servable: the stored tiles
830+
/// authenticate the last leaf against `reference_root(size)`, and the cut
831+
/// data bundle decodes to the expected leaves.
832+
async fn assert_servable_at(obj: &MemBackend, size: u64) {
833+
use length_prefixed::ReadLengthPrefixedBytesExt as _;
834+
let root = reference_root(size);
835+
836+
let idx = [stored_hash_index(0, size - 1)];
837+
let tiles = fetch_authenticated_tiles(obj, size, &idx).await.unwrap();
838+
let reader = PreloadedTlogTileReader(tiles);
839+
let got = TileHashReader::new(size, root, &reader)
840+
.read_hashes(&idx)
841+
.expect("tiles authenticate against root");
842+
assert_eq!(got[0], record_hash(&entry(size - 1)));
843+
844+
// For a mid-tile size, the cut data bundle must decode to exactly the
845+
// trailing leaves [subtree_start, size).
846+
if !size.is_multiple_of(TILE_WIDTH) {
847+
let subtree_start = (size / TILE_WIDTH) * TILE_WIDTH;
848+
let data_key = TlogTile::from_index(stored_hash_index(0, size - 1))
849+
.with_data_path(PathElem::Entries)
850+
.path();
851+
let bytes = obj
852+
.fetch(&data_key)
853+
.await
854+
.unwrap()
855+
.expect("cut data bundle stored");
856+
let mut cur: &[u8] = &bytes;
857+
for i in subtree_start..size {
858+
let got = cur.read_length_prefixed(2).unwrap();
859+
assert_eq!(got, entry(i), "leaf {i} mismatch in cut bundle");
860+
}
861+
assert!(cur.is_empty(), "cut bundle has trailing bytes");
862+
}
863+
}
864+
865+
#[tokio::test]
866+
async fn ensure_cut_tiles_first_block() {
867+
let obj = MemBackend::default();
868+
// Frontier at 300; a checkpoint at 100 (mid-tile, first block) needs
869+
// its width-100 data bundle synthesized. The frontier only wrote the
870+
// width-300 partial (a full width-256 tile 0 + width-44 tile 1).
871+
let frontier = persist_entries(&obj, 0, EMPTY_HASH, 300, &leaves(0..300))
872+
.await
873+
.unwrap();
874+
ensure_cut_tiles(&obj, 100, 300, frontier).await.unwrap();
875+
assert_servable_at(&obj, 100).await;
876+
}
877+
878+
#[tokio::test]
879+
async fn ensure_cut_tiles_aligned_below_frontier() {
880+
let obj = MemBackend::default();
881+
// Frontier at 768; a checkpoint at a tile-aligned 256 still needs the
882+
// upper-level partial hash tile (tile/1/000.p/1) synthesized: the
883+
// frontier wrote tile/1/000.p/3, never the .p/1 a tree of 256 needs.
884+
let frontier = persist_entries(&obj, 0, EMPTY_HASH, 768, &leaves(0..768))
885+
.await
886+
.unwrap();
887+
ensure_cut_tiles(&obj, 256, 768, frontier).await.unwrap();
888+
assert_servable_at(&obj, 256).await;
889+
}
890+
891+
#[tokio::test]
892+
async fn ensure_cut_tiles_later_block() {
893+
let obj = MemBackend::default();
894+
// Frontier at 1024, a checkpoint at 900. The level-1 width differs
895+
// (4 vs 3), so the cut needs tile/1/000.p/3, which the frontier
896+
// never wrote. A frontier of 1000 would share width 3 and pass
897+
// without exercising the upper level at all.
898+
let frontier = persist_entries(&obj, 0, EMPTY_HASH, 1024, &leaves(0..1024))
899+
.await
900+
.unwrap();
901+
ensure_cut_tiles(&obj, 900, 1024, frontier).await.unwrap();
902+
assert_servable_at(&obj, 900).await;
903+
}
904+
905+
#[tokio::test]
906+
async fn ensure_cut_tiles_frontier_far_ahead() {
907+
let obj = MemBackend::default();
908+
// One commit 0 -> 2000 writes only the widest partial at each level
909+
// (level-1 width 7). Cutting at 900 needs width 3, six blocks back.
910+
let frontier = persist_entries(&obj, 0, EMPTY_HASH, 2000, &leaves(0..2000))
911+
.await
912+
.unwrap();
913+
ensure_cut_tiles(&obj, 900, 2000, frontier).await.unwrap();
914+
assert_servable_at(&obj, 900).await;
915+
}
916+
917+
#[tokio::test]
918+
async fn ensure_cut_tiles_aligned_cut_far_below_frontier() {
919+
let obj = MemBackend::default();
920+
// A 256-aligned cut needs no data bundle, but still needs the
921+
// level-1 partial at width 2 against a frontier of width 7.
922+
let frontier = persist_entries(&obj, 0, EMPTY_HASH, 2000, &leaves(0..2000))
923+
.await
924+
.unwrap();
925+
ensure_cut_tiles(&obj, 512, 2000, frontier).await.unwrap();
926+
assert_servable_at(&obj, 512).await;
927+
}
928+
929+
#[tokio::test]
930+
async fn ensure_cut_tiles_spans_three_levels() {
931+
let obj = MemBackend::default();
932+
// Past 65536 the cut also needs a level-2 tile, so the truncation
933+
// walks every level rather than stopping at level 1.
934+
let frontier = persist_entries(&obj, 0, EMPTY_HASH, 70000, &leaves(0..70000))
935+
.await
936+
.unwrap();
937+
ensure_cut_tiles(&obj, 66000, 70000, frontier)
938+
.await
939+
.unwrap();
940+
assert_servable_at(&obj, 66000).await;
941+
}
942+
943+
#[tokio::test]
944+
async fn ensure_cut_tiles_is_idempotent() {
945+
let obj = MemBackend::default();
946+
let frontier = persist_entries(&obj, 0, EMPTY_HASH, 2000, &leaves(0..2000))
947+
.await
948+
.unwrap();
949+
ensure_cut_tiles(&obj, 900, 2000, frontier).await.unwrap();
950+
ensure_cut_tiles(&obj, 900, 2000, frontier).await.unwrap();
951+
assert_servable_at(&obj, 900).await;
952+
}
953+
954+
#[tokio::test]
955+
async fn ensure_cut_tiles_rejects_wrong_frontier_hash() {
956+
let obj = MemBackend::default();
957+
persist_entries(&obj, 0, EMPTY_HASH, 300, &leaves(0..300))
958+
.await
959+
.unwrap();
960+
// A frontier hash that doesn't match storage must fail authentication
961+
// rather than write mismatched tiles.
962+
assert!(
963+
ensure_cut_tiles(&obj, 100, 300, Hash([0xab; tlog_core::HASH_SIZE]))
964+
.await
965+
.is_err()
966+
);
967+
}
968+
714969
#[tokio::test]
715970
async fn hash_tiles_authenticate_against_root() {
716971
let obj = MemBackend::default();

0 commit comments

Comments
 (0)