@@ -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,101 @@ 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 ;
30- /// The maximum amount of snapshot allowed before a compaction is required
32+ /// The default maximum number of snapshot files allowed before a compaction is required.
33+ /// Overridable via `SQLD_MAX_SNAPSHOT_COUNT`.
3134const MAX_SNAPSHOT_NUMBER : usize = 32 ;
3235
36+ /// Returns the configured upper bound, in frames, on the cumulative size of accumulated
37+ /// snapshot files before merging is triggered, and on the size of any single merged
38+ /// snapshot file produced by the merger. Sourced once from the `SQLD_MAX_SNAPSHOT_SIZE`
39+ /// environment variable (in MB, mirroring `SQLD_MAX_LOG_SIZE`).
40+ ///
41+ /// When unset, the legacy `SNAPHOT_SPACE_AMPLIFICATION_FACTOR * db_page_count` rule is
42+ /// used and the merger combines all accumulated snapshots into a single file regardless
43+ /// of size.
44+ /// Group an ordered list of accumulated snapshots into contiguous batches for merging,
45+ /// ensuring no batch's cumulative frame count exceeds `max_frames`.
46+ ///
47+ /// Returns ranges into `snapshots`. With `max_frames = None`, returns a single range
48+ /// covering the whole input (legacy single-file merge). An input snapshot whose own
49+ /// frame count already exceeds `max_frames` is placed in a singleton batch so the merger
50+ /// can leave it as-is rather than produce an even larger file.
51+ fn group_snapshots_for_merge (
52+ snapshots : & [ ( String , u64 ) ] ,
53+ max_frames : Option < u64 > ,
54+ ) -> Vec < std:: ops:: Range < usize > > {
55+ if snapshots. is_empty ( ) {
56+ return Vec :: new ( ) ;
57+ }
58+ let Some ( max) = max_frames else {
59+ return vec ! [ 0 ..snapshots. len( ) ] ;
60+ } ;
61+
62+ let mut batches: Vec < std:: ops:: Range < usize > > = Vec :: new ( ) ;
63+ let mut start = 0usize ;
64+ let mut acc: u64 = 0 ;
65+ for ( i, ( _, count) ) in snapshots. iter ( ) . enumerate ( ) {
66+ // If this single file already exceeds the cap, flush any pending batch and emit
67+ // it as its own singleton.
68+ if * count >= max {
69+ if i > start {
70+ batches. push ( start..i) ;
71+ }
72+ batches. push ( i..i + 1 ) ;
73+ start = i + 1 ;
74+ acc = 0 ;
75+ continue ;
76+ }
77+ if acc + count > max && i > start {
78+ batches. push ( start..i) ;
79+ start = i;
80+ acc = 0 ;
81+ }
82+ acc += count;
83+ }
84+ if start < snapshots. len ( ) {
85+ batches. push ( start..snapshots. len ( ) ) ;
86+ }
87+ batches
88+ }
89+
90+ fn max_snapshot_frames ( ) -> Option < u64 > {
91+ static CACHED : OnceLock < Option < u64 > > = OnceLock :: new ( ) ;
92+ * CACHED . get_or_init ( || {
93+ let mb = std:: env:: var ( "SQLD_MAX_SNAPSHOT_SIZE" )
94+ . ok ( ) ?
95+ . parse :: < u64 > ( )
96+ . ok ( ) ?;
97+ if mb == 0 {
98+ return None ;
99+ }
100+ Some ( mb * 1_000_000 / LogFile :: FRAME_SIZE as u64 )
101+ } )
102+ }
103+
104+ /// Maximum number of accumulated snapshot files before the merger is forced to compact,
105+ /// independently of total size. Sourced from `SQLD_MAX_SNAPSHOT_COUNT`; falls back to
106+ /// `MAX_SNAPSHOT_NUMBER` (32) when unset.
107+ ///
108+ /// When pairing a low `SQLD_MAX_SNAPSHOT_SIZE` with a low `SQLD_MAX_LOG_SIZE` (so that
109+ /// each `.snap` file stays small on disk), this count needs to be raised in lockstep,
110+ /// otherwise the count-trigger fires and undoes the chunking by merging many small files
111+ /// into one large one. Rule of thumb: set it to at least
112+ /// `expected_total_snapshot_bytes / max_log_size_bytes`.
113+ fn max_snapshot_count ( ) -> usize {
114+ static CACHED : OnceLock < usize > = OnceLock :: new ( ) ;
115+ * CACHED . get_or_init ( || {
116+ std:: env:: var ( "SQLD_MAX_SNAPSHOT_COUNT" )
117+ . ok ( )
118+ . and_then ( |s| s. parse :: < usize > ( ) . ok ( ) )
119+ . filter ( |n| * n > 0 )
120+ . unwrap_or ( MAX_SNAPSHOT_NUMBER )
121+ } )
122+ }
123+
33124/// returns (db_id, start_frame_no, end_frame_no) for the given snapshot name
34125fn parse_snapshot_name ( name : & str ) -> Option < ( Uuid , u64 , u64 ) > {
35126 let ( db_id_str, remaining) = name. split_at ( 36 ) ;
@@ -271,8 +362,11 @@ impl SnapshotMerger {
271362
272363 fn should_compact ( snapshots : & [ ( String , u64 ) ] , db_page_count : u32 ) -> bool {
273364 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
365+ let size_trigger = match max_snapshot_frames ( ) {
366+ Some ( max) => snapshots_size >= max,
367+ None => snapshots_size >= SNAPHOT_SPACE_AMPLIFICATION_FACTOR * db_page_count as u64 ,
368+ } ;
369+ size_trigger || snapshots. len ( ) > max_snapshot_count ( )
276370 }
277371
278372 async fn run_snapshot_merger_loop (
@@ -310,8 +404,13 @@ impl SnapshotMerger {
310404 working = false ;
311405 job. set( std:: future:: pending( ) ) ;
312406 let ret = ret??;
313- // the new merged snapshot is prepended to the snapshot list
314- snapshots. insert( 0 , ret) ;
407+ // The merged snapshot(s) cover the oldest frames, so they are prepended
408+ // to the snapshot list in chronological (oldest-first) order. With
409+ // SQLD_MAX_SNAPSHOT_SIZE set, merge_snapshots may return more than one
410+ // surviving snapshot.
411+ for ( i, item) in ret. into_iter( ) . enumerate( ) {
412+ snapshots. insert( i, item) ;
413+ }
315414 }
316415 else => return Ok ( ( ) )
317416 }
@@ -359,41 +458,69 @@ impl SnapshotMerger {
359458 log_id : Uuid ,
360459 scripted_backup : Option < ScriptBackupManager > ,
361460 namespace : NamespaceName ,
362- ) -> anyhow:: Result < ( String , u64 ) > {
363- let mut builder = SnapshotBuilder :: new ( db_path, log_id, scripted_backup, namespace) . await ?;
461+ ) -> anyhow:: Result < Vec < ( String , u64 ) > > {
462+ // When SQLD_MAX_SNAPSHOT_SIZE is set, group input snapshots greedily into batches
463+ // whose summed frame count fits under the cap. Each batch becomes one output
464+ // `.snap` file, ensuring no merger-produced file ever exceeds the cap. A batch of
465+ // size 1 is left as-is (skip the merge copy). When unset, fall back to the legacy
466+ // behavior: collapse everything into a single file.
467+ let max_frames = max_snapshot_frames ( ) ;
468+ let batches = group_snapshots_for_merge ( & snapshots, max_frames) ;
469+ tracing:: debug!(
470+ "merging {} snapshots for {log_id} into {} batch(es) (max_frames={:?})" ,
471+ snapshots. len( ) ,
472+ batches. len( ) ,
473+ max_frames,
474+ ) ;
475+
364476 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 ) ;
477+ let mut out : Vec < ( String , u64 ) > = Vec :: with_capacity ( batches . len ( ) ) ;
478+ for batch in batches {
479+ let batch = & snapshots [ batch ] ;
480+ if batch . len ( ) == 1 {
481+ // Single oversized or stand-alone snapshot — leave it in place untouched
482+ // so that we never produce a merged file larger than the configured cap.
483+ out . push ( batch [ 0 ] . clone ( ) ) ;
484+ continue ;
373485 }
374- builder
375- . append_frames ( snapshot. into_stream_mut ( ) . map_err ( |e| anyhow:: anyhow!( e) ) )
376- . await ?;
377- }
378486
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 ( ) ;
487+ let mut builder =
488+ SnapshotBuilder :: new ( db_path, log_id, scripted_backup. clone ( ) , namespace. clone ( ) )
489+ . await ?;
490+ let mut size_after = None ;
491+ for ( name, _) in batch. iter ( ) . rev ( ) {
492+ // NOTICE: no encryptor passed in order to read frames as is, still encrypted
493+ let snapshot = SnapshotFile :: open ( & snapshot_dir_path. join ( name) , None ) . await ?;
494+ // The size after the merged snapshot is the size after the first snapshot to be merged
495+ if size_after. is_none ( ) {
496+ size_after. replace ( snapshot. header ( ) . size_after ) ;
497+ }
498+ builder
499+ . append_frames ( snapshot. into_stream_mut ( ) . map_err ( |e| anyhow:: anyhow!( e) ) )
500+ . await ?;
501+ }
381502
382- tracing:: debug!(
383- "created merged snapshot for {log_id} from frame {start_frame_no} to {end_frame_no}"
384- ) ;
503+ let ( _, start_frame_no, _) = parse_snapshot_name ( & batch[ 0 ] . 0 ) . unwrap ( ) ;
504+ let ( _, _, end_frame_no) = parse_snapshot_name ( & batch. last ( ) . unwrap ( ) . 0 ) . unwrap ( ) ;
385505
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 ( ) ;
506+ tracing :: debug! (
507+ "created merged snapshot for {log_id} from frame {start_frame_no} to { end_frame_no}"
508+ ) ;
389509
390- let meta = builder. finish ( ) . await ?;
510+ builder. header . start_frame_no = start_frame_no. into ( ) ;
511+ builder. header . end_frame_no = end_frame_no. into ( ) ;
512+ builder. header . size_after = size_after. unwrap ( ) ;
391513
392- for ( name, _) in snapshots. iter ( ) {
393- tokio:: fs:: remove_file ( & snapshot_dir_path. join ( name) ) . await ?;
514+ let meta = builder. finish ( ) . await ?;
515+
516+ for ( name, _) in batch. iter ( ) {
517+ tokio:: fs:: remove_file ( & snapshot_dir_path. join ( name) ) . await ?;
518+ }
519+
520+ out. push ( ( meta. 0 , meta. 1 ) ) ;
394521 }
395522
396- Ok ( ( meta . 0 , meta . 1 ) )
523+ Ok ( out )
397524 }
398525
399526 async fn register_snapshot (
@@ -582,6 +709,48 @@ mod test {
582709 use crate :: replication:: snapshot:: SnapshotFile ;
583710 use crate :: LIBSQL_PAGE_SIZE ;
584711
712+ fn s ( name : & str , count : u64 ) -> ( String , u64 ) {
713+ ( name. to_string ( ) , count)
714+ }
715+
716+ #[ test]
717+ fn group_snapshots_legacy_no_max_returns_single_batch ( ) {
718+ let snaps = vec ! [ s( "a" , 10 ) , s( "b" , 20 ) , s( "c" , 5 ) ] ;
719+ let batches = super :: group_snapshots_for_merge ( & snaps, None ) ;
720+ assert_eq ! ( batches, vec![ 0 ..3 ] ) ;
721+ }
722+
723+ #[ test]
724+ fn group_snapshots_empty_input ( ) {
725+ let batches = super :: group_snapshots_for_merge ( & [ ] , Some ( 100 ) ) ;
726+ assert ! ( batches. is_empty( ) ) ;
727+ }
728+
729+ #[ test]
730+ fn group_snapshots_packs_under_max ( ) {
731+ // max = 25, sequence 10,10,10,10 → (10+10) (10+10)
732+ let snaps = vec ! [ s( "a" , 10 ) , s( "b" , 10 ) , s( "c" , 10 ) , s( "d" , 10 ) ] ;
733+ let batches = super :: group_snapshots_for_merge ( & snaps, Some ( 25 ) ) ;
734+ assert_eq ! ( batches, vec![ 0 ..2 , 2 ..4 ] ) ;
735+ }
736+
737+ #[ test]
738+ fn group_snapshots_oversized_input_is_singleton ( ) {
739+ // Pre-existing big file (50) exceeds cap (25). It must be left alone in its own
740+ // batch so the merger does not produce an even bigger merged file.
741+ let snaps = vec ! [ s( "a" , 10 ) , s( "big" , 50 ) , s( "c" , 10 ) , s( "d" , 10 ) ] ;
742+ let batches = super :: group_snapshots_for_merge ( & snaps, Some ( 25 ) ) ;
743+ assert_eq ! ( batches, vec![ 0 ..1 , 1 ..2 , 2 ..4 ] ) ;
744+ }
745+
746+ #[ test]
747+ fn group_snapshots_each_under_cap_but_sum_over ( ) {
748+ // max=15, sequence 10,10,10 → each fits alone (10<15), pairs do not (20>15).
749+ let snaps = vec ! [ s( "a" , 10 ) , s( "b" , 10 ) , s( "c" , 10 ) ] ;
750+ let batches = super :: group_snapshots_for_merge ( & snaps, Some ( 15 ) ) ;
751+ assert_eq ! ( batches, vec![ 0 ..1 , 1 ..2 , 2 ..3 ] ) ;
752+ }
753+
585754 use super :: * ;
586755
587756 async fn dir_is_empty ( p : & Path ) -> bool {
0 commit comments