Skip to content

Commit d6fbbd9

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 d6fbbd9

1 file changed

Lines changed: 201 additions & 32 deletions

File tree

libsql-server/src/replication/snapshot.rs

Lines changed: 201 additions & 32 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,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).
2931
const 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`.
3134
const 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
34125
fn 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

Comments
 (0)