Skip to main content

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}