Skip to content

Commit 5506511

Browse files
committed
libsql-server: cap individual snapshot files at SQLD_MAX_SNAPSHOT_SIZE
Catalog-init for large shops downloads the whole .snap in a single Snapshot RPC because the compactor merges accumulated .snap files into one giant blob whenever their cumulative size exceeds 2x the live database (SNAPHOT_SPACE_AMPLIFICATION_FACTOR). Combined with a 200 MB max_log_size, the result is a single 200-500 MB streaming RPC that routinely runs over the 100 s SFE cap on degraded mobile links. Add a new SQLD_MAX_SNAPSHOT_SIZE env var (in MB, mirroring SQLD_MAX_LOG_SIZE). When set: - should_compact triggers a merge when the cumulative size of accumulated snapshots reaches the cap, instead of using the 2x amplification rule. - merge_snapshots groups input snapshots greedily into contiguous batches whose summed frame count fits under the cap, producing one output file per batch instead of a single combined blob. - A pre-existing snapshot whose own size already exceeds the cap is left in place as a singleton batch, so the merger never produces a file larger than the configured cap. When unset, behavior is unchanged: legacy 2x-db-page-count amplification, single combined merge output. The env var is read once via OnceLock to keep the change local to snapshot.rs (no DbConfig/PrimaryConfig plumbing) and small for a fork patch. Pairs with lowering SQLD_MAX_LOG_SIZE in the production env so each .snap written by the compactor is small (e.g., 20 MB), making the existing client-side replicator loop stream a 500 MB catalog as ~25 small Snapshot RPCs instead of one giant one. Refs: ae-task 259, GSD 48518
1 parent 30f4d57 commit 5506511

1 file changed

Lines changed: 179 additions & 31 deletions

File tree

libsql-server/src/replication/snapshot.rs

Lines changed: 179 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use std::io::SeekFrom;
33
use std::mem::size_of;
44
use std::path::{Path, PathBuf};
55
use std::str::FromStr;
6-
use std::sync::Arc;
6+
use std::sync::{Arc, OnceLock};
77
use std::time::Instant;
88

99
use 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).
2931
const SNAPHOT_SPACE_AMPLIFICATION_FACTOR: u64 = 2;
3032
/// The maximum amount of snapshot allowed before a compaction is required
3133
const 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
34104
fn 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

Comments
 (0)