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 /// `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("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. Paged stores need
53 // metadata sidecars even when opened with embedded options, and
54 // operational stores also own manifest/collection/index files in
55 // .ops. Reporting success here would publish an unrestorable snapshot.
56 // A checkpoint-consistent physical bundle is required before this
57 // online path can support operational-directory packaging.
58 if self.inner.db.remote_backend.is_some()
59 && (!self.inner.embedded_single_file || self.inner.db.is_paged())
60 {
61 return Err(RedDBError::InvalidOperation(
62 "online backup of paged or operational-directory stores requires a checkpoint-consistent physical bundle; use a fenced offline backup of the complete store layout".into(),
63 ));
64 }
65 let started = std::time::Instant::now();
66 // Bind the descriptor, immutable bytes, and WAL frontier to one
67 // publication cut. The remote upload happens after releasing the
68 // fence so slow object storage does not block foreground writes.
69 let (snapshot, snapshot_payload, current_lsn) = {
70 let store = self.inner.db.store();
71 let _publication = store.vault_publication_lock.lock();
72 let snapshot = self.create_snapshot()?;
73 let payload =
74 if self.inner.db.remote_backend.is_some() && self.inner.db.path().is_some() {
75 Some(self.capture_serverless_snapshot()?)
76 } else {
77 None
78 };
79 let current_lsn = self
80 .inner
81 .db
82 .replication
83 .as_ref()
84 .map(|repl| {
85 repl.logical_wal_spool
86 .as_ref()
87 .map(|spool| spool.current_lsn())
88 .unwrap_or_else(|| repl.wal_buffer.current_lsn())
89 })
90 .unwrap_or_else(|| self.inner.cdc.current_lsn());
91 (snapshot, payload, current_lsn)
92 };
93 let mut uploaded = false;
94
95 if let (Some(backend), Some(path)) =
96 (&self.inner.db.remote_backend, self.inner.db.path())
97 {
98 let snapshot_path = snapshot_payload
99 .as_ref()
100 .map(|payload| payload.path())
101 .unwrap_or(path);
102 let default_snapshot_prefix = self.inner.db.options().default_snapshot_prefix();
103 let default_wal_prefix = self.inner.db.options().default_wal_archive_prefix();
104 let default_head_key = self.inner.db.options().default_backup_head_key();
105 let snapshot_prefix =
106 self.config_string("backup.snapshot_prefix", &default_snapshot_prefix);
107 let wal_prefix = self.config_string("wal.archive.prefix", &default_wal_prefix);
108 let head_key = self.config_string("backup.head_key", &default_head_key);
109 let timeline_id = self.config_string("timeline.id", "main");
110 let snapshot_key = crate::storage::wal::archive_snapshot(
111 backend.as_ref(),
112 snapshot_path,
113 snapshot.snapshot_id,
114 &snapshot_prefix,
115 )
116 .map_err(|err| RedDBError::Internal(err.to_string()))?;
117 let last_archived_lsn = self.config_u64("timeline.last_archived_lsn", 0);
118 // Hash the local snapshot bytes so the manifest can carry
119 // the digest for restore-side verification (PLAN.md
120 // Phase 4). Failure to hash is non-fatal — we still
121 // publish the manifest, just without a checksum, so a
122 // future fix can backfill rather than losing the backup.
123 let snapshot_sha256 =
124 reddb_file::SnapshotManifest::compute_snapshot_sha256(snapshot_path)
125 .map_err(|err| {
126 tracing::warn!(
127 target: "reddb::backup",
128 error = %err,
129 snapshot_id = snapshot.snapshot_id,
130 "snapshot hash failed; manifest will lack checksum"
131 );
132 })
133 .ok();
134 let manifest = reddb_file::SnapshotManifest {
135 timeline_id: timeline_id.clone(),
136 snapshot_key: snapshot_key.clone(),
137 snapshot_id: snapshot.snapshot_id,
138 snapshot_time: snapshot.created_at_unix_ms as u64,
139 base_lsn: current_lsn,
140 schema_version: crate::api::REDDB_FORMAT_VERSION,
141 format_version: crate::api::REDDB_FORMAT_VERSION,
142 snapshot_sha256,
143 };
144 crate::storage::wal::publish_snapshot_manifest(backend.as_ref(), &manifest)
145 .map_err(|err| RedDBError::Internal(err.to_string()))?;
146
147 // PLAN.md Phase 11.3 — read the head of the WAL hash chain
148 // so the new segment can link back. `None` means we're
149 // starting a fresh timeline (after a clean restore or on
150 // first archive ever); the segment's `prev_hash` will be
151 // `None` and restore-side validation accepts that only for
152 // the first segment in `plan.wal_segments`.
153 let prev_segment_hash = self.config_string("timeline.last_segment_hash", "");
154 let prev_hash_arg = if prev_segment_hash.is_empty() {
155 None
156 } else {
157 Some(prev_segment_hash)
158 };
159
160 let archived_lsn = if let Some(primary) = &self.inner.db.replication {
161 let oldest = primary
162 .logical_wal_spool
163 .as_ref()
164 .and_then(|spool| spool.oldest_lsn().ok().flatten())
165 .or_else(|| primary.wal_buffer.oldest_lsn())
166 .unwrap_or(last_archived_lsn);
167 if last_archived_lsn > 0 && last_archived_lsn < oldest.saturating_sub(1) {
168 return Err(RedDBError::Internal(format!(
169 "logical WAL gap detected: last_archived_lsn={last_archived_lsn}, oldest_available_lsn={oldest}"
170 )));
171 }
172 let mut records = if let Some(spool) = &primary.logical_wal_spool {
173 spool
174 .read_since(last_archived_lsn, usize::MAX)
175 .map_err(|err| RedDBError::Internal(err.to_string()))?
176 } else {
177 primary.wal_buffer.read_since(last_archived_lsn, usize::MAX)
178 };
179 records.retain(|(lsn, _)| *lsn <= current_lsn);
180 if let Some(meta) = crate::storage::wal::archive_change_records(
181 backend.as_ref(),
182 &wal_prefix,
183 &records,
184 prev_hash_arg,
185 )
186 .map_err(|err| RedDBError::Internal(err.to_string()))?
187 {
188 let _ = primary.prune_retained_wal_through(meta.lsn_end);
189 if let Err(err) = self.prune_primary_replica_wal_segments() {
190 tracing::warn!(
191 error = %err,
192 "failed to prune primary-replica WAL segments"
193 );
194 }
195 // Advance the chain head so the next archive call
196 // links to this segment's hash. If the segment has
197 // no sha256 (legacy / hashing failed) we leave the
198 // head as-is — the next segment then carries the
199 // prior chain head, preserving continuity.
200 if let Some(sha) = &meta.sha256 {
201 self.inner.db.store().set_config_tree(
202 "timeline",
203 &crate::json!({ "last_segment_hash": sha }),
204 );
205 }
206 meta.lsn_end
207 } else {
208 last_archived_lsn
209 }
210 } else {
211 last_archived_lsn
212 };
213
214 let head = reddb_file::BackupHead {
215 timeline_id,
216 snapshot_key,
217 snapshot_id: snapshot.snapshot_id,
218 snapshot_time: snapshot.created_at_unix_ms as u64,
219 current_lsn,
220 last_archived_lsn: archived_lsn,
221 wal_prefix,
222 };
223 crate::storage::wal::publish_backup_head(backend.as_ref(), &head_key, &head)
224 .map_err(|err| RedDBError::Internal(err.to_string()))?;
225 self.inner.db.store().set_config_tree(
226 "timeline",
227 &crate::json!({
228 "last_archived_lsn": archived_lsn,
229 "id": head.timeline_id
230 }),
231 );
232
233 // PLAN.md Phase 2.4 — refresh the unified `MANIFEST.json`
234 // at the prefix root so external tooling sees a single
235 // catalog of every snapshot + WAL segment with their
236 // checksums. Best-effort: a manifest publish failure
237 // doesn't fail the backup (the per-artifact sidecars
238 // already give restore-side integrity), but it does log
239 // so dashboards can flag stale catalogs.
240 if let Err(err) = crate::storage::wal::publish_unified_manifest_for_prefix(
241 backend.as_ref(),
242 &snapshot_prefix,
243 ) {
244 tracing::warn!(
245 target: "reddb::backup",
246 error = %err,
247 snapshot_prefix = %snapshot_prefix,
248 "unified MANIFEST.json refresh failed; per-artifact sidecars unaffected"
249 );
250 }
251
252 // PLAN.md Phase 11.4 — when the operator picked a
253 // commit policy that demands replica durability, block
254 // until the configured count of replicas has acked the
255 // archived LSN (or the timeout fires). For backup the
256 // policy decides the *DR posture* — `local` returns
257 // immediately, `ack_n` ensures at least N replicas saw
258 // the new tail before we report success to the
259 // operator. A `TimedOut` is logged but does NOT fail
260 // the backup: the local WAL + remote upload are durable
261 // regardless; the missing acks are reported via
262 // /metrics and /admin/status so the operator can decide.
263 match self.commit_policy() {
264 crate::replication::CommitPolicy::AckN(n) if n > 0 => {
265 let timeout = std::env::var("RED_REPLICATION_ACK_TIMEOUT_MS")
266 .ok()
267 .and_then(|v| v.parse::<u64>().ok())
268 .unwrap_or(5_000);
269 let outcome = self.await_replica_acks(
270 archived_lsn,
271 n,
272 std::time::Duration::from_millis(timeout),
273 );
274 match outcome {
275 crate::replication::AwaitOutcome::Reached(count) => {
276 tracing::debug!(
277 target: "reddb::backup",
278 archived_lsn,
279 n,
280 count,
281 "ack_n: replicas synced before backup return"
282 );
283 }
284 crate::replication::AwaitOutcome::TimedOut { observed, required } => {
285 tracing::warn!(
286 target: "reddb::backup",
287 archived_lsn,
288 observed,
289 required,
290 timeout_ms = timeout,
291 "ack_n: timed out waiting for replicas; backup uploaded but DR posture degraded"
292 );
293 }
294 crate::replication::AwaitOutcome::NotRequired => {}
295 }
296 }
297 _ => {} // Local / RemoteWal / Quorum: no blocking yet
298 }
299
300 // Compatibility path for the legacy standalone L2 pager.
301 // The server's single-file L2 is an internal collection in
302 // the primary snapshot and therefore has no separate path
303 // to archive here.
304 //
305 // The L2 tree is *derived* state (ADR 0006) — its absence
306 // never causes data loss; it only affects post-restore
307 // p99 latency until the cache re-warms. We therefore log
308 // (not fail) on per-file upload errors so a partial L2
309 // upload never aborts a healthy snapshot+WAL backup.
310 if self.config_bool("backup.include_blob_cache", false) {
311 let blob_cache_prefix = self.config_string(
312 "backup.blob_cache_prefix",
313 &format!("{snapshot_prefix}blob_cache/"),
314 );
315 if let Some(l2_path) = self.inner.result_blob_cache.l2_path() {
316 match crate::storage::cache::archive_blob_cache_l2(
317 backend.as_ref(),
318 l2_path,
319 &blob_cache_prefix,
320 ) {
321 Ok(count) => {
322 tracing::info!(
323 target: "reddb::backup",
324 files_uploaded = count,
325 blob_cache_prefix = %blob_cache_prefix,
326 "include_blob_cache: archived L2 directory"
327 );
328 }
329 Err(err) => {
330 tracing::warn!(
331 target: "reddb::backup",
332 error = %err,
333 blob_cache_prefix = %blob_cache_prefix,
334 "include_blob_cache: L2 archive failed; backup proceeding (cache is derived state)"
335 );
336 }
337 }
338 } else {
339 tracing::debug!(
340 target: "reddb::backup",
341 "include_blob_cache=true but no legacy standalone L2 path is configured"
342 );
343 }
344 }
345
346 uploaded = true;
347 }
348
349 Ok(crate::replication::scheduler::BackupResult {
350 snapshot_id: snapshot.snapshot_id,
351 uploaded,
352 duration_ms: started.elapsed().as_millis() as u64,
353 timestamp: snapshot.created_at_unix_ms as u64,
354 })
355 })();
356
357 use crate::runtime::control_events::{EventKind, Outcome, Sensitivity};
358 let (current_lsn, last_archived_lsn) = self.wal_archive_progress();
359 let mut fields = vec![
360 (
361 "current_lsn".to_string(),
362 Sensitivity::raw(current_lsn.to_string()),
363 ),
364 (
365 "last_archived_lsn".to_string(),
366 Sensitivity::raw(last_archived_lsn.to_string()),
367 ),
368 ];
369 if let Ok(backup) = &result {
370 fields.push((
371 "snapshot_id".to_string(),
372 Sensitivity::raw(backup.snapshot_id.to_string()),
373 ));
374 fields.push((
375 "uploaded".to_string(),
376 Sensitivity::raw(backup.uploaded.to_string()),
377 ));
378 fields.push((
379 "duration_ms".to_string(),
380 Sensitivity::raw(backup.duration_ms.to_string()),
381 ));
382 fields.push((
383 "snapshot_time".to_string(),
384 Sensitivity::raw(backup.timestamp.to_string()),
385 ));
386 }
387 let outcome = match &result {
388 Ok(_) => Outcome::Allowed,
389 Err(err) => crate::runtime::impl_core::control_event_outcome_for_error(err),
390 };
391 let reason = result.as_ref().err().map(|err| err.to_string());
392 self.emit_control_event(
393 EventKind::BackupRun,
394 outcome,
395 "backup_trigger",
396 Some("backup:trigger".to_string()),
397 reason,
398 fields,
399 )?;
400 result
401 }
402}