@@ -3,7 +3,7 @@ use std::io::SeekFrom;
33use std:: mem:: size_of;
44use std:: path:: { Path , PathBuf } ;
55use std:: str:: FromStr ;
6- use std:: sync:: Arc ;
6+ use std:: sync:: { Arc , OnceLock } ;
77use std:: time:: Instant ;
88
99use anyhow:: { bail, Context } ;
@@ -26,10 +26,80 @@ use super::FrameNo;
2626
2727/// This is the ratio of the space required to store snapshot vs size of the actual database.
2828/// When this ratio is exceeded, compaction is triggered.
29+ ///
30+ /// Used only when `SQLD_MAX_SNAPSHOT_SIZE` is unset (legacy behavior).
2931const SNAPHOT_SPACE_AMPLIFICATION_FACTOR : u64 = 2 ;
3032/// The maximum amount of snapshot allowed before a compaction is required
3133const MAX_SNAPSHOT_NUMBER : usize = 32 ;
3234
35+ /// Returns the configured upper bound, in frames, on the cumulative size of accumulated
36+ /// snapshot files before merging is triggered, and on the size of any single merged
37+ /// snapshot file produced by the merger. Sourced once from the `SQLD_MAX_SNAPSHOT_SIZE`
38+ /// environment variable (in MB, mirroring `SQLD_MAX_LOG_SIZE`).
39+ ///
40+ /// When unset, the legacy `SNAPHOT_SPACE_AMPLIFICATION_FACTOR * db_page_count` rule is
41+ /// used and the merger combines all accumulated snapshots into a single file regardless
42+ /// of size.
43+ /// Group an ordered list of accumulated snapshots into contiguous batches for merging,
44+ /// ensuring no batch's cumulative frame count exceeds `max_frames`.
45+ ///
46+ /// Returns ranges into `snapshots`. With `max_frames = None`, returns a single range
47+ /// covering the whole input (legacy single-file merge). An input snapshot whose own
48+ /// frame count already exceeds `max_frames` is placed in a singleton batch so the merger
49+ /// can leave it as-is rather than produce an even larger file.
50+ fn group_snapshots_for_merge (
51+ snapshots : & [ ( String , u64 ) ] ,
52+ max_frames : Option < u64 > ,
53+ ) -> Vec < std:: ops:: Range < usize > > {
54+ if snapshots. is_empty ( ) {
55+ return Vec :: new ( ) ;
56+ }
57+ let Some ( max) = max_frames else {
58+ return vec ! [ 0 ..snapshots. len( ) ] ;
59+ } ;
60+
61+ let mut batches: Vec < std:: ops:: Range < usize > > = Vec :: new ( ) ;
62+ let mut start = 0usize ;
63+ let mut acc: u64 = 0 ;
64+ for ( i, ( _, count) ) in snapshots. iter ( ) . enumerate ( ) {
65+ // If this single file already exceeds the cap, flush any pending batch and emit
66+ // it as its own singleton.
67+ if * count >= max {
68+ if i > start {
69+ batches. push ( start..i) ;
70+ }
71+ batches. push ( i..i + 1 ) ;
72+ start = i + 1 ;
73+ acc = 0 ;
74+ continue ;
75+ }
76+ if acc + count > max && i > start {
77+ batches. push ( start..i) ;
78+ start = i;
79+ acc = 0 ;
80+ }
81+ acc += count;
82+ }
83+ if start < snapshots. len ( ) {
84+ batches. push ( start..snapshots. len ( ) ) ;
85+ }
86+ batches
87+ }
88+
89+ fn max_snapshot_frames ( ) -> Option < u64 > {
90+ static CACHED : OnceLock < Option < u64 > > = OnceLock :: new ( ) ;
91+ * CACHED . get_or_init ( || {
92+ let mb = std:: env:: var ( "SQLD_MAX_SNAPSHOT_SIZE" )
93+ . ok ( ) ?
94+ . parse :: < u64 > ( )
95+ . ok ( ) ?;
96+ if mb == 0 {
97+ return None ;
98+ }
99+ Some ( mb * 1_000_000 / LogFile :: FRAME_SIZE as u64 )
100+ } )
101+ }
102+
33103/// returns (db_id, start_frame_no, end_frame_no) for the given snapshot name
34104fn parse_snapshot_name ( name : & str ) -> Option < ( Uuid , u64 , u64 ) > {
35105 let ( db_id_str, remaining) = name. split_at ( 36 ) ;
@@ -271,8 +341,11 @@ impl SnapshotMerger {
271341
272342 fn should_compact ( snapshots : & [ ( String , u64 ) ] , db_page_count : u32 ) -> bool {
273343 let snapshots_size: u64 = snapshots. iter ( ) . map ( |( _, s) | * s) . sum ( ) ;
274- snapshots_size >= SNAPHOT_SPACE_AMPLIFICATION_FACTOR * db_page_count as u64
275- || snapshots. len ( ) > MAX_SNAPSHOT_NUMBER
344+ let size_trigger = match max_snapshot_frames ( ) {
345+ Some ( max) => snapshots_size >= max,
346+ None => snapshots_size >= SNAPHOT_SPACE_AMPLIFICATION_FACTOR * db_page_count as u64 ,
347+ } ;
348+ size_trigger || snapshots. len ( ) > MAX_SNAPSHOT_NUMBER
276349 }
277350
278351 async fn run_snapshot_merger_loop (
@@ -310,8 +383,13 @@ impl SnapshotMerger {
310383 working = false ;
311384 job. set( std:: future:: pending( ) ) ;
312385 let ret = ret??;
313- // the new merged snapshot is prepended to the snapshot list
314- snapshots. insert( 0 , ret) ;
386+ // The merged snapshot(s) cover the oldest frames, so they are prepended
387+ // to the snapshot list in chronological (oldest-first) order. With
388+ // SQLD_MAX_SNAPSHOT_SIZE set, merge_snapshots may return more than one
389+ // surviving snapshot.
390+ for ( i, item) in ret. into_iter( ) . enumerate( ) {
391+ snapshots. insert( i, item) ;
392+ }
315393 }
316394 else => return Ok ( ( ) )
317395 }
@@ -359,41 +437,69 @@ impl SnapshotMerger {
359437 log_id : Uuid ,
360438 scripted_backup : Option < ScriptBackupManager > ,
361439 namespace : NamespaceName ,
362- ) -> anyhow:: Result < ( String , u64 ) > {
363- let mut builder = SnapshotBuilder :: new ( db_path, log_id, scripted_backup, namespace) . await ?;
440+ ) -> anyhow:: Result < Vec < ( String , u64 ) > > {
441+ // When SQLD_MAX_SNAPSHOT_SIZE is set, group input snapshots greedily into batches
442+ // whose summed frame count fits under the cap. Each batch becomes one output
443+ // `.snap` file, ensuring no merger-produced file ever exceeds the cap. A batch of
444+ // size 1 is left as-is (skip the merge copy). When unset, fall back to the legacy
445+ // behavior: collapse everything into a single file.
446+ let max_frames = max_snapshot_frames ( ) ;
447+ let batches = group_snapshots_for_merge ( & snapshots, max_frames) ;
448+ tracing:: debug!(
449+ "merging {} snapshots for {log_id} into {} batch(es) (max_frames={:?})" ,
450+ snapshots. len( ) ,
451+ batches. len( ) ,
452+ max_frames,
453+ ) ;
454+
364455 let snapshot_dir_path = snapshot_dir_path ( db_path) ;
365- let mut size_after = None ;
366- tracing :: debug! ( "merging {} snashots for {log_id}" , snapshots . len ( ) ) ;
367- for ( name , _ ) in snapshots . iter ( ) . rev ( ) {
368- // NOTICE: no encryptor passed in order to read frames as is, still encrypted
369- let snapshot = SnapshotFile :: open ( & snapshot_dir_path . join ( name ) , None ) . await ? ;
370- // The size after the merged snapshot is the size after the first snapshot to be merged
371- if size_after . is_none ( ) {
372- size_after . replace ( snapshot . header ( ) . size_after ) ;
456+ let mut out : Vec < ( String , u64 ) > = Vec :: with_capacity ( batches . len ( ) ) ;
457+ for batch in batches {
458+ let batch = & snapshots [ batch ] ;
459+ if batch . len ( ) == 1 {
460+ // Single oversized or stand-alone snapshot — leave it in place untouched
461+ // so that we never produce a merged file larger than the configured cap.
462+ out . push ( batch [ 0 ] . clone ( ) ) ;
463+ continue ;
373464 }
374- builder
375- . append_frames ( snapshot. into_stream_mut ( ) . map_err ( |e| anyhow:: anyhow!( e) ) )
376- . await ?;
377- }
378465
379- let ( _, start_frame_no, _) = parse_snapshot_name ( & snapshots[ 0 ] . 0 ) . unwrap ( ) ;
380- let ( _, _, end_frame_no) = parse_snapshot_name ( & snapshots. last ( ) . unwrap ( ) . 0 ) . unwrap ( ) ;
466+ let mut builder =
467+ SnapshotBuilder :: new ( db_path, log_id, scripted_backup. clone ( ) , namespace. clone ( ) )
468+ . await ?;
469+ let mut size_after = None ;
470+ for ( name, _) in batch. iter ( ) . rev ( ) {
471+ // NOTICE: no encryptor passed in order to read frames as is, still encrypted
472+ let snapshot = SnapshotFile :: open ( & snapshot_dir_path. join ( name) , None ) . await ?;
473+ // The size after the merged snapshot is the size after the first snapshot to be merged
474+ if size_after. is_none ( ) {
475+ size_after. replace ( snapshot. header ( ) . size_after ) ;
476+ }
477+ builder
478+ . append_frames ( snapshot. into_stream_mut ( ) . map_err ( |e| anyhow:: anyhow!( e) ) )
479+ . await ?;
480+ }
381481
382- tracing:: debug!(
383- "created merged snapshot for {log_id} from frame {start_frame_no} to {end_frame_no}"
384- ) ;
482+ let ( _, start_frame_no, _) = parse_snapshot_name ( & batch[ 0 ] . 0 ) . unwrap ( ) ;
483+ let ( _, _, end_frame_no) = parse_snapshot_name ( & batch. last ( ) . unwrap ( ) . 0 ) . unwrap ( ) ;
484+
485+ tracing:: debug!(
486+ "created merged snapshot for {log_id} from frame {start_frame_no} to {end_frame_no}"
487+ ) ;
488+
489+ builder. header . start_frame_no = start_frame_no. into ( ) ;
490+ builder. header . end_frame_no = end_frame_no. into ( ) ;
491+ builder. header . size_after = size_after. unwrap ( ) ;
385492
386- builder. header . start_frame_no = start_frame_no. into ( ) ;
387- builder. header . end_frame_no = end_frame_no. into ( ) ;
388- builder. header . size_after = size_after. unwrap ( ) ;
493+ let meta = builder. finish ( ) . await ?;
389494
390- let meta = builder. finish ( ) . await ?;
495+ for ( name, _) in batch. iter ( ) {
496+ tokio:: fs:: remove_file ( & snapshot_dir_path. join ( name) ) . await ?;
497+ }
391498
392- for ( name, _) in snapshots. iter ( ) {
393- tokio:: fs:: remove_file ( & snapshot_dir_path. join ( name) ) . await ?;
499+ out. push ( ( meta. 0 , meta. 1 ) ) ;
394500 }
395501
396- Ok ( ( meta . 0 , meta . 1 ) )
502+ Ok ( out )
397503 }
398504
399505 async fn register_snapshot (
@@ -582,6 +688,48 @@ mod test {
582688 use crate :: replication:: snapshot:: SnapshotFile ;
583689 use crate :: LIBSQL_PAGE_SIZE ;
584690
691+ fn s ( name : & str , count : u64 ) -> ( String , u64 ) {
692+ ( name. to_string ( ) , count)
693+ }
694+
695+ #[ test]
696+ fn group_snapshots_legacy_no_max_returns_single_batch ( ) {
697+ let snaps = vec ! [ s( "a" , 10 ) , s( "b" , 20 ) , s( "c" , 5 ) ] ;
698+ let batches = super :: group_snapshots_for_merge ( & snaps, None ) ;
699+ assert_eq ! ( batches, vec![ 0 ..3 ] ) ;
700+ }
701+
702+ #[ test]
703+ fn group_snapshots_empty_input ( ) {
704+ let batches = super :: group_snapshots_for_merge ( & [ ] , Some ( 100 ) ) ;
705+ assert ! ( batches. is_empty( ) ) ;
706+ }
707+
708+ #[ test]
709+ fn group_snapshots_packs_under_max ( ) {
710+ // max = 25, sequence 10,10,10,10 → (10+10) (10+10)
711+ let snaps = vec ! [ s( "a" , 10 ) , s( "b" , 10 ) , s( "c" , 10 ) , s( "d" , 10 ) ] ;
712+ let batches = super :: group_snapshots_for_merge ( & snaps, Some ( 25 ) ) ;
713+ assert_eq ! ( batches, vec![ 0 ..2 , 2 ..4 ] ) ;
714+ }
715+
716+ #[ test]
717+ fn group_snapshots_oversized_input_is_singleton ( ) {
718+ // Pre-existing big file (50) exceeds cap (25). It must be left alone in its own
719+ // batch so the merger does not produce an even bigger merged file.
720+ let snaps = vec ! [ s( "a" , 10 ) , s( "big" , 50 ) , s( "c" , 10 ) , s( "d" , 10 ) ] ;
721+ let batches = super :: group_snapshots_for_merge ( & snaps, Some ( 25 ) ) ;
722+ assert_eq ! ( batches, vec![ 0 ..1 , 1 ..2 , 2 ..4 ] ) ;
723+ }
724+
725+ #[ test]
726+ fn group_snapshots_each_under_cap_but_sum_over ( ) {
727+ // max=15, sequence 10,10,10 → each fits alone (10<15), pairs do not (20>15).
728+ let snaps = vec ! [ s( "a" , 10 ) , s( "b" , 10 ) , s( "c" , 10 ) ] ;
729+ let batches = super :: group_snapshots_for_merge ( & snaps, Some ( 15 ) ) ;
730+ assert_eq ! ( batches, vec![ 0 ..1 , 1 ..2 , 2 ..3 ] ) ;
731+ }
732+
585733 use super :: * ;
586734
587735 async fn dir_is_empty ( p : & Path ) -> bool {
0 commit comments