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