@@ -39,7 +39,8 @@ use generic_log_worker::{
3939} ;
4040use length_prefixed:: { ReadLengthPrefixedBytesExt as _, WriteLengthPrefixedBytesExt as _} ;
4141use 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} ;
4445use 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`.
440555async 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