reddb_server/runtime/impl_backup.rs
1use super::*;
2
3impl RedDBRuntime {
4 /// Get backup scheduler status.
5 pub fn backup_status(&self) -> crate::replication::scheduler::BackupStatus {
6 self.inner.backup_scheduler.status()
7 }
8
9 /// Borrow the runtime's result Blob Cache.
10 ///
11 /// Wired for the `/admin/blob_cache/sweep` and
12 /// `/admin/blob_cache/flush_namespace` HTTP handlers (issue #148
13 /// follow-up): both delegate to
14 /// `crate::storage::cache::sweeper::BlobCacheSweeper`, which takes a
15 /// `&BlobCache`. Also used by `trigger_backup` when
16 /// `red.config.backup.include_blob_cache=true` to locate the L2
17 /// directory for archival.
18 pub fn result_blob_cache(&self) -> &crate::storage::cache::BlobCache {
19 &self.inner.result_blob_cache
20 }
21
22 /// Current local LSN paired with the LSN of the most recently
23 /// archived WAL segment. The difference is the replication /
24 /// archive lag operators alert on (PLAN.md Phase 5.1). Returns
25 /// `(0, 0)` when neither replication nor archiving is configured.
26 pub fn wal_archive_progress(&self) -> (u64, u64) {
27 let current_lsn = self
28 .inner
29 .db
30 .replication
31 .as_ref()
32 .map(|repl| {
33 repl.logical_wal_spool
34 .as_ref()
35 .map(|spool| spool.current_lsn())
36 .unwrap_or_else(|| repl.wal_buffer.current_lsn())
37 })
38 .unwrap_or_else(|| self.inner.cdc.current_lsn());
39 let last_archived_lsn = self.config_u64("red.config.timeline.last_archived_lsn", 0);
40 (current_lsn, last_archived_lsn)
41 }
42
43 /// Trigger an immediate backup.
44 pub fn trigger_backup(&self) -> RedDBResult<crate::replication::scheduler::BackupResult> {
45 let result = (|| {
46 self.check_write(crate::runtime::write_gate::WriteKind::Backup)?;
47 // Defense in depth — check_write above already rejects when
48 // the lease is NotHeld, but log + audit the lease angle here
49 // explicitly so dashboards distinguish "lease lost" from a
50 // generic read-only refusal.
51 self.assert_remote_write_allowed("admin/backup")?;
52 // This transport archives only the main file. Operational stores
53 // also own manifest/collection/index files in .ops; reporting a
54 // successful backup here would publish an unrestorable snapshot.
55 // A checkpoint-consistent physical bundle is required before this
56 // online path can support operational-directory packaging.
57 if self.inner.db.remote_backend.is_some() && !self.inner.embedded_single_file {
58 return Err(RedDBError::InvalidOperation(
59 "online backup of operational-directory stores requires a checkpoint-consistent physical bundle; use a fenced offline backup of the complete store layout".into(),
60 ));
61 }
62 let started = std::time::Instant::now();
63 let snapshot = self.create_snapshot()?;
64 let mut uploaded = false;
65
66 if let (Some(backend), Some(path)) =
67 (&self.inner.db.remote_backend, self.inner.db.path())
68 {
69 let default_snapshot_prefix = self.inner.db.options().default_snapshot_prefix();
70 let default_wal_prefix = self.inner.db.options().default_wal_archive_prefix();
71 let default_head_key = self.inner.db.options().default_backup_head_key();
72 let snapshot_prefix = self.config_string(
73 "red.config.backup.snapshot_prefix",
74 &default_snapshot_prefix,
75 );
76 let wal_prefix =
77 self.config_string("red.config.wal.archive.prefix", &default_wal_prefix);
78 let head_key = self.config_string("red.config.backup.head_key", &default_head_key);
79 let timeline_id = self.config_string("red.config.timeline.id", "main");
80 let snapshot_key = crate::storage::wal::archive_snapshot(
81 backend.as_ref(),
82 path,
83 snapshot.snapshot_id,
84 &snapshot_prefix,
85 )
86 .map_err(|err| RedDBError::Internal(err.to_string()))?;
87 let current_lsn = self
88 .inner
89 .db
90 .replication
91 .as_ref()
92 .map(|repl| {
93 repl.logical_wal_spool
94 .as_ref()
95 .map(|spool| spool.current_lsn())
96 .unwrap_or_else(|| repl.wal_buffer.current_lsn())
97 })
98 .unwrap_or_else(|| self.inner.cdc.current_lsn());
99 let last_archived_lsn = self.config_u64("red.config.timeline.last_archived_lsn", 0);
100 // Hash the local snapshot bytes so the manifest can carry
101 // the digest for restore-side verification (PLAN.md
102 // Phase 4). Failure to hash is non-fatal — we still
103 // publish the manifest, just without a checksum, so a
104 // future fix can backfill rather than losing the backup.
105 let snapshot_sha256 = reddb_file::SnapshotManifest::compute_snapshot_sha256(path)
106 .map_err(|err| {
107 tracing::warn!(
108 target: "reddb::backup",
109 error = %err,
110 snapshot_id = snapshot.snapshot_id,
111 "snapshot hash failed; manifest will lack checksum"
112 );
113 })
114 .ok();
115 let manifest = reddb_file::SnapshotManifest {
116 timeline_id: timeline_id.clone(),
117 snapshot_key: snapshot_key.clone(),
118 snapshot_id: snapshot.snapshot_id,
119 snapshot_time: snapshot.created_at_unix_ms as u64,
120 base_lsn: current_lsn,
121 schema_version: crate::api::REDDB_FORMAT_VERSION,
122 format_version: crate::api::REDDB_FORMAT_VERSION,
123 snapshot_sha256,
124 };
125 crate::storage::wal::publish_snapshot_manifest(backend.as_ref(), &manifest)
126 .map_err(|err| RedDBError::Internal(err.to_string()))?;
127
128 // PLAN.md Phase 11.3 — read the head of the WAL hash chain
129 // so the new segment can link back. `None` means we're
130 // starting a fresh timeline (after a clean restore or on
131 // first archive ever); the segment's `prev_hash` will be
132 // `None` and restore-side validation accepts that only for
133 // the first segment in `plan.wal_segments`.
134 let prev_segment_hash =
135 self.config_string("red.config.timeline.last_segment_hash", "");
136 let prev_hash_arg = if prev_segment_hash.is_empty() {
137 None
138 } else {
139 Some(prev_segment_hash)
140 };
141
142 let archived_lsn = if let Some(primary) = &self.inner.db.replication {
143 let oldest = primary
144 .logical_wal_spool
145 .as_ref()
146 .and_then(|spool| spool.oldest_lsn().ok().flatten())
147 .or_else(|| primary.wal_buffer.oldest_lsn())
148 .unwrap_or(last_archived_lsn);
149 if last_archived_lsn > 0 && last_archived_lsn < oldest.saturating_sub(1) {
150 return Err(RedDBError::Internal(format!(
151 "logical WAL gap detected: last_archived_lsn={last_archived_lsn}, oldest_available_lsn={oldest}"
152 )));
153 }
154 let records = if let Some(spool) = &primary.logical_wal_spool {
155 spool
156 .read_since(last_archived_lsn, usize::MAX)
157 .map_err(|err| RedDBError::Internal(err.to_string()))?
158 } else {
159 primary.wal_buffer.read_since(last_archived_lsn, usize::MAX)
160 };
161 if let Some(meta) = crate::storage::wal::archive_change_records(
162 backend.as_ref(),
163 &wal_prefix,
164 &records,
165 prev_hash_arg,
166 )
167 .map_err(|err| RedDBError::Internal(err.to_string()))?
168 {
169 let _ = primary.prune_retained_wal_through(meta.lsn_end);
170 if let Err(err) = self.prune_primary_replica_wal_segments() {
171 tracing::warn!(
172 error = %err,
173 "failed to prune primary-replica WAL segments"
174 );
175 }
176 // Advance the chain head so the next archive call
177 // links to this segment's hash. If the segment has
178 // no sha256 (legacy / hashing failed) we leave the
179 // head as-is — the next segment then carries the
180 // prior chain head, preserving continuity.
181 if let Some(sha) = &meta.sha256 {
182 self.inner.db.store().set_config_tree(
183 "red.config.timeline",
184 &crate::json!({ "last_segment_hash": sha }),
185 );
186 }
187 meta.lsn_end
188 } else {
189 last_archived_lsn
190 }
191 } else {
192 last_archived_lsn
193 };
194
195 let head = reddb_file::BackupHead {
196 timeline_id,
197 snapshot_key,
198 snapshot_id: snapshot.snapshot_id,
199 snapshot_time: snapshot.created_at_unix_ms as u64,
200 current_lsn,
201 last_archived_lsn: archived_lsn,
202 wal_prefix,
203 };
204 crate::storage::wal::publish_backup_head(backend.as_ref(), &head_key, &head)
205 .map_err(|err| RedDBError::Internal(err.to_string()))?;
206 self.inner.db.store().set_config_tree(
207 "red.config.timeline",
208 &crate::json!({
209 "last_archived_lsn": archived_lsn,
210 "id": head.timeline_id
211 }),
212 );
213
214 // PLAN.md Phase 2.4 — refresh the unified `MANIFEST.json`
215 // at the prefix root so external tooling sees a single
216 // catalog of every snapshot + WAL segment with their
217 // checksums. Best-effort: a manifest publish failure
218 // doesn't fail the backup (the per-artifact sidecars
219 // already give restore-side integrity), but it does log
220 // so dashboards can flag stale catalogs.
221 if let Err(err) = crate::storage::wal::publish_unified_manifest_for_prefix(
222 backend.as_ref(),
223 &snapshot_prefix,
224 ) {
225 tracing::warn!(
226 target: "reddb::backup",
227 error = %err,
228 snapshot_prefix = %snapshot_prefix,
229 "unified MANIFEST.json refresh failed; per-artifact sidecars unaffected"
230 );
231 }
232
233 // PLAN.md Phase 11.4 — when the operator picked a
234 // commit policy that demands replica durability, block
235 // until the configured count of replicas has acked the
236 // archived LSN (or the timeout fires). For backup the
237 // policy decides the *DR posture* — `local` returns
238 // immediately, `ack_n` ensures at least N replicas saw
239 // the new tail before we report success to the
240 // operator. A `TimedOut` is logged but does NOT fail
241 // the backup: the local WAL + remote upload are durable
242 // regardless; the missing acks are reported via
243 // /metrics and /admin/status so the operator can decide.
244 match self.commit_policy() {
245 crate::replication::CommitPolicy::AckN(n) if n > 0 => {
246 let timeout = std::env::var("RED_REPLICATION_ACK_TIMEOUT_MS")
247 .ok()
248 .and_then(|v| v.parse::<u64>().ok())
249 .unwrap_or(5_000);
250 let outcome = self.await_replica_acks(
251 archived_lsn,
252 n,
253 std::time::Duration::from_millis(timeout),
254 );
255 match outcome {
256 crate::replication::AwaitOutcome::Reached(count) => {
257 tracing::debug!(
258 target: "reddb::backup",
259 archived_lsn,
260 n,
261 count,
262 "ack_n: replicas synced before backup return"
263 );
264 }
265 crate::replication::AwaitOutcome::TimedOut { observed, required } => {
266 tracing::warn!(
267 target: "reddb::backup",
268 archived_lsn,
269 observed,
270 required,
271 timeout_ms = timeout,
272 "ack_n: timed out waiting for replicas; backup uploaded but DR posture degraded"
273 );
274 }
275 crate::replication::AwaitOutcome::NotRequired => {}
276 }
277 }
278 _ => {} // Local / RemoteWal / Quorum: no blocking yet
279 }
280
281 // Issue #148 follow-up — opt-in archive of the L2 Blob Cache
282 // directory tree. Default off so a standard backup stays
283 // small; flip via `red.config.backup.include_blob_cache=true`
284 // when warm-cache restore is required (per
285 // docs/operations/blob-cache-backup-restore.md §1).
286 //
287 // The L2 tree is *derived* state (ADR 0006) — its absence
288 // never causes data loss; it only affects post-restore
289 // p99 latency until the cache re-warms. We therefore log
290 // (not fail) on per-file upload errors so a partial L2
291 // upload never aborts a healthy snapshot+WAL backup.
292 if self.config_bool("red.config.backup.include_blob_cache", false) {
293 let blob_cache_prefix = self.config_string(
294 "red.config.backup.blob_cache_prefix",
295 &format!("{snapshot_prefix}blob_cache/"),
296 );
297 if let Some(l2_path) = self.inner.result_blob_cache.l2_path() {
298 match crate::storage::cache::archive_blob_cache_l2(
299 backend.as_ref(),
300 l2_path,
301 &blob_cache_prefix,
302 ) {
303 Ok(count) => {
304 tracing::info!(
305 target: "reddb::backup",
306 files_uploaded = count,
307 blob_cache_prefix = %blob_cache_prefix,
308 "include_blob_cache: archived L2 directory"
309 );
310 }
311 Err(err) => {
312 tracing::warn!(
313 target: "reddb::backup",
314 error = %err,
315 blob_cache_prefix = %blob_cache_prefix,
316 "include_blob_cache: L2 archive failed; backup proceeding (cache is derived state)"
317 );
318 }
319 }
320 } else {
321 tracing::debug!(
322 target: "reddb::backup",
323 "include_blob_cache=true but no L2 path configured; nothing to archive"
324 );
325 }
326 }
327
328 uploaded = true;
329 }
330
331 Ok(crate::replication::scheduler::BackupResult {
332 snapshot_id: snapshot.snapshot_id,
333 uploaded,
334 duration_ms: started.elapsed().as_millis() as u64,
335 timestamp: snapshot.created_at_unix_ms as u64,
336 })
337 })();
338
339 use crate::runtime::control_events::{EventKind, Outcome, Sensitivity};
340 let (current_lsn, last_archived_lsn) = self.wal_archive_progress();
341 let mut fields = vec![
342 (
343 "current_lsn".to_string(),
344 Sensitivity::raw(current_lsn.to_string()),
345 ),
346 (
347 "last_archived_lsn".to_string(),
348 Sensitivity::raw(last_archived_lsn.to_string()),
349 ),
350 ];
351 if let Ok(backup) = &result {
352 fields.push((
353 "snapshot_id".to_string(),
354 Sensitivity::raw(backup.snapshot_id.to_string()),
355 ));
356 fields.push((
357 "uploaded".to_string(),
358 Sensitivity::raw(backup.uploaded.to_string()),
359 ));
360 fields.push((
361 "duration_ms".to_string(),
362 Sensitivity::raw(backup.duration_ms.to_string()),
363 ));
364 fields.push((
365 "snapshot_time".to_string(),
366 Sensitivity::raw(backup.timestamp.to_string()),
367 ));
368 }
369 let outcome = match &result {
370 Ok(_) => Outcome::Allowed,
371 Err(err) => crate::runtime::impl_core::control_event_outcome_for_error(err),
372 };
373 let reason = result.as_ref().err().map(|err| err.to_string());
374 self.emit_control_event(
375 EventKind::BackupRun,
376 outcome,
377 "backup_trigger",
378 Some("backup:trigger".to_string()),
379 reason,
380 fields,
381 )?;
382 result
383 }
384}