Skip to content

Commit 65c3dde

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 93e30e5 commit 65c3dde

2 files changed

Lines changed: 211 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: 195 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+
EMPTY_HASH, 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,89 @@ 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 upper-level partial hash tiles) may be missing.
461+
///
462+
/// Re-persists the leaves `[base, cut_size)`, `base` being the largest
463+
/// 256-boundary below `cut_size`, on top of the aligned prefix, which rewrites
464+
/// exactly the edge tiles at `cut_size`. Re-uploads are idempotent, so racing
465+
/// a concurrent commit is safe. `frontier_size`/`frontier_hash` locate and
466+
/// authenticate the wider stored bundle and the aligned prefix root.
467+
///
468+
/// # Errors
469+
///
470+
/// Returns an error on a storage fault, or if the reloaded leaves fail
471+
/// authentication.
472+
pub(crate) async fn ensure_cut_tiles(
473+
object: &impl ObjectBackend,
474+
cut_size: u64,
475+
frontier_size: u64,
476+
frontier_hash: Hash,
477+
) -> Result<()> {
478+
if cut_size == 0 {
479+
return Ok(());
480+
}
481+
let base = ((cut_size - 1) / TILE_WIDTH) * TILE_WIDTH;
482+
483+
// Reload the cut leaves from the wider stored bundle, authenticated
484+
// against the frontier tiles.
485+
let leaves =
486+
read_persisted_leaves(object, base, cut_size - base, frontier_size, frontier_hash).await?;
487+
488+
// Root of the aligned prefix, the base persist_entries grows from,
489+
// authenticated against the frontier tree (whose edge tiles exist).
490+
let prefix_hash = if base == 0 {
491+
EMPTY_HASH
492+
} else {
493+
subtree_prefix_hash(object, base, frontier_size, frontier_hash).await?
494+
};
495+
496+
persist_entries(object, base, prefix_hash, cut_size, &leaves).await?;
497+
Ok(())
498+
}
499+
500+
/// Tree hash of the prefix `[0, prefix_size)`, where `prefix_size` is
501+
/// 256-aligned, read from the full hash tiles authenticated against the target
502+
/// `(tree_size, tree_hash)`.
503+
async fn subtree_prefix_hash(
504+
object: &impl ObjectBackend,
505+
prefix_size: u64,
506+
tree_size: u64,
507+
tree_hash: Hash,
508+
) -> Result<Hash> {
509+
let indexes = tlog_core::tree_hash_indexes(prefix_size);
510+
let tiles = fetch_authenticated_tiles(object, tree_size, &indexes).await?;
511+
let reader = PreloadedTlogTileReader(tiles);
512+
let hashes = TileHashReader::new(tree_size, tree_hash, &reader)
513+
.read_hashes(&indexes)
514+
.map_err(|e| Error::from(format!("read prefix hashes: {e:?}")))?;
515+
tlog_core::tree_hash(prefix_size, &StaticHashReader(&indexes, &hashes))
516+
.map_err(|e| Error::from(format!("compute prefix hash: {e:?}")))
517+
}
518+
519+
/// A [`HashReader`] over a fixed index-to-hash mapping, for computing a tree
520+
/// hash from a preauthenticated set of stored hashes.
521+
struct StaticHashReader<'a>(&'a [u64], &'a [Hash]);
522+
impl HashReader for StaticHashReader<'_> {
523+
fn read_hashes(&self, indexes: &[u64]) -> std::result::Result<Vec<Hash>, TlogError> {
524+
indexes
525+
.iter()
526+
.map(|want| {
527+
self.0
528+
.iter()
529+
.position(|i| i == want)
530+
.map(|p| self.1[p])
531+
.ok_or(TlogError::IndexesNotInTree)
532+
})
533+
.collect()
534+
}
535+
}
536+
438537
/// Upload one entry bundle (data tile). `n` is the tree size after the
439538
/// bundle's last entry, so the bundle covers leaves ending at `n - 1`.
440539
async fn upload_entry_bundle(object: &impl ObjectBackend, n: u64, bytes: Vec<u8>) -> Result<()> {
@@ -711,6 +810,95 @@ mod tests {
711810
);
712811
}
713812

