turso_backup/dedup.rs
1//! Tier 1b — incremental page-dedup snapshot.
2//!
3//! Raw point-in-time-consistent copy of the live `.db` (NOT `VACUUM INTO`,
4//! which reshuffles pages and defeats dedup) → split into fixed 4 KB pages →
5//! content-address (hash) each → upload only changed pages + a per-snapshot
6//! manifest. Restore reassembles from the manifest. Engine-agnostic.
7//!
8//! @yah:relay(R004, "Tier 1b — incremental page-dedup snapshot")
9//! @yah:at(2026-05-26T22:28:31Z)
10//! @yah:status(open)
11//! @yah:phase(P2)
12//! @yah:parent(Q002)
13//! @arch:see(.yah/docs/working/turso-s3-backup.md)
14//!
15//! @yah:ticket(R004-T1, "Spike: raw point-in-time-consistent copy primitive (main+WAL); confirm pages keep fixed offsets across snapshots")
16//! @yah:assignee(agent:claude)
17//! @yah:at(2026-05-26T22:30:06Z)
18//! @yah:status(review)
19//! @yah:phase(P2)
20//! @yah:parent(R004)
21//! @arch:see(.yah/docs/working/turso-s3-backup.md)
22//! @yah:handoff("Built dedup::raw_consistent_copy(db_path)->Vec<u8> + dedup::page_size(image). Primitive = PRAGMA wal_checkpoint(TRUNCATE) (turso supports it) to fold WAL->main, drop conn, fs::read main file. NOT vacuum (no repack). Confirmed point-in-time consistent main+WAL: writing only the main bytes to a fresh path reopens with the full row count.")
23//! @yah:handoff("Page-offset stability CONFIRMED and it is structural: page N is always at (N-1)*page_size; existing pages never renumber on a raw write. Measured 1-row insert into a 5000-row DB = 1/18 pages changed. page_size read from header u16@16 = 4096 (turso default).")
24//! @yah:handoff("CORRECTION to working doc: 'vacuum reshuffles pages and defeats dedup' is too strong. Append-only: raw 1/18 == vacuum 1/18 (equivalent). Vacuum only diverges under reclamation: delete-low-block = raw 10/18 vs vacuum 18/18 (vacuum renumbers all survivors). Reason to copy raw = its offset stability holds for every mutation shape; vacuum's is incidental. Doc updated with the spike findings section.")
25//! @yah:verify("cargo test -p turso-backup dedup -- --nocapture: 3 spike tests green (raw_copy_keeps_page_offsets_stable, raw_copy_beats_vacuum_as_dedup_base, raw_copy_folds_wal_and_is_self_contained). Full suite 6/6, clippy --all-targets clean.")
26//! @yah:next("R004-F2: chunk raw_consistent_copy output on page_size(), sha256 each page, diff against prior manifest, put only changed pages content-addressed + write manifest. Caller must release write conns first (snapshot/DR model) — a live writer can make the TRUNCATE checkpoint return busy; read-only WAL-replay copy is tier-2.")
27//!
28//! @yah:ticket(R004-F2, "Page chunking + content-addressed dedup upload (changed pages only) + manifest format")
29//! @yah:assignee(agent:claude)
30//! @yah:at(2026-05-26T22:30:07Z)
31//! @yah:status(review)
32//! @yah:phase(P2)
33//! @yah:parent(R004)
34//! @arch:see(.yah/docs/working/turso-s3-backup.md)
35//! @yah:depends_on(R004-T1)
36//! @yah:handoff("Built snapshot_dedup(db_path, &BackupTarget)->DedupOutcome on top of raw_consistent_copy: chunk on header page_size, sha256 each page, diff vs latest manifest, put only new pages content-addressed at pages/{hash}, write manifests/manifest-{nanos:020}.manifest last. Reuses tier-1a BackupTarget. DedupOutcome::{Snapshotted{total/uploaded/reused},Unchanged}. Manifest = dep-free text format (header + page_size + page_count + ordered hex hashes); to_bytes/from_bytes + image_len. load_latest_manifest is pub for F3.")
37//! @yah:handoff("Measured: first snapshot of 5000 rows puts all 18 pages + 1 manifest; a 1-row insert then puts exactly 1 page + a 2nd manifest (pages/ grows by 1); both manifests reassemble to valid SQLite (5000/5001 rows). Unchanged path: re-snapshot with no change uploads nothing, writes no new manifest. Note: page-blob GC (unreferenced pages) is out of scope — flag for a later tier.")
38//! @yah:verify("cargo test -p turso-backup: 9/9 green (3 dedup-F2: dedup_uploads_only_changed_pages_and_reassembles, dedup_unchanged_when_pages_identical, manifest_round_trips). clippy --all-targets clean. Run from turso-backup/ dir, NOT db_test root (root yah workspace has unrelated pre-existing compile errors in crates/yah/forms).")
39//! @yah:next("R004-F3: implement restore_from_manifest(target, manifest, dest) — fetch each pages/{hash} in order, concat, write file. The test helper reassemble_latest already does this; F3 packages it + a restore_latest_dedup that calls load_latest_manifest first. Then R004-T4: harness e2e (two snapshots, assert only changed pages uploaded, restore both).")
40//!
41//! @yah:ticket(R004-F3, "Restore from manifest: fetch pages by hash, reassemble valid SQLite")
42//! @yah:assignee(agent:claude)
43//! @yah:at(2026-05-26T22:30:07Z)
44//! @yah:status(review)
45//! @yah:phase(P2)
46//! @yah:parent(R004)
47//! @arch:see(.yah/docs/working/turso-s3-backup.md)
48//! @yah:depends_on(R004-F2)
49//! @yah:handoff("Built restore_from_manifest(target, manifest, dest) + restore_latest_dedup(target, dest). Restore fetches each pages/{hash} in manifest order, VERIFIES content hash (corrupt/truncated blob -> loud error, not a silent-corrupt DB), concats, asserts SQLite magic, writes dest. No -wal needed (snapshot folded WAL in). restore_latest_dedup = load_latest_manifest (newest by nanos key) then restore_from_manifest; errors if no snapshot. Tier-1b counterpart to tier-1a snapshot::restore_latest.")
50//! @yah:verify("cargo test -p turso-backup: 11/11 green (3 F3: restore_latest_dedup_picks_newest_and_round_trips, restore_rejects_corrupt_page; empty-prefix error covered). clippy --all-targets clean. Run from turso-backup/ dir.")
51//! @yah:next("R004-T4: harness e2e against MinIO — wire writer to snapshot_dedup, take two snapshots with a mutation between, assert only changed pages uploaded (DedupOutcome.uploaded_pages), restore_latest_dedup both, sqlite3 verify. Lib round-trips use InMemory; T4 proves it on the object_store aws backend.")
52//!
53//! @yah:ticket(R004-T4, "Harness e2e incremental: two snapshots, assert only changed pages uploaded, restore both")
54//! @yah:assignee(agent:claude)
55//! @yah:at(2026-05-26T22:30:08Z)
56//! @yah:status(review)
57//! @yah:phase(P2)
58//! @yah:parent(R004)
59//! @arch:see(.yah/docs/working/turso-s3-backup.md)
60//! @yah:depends_on(R004-F3)
61//! @yah:handoff("GREEN e2e against MinIO. Added a tier-1b docker flow parallel to tier-1a (own bins + bucket prefix 'dedup'; tier-1a writer/verifier/run.sh untouched): harness bins dedup_writer (2 snapshots w/ a 5-row append between; ASSERTS uploaded2<uploaded1 && reused2>0 so a dedup regression fails the build) + dedup_restore (by MANIFEST_KEY or latest); shared row helpers in harness/src/lib.rs; verify_dedup.sh restores BOTH (snapshot1 by key, snapshot2 latest) + sqlite3 integrity/rows/boundary/hash; Dockerfile dedup-writer/dedup-verifier targets; compose services; ci/run_dedup.sh (staged). Lib gained dedup::restore_manifest_key (point-in-time restore by key).")
62//! @yah:handoff("Result at 5000 rows + 5-row append: snapshot1 109/109 pages uploaded (446KB), snapshot2 1/109 uploaded + 108 reused (only the tail leaf changed; page 1 byte-identical since change_counter is dead + file didn't grow). Both restored to vanilla SQLite: by-key=5000 rows, latest=5005 rows, full-table hashes matched byte-for-byte, exit 0. Restoring snapshot1 by key proves its pages survived snapshot2's deduplicated upload.")
63//! @yah:verify("bash ci/run_dedup.sh (staged: minio -> minio-init -> dedup-writer -> dedup-verifier; verifier exit 0 = green). Lib: cargo test -p turso-backup = 12/12, clippy --all-targets clean (run from turso-backup/ dir).")
64//! @yah:next("R004 complete pending sign-off: all 4 children (T1/F2/F3/T4) in review. Followups filed: page-blob GC = relay R006; live-writer path = R005-F4. Tier 2 (R005, WAL-frame streaming) is the next major relay.")
65//!
66//! @yah:relay(R006, "Tier 1b — snapshot retention + page-blob GC")
67//! @yah:at(2026-05-27T03:10:00Z)
68//! @yah:status(open)
69//! @yah:phase(P2)
70//! @yah:parent(Q002)
71//! @arch:see(.yah/docs/working/turso-s3-backup.md)
72//!
73//! @yah:ticket(R006-F1, "Retention + mark-and-sweep GC: keep the most recent N manifests, delete pages/{hash} blobs no retained manifest references, prune the pruned manifests")
74//! @yah:assignee(agent:claude)
75//! @yah:at(2026-05-27T03:10:01Z)
76//! @yah:status(review)
77//! @yah:phase(P2)
78//! @yah:parent(R006)
79//! @arch:see(.yah/docs/working/turso-s3-backup.md)
80//! @yah:gotcha("R004's snapshot_dedup never deletes pages — pages/{hash} blobs accumulate forever as content changes. GC = compute the union of page hashes across the retained manifests, delete every pages/* blob outside that union, then delete the non-retained manifests. Design the retained-set so a concurrent restore can't lose a page mid-sweep (delete manifests last / GC infrequently).")
81//! @yah:handoff("Implemented dedup::gc_dedup(target, keep_n) -> GcOutcome. Mark-and-sweep: list manifests/, the lexically-greatest keep_n are retained; download each, union their page_hashes into the retain set; list pages/, delete every blob whose leaf isn't in the retain union; finally delete the pruned manifests. Order is pages-first / manifests-last so a retained manifest never references a missing page at any moment of a partial GC, and restore_latest_dedup (which targets the newest = retained) is safe through the whole sweep.")
82//! @yah:handoff("GcOutcome reports {retained_manifests, pruned_manifests, deleted_pages, retained_pages}. keep_n >= total is a no-op (saturating_sub). restore_manifest_key against a soon-to-be-pruned manifest is documented as inherently racy — schedule GC infrequently, in windows where no point-in-time restores are in flight.")
83//! @yah:next("R006-T2: the F1 lib tests already cover keep-N retains restorable + orphan pages deleted + newest still restores byte-for-byte. The piece T2 still owns: prove an *in-window older* snapshot (e.g. GC keep_n=2 with 3 snapshots → assert older retained manifest still restores byte-for-byte via restore_manifest_key). Optionally extend the dedup-writer/verifier harness to call gc_dedup between snapshots and re-verify both restores against MinIO, parallel to R004-T4's tier-1b e2e shape.")
84//! @yah:verify("cargo test -p turso-backup: 41/41 lib green (3 new GC tests: gc_prunes_older_manifests_and_sweeps_orphan_pages, gc_keep_n_at_or_above_total_is_noop, gc_keeps_pages_shared_with_retained_manifests). cargo clippy --all-targets clean. Run from turso-backup/ dir.")
85//!
86//! @yah:ticket(R006-T2, "Test GC: keep-N retains restorable snapshots, orphaned pages deleted, latest + an in-window older snapshot still restore byte-for-byte")
87//! @yah:assignee(agent:claude)
88//! @yah:at(2026-05-27T03:10:02Z)
89//! @yah:status(review)
90//! @yah:phase(P2)
91//! @yah:parent(R006)
92//! @arch:see(.yah/docs/working/turso-s3-backup.md)
93//! @yah:depends_on(R006-F1)
94//! @yah:handoff("Added gc_keep_two_lets_in_window_older_snapshot_restore_byte_for_byte: takes 3 snapshots with mutations between, restores the middle snapshot via restore_manifest_key BEFORE GC as ground-truth bytes, runs gc_dedup(target, 2), then asserts (a) snapshot 1's pruned key errors on restore, (b) the in-window older snapshot 2 restores BYTE-FOR-BYTE equal to the pre-GC bytes, (c) restore_latest_dedup picks snapshot 3 with the post-second-mutation row count. Locks in the property that GC's mark set covers every retained manifest, not just the one restore_latest_dedup picks.")
95//! @yah:handoff("F1's three tests already covered keep-N retain + orphan delete + newest-restores-byte-for-byte + shared-page survival; T2 added the missing piece (in-window older snapshot byte-equality). 42/42 lib tests green, clippy clean.")
96//! @yah:handoff("NB: the turso-backup crate was relocated from external/db_test/turso-backup to external/turso-backup during this session — board tools now need path=/Users/user/ss/yah to resolve R006 tickets.")
97//! @yah:next("R006 complete pending sign-off: both children (F1, T2) in review. Optional follow-up — extend dedup-writer/verifier docker harness to call gc_dedup between snapshots and re-verify both restores against MinIO, parallel to R004-T4's tier-1b e2e shape. Not blocking R006 archive.")
98//! @yah:verify("cd /Users/user/ss/yah/external/turso-backup && cargo test --lib: 42/42 green (4 GC tests under dedup::tests::gc*). cargo clippy --all-targets clean.")
99
100use crate::snapshot::BackupTarget;
101use anyhow::{Context, Result};
102use object_store::path::Path as ObjPath;
103use object_store::{ObjectStore, ObjectStoreExt};
104use sha2::{Digest, Sha256};
105use std::collections::{HashMap, HashSet};
106use std::time::{SystemTime, UNIX_EPOCH};
107use turso::Builder;
108
109/// SQLite stores the database page size as a big-endian `u16` at byte offset 16
110/// of the file header. The encoded value `1` means the maximum 65 536-byte page.
111const PAGE_SIZE_OFFSET: usize = 16;
112
113/// The SQLite file-format magic, present at the start of every valid database
114/// (and every `VACUUM INTO` / raw-copy image we produce).
115const SQLITE_MAGIC: &[u8] = b"SQLite format 3\0";
116
117/// Read the page size (in bytes) from a SQLite file image's header.
118///
119/// Tier 1b chunks on **real page boundaries**, so the dedup unit is whatever the
120/// engine actually uses — read it from the header rather than assuming 4 KB.
121pub fn page_size(image: &[u8]) -> Result<usize> {
122 anyhow::ensure!(image.starts_with(SQLITE_MAGIC), "not a SQLite database (bad magic)");
123 anyhow::ensure!(image.len() >= PAGE_SIZE_OFFSET + 2, "image too short for a header");
124 let raw = u16::from_be_bytes([image[PAGE_SIZE_OFFSET], image[PAGE_SIZE_OFFSET + 1]]);
125 Ok(if raw == 1 { 65_536 } else { raw as usize })
126}
127
128/// Take a raw, point-in-time-consistent byte image of the database at `db_path`,
129/// preserving its native page layout. This is the tier-1b foundation and is
130/// deliberately **not** `VACUUM INTO`: vacuum repacks the b-tree into a fresh
131/// file, relocating pages and defeating content-addressed dedup.
132///
133/// We fold any pending WAL frames into the main file with a `TRUNCATE`
134/// checkpoint, then read the main file. After the checkpoint the main file is
135/// the complete, self-consistent image — page `N` lives at byte offset
136/// `(N - 1) * page_size`, so a logically-unchanged page hashes identically
137/// across snapshots. That fixed-offset stability is the precondition that makes
138/// "upload only changed pages" actually save bytes (confirmed by the spike tests
139/// below: a one-row insert into a 5 000-row DB rewrites only a handful of pages
140/// via this path, but a large fraction of them via `VACUUM INTO`).
141///
142/// Consistency comes from folding **main + WAL** into one image: any committed
143/// frame still sitting in `-wal` is written back to its page slot, so the
144/// returned bytes open in vanilla SQLite with no sidecar.
145///
146/// Assumes no live writer holds the WAL — the snapshot/DR model, where the
147/// caller has released its write connections (as the harness writer does before
148/// backing up). A concurrent writer can make a `TRUNCATE` checkpoint return
149/// `busy`; the read-only "copy main + replay WAL frames ourselves" path that
150/// would cover live writers is tier-2 territory.
151pub async fn raw_consistent_copy(db_path: &str) -> Result<Vec<u8>> {
152 // Fold WAL -> main and reset the WAL so the main file alone is complete.
153 {
154 let db = Builder::new_local(db_path)
155 .build()
156 .await
157 .with_context(|| format!("opening turso db {db_path}"))?;
158 let conn = db.connect().context("connecting to turso db")?;
159 let mut rows = conn
160 .query("PRAGMA wal_checkpoint(TRUNCATE)", ())
161 .await
162 .context("PRAGMA wal_checkpoint(TRUNCATE)")?;
163 while rows.next().await.context("draining wal_checkpoint")?.is_some() {}
164 } // drop the connection before reading the file off disk
165
166 let bytes = std::fs::read(db_path).with_context(|| format!("reading db image {db_path}"))?;
167 anyhow::ensure!(
168 bytes.starts_with(SQLITE_MAGIC),
169 "raw copy of {db_path} is not a SQLite database"
170 );
171 Ok(bytes)
172}
173
174/// The first line of every serialized [`Manifest`] — magic + format version.
175const MANIFEST_HEADER: &str = "TURSO-BACKUP MANIFEST v1";
176
177/// A per-snapshot manifest: the page size plus the ordered content hashes of the
178/// database's pages. Page `i` of the restored file is the blob stored at
179/// `pages/{page_hashes[i]}`, so the manifest alone is enough to reassemble the
180/// whole image (see [`restore_from_manifest`]).
181///
182/// Serialized as a small, dependency-free text format (no serde):
183///
184/// ```text
185/// TURSO-BACKUP MANIFEST v1
186/// page_size 4096
187/// page_count 18
188/// <hex sha256 of page 0>
189/// <hex sha256 of page 1>
190/// ...
191/// ```
192#[derive(Debug, Clone, PartialEq, Eq)]
193pub struct Manifest {
194 pub page_size: usize,
195 /// Lowercase-hex SHA-256 of each page, in file order.
196 pub page_hashes: Vec<String>,
197}
198
199impl Manifest {
200 /// Serialize to the text wire format.
201 pub fn to_bytes(&self) -> Vec<u8> {
202 let mut s = String::with_capacity(64 + self.page_hashes.len() * 65);
203 s.push_str(MANIFEST_HEADER);
204 s.push('\n');
205 s.push_str(&format!("page_size {}\n", self.page_size));
206 s.push_str(&format!("page_count {}\n", self.page_hashes.len()));
207 for h in &self.page_hashes {
208 s.push_str(h);
209 s.push('\n');
210 }
211 s.into_bytes()
212 }
213
214 /// Parse the text wire format, validating the header and page count.
215 pub fn from_bytes(bytes: &[u8]) -> Result<Manifest> {
216 let text = std::str::from_utf8(bytes).context("manifest is not valid UTF-8")?;
217 let mut lines = text.lines();
218 let header = lines.next().context("empty manifest")?;
219 anyhow::ensure!(header == MANIFEST_HEADER, "unexpected manifest header: {header:?}");
220 let page_size = parse_header_field(lines.next(), "page_size")?;
221 let page_count = parse_header_field(lines.next(), "page_count")?;
222 let page_hashes: Vec<String> = lines.map(|l| l.trim().to_string()).collect();
223 anyhow::ensure!(
224 page_hashes.len() == page_count,
225 "manifest page_count {page_count} != {} hash lines",
226 page_hashes.len()
227 );
228 Ok(Manifest { page_size, page_hashes })
229 }
230
231 /// Total reassembled image size in bytes.
232 pub fn image_len(&self) -> usize {
233 self.page_size * self.page_hashes.len()
234 }
235}
236
237/// Parse a `"<name> <usize>"` header line.
238fn parse_header_field(line: Option<&str>, name: &str) -> Result<usize> {
239 let line = line.with_context(|| format!("manifest missing {name}"))?;
240 let value = line
241 .strip_prefix(name)
242 .and_then(|rest| rest.trim().parse::<usize>().ok())
243 .with_context(|| format!("malformed manifest {name} line: {line:?}"))?;
244 Ok(value)
245}
246
247/// Outcome of a [`snapshot_dedup`] call.
248#[derive(Debug, Clone, PartialEq, Eq)]
249pub enum DedupOutcome {
250 /// A new manifest was written. `uploaded_pages` is the number of distinct
251 /// pages put this run (changed or new); `reused_pages` were already present
252 /// from an earlier snapshot and so were *not* re-uploaded.
253 Snapshotted {
254 manifest_key: String,
255 total_pages: usize,
256 uploaded_pages: usize,
257 reused_pages: usize,
258 },
259 /// The page set was byte-identical to the latest manifest, so nothing was
260 /// uploaded and no new manifest was written — the prior one still stands.
261 Unchanged { manifest_key: String },
262}
263
264/// Take a raw consistent snapshot of the database at `db_path` and upload only
265/// the pages that changed since the last snapshot, plus a per-snapshot manifest.
266/// Tier 1b.
267///
268/// 1. [`raw_consistent_copy`] the database (page layout preserved — the
269/// precondition for dedup, see that fn).
270/// 2. Split on the header [`page_size`] and SHA-256 each page.
271/// 3. Load the latest manifest (if any). If this snapshot's ordered page hashes
272/// are identical, return [`Unchanged`] without uploading.
273/// 4. Otherwise upload every page whose hash isn't already referenced by the
274/// prior manifest — content-addressed at `{prefix}/pages/{hash}`, so
275/// identical pages (within or across snapshots) are stored once — then write
276/// the manifest at `{prefix}/manifests/manifest-{unix_nanos:020}.manifest`.
277///
278/// The prior-manifest diff is conservative: a page present in the store but not
279/// in the prior manifest is re-uploaded (idempotent — same content-addressed
280/// key), so the failure direction is a redundant put, never a missing page.
281///
282/// [`Unchanged`]: DedupOutcome::Unchanged
283pub async fn snapshot_dedup(db_path: &str, target: &BackupTarget) -> Result<DedupOutcome> {
284 let image = raw_consistent_copy(db_path).await?;
285 snapshot_dedup_image(&image, target).await
286}
287
288/// Steps 2–4 of [`snapshot_dedup`] against an image the caller already holds.
289///
290/// R850-F1 split this out for the caller [`snapshot_dedup`] cannot serve: a tail
291/// running **beside a live application** that holds the database open. Step 1's
292/// [`raw_consistent_copy`] takes a `PRAGMA wal_checkpoint(TRUNCATE)` through an
293/// ordinary writable connection, and turso locks the whole file at open — so
294/// against a held database that step does not merely block the checkpoint, it
295/// fails to open at all (measured: `examples/appliance_tail_probe.rs`, A1).
296/// Such a caller produces its image with
297/// [`stream::raw_consistent_copy_live`](crate::stream::raw_consistent_copy_live)
298/// instead, which opens read-only and validates optimistically, and hands it
299/// here.
300///
301/// The page-layout precondition [`snapshot_dedup`] documents is unchanged and is
302/// now the caller's to keep: the image must be a raw page-preserving copy, not
303/// `VACUUM INTO` output, or every page hash moves on every run and the dedup
304/// stores a fresh full copy each time.
305pub async fn snapshot_dedup_image(image: &[u8], target: &BackupTarget) -> Result<DedupOutcome> {
306 let ps = page_size(image)?;
307
308 let page_hashes: Vec<String> = image.chunks(ps).map(sha256_hex).collect();
309 let total_pages = page_hashes.len();
310
311 // Latest manifest = the prior snapshot's page set (empty on the first run).
312 let prior = load_latest_manifest(target).await?;
313 if let Some((key, manifest)) = &prior {
314 if manifest.page_hashes == page_hashes {
315 return Ok(DedupOutcome::Unchanged { manifest_key: key.clone() });
316 }
317 }
318 let already: HashSet<&str> = prior
319 .as_ref()
320 .map(|(_, m)| m.page_hashes.iter().map(String::as_str).collect())
321 .unwrap_or_default();
322
323 // Distinct pages new to this snapshot (dedups identical pages within it too).
324 let mut to_upload: HashMap<&str, &[u8]> = HashMap::new();
325 for (hash, page) in page_hashes.iter().zip(image.chunks(ps)) {
326 if !already.contains(hash.as_str()) {
327 to_upload.insert(hash.as_str(), page);
328 }
329 }
330 let uploaded_pages = to_upload.len();
331 for (hash, page) in &to_upload {
332 let key = page_key(target, hash);
333 target
334 .store
335 .put(&key, page.to_vec().into())
336 .await
337 .with_context(|| format!("uploading page {key}"))?;
338 }
339
340 // Write the manifest last, so a manifest never references a missing page.
341 let manifest = Manifest { page_size: ps, page_hashes };
342 let key = manifest_key(target, unix_nanos());
343 target
344 .store
345 .put(&key, manifest.to_bytes().into())
346 .await
347 .with_context(|| format!("uploading manifest {key}"))?;
348
349 Ok(DedupOutcome::Snapshotted {
350 manifest_key: key.to_string(),
351 total_pages,
352 uploaded_pages,
353 reused_pages: total_pages - uploaded_pages,
354 })
355}
356
357/// Load the most recent manifest under `{prefix}/manifests/`, returning its key
358/// and parsed contents, or `None` if no snapshot has been taken yet. Manifest
359/// keys are zero-padded nanosecond timestamps, so the lexically-greatest key is
360/// the newest (clock-skew-immune, matching tier 1a's `restore_latest`). Shared
361/// with restore (R004-F3).
362pub async fn load_latest_manifest(target: &BackupTarget) -> Result<Option<(String, Manifest)>> {
363 let prefix = join_key(&target.prefix, "manifests");
364 let listing = target
365 .store
366 .list_with_delimiter(Some(&prefix))
367 .await
368 .with_context(|| format!("listing manifests under {prefix}"))?;
369 let Some(newest) = listing.objects.into_iter().max_by(|a, b| a.location.cmp(&b.location)) else {
370 return Ok(None);
371 };
372 let bytes = target
373 .store
374 .get(&newest.location)
375 .await
376 .with_context(|| format!("downloading manifest {}", newest.location))?
377 .bytes()
378 .await
379 .with_context(|| format!("reading manifest body {}", newest.location))?;
380 let manifest = Manifest::from_bytes(&bytes)
381 .with_context(|| format!("parsing manifest {}", newest.location))?;
382 Ok(Some((newest.location.to_string(), manifest)))
383}
384
385/// Reassemble a database file from a manifest + content-addressed pages, writing
386/// it to `dest`. Tier 1b restore.
387///
388/// Fetches each `pages/{hash}` in manifest order, **verifies its content hash**
389/// (the page is content-addressed, so a hash mismatch means a corrupt or
390/// truncated blob — fail loudly rather than write a silently-broken database),
391/// concatenates, and writes the image. The result is a valid vanilla-SQLite file
392/// needing no `-wal` sidecar — the snapshot folded WAL into the image.
393pub async fn restore_from_manifest(
394 target: &BackupTarget,
395 manifest: &Manifest,
396 dest: &str,
397) -> Result<()> {
398 let mut image = Vec::with_capacity(manifest.image_len());
399 for (i, hash) in manifest.page_hashes.iter().enumerate() {
400 let key = page_key(target, hash);
401 let bytes = target
402 .store
403 .get(&key)
404 .await
405 .with_context(|| format!("fetching page {i} ({key})"))?
406 .bytes()
407 .await
408 .with_context(|| format!("reading page {i} body ({key})"))?;
409 let got = sha256_hex(&bytes);
410 anyhow::ensure!(
411 &got == hash,
412 "page {i} ({key}) content hash {got} != manifest {hash} — corrupt or truncated blob"
413 );
414 image.extend_from_slice(&bytes);
415 }
416 anyhow::ensure!(
417 image.starts_with(SQLITE_MAGIC),
418 "reassembled image is not a SQLite database"
419 );
420 std::fs::write(dest, &image).with_context(|| format!("writing restored db to {dest}"))?;
421 Ok(())
422}
423
424/// Restore the most recent snapshot for `target` to `dest`, returning the
425/// manifest key it restored from. Errors if no snapshot exists under the prefix.
426/// This is the tier-1b counterpart to tier 1a's `snapshot::restore_latest`.
427pub async fn restore_latest_dedup(target: &BackupTarget, dest: &str) -> Result<String> {
428 let (key, manifest) = load_latest_manifest(target)
429 .await?
430 .context("no snapshot manifest found under prefix")?;
431 restore_from_manifest(target, &manifest, dest).await?;
432 Ok(key)
433}
434
435/// Restore a *specific* snapshot, named by its manifest object key (as returned
436/// in [`DedupOutcome::Snapshotted::manifest_key`]), to `dest`. Use this for a
437/// point-in-time restore of an older snapshot; [`restore_latest_dedup`] covers
438/// the common "newest" case. Fetches and parses the manifest object, then defers
439/// to [`restore_from_manifest`] (so the same per-page content-hash verification
440/// applies).
441pub async fn restore_manifest_key(target: &BackupTarget, manifest_key: &str, dest: &str) -> Result<()> {
442 let key = ObjPath::from(manifest_key);
443 let bytes = target
444 .store
445 .get(&key)
446 .await
447 .with_context(|| format!("fetching manifest {key}"))?
448 .bytes()
449 .await
450 .with_context(|| format!("reading manifest body {key}"))?;
451 let manifest =
452 Manifest::from_bytes(&bytes).with_context(|| format!("parsing manifest {key}"))?;
453 restore_from_manifest(target, &manifest, dest).await
454}
455
456/// Outcome of a [`gc_dedup`] call.
457#[derive(Debug, Clone, PartialEq, Eq)]
458pub struct GcOutcome {
459 /// Number of manifests retained (the `keep_n` newest, or fewer if the store
460 /// holds less).
461 pub retained_manifests: usize,
462 /// Number of older manifests deleted from `manifests/`.
463 pub pruned_manifests: usize,
464 /// Number of `pages/{hash}` blobs deleted (referenced by no retained
465 /// manifest).
466 pub deleted_pages: usize,
467 /// Number of distinct page hashes referenced by the retained manifests.
468 /// Every blob with one of these hashes survives the sweep.
469 pub retained_pages: usize,
470}
471
472/// Mark-and-sweep GC: keep the `keep_n` newest manifests, delete every
473/// `pages/{hash}` blob no retained manifest references, then prune the older
474/// manifests.
475///
476/// Manifest keys are zero-padded nanosecond timestamps, so lexical order is
477/// chronological — the `keep_n` lexically-greatest keys are the newest
478/// snapshots. If the store holds `<= keep_n` manifests, nothing is pruned.
479///
480/// ## Sweep order
481///
482/// 1. List `manifests/`, split into retained (newest `keep_n`) vs pruned.
483/// 2. Download + parse the retained manifests; union their `page_hashes`.
484/// 3. List `pages/`, delete every blob whose leaf isn't in the retained union.
485/// 4. **Delete the pruned manifests last.**
486///
487/// Concurrent-restore safety: [`restore_latest_dedup`] always targets the
488/// lexically-greatest manifest, which is in the retain set, and *all* of its
489/// pages are in the retain union — so it survives a concurrent GC. The
490/// `manifests-last` ordering means that even if we crash mid-sweep, every
491/// surviving manifest still resolves: orphan pages may briefly outlive their
492/// (already-pruned) referrers, but no retained manifest ever points at a
493/// missing page. The reverse order would risk a half-deleted retain set.
494///
495/// `restore_manifest_key` against a manifest in the prune set is inherently a
496/// race — the caller is asking to restore something we've decided to discard.
497/// Schedule GC to run when no point-in-time restores are in flight.
498pub async fn gc_dedup(target: &BackupTarget, keep_n: usize) -> Result<GcOutcome> {
499 // 1. List manifests, sort lexically (== chronologically).
500 let manifests_prefix = join_key(&target.prefix, "manifests");
501 let manifests_listing = target
502 .store
503 .list_with_delimiter(Some(&manifests_prefix))
504 .await
505 .with_context(|| format!("listing manifests under {manifests_prefix}"))?;
506 let mut all_manifests: Vec<ObjPath> =
507 manifests_listing.objects.into_iter().map(|o| o.location).collect();
508 all_manifests.sort();
509
510 // Split: the newest `keep_n` retained, the rest pruned. If keep_n exceeds
511 // the count, every manifest is retained and there's nothing to prune.
512 let total = all_manifests.len();
513 let split = total.saturating_sub(keep_n);
514 let pruned: Vec<ObjPath> = all_manifests.iter().take(split).cloned().collect();
515 let retained: Vec<ObjPath> = all_manifests.into_iter().skip(split).collect();
516
517 // 2. Compute the union of page hashes referenced by the retained manifests.
518 let mut retained_hashes: HashSet<String> = HashSet::new();
519 for key in &retained {
520 let bytes = target
521 .store
522 .get(key)
523 .await
524 .with_context(|| format!("fetching retained manifest {key}"))?
525 .bytes()
526 .await
527 .with_context(|| format!("reading retained manifest body {key}"))?;
528 let manifest = Manifest::from_bytes(&bytes)
529 .with_context(|| format!("parsing retained manifest {key}"))?;
530 retained_hashes.extend(manifest.page_hashes);
531 }
532
533 // 3. List pages, delete every blob whose hash isn't in the retain union.
534 // Pages are stored at `{prefix}/pages/{hash}` with no further slashes, so
535 // list_with_delimiter returns each one directly under `objects`.
536 let pages_prefix = join_key(&target.prefix, "pages");
537 let pages_listing = target
538 .store
539 .list_with_delimiter(Some(&pages_prefix))
540 .await
541 .with_context(|| format!("listing pages under {pages_prefix}"))?;
542 let mut deleted_pages = 0usize;
543 for obj in pages_listing.objects {
544 let leaf = obj
545 .location
546 .filename()
547 .with_context(|| format!("page key has no leaf: {}", obj.location))?
548 .to_string();
549 if !retained_hashes.contains(&leaf) {
550 target
551 .store
552 .delete(&obj.location)
553 .await
554 .with_context(|| format!("deleting orphan page {}", obj.location))?;
555 deleted_pages += 1;
556 }
557 }
558
559 // 4. Delete the pruned manifests LAST (see fn doc — keeps retained
560 // manifests fully resolvable through the entire sweep).
561 for key in &pruned {
562 target
563 .store
564 .delete(key)
565 .await
566 .with_context(|| format!("deleting pruned manifest {key}"))?;
567 }
568
569 Ok(GcOutcome {
570 retained_manifests: retained.len(),
571 pruned_manifests: pruned.len(),
572 deleted_pages,
573 retained_pages: retained_hashes.len(),
574 })
575}
576
577/// Object key for a content-addressed page blob.
578fn page_key(target: &BackupTarget, hash: &str) -> ObjPath {
579 join_key(&target.prefix, &format!("pages/{hash}"))
580}
581
582/// Object key for a snapshot's manifest, taken at `unix_nanos` (zero-padded so
583/// lexical order matches chronological order).
584fn manifest_key(target: &BackupTarget, unix_nanos: u128) -> ObjPath {
585 join_key(&target.prefix, &format!("manifests/manifest-{unix_nanos:020}.manifest"))
586}
587
588/// Join an object-store prefix and a leaf into a normalized [`ObjPath`],
589/// tolerating empty/slash-padded prefixes.
590fn join_key(prefix: &str, leaf: &str) -> ObjPath {
591 let prefix = prefix.trim_matches('/');
592 if prefix.is_empty() {
593 ObjPath::from(leaf)
594 } else {
595 ObjPath::from(format!("{prefix}/{leaf}"))
596 }
597}
598
599/// Lowercase-hex SHA-256 of a byte buffer.
600fn sha256_hex(bytes: &[u8]) -> String {
601 let mut hasher = Sha256::new();
602 hasher.update(bytes);
603 hex::encode(hasher.finalize())
604}
605
606fn unix_nanos() -> u128 {
607 SystemTime::now()
608 .duration_since(UNIX_EPOCH)
609 .map(|d| d.as_nanos())
610 .unwrap_or(0)
611}
612
613#[cfg(test)]
614mod tests {
615 use super::*;
616 use object_store::memory::InMemory;
617 use std::sync::Arc;
618
619 /// A throwaway db path under the OS temp dir, cleaned up (with sidecars) on drop.
620 struct TempDb(std::path::PathBuf);
621 impl TempDb {
622 fn new(tag: &str) -> Self {
623 TempDb(std::env::temp_dir().join(format!(
624 "turso-dedup-test-{tag}-{}-{}.db",
625 std::process::id(),
626 unix_nanos()
627 )))
628 }
629 fn path(&self) -> &str {
630 self.0.to_str().unwrap()
631 }
632 }
633 impl Drop for TempDb {
634 fn drop(&mut self) {
635 for suffix in ["", "-wal", "-shm"] {
636 let _ = std::fs::remove_file(format!("{}{suffix}", self.0.display()));
637 }
638 }
639 }
640
641 async fn seed_db(path: &str, rows: i64) {
642 let db = Builder::new_local(path).build().await.unwrap();
643 let conn = db.connect().unwrap();
644 conn.execute("CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY, v TEXT)", ())
645 .await
646 .unwrap();
647 conn.execute("BEGIN", ()).await.unwrap();
648 for i in 0..rows {
649 conn.execute("INSERT INTO t (id, v) VALUES (?, ?)", (i, format!("v{i}")))
650 .await
651 .unwrap();
652 }
653 conn.execute("COMMIT", ()).await.unwrap();
654 }
655
656 async fn insert_rows(path: &str, start: i64, n: i64) {
657 let db = Builder::new_local(path).build().await.unwrap();
658 let conn = db.connect().unwrap();
659 for i in 0..n {
660 conn.execute("INSERT INTO t (id, v) VALUES (?, ?)", (start + i, format!("v{}", start + i)))
661 .await
662 .unwrap();
663 }
664 }
665
666 async fn count_rows(path: &str) -> i64 {
667 let db = Builder::new_local(path).build().await.unwrap();
668 let conn = db.connect().unwrap();
669 let mut r = conn.query("SELECT COUNT(*) FROM t", ()).await.unwrap();
670 let row = r.next().await.unwrap().unwrap();
671 row.get::<i64>(0).unwrap()
672 }
673
674 /// `VACUUM INTO` a fresh file and return its bytes — the contrast case that
675 /// shows why tier 1b copies raw instead of vacuuming.
676 async fn vacuum_image(db_path: &str) -> Vec<u8> {
677 let out = std::env::temp_dir().join(format!(
678 "turso-dedup-vac-{}-{}.db",
679 std::process::id(),
680 unix_nanos()
681 ));
682 let _ = std::fs::remove_file(&out);
683 let db = Builder::new_local(db_path).build().await.unwrap();
684 let conn = db.connect().unwrap();
685 let escaped = out.to_string_lossy().replace('\'', "''");
686 let mut rows = conn.query(&format!("VACUUM INTO '{escaped}'"), ()).await.unwrap();
687 while rows.next().await.unwrap().is_some() {}
688 drop(conn);
689 let bytes = std::fs::read(&out).unwrap();
690 let _ = std::fs::remove_file(&out);
691 bytes
692 }
693
694 /// Count pages that differ at the same offset between two images, padding the
695 /// shorter image's tail to `total` (a grown file = appended pages = changed).
696 fn changed_pages(a: &[u8], b: &[u8], ps: usize) -> (usize, usize) {
697 let pa: Vec<&[u8]> = a.chunks(ps).collect();
698 let pb: Vec<&[u8]> = b.chunks(ps).collect();
699 let total = pa.len().max(pb.len());
700 let mut changed = 0;
701 for i in 0..total {
702 if pa.get(i) != pb.get(i) {
703 changed += 1;
704 }
705 }
706 (changed, total)
707 }
708
709 /// SPIKE R004-T1 (headline): a raw consistent copy keeps unchanged pages at
710 /// fixed offsets, so a one-row mutation rewrites only a few pages — the
711 /// precondition for content-addressed dedup.
712 #[tokio::test]
713 async fn raw_copy_keeps_page_offsets_stable() {
714 let src = TempDb::new("offsets");
715 seed_db(src.path(), 5000).await;
716
717 let a = raw_consistent_copy(src.path()).await.unwrap();
718 let ps = page_size(&a).unwrap();
719 assert_eq!(ps, 4096, "expected turso's default 4 KB page, got {ps}");
720
721 // A single-row insert: the smallest meaningful mutation.
722 insert_rows(src.path(), 1_000_000, 1).await;
723 let b = raw_consistent_copy(src.path()).await.unwrap();
724 assert_eq!(page_size(&b).unwrap(), ps, "page size must not change");
725
726 let (changed, total) = changed_pages(&a, &b, ps);
727 eprintln!(
728 "[R004-T1] raw copy: {changed}/{total} pages changed by a 1-row insert (page_size={ps})"
729 );
730 assert!(changed >= 1, "the mutation must change at least one page");
731 assert!(
732 changed * 4 < total,
733 "raw copy not dedup-friendly: {changed}/{total} pages changed by one row"
734 );
735
736 // The second image is itself complete and consistent (opens in turso).
737 let restored = TempDb::new("offsets-restored");
738 std::fs::write(restored.path(), &b).unwrap();
739 assert_eq!(count_rows(restored.path()).await, 5001);
740 }
741
742 /// SPIKE R004-T1 (contrast): the honest comparison of raw copy vs
743 /// `VACUUM INTO` as the dedup base, across two mutation shapes.
744 ///
745 /// - **Append** (1-row insert): the two are *equivalent* — both touch a
746 /// single page. The working doc's blanket "vacuum reshuffles and defeats
747 /// dedup" does NOT hold for append-only growth.
748 /// - **Reclamation** (delete a low-id block): vacuum repacks the surviving
749 /// rows from page 1, renumbering them, so a large fraction of pages
750 /// change. A raw copy leaves every surviving page at its original offset,
751 /// so only the freed region changes.
752 ///
753 /// The takeaway for F2: raw copy's offset stability is a *structural
754 /// guarantee* (page N is always at `(N-1)*ps`, existing pages never
755 /// renumber), independent of workload. Vacuum's dedup-friendliness is
756 /// incidental and collapses under reclamation — so tier 1b copies raw.
757 #[tokio::test]
758 async fn raw_copy_beats_vacuum_as_dedup_base() {
759 let src = TempDb::new("contrast");
760 seed_db(src.path(), 5000).await;
761
762 let raw_a = raw_consistent_copy(src.path()).await.unwrap();
763 let vac_a = vacuum_image(src.path()).await;
764 let ps = page_size(&raw_a).unwrap();
765
766 // Phase 1 — append one row: raw and vacuum should be ~equivalent.
767 insert_rows(src.path(), 1_000_000, 1).await;
768 let raw_b = raw_consistent_copy(src.path()).await.unwrap();
769 let vac_b = vacuum_image(src.path()).await;
770 let (raw_app, raw_app_total) = changed_pages(&raw_a, &raw_b, ps);
771 let (vac_app, vac_app_total) = changed_pages(&vac_a, &vac_b, ps);
772 eprintln!(
773 "[R004-T1] append 1 row -> raw: {raw_app}/{raw_app_total} | vacuum: {vac_app}/{vac_app_total} (expected ~equal)"
774 );
775
776 // Phase 2 — free a low-id block: vacuum renumbers survivors, raw doesn't.
777 let src2 = TempDb::new("contrast-reclaim");
778 seed_db(src2.path(), 5000).await;
779 let raw_c = raw_consistent_copy(src2.path()).await.unwrap();
780 let vac_c = vacuum_image(src2.path()).await;
781 {
782 let db = Builder::new_local(src2.path()).build().await.unwrap();
783 let conn = db.connect().unwrap();
784 conn.execute("DELETE FROM t WHERE id < 2000", ()).await.unwrap();
785 }
786 let raw_d = raw_consistent_copy(src2.path()).await.unwrap();
787 let vac_d = vacuum_image(src2.path()).await;
788 let (raw_rec, raw_rec_total) = changed_pages(&raw_c, &raw_d, ps);
789 let (vac_rec, vac_rec_total) = changed_pages(&vac_c, &vac_d, ps);
790 eprintln!(
791 "[R004-T1] delete low block -> raw: {raw_rec}/{raw_rec_total} | vacuum: {vac_rec}/{vac_rec_total} (vacuum renumbers)"
792 );
793
794 // The defensible, workload-independent claim: under reclamation the raw
795 // copy preserves more pages than vacuum does.
796 assert!(
797 vac_rec > raw_rec,
798 "under reclamation expected vacuum to perturb more pages than raw copy \
799 (raw {raw_rec}, vacuum {vac_rec})"
800 );
801 }
802
803 /// SPIKE R004-T1: `raw_consistent_copy` folds main + WAL into one image, so
804 /// the returned bytes are complete with no `-wal` sidecar.
805 #[tokio::test]
806 async fn raw_copy_folds_wal_and_is_self_contained() {
807 let src = TempDb::new("walfold");
808 seed_db(src.path(), 100).await;
809 insert_rows(src.path(), 200, 50).await;
810
811 let img = raw_consistent_copy(src.path()).await.unwrap();
812 assert!(img.starts_with(SQLITE_MAGIC));
813
814 // Write ONLY the main image to a fresh path (no -wal/-shm): a complete
815 // image means the WAL frames were folded in.
816 let restored = TempDb::new("walfold-restored");
817 std::fs::write(restored.path(), &img).unwrap();
818 assert_eq!(count_rows(restored.path()).await, 150);
819 }
820
821 // ---- R004-F2: page chunking + content-addressed dedup upload + manifest ----
822
823 /// Number of objects stored under `{prefix}/{leaf}/`.
824 async fn count_under(target: &BackupTarget, leaf: &str) -> usize {
825 target
826 .store
827 .list_with_delimiter(Some(&join_key(&target.prefix, leaf)))
828 .await
829 .unwrap()
830 .objects
831 .len()
832 }
833
834 /// Reassemble the image described by the latest manifest, fetching each page
835 /// blob by its content hash (what R004-F3 will package as `restore`).
836 async fn reassemble_latest(target: &BackupTarget) -> Vec<u8> {
837 let (_, m) = load_latest_manifest(target).await.unwrap().unwrap();
838 let mut out = Vec::with_capacity(m.image_len());
839 for h in &m.page_hashes {
840 let bytes = target.store.get(&page_key(target, h)).await.unwrap().bytes().await.unwrap();
841 out.extend_from_slice(&bytes);
842 }
843 out
844 }
845
846 #[test]
847 fn manifest_round_trips() {
848 let m = Manifest {
849 page_size: 4096,
850 page_hashes: vec!["aa".repeat(32), "bb".repeat(32)],
851 };
852 assert_eq!(Manifest::from_bytes(&m.to_bytes()).unwrap(), m);
853 assert_eq!(m.image_len(), 8192);
854 assert!(Manifest::from_bytes(b"not a manifest\n").is_err());
855 assert!(Manifest::from_bytes(&[0xff, 0xfe]).is_err());
856 }
857
858 /// First snapshot uploads every distinct page + a manifest; a one-row change
859 /// then uploads only the few changed pages and writes a second manifest. Both
860 /// manifests reassemble to a valid SQLite with the expected row count.
861 #[tokio::test]
862 async fn dedup_uploads_only_changed_pages_and_reassembles() {
863 let src = TempDb::new("dedup-src");
864 seed_db(src.path(), 5000).await;
865 let target = BackupTarget { store: Arc::new(InMemory::new()), prefix: "backups".into() };
866
867 // First snapshot: nothing prior -> every distinct page is new.
868 let total = match snapshot_dedup(src.path(), &target).await.unwrap() {
869 DedupOutcome::Snapshotted { total_pages, uploaded_pages, reused_pages, .. } => {
870 assert_eq!(reused_pages, 0, "no prior snapshot -> nothing reused");
871 assert_eq!(uploaded_pages, count_under(&target, "pages").await, "every put is a distinct page");
872 total_pages
873 }
874 other => panic!("expected Snapshotted, got {other:?}"),
875 };
876 assert_eq!(count_under(&target, "manifests").await, 1);
877 let pages_after_first = count_under(&target, "pages").await;
878
879 // Reassembles to a valid SQLite with all 5000 rows.
880 let img = reassemble_latest(&target).await;
881 assert!(img.starts_with(SQLITE_MAGIC));
882 let r1 = TempDb::new("dedup-r1");
883 std::fs::write(r1.path(), &img).unwrap();
884 assert_eq!(count_rows(r1.path()).await, 5000);
885
886 // One-row mutation -> only a handful of changed pages uploaded.
887 insert_rows(src.path(), 1_000_000, 1).await;
888 match snapshot_dedup(src.path(), &target).await.unwrap() {
889 DedupOutcome::Snapshotted { uploaded_pages, total_pages, .. } => {
890 assert!(
891 uploaded_pages >= 1 && uploaded_pages * 4 < total_pages,
892 "expected few changed pages, got {uploaded_pages}/{total_pages}"
893 );
894 // Content-addressed store grew by exactly the new pages.
895 assert_eq!(
896 count_under(&target, "pages").await - pages_after_first,
897 uploaded_pages
898 );
899 }
900 other => panic!("expected Snapshotted, got {other:?}"),
901 }
902 assert_eq!(count_under(&target, "manifests").await, 2, "second snapshot -> second manifest");
903 assert_eq!(total, 18, "sanity: 5000 short rows -> 18 pages");
904
905 // The newest manifest reassembles to the post-mutation database.
906 let img2 = reassemble_latest(&target).await;
907 let r2 = TempDb::new("dedup-r2");
908 std::fs::write(r2.path(), &img2).unwrap();
909 assert_eq!(count_rows(r2.path()).await, 5001);
910 }
911
912 /// Re-snapshotting an unchanged database uploads nothing and writes no new
913 /// manifest — the prior one still stands.
914 #[tokio::test]
915 async fn dedup_unchanged_when_pages_identical() {
916 let src = TempDb::new("dedup-unch");
917 seed_db(src.path(), 50).await;
918 let target = BackupTarget { store: Arc::new(InMemory::new()), prefix: String::new() };
919
920 let first_key = match snapshot_dedup(src.path(), &target).await.unwrap() {
921 DedupOutcome::Snapshotted { manifest_key, .. } => manifest_key,
922 other => panic!("expected Snapshotted, got {other:?}"),
923 };
924 let pages = count_under(&target, "pages").await;
925
926 match snapshot_dedup(src.path(), &target).await.unwrap() {
927 DedupOutcome::Unchanged { manifest_key } => assert_eq!(manifest_key, first_key),
928 other => panic!("expected Unchanged, got {other:?}"),
929 }
930 assert_eq!(count_under(&target, "manifests").await, 1, "no new manifest on unchanged");
931 assert_eq!(count_under(&target, "pages").await, pages, "no new pages on unchanged");
932 }
933
934 // ---- R004-F3: restore from manifest ----
935
936 /// `restore_latest_dedup` errors when empty, then restores the newest of two
937 /// snapshots (post-mutation row count) as a valid vanilla-SQLite file.
938 #[tokio::test]
939 async fn restore_latest_dedup_picks_newest_and_round_trips() {
940 let src = TempDb::new("f3-src");
941 seed_db(src.path(), 200).await;
942 let target = BackupTarget { store: Arc::new(InMemory::new()), prefix: "backups".into() };
943
944 // No snapshot yet -> error, no file written.
945 let dest = TempDb::new("f3-dest");
946 assert!(restore_latest_dedup(&target, dest.path()).await.is_err());
947
948 snapshot_dedup(src.path(), &target).await.unwrap();
949 insert_rows(src.path(), 1_000_000, 5).await;
950 let newest = match snapshot_dedup(src.path(), &target).await.unwrap() {
951 DedupOutcome::Snapshotted { manifest_key, .. } => manifest_key,
952 other => panic!("expected Snapshotted, got {other:?}"),
953 };
954
955 let restored_from = restore_latest_dedup(&target, dest.path()).await.unwrap();
956 assert_eq!(restored_from, newest, "should restore the newest manifest");
957
958 let on_disk = std::fs::read(dest.path()).unwrap();
959 assert!(on_disk.starts_with(SQLITE_MAGIC), "restored file is not SQLite");
960 assert_eq!(count_rows(dest.path()).await, 205);
961 }
962
963 /// A corrupt page blob is caught by the content-hash check rather than
964 /// producing a silently-broken database.
965 #[tokio::test]
966 async fn restore_rejects_corrupt_page() {
967 let src = TempDb::new("f3-corrupt");
968 seed_db(src.path(), 100).await;
969 let target = BackupTarget { store: Arc::new(InMemory::new()), prefix: String::new() };
970 snapshot_dedup(src.path(), &target).await.unwrap();
971
972 let (_, manifest) = load_latest_manifest(&target).await.unwrap().unwrap();
973 // Overwrite one page blob with garbage of the right length (so only the
974 // hash check, not a size check, can catch it).
975 let victim = &manifest.page_hashes[0];
976 target
977 .store
978 .put(&page_key(&target, victim), vec![0xab; manifest.page_size].into())
979 .await
980 .unwrap();
981
982 let dest = TempDb::new("f3-corrupt-dest");
983 let err = restore_from_manifest(&target, &manifest, dest.path()).await.unwrap_err();
984 assert!(
985 err.to_string().contains("content hash"),
986 "expected a hash-mismatch error, got: {err}"
987 );
988 }
989
990 /// `restore_manifest_key` targets an *older* snapshot by key while the latest
991 /// reflects the post-mutation state — both reassemble to their own row counts.
992 #[tokio::test]
993 async fn restore_manifest_key_targets_older_snapshot() {
994 let src = TempDb::new("f3-key-src");
995 seed_db(src.path(), 100).await;
996 let target = BackupTarget { store: Arc::new(InMemory::new()), prefix: "backups".into() };
997
998 let key1 = match snapshot_dedup(src.path(), &target).await.unwrap() {
999 DedupOutcome::Snapshotted { manifest_key, .. } => manifest_key,
1000 other => panic!("expected Snapshotted, got {other:?}"),
1001 };
1002 insert_rows(src.path(), 1_000_000, 10).await;
1003 snapshot_dedup(src.path(), &target).await.unwrap();
1004
1005 // Older snapshot by key -> 100 rows.
1006 let d1 = TempDb::new("f3-key-d1");
1007 restore_manifest_key(&target, &key1, d1.path()).await.unwrap();
1008 assert_eq!(count_rows(d1.path()).await, 100);
1009
1010 // Latest -> 110 rows.
1011 let d2 = TempDb::new("f3-key-d2");
1012 restore_latest_dedup(&target, d2.path()).await.unwrap();
1013 assert_eq!(count_rows(d2.path()).await, 110);
1014 }
1015
1016 // ---- R006-F1: retention + page-blob GC ----
1017
1018 /// Take three snapshots, GC down to the newest one. Older manifests are
1019 /// pruned, every page hash referenced *only* by them is swept, the pages
1020 /// the surviving manifest still references stay put, and the surviving
1021 /// snapshot restores byte-for-byte against the un-GC'd image.
1022 #[tokio::test]
1023 async fn gc_prunes_older_manifests_and_sweeps_orphan_pages() {
1024 let src = TempDb::new("gc-src");
1025 seed_db(src.path(), 5000).await;
1026 let target = BackupTarget { store: Arc::new(InMemory::new()), prefix: "backups".into() };
1027
1028 // Three snapshots with a row appended between each — every snapshot
1029 // produces at least one fresh page that the next one doesn't reference.
1030 snapshot_dedup(src.path(), &target).await.unwrap();
1031 insert_rows(src.path(), 1_000_000, 1).await;
1032 snapshot_dedup(src.path(), &target).await.unwrap();
1033 insert_rows(src.path(), 2_000_000, 1).await;
1034 let newest_key = match snapshot_dedup(src.path(), &target).await.unwrap() {
1035 DedupOutcome::Snapshotted { manifest_key, .. } => manifest_key,
1036 other => panic!("expected Snapshotted, got {other:?}"),
1037 };
1038 assert_eq!(count_under(&target, "manifests").await, 3);
1039 let pages_before_gc = count_under(&target, "pages").await;
1040
1041 // Snapshot the bytes-on-disk of every page the newest manifest needs,
1042 // so we can prove the GC leaves them byte-identical.
1043 let (_, newest_manifest) = load_latest_manifest(&target).await.unwrap().unwrap();
1044 let mut expected_pages: HashMap<String, Vec<u8>> = HashMap::new();
1045 for h in &newest_manifest.page_hashes {
1046 let bytes = target.store.get(&page_key(&target, h)).await.unwrap().bytes().await.unwrap();
1047 expected_pages.insert(h.clone(), bytes.to_vec());
1048 }
1049
1050 // GC down to keep=1 (just the newest manifest).
1051 let outcome = gc_dedup(&target, 1).await.unwrap();
1052 assert_eq!(outcome.retained_manifests, 1);
1053 assert_eq!(outcome.pruned_manifests, 2);
1054 assert_eq!(outcome.retained_pages, newest_manifest.page_hashes.len());
1055 assert!(
1056 outcome.deleted_pages > 0,
1057 "earlier snapshots wrote at least one page the newest manifest no longer references"
1058 );
1059
1060 // Store state matches the outcome counts.
1061 assert_eq!(count_under(&target, "manifests").await, 1, "only the newest manifest survives");
1062 assert_eq!(
1063 count_under(&target, "pages").await,
1064 pages_before_gc - outcome.deleted_pages,
1065 "pages/ shrank by exactly the orphan count"
1066 );
1067
1068 // Every page the newest manifest references is still there, byte-identical.
1069 for (h, want) in &expected_pages {
1070 let got = target.store.get(&page_key(&target, h)).await.unwrap().bytes().await.unwrap();
1071 assert_eq!(&got[..], &want[..], "retained page {h} was mutated by GC");
1072 }
1073
1074 // And the newest manifest still restores to a valid SQLite with the
1075 // post-second-mutation row count (5000 + 1 + 1 = 5002).
1076 let dest = TempDb::new("gc-restored");
1077 let restored_from = restore_latest_dedup(&target, dest.path()).await.unwrap();
1078 assert_eq!(restored_from, newest_key);
1079 assert_eq!(count_rows(dest.path()).await, 5002);
1080 }
1081
1082 /// keep_n >= total manifests is a no-op: nothing pruned, no pages deleted.
1083 #[tokio::test]
1084 async fn gc_keep_n_at_or_above_total_is_noop() {
1085 let src = TempDb::new("gc-noop");
1086 seed_db(src.path(), 100).await;
1087 let target = BackupTarget { store: Arc::new(InMemory::new()), prefix: String::new() };
1088
1089 snapshot_dedup(src.path(), &target).await.unwrap();
1090 insert_rows(src.path(), 1_000_000, 1).await;
1091 snapshot_dedup(src.path(), &target).await.unwrap();
1092 let manifests_before = count_under(&target, "manifests").await;
1093 let pages_before = count_under(&target, "pages").await;
1094
1095 // keep_n == total
1096 let outcome = gc_dedup(&target, 2).await.unwrap();
1097 assert_eq!(outcome.pruned_manifests, 0);
1098 assert_eq!(outcome.deleted_pages, 0);
1099 assert_eq!(outcome.retained_manifests, manifests_before);
1100
1101 // keep_n > total
1102 let outcome = gc_dedup(&target, 999).await.unwrap();
1103 assert_eq!(outcome.pruned_manifests, 0);
1104 assert_eq!(outcome.deleted_pages, 0);
1105
1106 assert_eq!(count_under(&target, "manifests").await, manifests_before);
1107 assert_eq!(count_under(&target, "pages").await, pages_before);
1108 }
1109
1110 /// Pages shared between a pruned and a retained manifest survive — content
1111 /// addressing means they're one blob, and the retain union still needs it.
1112 /// This is the load-bearing safety property: incremental snapshots reuse
1113 /// most pages, so a naive "delete every page from pruned manifests" would
1114 /// destroy the retained snapshot.
1115 #[tokio::test]
1116 async fn gc_keeps_pages_shared_with_retained_manifests() {
1117 let src = TempDb::new("gc-shared");
1118 seed_db(src.path(), 5000).await;
1119 let target = BackupTarget { store: Arc::new(InMemory::new()), prefix: "backups".into() };
1120
1121 // Snapshot 1 — all pages new.
1122 snapshot_dedup(src.path(), &target).await.unwrap();
1123 let (_, m1) = load_latest_manifest(&target).await.unwrap().unwrap();
1124 let s1_hashes: HashSet<String> = m1.page_hashes.iter().cloned().collect();
1125
1126 // Small mutation -> snapshot 2 shares almost every page with snapshot 1.
1127 insert_rows(src.path(), 1_000_000, 1).await;
1128 snapshot_dedup(src.path(), &target).await.unwrap();
1129 let (_, m2) = load_latest_manifest(&target).await.unwrap().unwrap();
1130 let s2_hashes: HashSet<String> = m2.page_hashes.iter().cloned().collect();
1131 let shared: HashSet<&String> = s1_hashes.intersection(&s2_hashes).collect();
1132 assert!(
1133 shared.len() > s1_hashes.len() / 2,
1134 "incremental snapshot should share most pages with the prior \
1135 (shared {}/{})",
1136 shared.len(),
1137 s1_hashes.len()
1138 );
1139
1140 // GC keep=1: snapshot 1 is pruned, but every page snapshot 2 still
1141 // references must survive — including the ones it shares with snapshot 1.
1142 gc_dedup(&target, 1).await.unwrap();
1143 for h in &s2_hashes {
1144 assert!(
1145 target.store.get(&page_key(&target, h)).await.is_ok(),
1146 "shared page {h} (in retained snapshot 2) was deleted"
1147 );
1148 }
1149 // Pages exclusive to snapshot 1 are gone.
1150 for h in s1_hashes.difference(&s2_hashes) {
1151 assert!(
1152 target.store.get(&page_key(&target, h)).await.is_err(),
1153 "page {h} exclusive to pruned snapshot 1 still exists"
1154 );
1155 }
1156 }
1157
1158 // ---- R006-T2: GC preserves byte-for-byte restorability of every
1159 // retained snapshot, not just the latest. Three snapshots, keep_n=2 ----
1160 //
1161 // The headline contract: an in-window *older* snapshot (the middle one
1162 // here) must restore byte-for-byte after GC, proving that GC's mark set
1163 // covers every retained manifest — not just the lexically-newest one
1164 // restore_latest_dedup uses. Ground truth is the pre-GC restore of the
1165 // same key; post-GC bytes are asserted equal.
1166 #[tokio::test]
1167 async fn gc_keep_two_lets_in_window_older_snapshot_restore_byte_for_byte() {
1168 let src = TempDb::new("gc-window");
1169 seed_db(src.path(), 5000).await;
1170 let target = BackupTarget { store: Arc::new(InMemory::new()), prefix: "backups".into() };
1171
1172 // Three snapshots with a mutation between each.
1173 let key1 = match snapshot_dedup(src.path(), &target).await.unwrap() {
1174 DedupOutcome::Snapshotted { manifest_key, .. } => manifest_key,
1175 other => panic!("expected Snapshotted, got {other:?}"),
1176 };
1177 insert_rows(src.path(), 1_000_000, 1).await;
1178 let key2 = match snapshot_dedup(src.path(), &target).await.unwrap() {
1179 DedupOutcome::Snapshotted { manifest_key, .. } => manifest_key,
1180 other => panic!("expected Snapshotted, got {other:?}"),
1181 };
1182 insert_rows(src.path(), 2_000_000, 1).await;
1183 let key3 = match snapshot_dedup(src.path(), &target).await.unwrap() {
1184 DedupOutcome::Snapshotted { manifest_key, .. } => manifest_key,
1185 other => panic!("expected Snapshotted, got {other:?}"),
1186 };
1187
1188 // Ground truth: restore the middle snapshot BEFORE GC.
1189 let pre = TempDb::new("gc-window-pre");
1190 restore_manifest_key(&target, &key2, pre.path()).await.unwrap();
1191 let pre_bytes = std::fs::read(pre.path()).unwrap();
1192 assert_eq!(count_rows(pre.path()).await, 5001);
1193
1194 // GC keep_n=2 -> snapshot 1 pruned, 2 and 3 retained.
1195 let outcome = gc_dedup(&target, 2).await.unwrap();
1196 assert_eq!(outcome.pruned_manifests, 1);
1197 assert_eq!(outcome.retained_manifests, 2);
1198 assert!(
1199 outcome.deleted_pages > 0,
1200 "snapshot 1 had at least one page neither snapshot 2 nor 3 references"
1201 );
1202
1203 // Snapshot 1 is gone — its manifest object no longer exists.
1204 let gone = TempDb::new("gc-window-gone");
1205 assert!(
1206 restore_manifest_key(&target, &key1, gone.path()).await.is_err(),
1207 "pruned snapshot 1 should not be restorable"
1208 );
1209
1210 // Snapshot 2 (in-window older) restores BYTE-FOR-BYTE to the pre-GC bytes.
1211 let post = TempDb::new("gc-window-post");
1212 restore_manifest_key(&target, &key2, post.path()).await.unwrap();
1213 let post_bytes = std::fs::read(post.path()).unwrap();
1214 assert_eq!(
1215 pre_bytes, post_bytes,
1216 "in-window older snapshot must restore byte-for-byte after GC"
1217 );
1218
1219 // And the newest (snapshot 3) restores to its own row count via the
1220 // latest path — proving keep_n=2 left both retained snapshots whole.
1221 let latest = TempDb::new("gc-window-latest");
1222 let restored_from = restore_latest_dedup(&target, latest.path()).await.unwrap();
1223 assert_eq!(restored_from, key3);
1224 assert_eq!(count_rows(latest.path()).await, 5002);
1225 }
1226}