Skip to main content

reddb_server/replication/
primary.rs

1//! Primary-side replication: WAL record production and snapshot serving.
2//!
3//! The logical WAL spool byte format is a `reddb-file` contract. This module
4//! owns runtime policy: appending after writes, syncing acknowledged records,
5//! serving replica pulls, and pruning once slots make records removable.
6
7use std::collections::{BTreeMap, VecDeque};
8use std::fs::{self, File, OpenOptions};
9use std::io::{self, Write};
10use std::path::{Path, PathBuf};
11use std::sync::{Arc, Condvar, Mutex, RwLock};
12use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
13
14use reddb_file::{ReplicationSlot, ReplicationSlotInvalidationCause};
15use tracing::warn;
16
17mod slots;
18use slots::{
19    load_replication_slot_catalog, load_replication_slots, persist_replication_slot_catalog,
20    persist_replication_slots,
21};
22
23fn term_from_payload(payload: &[u8]) -> u64 {
24    crate::replication::cdc::ChangeRecord::decode(payload)
25        .map(|record| record.term)
26        .unwrap_or(crate::replication::DEFAULT_REPLICATION_TERM)
27}
28
29fn authority_from_payload(payload: &[u8]) -> (u64, Option<u64>) {
30    crate::replication::cdc::ChangeRecord::decode(payload)
31        .map(|record| (record.term, record.ownership_epoch))
32        .unwrap_or((crate::replication::DEFAULT_REPLICATION_TERM, None))
33}
34
35/// In-memory WAL buffer for replication.
36/// Primary appends records here; replicas consume from it.
37///
38/// Each record payload is stored behind an `Arc<[u8]>` so fan-out to
39/// multiple replicas shares a single heap allocation per record
40/// (issue #832): a pull clones the `Arc` handle, never the bytes, so
41/// adding replicas does not multiply the primary's send-buffer memory.
42pub struct WalBuffer {
43    /// Circular buffer of (lsn, ref-counted serialized record) pairs.
44    records: RwLock<VecDeque<(u64, Arc<[u8]>)>>,
45    /// Current write LSN.
46    current_lsn: RwLock<u64>,
47}
48
49impl WalBuffer {
50    pub fn new(max_size: usize) -> Self {
51        Self {
52            records: RwLock::new(VecDeque::with_capacity(max_size)),
53            current_lsn: RwLock::new(0),
54        }
55    }
56
57    /// Append a WAL record. Called by the storage engine after each write.
58    pub fn append(&self, lsn: u64, data: Vec<u8>) {
59        let mut records = self.records.write().unwrap_or_else(|e| e.into_inner());
60        records.push_back((lsn, Arc::from(data.into_boxed_slice())));
61
62        let mut current = self.current_lsn.write().unwrap_or_else(|e| e.into_inner());
63        *current = (*current).max(lsn);
64    }
65
66    /// Read records since the given LSN (exclusive), copying each
67    /// payload into an owned `Vec<u8>`. Kept for callers (WAL
68    /// archiving, retention bookkeeping) that need owned bytes; the
69    /// per-replica fan-out path should prefer [`Self::read_since_shared`]
70    /// to avoid copying.
71    pub fn read_since(&self, since_lsn: u64, max_count: usize) -> Vec<(u64, Vec<u8>)> {
72        self.read_since_shared(since_lsn, max_count)
73            .into_iter()
74            .map(|(lsn, data)| (lsn, data.to_vec()))
75            .collect()
76    }
77
78    /// Read records since the given LSN (exclusive) sharing the stored
79    /// `Arc<[u8]>` payloads. Fan-out to N replicas clones only the
80    /// reference-counted handles, so the buffer's bytes are never
81    /// duplicated per replica (issue #832).
82    pub fn read_since_shared(&self, since_lsn: u64, max_count: usize) -> Vec<(u64, Arc<[u8]>)> {
83        let records = self.records.read().unwrap_or_else(|e| e.into_inner());
84        records
85            .iter()
86            .filter(|(lsn, _)| *lsn > since_lsn)
87            .take(max_count)
88            .cloned()
89            .collect()
90    }
91
92    /// Current LSN.
93    pub fn current_lsn(&self) -> u64 {
94        *self.current_lsn.read().unwrap_or_else(|e| e.into_inner())
95    }
96
97    pub fn set_current_lsn(&self, lsn: u64) {
98        let mut current = self.current_lsn.write().unwrap_or_else(|e| e.into_inner());
99        *current = (*current).max(lsn);
100    }
101
102    pub fn prune_through(&self, upto_lsn: u64) {
103        let mut records = self.records.write().unwrap_or_else(|e| e.into_inner());
104        while records
105            .front()
106            .map(|(lsn, _)| *lsn <= upto_lsn)
107            .unwrap_or(false)
108        {
109            records.pop_front();
110        }
111    }
112
113    /// Oldest available LSN (for gap detection).
114    pub fn oldest_lsn(&self) -> Option<u64> {
115        let records = self.records.read().unwrap_or_else(|e| e.into_inner());
116        records.front().map(|(lsn, _)| *lsn)
117    }
118}
119
120fn logical_wal_entry_term(entry: &reddb_file::LogicalWalEntry) -> u64 {
121    if entry.term == 0 {
122        term_from_payload(&entry.data)
123    } else {
124        entry.term
125    }
126}
127
128fn logical_wal_data_with_framing_term(entry: &reddb_file::LogicalWalEntry) -> Vec<u8> {
129    let term = logical_wal_entry_term(entry);
130    match crate::replication::cdc::ChangeRecord::decode(&entry.data) {
131        Ok(mut record)
132            if record.term != term || record.ownership_epoch != entry.ownership_epoch =>
133        {
134            record.term = term;
135            if entry.ownership_epoch.is_some() {
136                record.ownership_epoch = entry.ownership_epoch;
137            }
138            record.encode()
139        }
140        _ => entry.data.clone(),
141    }
142}
143
144/// One in every `SEEK_INDEX_INTERVAL` records is checkpointed into the
145/// spool's in-memory seek index. A briefly-disconnected replica
146/// resuming from its slot LSN binary-searches this sparse index and
147/// seeks straight to the nearest preceding checkpoint, then scans
148/// forward at most `SEEK_INDEX_INTERVAL` records — turning resume from
149/// an O(n) full-file scan into a sub-linear seek (issue #832). The
150/// index is rebuilt on `open` and extended on every `append`.
151#[derive(Debug, Default)]
152struct LogicalWalSpoolState {
153    current_lsn: u64,
154    /// Sparse, strictly LSN-ascending `(lsn, byte_offset)` checkpoints
155    /// into the spool file. `byte_offset` is the start of the record
156    /// whose LSN is `lsn`.
157    seek_index: Vec<(u64, u64)>,
158    /// Byte length of the spool file (offset at which the next append
159    /// lands). Tracked so `append` can record a checkpoint's offset
160    /// without an extra `stat`.
161    write_offset: u64,
162    /// Total records appended/recovered, used to space checkpoints
163    /// `SEEK_INDEX_INTERVAL` records apart.
164    record_count: u64,
165}
166
167impl LogicalWalSpoolState {
168    /// Push a checkpoint for the record at `offset` if it falls on a
169    /// `SEEK_INDEX_INTERVAL` boundary. `ordinal` is the record's
170    /// zero-based position in the spool.
171    fn note_record(&mut self, ordinal: u64, lsn: u64, offset: u64) {
172        if ordinal.is_multiple_of(reddb_file::LOGICAL_WAL_SEEK_INDEX_INTERVAL) {
173            // Keep the index strictly ascending even if LSNs repeat
174            // (they should not, but a defensive guard keeps the binary
175            // search total).
176            if self.seek_index.last().map(|(l, _)| *l) != Some(lsn) {
177                self.seek_index.push((lsn, offset));
178            }
179        }
180    }
181
182    /// Byte offset to start a forward scan from when resuming at
183    /// `since_lsn` (exclusive). Returns the offset of the latest
184    /// checkpoint whose LSN is `<= since_lsn`, or `0` when no such
185    /// checkpoint exists.
186    fn seek_floor_offset(&self, since_lsn: u64) -> u64 {
187        match self
188            .seek_index
189            .binary_search_by(|(lsn, _)| lsn.cmp(&since_lsn))
190        {
191            Ok(idx) => self.seek_index[idx].1,
192            Err(0) => 0,
193            Err(idx) => self.seek_index[idx - 1].1,
194        }
195    }
196}
197
198/// Durable append-only logical WAL spool kept beside the main `.rdb` file.
199///
200/// This is not the storage-engine WAL; it is a structured replication/PITR log.
201pub struct LogicalWalSpool {
202    path: PathBuf,
203    state: Mutex<LogicalWalSpoolState>,
204}
205
206impl LogicalWalSpool {
207    pub fn path_for(data_path: &Path) -> PathBuf {
208        reddb_file::layout::logical_wal_path(data_path)
209    }
210
211    pub fn open(data_path: &Path) -> io::Result<Self> {
212        let path = Self::path_for(data_path);
213        if let Some(parent) = path.parent() {
214            fs::create_dir_all(parent)?;
215        }
216        if !path.exists() {
217            File::create(&path)?;
218        }
219        // Recover-or-truncate to the longest valid prefix. A torn tail
220        // from the previous process exit (power loss, OOM kill, ENOSPC
221        // mid-write) is silently dropped; the warning surfaces to the
222        // operator log but the spool stays open.
223        let entries = reddb_file::read_and_repair_logical_wal_entries(&path)?;
224        let current_lsn = entries.last().map(|entry| entry.lsn).unwrap_or(0);
225        // Rebuild the sparse seek index from the (now repaired) file so
226        // a post-restart resume is sub-linear from the first pull.
227        let (seek_index, write_offset, record_count) =
228            reddb_file::build_logical_wal_seek_index(&path)?;
229        Ok(Self {
230            path,
231            state: Mutex::new(LogicalWalSpoolState {
232                current_lsn,
233                seek_index,
234                write_offset,
235                record_count,
236            }),
237        })
238    }
239
240    pub fn append(&self, lsn: u64, data: &[u8]) -> io::Result<()> {
241        let timestamp_ms = SystemTime::now()
242            .duration_since(UNIX_EPOCH)
243            .map(|d| d.as_millis() as u64)
244            .unwrap_or(0);
245        self.append_with_timestamp(lsn, timestamp_ms, data)
246    }
247
248    /// Append a record with an explicit framing timestamp. Used in
249    /// tests to produce deterministic timestamps; production callers
250    /// should use `append`.
251    pub fn append_with_timestamp(
252        &self,
253        lsn: u64,
254        timestamp_ms: u64,
255        data: &[u8],
256    ) -> io::Result<()> {
257        let (term, ownership_epoch) = authority_from_payload(data);
258        self.append_with_term_epoch_and_timestamp(term, ownership_epoch, lsn, timestamp_ms, data)
259    }
260
261    pub fn append_with_term_and_timestamp(
262        &self,
263        term: u64,
264        lsn: u64,
265        timestamp_ms: u64,
266        data: &[u8],
267    ) -> io::Result<()> {
268        let (_, ownership_epoch) = authority_from_payload(data);
269        self.append_with_term_epoch_and_timestamp(term, ownership_epoch, lsn, timestamp_ms, data)
270    }
271
272    pub fn append_with_term_epoch_and_timestamp(
273        &self,
274        term: u64,
275        ownership_epoch: Option<u64>,
276        lsn: u64,
277        timestamp_ms: u64,
278        data: &[u8],
279    ) -> io::Result<()> {
280        let mut file = OpenOptions::new()
281            .create(true)
282            .append(true)
283            .open(&self.path)?;
284        // Pre-build the record in memory so a single write_all keeps
285        // the on-disk record contiguous. Two side-effects:
286        //   (a) crash recovery sees either a complete record or a torn
287        //       header, never an interleaved partial frame from two
288        //       writers (the spool is not multi-writer today, but the
289        //       single-write semantics make that future-safe);
290        //   (b) crc32 is computed exactly once over the same bytes the
291        //       reader will checksum, with zero risk of header/payload
292        //       drift from a partial flush.
293        let frame =
294            reddb_file::encode_logical_wal_v3(term, ownership_epoch, lsn, timestamp_ms, data)?;
295
296        file.write_all(&frame)?;
297        // PLAN.md Phase 2 mandates `sync_all` for logical WAL durability.
298        // `flush()` only drains the std::io userspace buffer; without
299        // `sync_all` the kernel page cache may still be dirty when an
300        // acknowledged write supposedly committed.
301        file.sync_all()?;
302
303        let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
304        state.current_lsn = state.current_lsn.max(lsn);
305        // The record we just wrote starts at the prior end-of-file.
306        // Checkpoint it into the seek index if it lands on an interval
307        // boundary, then advance the tracked write offset.
308        let record_start = state.write_offset;
309        let ordinal = state.record_count;
310        state.note_record(ordinal, lsn, record_start);
311        state.write_offset = record_start + frame.len() as u64;
312        state.record_count = ordinal + 1;
313        Ok(())
314    }
315
316    pub fn read_since(&self, since_lsn: u64, max_count: usize) -> io::Result<Vec<(u64, Vec<u8>)>> {
317        // Seek straight to the nearest indexed checkpoint at or before
318        // `since_lsn` instead of scanning the whole spool from offset 0
319        // (issue #832). The file was already repaired on `open`, so the
320        // forward scan from the checkpoint is non-repairing and simply
321        // stops at the first torn tail (left for the next `open` to fix).
322        let start_offset = {
323            let state = self.state.lock().unwrap_or_else(|e| e.into_inner());
324            state.seek_floor_offset(since_lsn)
325        };
326        let entries = reddb_file::read_logical_wal_entries_from(&self.path, start_offset)?;
327        Ok(entries
328            .into_iter()
329            .filter(|entry| entry.lsn > since_lsn)
330            .take(max_count)
331            .map(|entry| (entry.lsn, logical_wal_data_with_framing_term(&entry)))
332            .collect())
333    }
334
335    /// Byte offset a resume at `since_lsn` would seek to before
336    /// forward-scanning. Exposed for tests asserting the resume is
337    /// sub-linear (starts past offset 0 for a mid-spool LSN).
338    #[cfg(test)]
339    fn seek_floor_offset(&self, since_lsn: u64) -> u64 {
340        self.state
341            .lock()
342            .unwrap_or_else(|e| e.into_inner())
343            .seek_floor_offset(since_lsn)
344    }
345
346    pub fn current_lsn(&self) -> u64 {
347        self.state
348            .lock()
349            .unwrap_or_else(|e| e.into_inner())
350            .current_lsn
351    }
352
353    pub fn oldest_lsn(&self) -> io::Result<Option<u64>> {
354        Ok(reddb_file::read_and_repair_logical_wal_entries(&self.path)?
355            .into_iter()
356            .next()
357            .map(|entry| entry.lsn))
358    }
359
360    pub fn prune_through(&self, upto_lsn: u64) -> io::Result<()> {
361        let previous_lsn = self.current_lsn();
362        let mut retained: Vec<_> = reddb_file::read_and_repair_logical_wal_entries(&self.path)?
363            .into_iter()
364            .filter(|entry| entry.lsn > upto_lsn)
365            .collect();
366        for entry in &mut retained {
367            entry.term = logical_wal_entry_term(entry);
368        }
369        let temp_path = reddb_file::layout::logical_wal_temp_path(&self.path);
370        for entry in &mut retained {
371            // Re-frame as v3 so the spool only ever contains current records
372            // after a prune. Legacy v1 records are upgraded by carrying
373            // their original LSN and default term forward; the framing timestamp is
374            // re-stamped to wall-clock-now because the original v1
375            // record didn't carry one — downstream consumers that need
376            // the operation's logical timestamp continue to use the
377            // payload's own ChangeRecord::timestamp field.
378            let timestamp_ms = if entry.timestamp_ms > 0 {
379                entry.timestamp_ms
380            } else {
381                SystemTime::now()
382                    .duration_since(UNIX_EPOCH)
383                    .map(|d| d.as_millis() as u64)
384                    .unwrap_or(0)
385            };
386            entry.timestamp_ms = timestamp_ms;
387        }
388        let current_lsn =
389            reddb_file::rewrite_logical_wal_entries(&self.path, &temp_path, &retained)?;
390
391        // The rewrite shifted every record's byte offset, so the old
392        // seek index is stale — rebuild it from the compacted file.
393        let (seek_index, write_offset, record_count) =
394            reddb_file::build_logical_wal_seek_index(&self.path)?;
395        let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
396        state.current_lsn = previous_lsn.max(current_lsn).max(upto_lsn);
397        state.seek_index = seek_index;
398        state.write_offset = write_offset;
399        state.record_count = record_count;
400        Ok(())
401    }
402}
403
404/// State of a connected replica. PLAN.md Phase 11.4 fields:
405/// `last_seen_at_unix_ms` updates on every interaction (pull or ack);
406/// `last_sent_lsn` updates when the primary serves a `pull_wal_records`
407/// batch; `last_durable_lsn` updates when the replica reports its WAL
408/// is durably written via `ack_replica_lsn`.
409#[derive(Debug, Clone)]
410pub struct ReplicaState {
411    pub id: String,
412    pub last_acked_lsn: u64,
413    pub last_sent_lsn: u64,
414    pub last_durable_lsn: u64,
415    pub apply_error_count: u64,
416    pub divergence_count: u64,
417    pub connected_at_unix_ms: u128,
418    pub last_seen_at_unix_ms: u128,
419    /// Region identifier declared by the replica at handshake time
420    /// (Phase 2.6 multi-region PG parity). `None` until the replica
421    /// handshake extension lands in 2.6.2; the quorum coordinator's
422    /// region-binding map covers the in-process case meanwhile.
423    pub region: Option<String>,
424    /// `true` while this replica is re-bootstrapping — loading a fresh
425    /// snapshot to replace its current dataset (issue #837). It keeps
426    /// serving non-causal reads from the old data, but the advertiser
427    /// surfaces this flag so a causal reader routes bookmark reads
428    /// elsewhere: the replica's `last_acked_lsn` describes data it is
429    /// about to discard. Cleared atomically when the swap completes.
430    pub rebootstrapping: bool,
431}
432
433/// Primary-side replication progress derived from the replica registry.
434#[derive(Debug, Clone, Copy, PartialEq, Eq)]
435pub struct ReplicationProgress {
436    pub lag_lsn: u64,
437    pub safe_replay_lsn: u64,
438}
439
440impl ReplicationProgress {
441    pub fn from_replicas(replicas: &[ReplicaState]) -> Option<Self> {
442        let max_sent_lsn = replicas.iter().map(|replica| replica.last_sent_lsn).max()?;
443        let min_acked_lsn = replicas
444            .iter()
445            .map(|replica| replica.last_acked_lsn)
446            .min()?;
447        let safe_replay_lsn = replicas
448            .iter()
449            .map(|replica| replica.last_durable_lsn)
450            .min()?;
451
452        Some(Self {
453            lag_lsn: max_sent_lsn.saturating_sub(min_acked_lsn),
454            safe_replay_lsn,
455        })
456    }
457}
458
459/// Primary replication manager.
460pub struct PrimaryReplication {
461    pub wal_buffer: Arc<WalBuffer>,
462    pub logical_wal_spool: Option<Arc<LogicalWalSpool>>,
463    pub replicas: RwLock<Vec<ReplicaState>>,
464    pub(crate) routing_endpoints: RwLock<BTreeMap<String, reddb_wire::routing::RoutingEndpoints>>,
465    wal_appended: (Mutex<u64>, Condvar),
466    metadata_store: Option<Arc<crate::storage::unified::UnifiedStore>>,
467    metadata_error: Mutex<Option<String>>,
468    slot_path: Option<PathBuf>,
469    slot_catalog_path: Option<PathBuf>,
470    primary_replica_file_plan: Option<reddb_file::PrimaryReplicaFilePlan>,
471    primary_replica_wal_lock: Mutex<()>,
472    slots: RwLock<BTreeMap<String, ReplicationSlot>>,
473    slot_retention_max_lag_lsn: u64,
474    slot_idle_timeout_ms: u64,
475    /// PLAN.md Phase 11.4 — ack-driven commit synchronization. Always
476    /// allocated so the policy enum can flip from `Local` to
477    /// `AckN`/`Quorum` without touching this struct's shape.
478    pub commit_waiter: Arc<crate::replication::commit_waiter::CommitWaiter>,
479    /// Monotonic registry-change counter consumed by the
480    /// `TopologyAdvertiser` (issue #167). Bumps on register,
481    /// unregister, and the periodic health sweep when a replica
482    /// flips between healthy/unhealthy. Clients use the epoch to
483    /// detect stale advertisements without comparing the full
484    /// replica list element-wise.
485    topology_epoch: std::sync::atomic::AtomicU64,
486    /// Count of pulls served as a partial resync — a replica resuming
487    /// incrementally from its retained slot position rather than
488    /// triggering a full re-bootstrap (issue #832). Surfaced as a
489    /// replication metric so a brief disconnect that recovers via
490    /// partial resync is observable.
491    partial_resync_count: std::sync::atomic::AtomicU64,
492    /// Count of pulls that forced a full re-bootstrap — the replica's
493    /// retained WAL no longer covers its requested position, so it must
494    /// discard its dataset and reload a fresh snapshot (issue #839).
495    /// This is the primary alert signal: a healthy cluster re-bootstraps
496    /// rarely, so any sustained rise means slots are being invalidated
497    /// faster than replicas can keep up.
498    full_resync_count: std::sync::atomic::AtomicU64,
499}
500
501/// How a replica's pull should be served, decided from its slot state.
502#[derive(Debug, Clone, Copy, PartialEq, Eq)]
503pub enum ResumeMode {
504    /// Resume incrementally from `resume_lsn` (the replica's slot
505    /// position, never behind it). The retained WAL still covers the
506    /// gap, so a brief disconnect costs only a partial resync.
507    PartialResync { resume_lsn: u64 },
508    /// The slot is past the retention cap (or otherwise invalidated);
509    /// the replica must discard and re-bootstrap from a fresh snapshot.
510    FullRebootstrap {
511        cause: ReplicationSlotInvalidationCause,
512    },
513}
514
515impl PrimaryReplication {
516    pub fn slot_path_for(data_path: &Path) -> PathBuf {
517        reddb_file::layout::legacy_logical_slots_path(data_path)
518    }
519
520    pub fn primary_replica_root_for(data_path: &Path) -> PathBuf {
521        reddb_file::layout::primary_replica_root(data_path)
522    }
523
524    pub fn slot_catalog_path_for(data_path: &Path) -> PathBuf {
525        Self::primary_replica_file_plan_for(data_path).slots_path()
526    }
527
528    fn primary_replica_file_plan_for(data_path: &Path) -> reddb_file::PrimaryReplicaFilePlan {
529        let root = Self::primary_replica_root_for(data_path);
530        let timeline =
531            Self::primary_replica_current_timeline_for_root(&root).unwrap_or_else(|err| {
532                warn!(
533                    target: "reddb::replication::primary",
534                    error = %err,
535                    "failed to read primary-replica timeline history; using initial timeline"
536                );
537                reddb_file::TimelineId::initial()
538            });
539        reddb_file::PrimaryReplicaFilePlan::new(root, timeline)
540    }
541
542    fn primary_replica_current_timeline_for_root(
543        root: &Path,
544    ) -> Result<reddb_file::TimelineId, reddb_file::RdbFileError> {
545        let path = reddb_file::PrimaryReplicaFilePlan::new(root, reddb_file::TimelineId::initial())
546            .timeline_history_path();
547        match reddb_file::TimelineHistory::read_from_path(&path) {
548            Ok(history) => Ok(history
549                .current()
550                .unwrap_or_else(reddb_file::TimelineId::initial)),
551            Err(reddb_file::RdbFileError::Io(err))
552                if err.kind() == std::io::ErrorKind::NotFound =>
553            {
554                Ok(reddb_file::TimelineId::initial())
555            }
556            Err(err) => Err(err),
557        }
558    }
559
560    pub fn new(data_path: Option<&Path>) -> Self {
561        Self::new_with_config(data_path, &crate::replication::ReplicationConfig::primary())
562    }
563
564    pub fn new_with_config(
565        data_path: Option<&Path>,
566        config: &crate::replication::ReplicationConfig,
567    ) -> Self {
568        let now_ms = crate::utils::now_unix_millis() as u128;
569        let slot_path = data_path.map(Self::slot_path_for);
570        let slot_catalog_path = data_path.map(Self::slot_catalog_path_for);
571        let primary_replica_file_plan = data_path.map(Self::primary_replica_file_plan_for);
572        let mut slots = load_replication_slot_catalog(slot_catalog_path.as_deref(), now_ms);
573        slots.extend(load_replication_slots(slot_path.as_deref(), now_ms));
574        let logical_wal_spool = data_path
575            .and_then(|path| LogicalWalSpool::open(path).ok())
576            .map(Arc::new);
577        let current_lsn = logical_wal_spool
578            .as_ref()
579            .map(|spool| spool.current_lsn())
580            .unwrap_or(0);
581        Self {
582            wal_buffer: Arc::new(WalBuffer::new(100_000)),
583            logical_wal_spool,
584            replicas: RwLock::new(Vec::new()),
585            routing_endpoints: RwLock::new(BTreeMap::new()),
586            wal_appended: (Mutex::new(current_lsn), Condvar::new()),
587            metadata_store: None,
588            metadata_error: Mutex::new(None),
589            slot_path,
590            slot_catalog_path,
591            primary_replica_file_plan,
592            primary_replica_wal_lock: Mutex::new(()),
593            slots: RwLock::new(slots),
594            slot_retention_max_lag_lsn: config.slot_retention_max_lag_lsn,
595            slot_idle_timeout_ms: config.slot_idle_timeout_ms,
596            commit_waiter: Arc::new(crate::replication::commit_waiter::CommitWaiter::new()),
597            topology_epoch: std::sync::atomic::AtomicU64::new(0),
598            partial_resync_count: std::sync::atomic::AtomicU64::new(0),
599            full_resync_count: std::sync::atomic::AtomicU64::new(0),
600        }
601    }
602
603    pub(crate) fn new_with_store(
604        data_path: Option<&Path>,
605        config: &crate::replication::ReplicationConfig,
606        store: Arc<crate::storage::unified::UnifiedStore>,
607    ) -> io::Result<Self> {
608        let mut primary = Self::new_with_config(data_path, config);
609        if let Some(bytes) = super::metadata::read(&store, super::metadata::SLOTS)? {
610            let catalog =
611                reddb_file::ReplicationSlotCatalog::decode(&bytes).map_err(io::Error::other)?;
612            primary.slots = RwLock::new(
613                catalog
614                    .slots
615                    .into_iter()
616                    .map(|slot| (slot.replica_id.clone(), slot))
617                    .collect(),
618            );
619        } else {
620            // Validate legacy sources before importing. An unreadable source
621            // must not silently become an empty authoritative catalog.
622            let mut valid_source = false;
623            let mut import_error = None;
624            for path in [&primary.slot_catalog_path, &primary.slot_path]
625                .into_iter()
626                .flatten()
627            {
628                if path.try_exists()? {
629                    let result = if primary.slot_catalog_path.as_ref() == Some(path) {
630                        reddb_file::ReplicationSlotCatalog::read_from_path(path)
631                    } else {
632                        reddb_file::ReplicationSlotCatalog::read_legacy_json_from_path(
633                            path,
634                            crate::utils::now_unix_millis() as u128,
635                        )
636                    };
637                    match result {
638                        Ok(_) => valid_source = true,
639                        Err(error) => import_error = Some(error),
640                    }
641                }
642            }
643            if !valid_source {
644                if let Some(error) = import_error {
645                    return Err(io::Error::other(error));
646                }
647            }
648            let slots = primary
649                .slots
650                .read()
651                .unwrap_or_else(|error| error.into_inner());
652            let bytes = slots::slot_catalog_from_map(&slots)
653                .and_then(|catalog| catalog.encode())
654                .map_err(io::Error::other)?;
655            super::metadata::write(&store, super::metadata::SLOTS, &bytes)?;
656        }
657        for path in [&primary.slot_catalog_path, &primary.slot_path]
658            .into_iter()
659            .flatten()
660        {
661            super::metadata::retire_legacy(path)?;
662        }
663        primary.metadata_store = Some(store);
664        Ok(primary)
665    }
666
667    fn current_timeline(
668        &self,
669        root: &Path,
670    ) -> Result<reddb_file::TimelineId, reddb_file::RdbFileError> {
671        if let Some(store) = &self.metadata_store {
672            let path =
673                reddb_file::PrimaryReplicaFilePlan::new(root, reddb_file::TimelineId::initial())
674                    .timeline_history_path();
675            return super::metadata::timeline(store, &path)
676                .map(|history| {
677                    history
678                        .current()
679                        .unwrap_or_else(reddb_file::TimelineId::initial)
680                })
681                .map_err(reddb_file::RdbFileError::Io);
682        }
683        Self::primary_replica_current_timeline_for_root(root)
684    }
685
686    pub fn append_logical_record(&self, lsn: u64, encoded: Vec<u8>) {
687        self.wal_buffer.append(lsn, encoded.clone());
688        if let Some(spool) = &self.logical_wal_spool {
689            let _ = spool.append(lsn, &encoded);
690        }
691        if let Some(plan) = &self.primary_replica_file_plan {
692            let _guard = self
693                .primary_replica_wal_lock
694                .lock()
695                .unwrap_or_else(|err| err.into_inner());
696            match self.current_timeline(&plan.root) {
697                Ok(timeline) => {
698                    let plan = reddb_file::PrimaryReplicaFilePlan::new(plan.root.clone(), timeline);
699                    if let Err(err) = plan.append_wal_record(lsn, &encoded) {
700                        warn!(
701                            target: "reddb::replication::primary",
702                            lsn,
703                            error = %err,
704                            "failed to append primary-replica WAL segment"
705                        );
706                    }
707                }
708                Err(err) => {
709                    warn!(
710                        target: "reddb::replication::primary",
711                        lsn,
712                        error = %err,
713                        "failed to read primary-replica timeline history; skipping WAL append"
714                    );
715                }
716            }
717        }
718        let (lock, cvar) = &self.wal_appended;
719        let mut latest = lock.lock().unwrap_or_else(|e| e.into_inner());
720        *latest = (*latest).max(lsn);
721        cvar.notify_all();
722    }
723
724    pub fn wait_for_logical_lsn_after(&self, since_lsn: u64, timeout: Duration) -> bool {
725        if self.current_logical_lsn() > since_lsn {
726            return true;
727        }
728        let deadline = Instant::now() + timeout;
729        let (lock, cvar) = &self.wal_appended;
730        let mut latest = lock.lock().unwrap_or_else(|e| e.into_inner());
731        while *latest <= since_lsn {
732            let now = Instant::now();
733            if now >= deadline {
734                return false;
735            }
736            let remaining = deadline.saturating_duration_since(now);
737            let (guard, result) = cvar
738                .wait_timeout(latest, remaining)
739                .unwrap_or_else(|e| e.into_inner());
740            latest = guard;
741            if result.timed_out() && *latest <= since_lsn {
742                return false;
743            }
744        }
745        true
746    }
747
748    pub fn register_replica(&self, id: String) -> u64 {
749        self.register_replica_with_region(id, None)
750    }
751
752    /// Register a replica with an explicit region tag (Phase 2.6 multi-region).
753    ///
754    /// Preferred when the replica handshake declares a region — the quorum
755    /// coordinator uses this field to decide whether the replica counts
756    /// toward a `QuorumMode::Regions` commit.
757    ///
758    /// Idempotent on reconnect (issue #812): if a replica with `id` is
759    /// already registered, the existing entry is *updated in place* rather
760    /// than duplicated — progress LSNs (`last_acked_lsn`, `last_sent_lsn`,
761    /// `last_durable_lsn`) are preserved so a reconnecting replica is not
762    /// rewound, only `last_seen_at_unix_ms` is refreshed (and `region` when
763    /// a non-`None` value is supplied). A re-registration is not a
764    /// registry-shape change, so it does **not** bump the topology epoch.
765    /// Returns the slot `restart_lsn` the replica should resume streaming from:
766    /// the current WAL LSN for a fresh registration, or the durable slot
767    /// restart point for a reconnect.
768    pub fn register_replica_with_region(&self, id: String, region: Option<String>) -> u64 {
769        self.register_replica_with_region_at(id, region, self.current_logical_lsn())
770    }
771
772    fn register_replica_with_region_at(
773        &self,
774        id: String,
775        region: Option<String>,
776        initial_lsn: u64,
777    ) -> u64 {
778        let now_ms = crate::utils::now_unix_millis() as u128;
779        let resume_lsn = self.ensure_slot(&id, initial_lsn);
780        let mut replicas = self.replicas.write().unwrap_or_else(|e| e.into_inner());
781        if let Some(existing) = replicas.iter_mut().find(|r| r.id == id) {
782            existing.last_seen_at_unix_ms = now_ms;
783            if region.is_some() {
784                existing.region = region;
785            }
786            return resume_lsn;
787        }
788        replicas.push(ReplicaState {
789            id,
790            last_acked_lsn: resume_lsn,
791            last_sent_lsn: resume_lsn,
792            last_durable_lsn: resume_lsn,
793            apply_error_count: 0,
794            divergence_count: 0,
795            connected_at_unix_ms: now_ms,
796            last_seen_at_unix_ms: now_ms,
797            region,
798            rebootstrapping: false,
799        });
800        drop(replicas);
801        self.bump_topology_epoch();
802        resume_lsn
803    }
804
805    /// Mark (or clear) a replica's re-bootstrap state (issue #837).
806    ///
807    /// While `rebootstrapping` is `true` the replica keeps serving
808    /// non-causal reads from its existing data, but the advertiser
809    /// surfaces the flag so causal (bookmark) reads route to a
810    /// caught-up peer instead — the rebuilding replica's applied
811    /// frontier describes data it is about to discard. The primary
812    /// flips this back to `false` when the replica reports its atomic
813    /// snapshot swap complete.
814    ///
815    /// A change to the flag is a registry-shape change for routing
816    /// purposes, so it bumps the topology epoch to force consumers to
817    /// re-read the advertisement. Returns `true` when a replica with
818    /// `id` was present and updated.
819    pub fn set_replica_rebootstrapping(&self, id: &str, rebootstrapping: bool) -> bool {
820        let mut replicas = self.replicas.write().unwrap_or_else(|e| e.into_inner());
821        let Some(state) = replicas.iter_mut().find(|r| r.id == id) else {
822            return false;
823        };
824        if state.rebootstrapping == rebootstrapping {
825            return true;
826        }
827        state.rebootstrapping = rebootstrapping;
828        drop(replicas);
829        self.bump_topology_epoch();
830        true
831    }
832
833    /// Ensure a replica identifying itself with `id` is present in the
834    /// registry (issue #812). This is the production self-registration hook
835    /// used by the `pull_wal_records` path: the first time a replica sends
836    /// its `replica_id` on a pull, the primary registers it so it is no
837    /// longer blind to that replica's existence; subsequent pulls are
838    /// idempotent no-ops. Returns `true` when a new registration was
839    /// created. A fresh slot starts at the replica's requested cursor rather
840    /// than the primary's current LSN, so an empty replica does not skip WAL
841    /// that was written before its first pull. Reconnects preserve existing
842    /// progress and do not bump the topology epoch.
843    pub fn ensure_replica_registered(&self, id: &str, requested_since_lsn: u64) -> bool {
844        let already = self
845            .replicas
846            .read()
847            .unwrap_or_else(|e| e.into_inner())
848            .iter()
849            .any(|r| r.id == id);
850        if already {
851            return false;
852        }
853        self.register_replica_with_region_at(id.to_string(), None, requested_since_lsn);
854        true
855    }
856
857    /// Unregister a replica by id. Returns `true` when the replica
858    /// was present (and removed). Bumps the topology epoch so a
859    /// pending advertisement reflects the new fleet size.
860    pub fn unregister_replica(&self, id: &str) -> bool {
861        let mut replicas = self.replicas.write().unwrap_or_else(|e| e.into_inner());
862        let before = replicas.len();
863        replicas.retain(|r| r.id != id);
864        let removed = replicas.len() != before;
865        drop(replicas);
866        if removed {
867            self.routing_endpoints
868                .write()
869                .expect("replica routing endpoint map lock must not be poisoned")
870                .remove(id);
871            self.commit_waiter.drop_replica(id);
872            self.bump_topology_epoch();
873        }
874        removed
875    }
876
877    /// Current topology epoch. Strictly monotonic, bumps on every
878    /// registry-shape change consumed by `TopologyAdvertiser`.
879    pub fn topology_epoch(&self) -> u64 {
880        self.topology_epoch
881            .load(std::sync::atomic::Ordering::Relaxed)
882    }
883
884    /// Advance the topology epoch. Call sites: register, unregister,
885    /// and the health-sweep tick that flips a replica between
886    /// healthy/unhealthy. Wrapping is not a concern in practice
887    /// (`u64::MAX` events would take centuries at any realistic ack
888    /// rate) but `fetch_add` saturates implicitly via wrap-around;
889    /// the consumer treats epoch as opaque so a wrap is still
890    /// strictly "different" from the previous value.
891    pub fn bump_topology_epoch(&self) {
892        self.topology_epoch
893            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
894    }
895
896    pub fn ack_replica(&self, id: &str, lsn: u64) {
897        let now_ms = crate::utils::now_unix_millis() as u128;
898        let mut replicas = self.replicas.write().unwrap_or_else(|e| e.into_inner());
899        if let Some(r) = replicas.iter_mut().find(|r| r.id == id) {
900            r.last_acked_lsn = r.last_acked_lsn.max(lsn);
901            r.last_durable_lsn = r.last_durable_lsn.max(lsn);
902            r.last_seen_at_unix_ms = now_ms;
903        }
904        drop(replicas);
905        self.commit_waiter.record_replica_ack(id, lsn);
906    }
907
908    /// PLAN.md Phase 11.4 — replica reports applied + durable LSN
909    /// after persisting a batch. Idempotent: only advances LSNs
910    /// monotonically. `last_seen_at_unix_ms` always refreshes.
911    /// Also signals `commit_waiter` so any thread blocked on
912    /// `ack_n` / `quorum` can wake and re-check its threshold.
913    pub fn ack_replica_lsn(&self, id: &str, applied_lsn: u64, durable_lsn: u64) {
914        self.ack_replica_lsn_with_observability(id, applied_lsn, durable_lsn, 0, 0);
915    }
916
917    pub fn ack_replica_lsn_with_observability(
918        &self,
919        id: &str,
920        applied_lsn: u64,
921        durable_lsn: u64,
922        apply_error_count: u64,
923        divergence_count: u64,
924    ) {
925        let now_ms = crate::utils::now_unix_millis() as u128;
926        self.advance_slot(id, applied_lsn, durable_lsn, now_ms);
927        let mut replicas = self.replicas.write().unwrap_or_else(|e| e.into_inner());
928        if let Some(r) = replicas.iter_mut().find(|r| r.id == id) {
929            r.last_acked_lsn = r.last_acked_lsn.max(applied_lsn);
930            r.last_durable_lsn = r.last_durable_lsn.max(durable_lsn);
931            r.apply_error_count = r.apply_error_count.max(apply_error_count);
932            r.divergence_count = r.divergence_count.max(divergence_count);
933            r.last_seen_at_unix_ms = now_ms;
934        }
935        // Drop the write lock before signaling so a waiter that
936        // wakes immediately can read replica state without
937        // contending against us.
938        drop(replicas);
939        self.commit_waiter.record_replica_ack(id, durable_lsn);
940    }
941
942    /// PLAN.md Phase 11.4 — primary records the LSN it last sent to a
943    /// replica via pull_wal_records. Helpful for `lag_records =
944    /// last_sent_lsn - last_acked_lsn` to distinguish pull-side delay
945    /// from apply-side delay.
946    pub fn note_replica_pull(&self, id: &str, last_sent_lsn: u64) {
947        let now_ms = crate::utils::now_unix_millis() as u128;
948        self.touch_slot(id, now_ms);
949        let mut replicas = self.replicas.write().unwrap_or_else(|e| e.into_inner());
950        if let Some(r) = replicas.iter_mut().find(|r| r.id == id) {
951            r.last_sent_lsn = r.last_sent_lsn.max(last_sent_lsn);
952            r.last_seen_at_unix_ms = now_ms;
953        }
954    }
955
956    /// Snapshot of all currently registered replicas, for /metrics +
957    /// /admin/status. Returns owned clones so callers don't hold the
958    /// lock during serialization.
959    pub fn replica_snapshots(&self) -> Vec<ReplicaState> {
960        self.replicas
961            .read()
962            .unwrap_or_else(|e| e.into_inner())
963            .clone()
964    }
965
966    pub fn replication_progress(&self) -> Option<ReplicationProgress> {
967        let replicas = self.replicas.read().unwrap_or_else(|e| e.into_inner());
968        ReplicationProgress::from_replicas(&replicas)
969    }
970
971    pub fn slot_snapshots(&self) -> Vec<ReplicationSlot> {
972        self.slots
973            .read()
974            .unwrap_or_else(|e| e.into_inner())
975            .values()
976            .cloned()
977            .collect()
978    }
979
980    pub fn retention_floor_lsn(&self) -> Option<u64> {
981        // A slot update publishes its durability result while holding this lock.
982        // Check the error only after joining that same publication boundary.
983        let slots = self.slots.read().unwrap_or_else(|e| e.into_inner());
984        if self.ensure_slot_metadata_durable().is_err() {
985            return Some(0);
986        }
987        slots
988            .values()
989            .filter(|slot| slot.invalidation_reason.is_none())
990            .map(|slot| slot.restart_lsn)
991            .min()
992    }
993
994    pub fn prune_retained_wal_through(&self, archived_lsn: u64) -> io::Result<u64> {
995        self.enforce_retention_limits(crate::utils::now_unix_millis() as u128);
996        let prune_lsn = self
997            .retention_floor_lsn()
998            .map(|floor| floor.min(archived_lsn))
999            .unwrap_or(archived_lsn);
1000        if prune_lsn > 0 {
1001            if let Some(spool) = &self.logical_wal_spool {
1002                spool.prune_through(prune_lsn)?;
1003            }
1004            self.wal_buffer.prune_through(prune_lsn);
1005        }
1006        Ok(prune_lsn)
1007    }
1008
1009    pub fn replica_count(&self) -> usize {
1010        self.replicas
1011            .read()
1012            .unwrap_or_else(|e| e.into_inner())
1013            .len()
1014    }
1015
1016    /// Current primary write position (logical WAL LSN, falling back to
1017    /// the in-memory WAL buffer). Used as the reference point for
1018    /// per-replica lag — including issue #826 flow control.
1019    pub fn current_logical_lsn(&self) -> u64 {
1020        self.logical_wal_spool
1021            .as_ref()
1022            .map(|spool| spool.current_lsn())
1023            .unwrap_or_else(|| self.wal_buffer.current_lsn())
1024    }
1025
1026    fn ensure_slot(&self, id: &str, initial_lsn: u64) -> u64 {
1027        let now_ms = crate::utils::now_unix_millis() as u128;
1028        let _publication = self
1029            .metadata_store
1030            .as_ref()
1031            .map(|store| store.vault_publication_lock.lock());
1032        let mut slots = self.slots.write().unwrap_or_else(|e| e.into_inner());
1033        if let Some(slot) = slots.get_mut(id) {
1034            slot.last_seen_at_unix_ms = now_ms;
1035            let restart_lsn = slot.restart_lsn;
1036            self.persist_slots_locked(&slots);
1037            return restart_lsn;
1038        }
1039        let mut slot = ReplicationSlot::new(
1040            id.to_string(),
1041            reddb_file::TimelineId::initial(),
1042            initial_lsn,
1043        );
1044        slot.last_seen_at_unix_ms = now_ms;
1045        slots.insert(id.to_string(), slot);
1046        let restart_lsn = initial_lsn;
1047        self.persist_slots_locked(&slots);
1048        restart_lsn
1049    }
1050
1051    fn advance_slot(&self, id: &str, confirmed_lsn: u64, restart_lsn: u64, now_ms: u128) {
1052        let _publication = self
1053            .metadata_store
1054            .as_ref()
1055            .map(|store| store.vault_publication_lock.lock());
1056        let mut slots = self.slots.write().unwrap_or_else(|e| e.into_inner());
1057        let slot = slots.entry(id.to_string()).or_insert_with(|| {
1058            let mut slot =
1059                ReplicationSlot::new(id.to_string(), reddb_file::TimelineId::initial(), 0);
1060            slot.last_seen_at_unix_ms = now_ms;
1061            slot
1062        });
1063        if slot.invalidation_reason.is_some() {
1064            return;
1065        }
1066        slot.confirmed_write_lsn = slot.confirmed_lsn().max(confirmed_lsn).max(restart_lsn);
1067        slot.restart_lsn = slot.restart_lsn.max(restart_lsn);
1068        slot.confirmed_flush_lsn = slot.confirmed_flush_lsn.max(slot.restart_lsn);
1069        slot.confirmed_apply_lsn = slot.confirmed_apply_lsn.max(slot.restart_lsn);
1070        slot.last_seen_at_unix_ms = now_ms;
1071        self.persist_slots_locked(&slots);
1072    }
1073
1074    pub fn touch_slot(&self, id: &str, now_ms: u128) {
1075        let _publication = self
1076            .metadata_store
1077            .as_ref()
1078            .map(|store| store.vault_publication_lock.lock());
1079        let mut slots = self.slots.write().unwrap_or_else(|e| e.into_inner());
1080        let mut changed = false;
1081        if let Some(slot) = slots.get_mut(id) {
1082            if slot.invalidation_reason.is_none() {
1083                slot.last_seen_at_unix_ms = now_ms;
1084                changed = true;
1085            }
1086        }
1087        if changed {
1088            self.persist_slots_locked(&slots);
1089        }
1090    }
1091
1092    pub fn enforce_retention_limits(
1093        &self,
1094        now_ms: u128,
1095    ) -> Vec<(String, ReplicationSlotInvalidationCause)> {
1096        let current_lsn = self.current_logical_lsn();
1097        let mut invalidated = Vec::new();
1098        let _publication = self
1099            .metadata_store
1100            .as_ref()
1101            .map(|store| store.vault_publication_lock.lock());
1102        let mut slots = self.slots.write().unwrap_or_else(|e| e.into_inner());
1103        for slot in slots.values_mut() {
1104            if slot.invalidation_reason.is_some() {
1105                continue;
1106            }
1107            let reason = if self.slot_retention_max_lag_lsn > 0
1108                && current_lsn.saturating_sub(slot.restart_lsn) > self.slot_retention_max_lag_lsn
1109            {
1110                Some(ReplicationSlotInvalidationCause::Horizon)
1111            } else if self.slot_idle_timeout_ms > 0
1112                && now_ms.saturating_sub(slot.last_seen_at_unix_ms)
1113                    > u128::from(self.slot_idle_timeout_ms)
1114            {
1115                Some(ReplicationSlotInvalidationCause::IdleTimeout)
1116            } else {
1117                None
1118            };
1119            if let Some(reason) = reason {
1120                slot.invalidation_reason = Some(reason);
1121                slot.invalidated_at_unix_ms = Some(now_ms);
1122                invalidated.push((slot.replica_id.clone(), reason));
1123            }
1124        }
1125        if !invalidated.is_empty() {
1126            self.persist_slots_locked(&slots);
1127        }
1128        invalidated
1129    }
1130
1131    pub fn slot_rebootstrap_reason(
1132        &self,
1133        id: &str,
1134        requested_since_lsn: u64,
1135        oldest_available_lsn: Option<u64>,
1136    ) -> Option<ReplicationSlotInvalidationCause> {
1137        let now_ms = crate::utils::now_unix_millis() as u128;
1138        let _publication = self
1139            .metadata_store
1140            .as_ref()
1141            .map(|store| store.vault_publication_lock.lock());
1142        let mut slots = self.slots.write().unwrap_or_else(|e| e.into_inner());
1143        let slot = slots.get_mut(id)?;
1144        if let Some(reason) = slot.invalidation_reason {
1145            return Some(reason);
1146        }
1147        let slot_floor = slot.restart_lsn.max(requested_since_lsn);
1148        if oldest_available_lsn
1149            .map(|oldest| oldest > slot_floor.saturating_add(1))
1150            .unwrap_or(false)
1151        {
1152            slot.invalidation_reason = Some(ReplicationSlotInvalidationCause::WalRemoved);
1153            slot.invalidated_at_unix_ms = Some(now_ms);
1154            self.persist_slots_locked(&slots);
1155            return Some(ReplicationSlotInvalidationCause::WalRemoved);
1156        }
1157        None
1158    }
1159
1160    /// Decide how a reconnecting replica's pull should be served
1161    /// (issue #832). If the slot is invalidated or the requested
1162    /// position has fallen behind the retained WAL floor, the replica
1163    /// must re-bootstrap; otherwise it resumes via a partial resync
1164    /// from its slot position (never rewound behind it). Every
1165    /// partial-resync decision bumps the `partial_resync_count` metric
1166    /// so a brief disconnect that recovers without a full re-bootstrap
1167    /// is observable.
1168    pub fn plan_replica_resume(
1169        &self,
1170        id: &str,
1171        requested_since_lsn: u64,
1172        oldest_available_lsn: Option<u64>,
1173    ) -> ResumeMode {
1174        if let Some(cause) =
1175            self.slot_rebootstrap_reason(id, requested_since_lsn, oldest_available_lsn)
1176        {
1177            self.full_resync_count
1178                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1179            return ResumeMode::FullRebootstrap { cause };
1180        }
1181        let resume_lsn = self
1182            .slot_snapshots()
1183            .into_iter()
1184            .find(|slot| slot.replica_id == id)
1185            .map(|slot| requested_since_lsn.max(slot.restart_lsn))
1186            .unwrap_or(requested_since_lsn);
1187        self.partial_resync_count
1188            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1189        ResumeMode::PartialResync { resume_lsn }
1190    }
1191
1192    /// Number of pulls served as a partial resync since process start.
1193    /// Surfaced in the replication metrics/status payload (issue #832).
1194    pub fn partial_resync_count(&self) -> u64 {
1195        self.partial_resync_count
1196            .load(std::sync::atomic::Ordering::Relaxed)
1197    }
1198
1199    /// Number of pulls that forced a full re-bootstrap since process
1200    /// start (issue #839). Surfaced as `reddb_replication_full_resync_total`
1201    /// and in `/replication/status` — the primary operator alert signal.
1202    pub fn full_resync_count(&self) -> u64 {
1203        self.full_resync_count
1204            .load(std::sync::atomic::Ordering::Relaxed)
1205    }
1206
1207    fn persist_slots_locked(&self, slots: &BTreeMap<String, ReplicationSlot>) {
1208        if let Some(store) = &self.metadata_store {
1209            let result = slots::slot_catalog_from_map(slots)
1210                .and_then(|catalog| catalog.encode())
1211                .map_err(io::Error::other)
1212                .and_then(|bytes| super::metadata::write(store, super::metadata::SLOTS, &bytes));
1213            let mut last_error = self
1214                .metadata_error
1215                .lock()
1216                .unwrap_or_else(|error| error.into_inner());
1217            if let Err(error) = result {
1218                *last_error = Some(error.to_string());
1219                warn!(target: "reddb::replication::slots", %error,
1220                    "failed to persist replication slots inside database");
1221            } else {
1222                *last_error = None;
1223            }
1224            return;
1225        }
1226        if let Err(err) = persist_replication_slots(self.slot_path.as_deref(), slots) {
1227            warn!(
1228                target: "reddb::replication::slots",
1229                error = %err,
1230                "failed to persist replication slots"
1231            );
1232        }
1233        if let Err(err) = persist_replication_slot_catalog(self.slot_catalog_path.as_deref(), slots)
1234        {
1235            warn!(
1236                target: "reddb::replication::slots",
1237                error = %err,
1238                "failed to persist binary replication slot catalog"
1239            );
1240        }
1241    }
1242
1243    pub(crate) fn ensure_slot_metadata_durable(&self) -> io::Result<()> {
1244        match self
1245            .metadata_error
1246            .lock()
1247            .unwrap_or_else(|error| error.into_inner())
1248            .as_ref()
1249        {
1250            Some(error) => Err(io::Error::other(error.clone())),
1251            None => Ok(()),
1252        }
1253    }
1254}
1255
1256#[cfg(test)]
1257mod tests;