813+
/// Assert a checkpoint at `size` is servable: the stored tiles
814+
/// authenticate the last leaf against `reference_root(size)`, and the cut
815+
/// data bundle decodes to the expected leaves.
816+
async fn assert_servable_at(obj: &MemBackend, size: u64) {
817+
use length_prefixed::ReadLengthPrefixedBytesExt as _;
818+
let root = reference_root(size);
819+
820+
let idx = [stored_hash_index(0, size - 1)];
821+
let tiles = fetch_authenticated_tiles(obj, size, &idx).await.unwrap();
822+
let reader = PreloadedTlogTileReader(tiles);
823+
let got = TileHashReader::new(size, root, &reader)
824+
.read_hashes(&idx)
825+
.expect("tiles authenticate against root");
826+
assert_eq!(got[0], record_hash(&entry(size - 1)));
827+
828+
// For a mid-tile size, the cut data bundle must decode to exactly the
829+
// trailing leaves [subtree_start, size).
830+
if !size.is_multiple_of(TILE_WIDTH) {
831+
let subtree_start = (size / TILE_WIDTH) * TILE_WIDTH;
832+
let data_key = TlogTile::from_index(stored_hash_index(0, size - 1))
833+
.with_data_path(PathElem::Entries)
834+
.path();
835+
let bytes = obj
836+
.fetch(&data_key)
837+
.await
838+
.unwrap()
839+
.expect("cut data bundle stored");
840+
let mut cur: &[u8] = &bytes;
841+
for i in subtree_start..size {
842+
let got = cur.read_length_prefixed(2).unwrap();
843+
assert_eq!(got, entry(i), "leaf {i} mismatch in cut bundle");
844+
}
845+
assert!(cur.is_empty(), "cut bundle has trailing bytes");
846+
}
847+
}
848+
849+
#[tokio::test]
850+
async fn ensure_cut_tiles_first_block() {
851+
let obj = MemBackend::default();
852+
// Frontier at 300; a checkpoint at 100 (mid-tile, first block) needs
853+
// its width-100 data bundle synthesized. The frontier only wrote the
854+
// width-300 partial (a full width-256 tile 0 + width-44 tile 1).
855+
let frontier = persist_entries(&obj, 0, EMPTY_HASH, 300, &leaves(0..300))
856+
.await
857+
.unwrap();
858+
ensure_cut_tiles(&obj, 100, 300, frontier).await.unwrap();
859+
assert_servable_at(&obj, 100).await;
860+
}
861+
862+
#[tokio::test]
863+
async fn ensure_cut_tiles_aligned_below_frontier() {
864+
let obj = MemBackend::default();
865+
// Frontier at 768; a checkpoint at a tile-aligned 256 still needs the
866+
// upper-level partial hash tile (tile/1/000.p/1) synthesized: the
867+
// frontier wrote tile/1/000.p/3, never the .p/1 a tree of 256 needs.
868+
let frontier = persist_entries(&obj, 0, EMPTY_HASH, 768, &leaves(0..768))
869+
.await
870+
.unwrap();
871+
ensure_cut_tiles(&obj, 256, 768, frontier).await.unwrap();
872+
assert_servable_at(&obj, 256).await;
873+
}
874+
875+
#[tokio::test]
876+
async fn ensure_cut_tiles_later_block() {
877+
let obj = MemBackend::default();
878+
// Frontier at 1000; a checkpoint at 900 cuts a later block (base
879+
// 768), exercising the prefix-hash path.
880+
let frontier = persist_entries(&obj, 0, EMPTY_HASH, 1000, &leaves(0..1000))
881+
.await
882+
.unwrap();
883+
ensure_cut_tiles(&obj, 900, 1000, frontier).await.unwrap();
884+
assert_servable_at(&obj, 900).await;
885+
}
886+
887+
#[tokio::test]
888+
async fn ensure_cut_tiles_rejects_wrong_frontier_hash() {
889+
let obj = MemBackend::default();
890+
persist_entries(&obj, 0, EMPTY_HASH, 300, &leaves(0..300))
891+
.await
892+
.unwrap();
893+
// A frontier hash that doesn't match storage must fail authentication
894+
// rather than write mismatched tiles.
895+
assert!(
896+
ensure_cut_tiles(&obj, 100, 300, Hash([0xab; tlog_core::HASH_SIZE]))
897+
.await
898+
.is_err()
899+
);
900+
}
901+
714902
#[tokio::test]
715903
async fn hash_tiles_authenticate_against_root() {
716904
let obj = MemBackend::default();

0 commit comments

Comments
 (0)