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    // File contents and seek offsets must cross the same publication boundary.
231    state: Mutex<LogicalWalSpoolState>,
232}
233
234impl LogicalWalSpool {
235    pub fn path_for(data_path: &Path) -> PathBuf {
236        reddb_file::layout::logical_wal_path(data_path)
237    }
238
239    pub fn open(data_path: &Path) -> io::Result<Self> {
240        let path = Self::path_for(data_path);
241        if let Some(parent) = path.parent() {
242            fs::create_dir_all(parent)?;
243        }
244        if !path.exists() {
245            File::create(&path)?;
246        }
247        // Recover-or-truncate to the longest valid prefix. A torn tail
248        // from the previous process exit (power loss, OOM kill, ENOSPC
249        // mid-write) is silently dropped; the warning surfaces to the
250        // operator log but the spool stays open.
251        let entries = reddb_file::read_and_repair_logical_wal_entries(&path)?;
252        let current_lsn = entries.last().map(|entry| entry.lsn).unwrap_or(0);
253        // Rebuild the sparse seek index from the (now repaired) file so
254        // a post-restart resume is sub-linear from the first pull.
255        let (seek_index, write_offset, record_count) =
256            reddb_file::build_logical_wal_seek_index(&path)?;
257        Ok(Self {
258            path,
259            state: Mutex::new(LogicalWalSpoolState {
260                current_lsn,
261                seek_index,
262                write_offset,
263                record_count,
264            }),
265        })
266    }
267
268    pub fn append(&self, lsn: u64, data: &[u8]) -> io::Result<()> {
269        let timestamp_ms = SystemTime::now()
270            .duration_since(UNIX_EPOCH)
271            .map(|d| d.as_millis() as u64)
272            .unwrap_or(0);
273        self.append_with_timestamp(lsn, timestamp_ms, data)
274    }
275
276    /// Append a record with an explicit framing timestamp. Used in
277    /// tests to produce deterministic timestamps; production callers
278    /// should use `append`.
279    pub fn append_with_timestamp(
280        &self,
281        lsn: u64,
282        timestamp_ms: u64,
283        data: &[u8],
284    ) -> io::Result<()> {
285        let (term, ownership_epoch) = authority_from_payload(data);
286        self.append_with_term_epoch_and_timestamp(term, ownership_epoch, lsn, timestamp_ms, data)
287    }
288
289    pub fn append_with_term_and_timestamp(
290        &self,
291        term: u64,
292        lsn: u64,
293        timestamp_ms: u64,
294        data: &[u8],
295    ) -> io::Result<()> {
296        let (_, ownership_epoch) = authority_from_payload(data);
297        self.append_with_term_epoch_and_timestamp(term, ownership_epoch, lsn, timestamp_ms, data)
298    }
299
300    pub fn append_with_term_epoch_and_timestamp(
301        &self,
302        term: u64,
303        ownership_epoch: Option<u64>,
304        lsn: u64,
305        timestamp_ms: u64,
306        data: &[u8],
307    ) -> io::Result<()> {
308        // Encode before taking the lock, then retain exclusive file/index
309        // ownership through the durability barrier. Open after locking: a
310        // concurrent prune must not replace the inode we are about to append.
311        let frame =
312            reddb_file::encode_logical_wal_v3(term, ownership_epoch, lsn, timestamp_ms, data)?;
313        let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
314        let mut file = OpenOptions::new()
315            .create(true)
316            .append(true)
317            .open(&self.path)?;
318
319        file.write_all(&frame)?;
320        // PLAN.md Phase 2 mandates `sync_all` for logical WAL durability.
321        // `flush()` only drains the std::io userspace buffer; without
322        // `sync_all` the kernel page cache may still be dirty when an
323        // acknowledged write supposedly committed.
324        file.sync_all()?;
325
326        state.current_lsn = state.current_lsn.max(lsn);
327        // The record we just wrote starts at the prior end-of-file.
328        // Checkpoint it into the seek index if it lands on an interval
329        // boundary, then advance the tracked write offset.
330        let record_start = state.write_offset;
331        let ordinal = state.record_count;
332        state.note_record(ordinal, lsn, record_start);
333        state.write_offset = record_start + frame.len() as u64;
334        state.record_count = ordinal + 1;
335        Ok(())
336    }
337
338    pub fn read_since(&self, since_lsn: u64, max_count: usize) -> io::Result<Vec<(u64, Vec<u8>)>> {
339        // Seek straight to the nearest indexed checkpoint at or before
340        // `since_lsn` instead of scanning the whole spool from offset 0
341        // (issue #832). The file was already repaired on `open`, so the
342        // forward scan from the checkpoint is non-repairing and simply
343        // stops at the first torn tail (left for the next `open` to fix).
344        let state = self.state.lock().unwrap_or_else(|e| e.into_inner());
345        let start_offset = state.seek_floor_offset(since_lsn);
346        let entries = reddb_file::read_logical_wal_entries_from(&self.path, start_offset)?;
347        drop(state);
348        Ok(entries
349            .into_iter()
350            .filter(|entry| entry.lsn > since_lsn)
351            .take(max_count)
352            .map(|entry| (entry.lsn, logical_wal_data_with_framing_term(&entry)))
353            .collect())
354    }
355
356    pub fn read_since_bounded(
357        &self,
358        since_lsn: u64,
359        max_count: usize,
360        max_payload_bytes: usize,
361    ) -> io::Result<Vec<(u64, Vec<u8>)>> {
362        let state = self.state.lock().unwrap_or_else(|e| e.into_inner());
363        let start_offset = state.seek_floor_offset(since_lsn);
364        let entries = reddb_file::read_logical_wal_entries_from_bounded(
365            &self.path,
366            start_offset,
367            since_lsn,
368            max_count,
369            max_payload_bytes,
370        )?;
371        drop(state);
372        Ok(entries
373            .into_iter()
374            .map(|entry| (entry.lsn, logical_wal_data_with_framing_term(&entry)))
375            .collect())
376    }
377
378    /// Byte offset a resume at `since_lsn` would seek to before
379    /// forward-scanning. Exposed for tests asserting the resume is
380    /// sub-linear (starts past offset 0 for a mid-spool LSN).
381    #[cfg(test)]
382    fn seek_floor_offset(&self, since_lsn: u64) -> u64 {
383        self.state
384            .lock()
385            .unwrap_or_else(|e| e.into_inner())
386            .seek_floor_offset(since_lsn)
387    }
388
389    pub fn current_lsn(&self) -> u64 {
390        self.state
391            .lock()
392            .unwrap_or_else(|e| e.into_inner())
393            .current_lsn
394    }
395
396    /// Restore the committed prefix already materialized in a promoted replica.
397    /// This advances the allocation frontier without inventing spool records.
398    pub(crate) fn restore_lsn_floor(&self, lsn: u64) {
399        let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner());
400        state.current_lsn = state.current_lsn.max(lsn);
401    }
402
403    pub fn oldest_lsn(&self) -> io::Result<Option<u64>> {
404        let _state = self.state.lock().unwrap_or_else(|e| e.into_inner());
405        Ok(reddb_file::read_and_repair_logical_wal_entries(&self.path)?
406            .into_iter()
407            .next()
408            .map(|entry| entry.lsn))
409    }
410
411    pub fn prune_through(&self, upto_lsn: u64) -> io::Result<()> {
412        let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
413        let previous_lsn = state.current_lsn;
414        let mut retained: Vec<_> = reddb_file::read_and_repair_logical_wal_entries(&self.path)?
415            .into_iter()
416            .filter(|entry| entry.lsn > upto_lsn)
417            .collect();
418        for entry in &mut retained {
419            entry.term = logical_wal_entry_term(entry);
420        }
421        let temp_path = reddb_file::layout::logical_wal_temp_path(&self.path);
422        for entry in &mut retained {
423            // Re-frame as v3 so the spool only ever contains current records
424            // after a prune. Legacy v1 records are upgraded by carrying
425            // their original LSN and default term forward; the framing timestamp is
426            // re-stamped to wall-clock-now because the original v1
427            // record didn't carry one — downstream consumers that need
428            // the operation's logical timestamp continue to use the
429            // payload's own ChangeRecord::timestamp field.
430            let timestamp_ms = if entry.timestamp_ms > 0 {
431                entry.timestamp_ms
432            } else {
433                SystemTime::now()
434                    .duration_since(UNIX_EPOCH)
435                    .map(|d| d.as_millis() as u64)
436                    .unwrap_or(0)
437            };
438            entry.timestamp_ms = timestamp_ms;
439        }
440        let current_lsn =
441            reddb_file::rewrite_logical_wal_entries(&self.path, &temp_path, &retained)?;
442
443        // The rewrite shifted every record's byte offset, so the old
444        // seek index is stale — rebuild it from the compacted file.
445        let (seek_index, write_offset, record_count) =
446            reddb_file::build_logical_wal_seek_index(&self.path)?;
447        state.current_lsn = previous_lsn.max(current_lsn).max(upto_lsn);
448        state.seek_index = seek_index;
449        state.write_offset = write_offset;
450        state.record_count = record_count;
451        Ok(())
452    }
453}
454
455/// State of a connected replica. PLAN.md Phase 11.4 fields:
456/// `last_seen_at_unix_ms` updates on every interaction (pull or ack);
457/// `last_sent_lsn` updates when the primary serves a `pull_wal_records`
458/// batch; `last_durable_lsn` updates when the replica reports its WAL
459/// is durably written via `ack_replica_lsn`.
460#[derive(Debug, Clone)]
461pub struct ReplicaState {
462    pub id: String,
463    pub last_acked_lsn: u64,
464    pub last_sent_lsn: u64,
465    pub last_durable_lsn: u64,
466    pub apply_error_count: u64,
467    pub divergence_count: u64,
468    pub connected_at_unix_ms: u128,
469    pub last_seen_at_unix_ms: u128,
470    /// Region identifier declared by the replica at handshake time
471    /// (Phase 2.6 multi-region PG parity). `None` until the replica
472    /// handshake extension lands in 2.6.2; the quorum coordinator's
473    /// region-binding map covers the in-process case meanwhile.
474    pub region: Option<String>,
475    /// `true` while this replica is re-bootstrapping — loading a fresh
476    /// snapshot to replace its current dataset (issue #837). It keeps
477    /// serving non-causal reads from the old data, but the advertiser
478    /// surfaces this flag so a causal reader routes bookmark reads
479    /// elsewhere: the replica's `last_acked_lsn` describes data it is
480    /// about to discard. Cleared atomically when the swap completes.
481    pub rebootstrapping: bool,
482}
483
484/// Primary-side replication progress derived from the replica registry.
485#[derive(Debug, Clone, Copy, PartialEq, Eq)]
486pub struct ReplicationProgress {
487    pub lag_lsn: u64,
488    pub safe_replay_lsn: u64,
489}
490
491impl ReplicationProgress {
492    pub fn from_replicas(replicas: &[ReplicaState]) -> Option<Self> {
493        let max_sent_lsn = replicas.iter().map(|replica| replica.last_sent_lsn).max()?;
494        let min_acked_lsn = replicas
495            .iter()
496            .map(|replica| replica.last_acked_lsn)
497            .min()?;
498        let safe_replay_lsn = replicas
499            .iter()
500            .map(|replica| replica.last_durable_lsn)
501            .min()?;
502
503        Some(Self {
504            lag_lsn: max_sent_lsn.saturating_sub(min_acked_lsn),
505            safe_replay_lsn,
506        })
507    }
508}
509
510/// Primary replication manager.
511pub struct PrimaryReplication {
512    pub wal_buffer: Arc<WalBuffer>,
513    pub logical_wal_spool: Option<Arc<LogicalWalSpool>>,
514    pub replicas: RwLock<Vec<ReplicaState>>,
515    pub(crate) routing_endpoints: RwLock<BTreeMap<String, reddb_wire::routing::RoutingEndpoints>>,
516    wal_appended: (Mutex<u64>, Condvar),
517    metadata_store: Option<Arc<crate::storage::unified::UnifiedStore>>,
518    metadata_error: Mutex<Option<String>>,
519    slot_path: Option<PathBuf>,
520    slot_catalog_path: Option<PathBuf>,
521    primary_replica_file_plan: Option<reddb_file::PrimaryReplicaFilePlan>,
522    primary_replica_wal_lock: Mutex<()>,
523    slots: RwLock<BTreeMap<String, ReplicationSlot>>,
524    slot_timeline: reddb_file::TimelineId,
525    slot_retention_max_lag_lsn: u64,
526    slot_idle_timeout_ms: u64,
527    /// PLAN.md Phase 11.4 — ack-driven commit synchronization. Always
528    /// allocated so the policy enum can flip from `Local` to
529    /// `AckN`/`Quorum` without touching this struct's shape.
530    pub commit_waiter: Arc<crate::replication::commit_waiter::CommitWaiter>,
531    /// Monotonic registry-change counter consumed by the
532    /// `TopologyAdvertiser` (issue #167). Bumps on register,
533    /// unregister, and the periodic health sweep when a replica
534    /// flips between healthy/unhealthy. Clients use the epoch to
535    /// detect stale advertisements without comparing the full
536    /// replica list element-wise.
537    topology_epoch: std::sync::atomic::AtomicU64,
538    /// Count of pulls served as a partial resync — a replica resuming
539    /// incrementally from its retained slot position rather than
540    /// triggering a full re-bootstrap (issue #832). Surfaced as a
541    /// replication metric so a brief disconnect that recovers via
542    /// partial resync is observable.
543    partial_resync_count: std::sync::atomic::AtomicU64,
544    /// Count of pulls that forced a full re-bootstrap — the replica's
545    /// retained WAL no longer covers its requested position, so it must
546    /// discard its dataset and reload a fresh snapshot (issue #839).
547    /// This is the primary alert signal: a healthy cluster re-bootstraps
548    /// rarely, so any sustained rise means slots are being invalidated
549    /// faster than replicas can keep up.
550    full_resync_count: std::sync::atomic::AtomicU64,
551}
552
553/// How a replica's pull should be served, decided from its slot state.
554#[derive(Debug, Clone, Copy, PartialEq, Eq)]
555pub enum ResumeMode {
556    /// Resume incrementally from `resume_lsn` (the replica's slot
557    /// position, never behind it). The retained WAL still covers the
558    /// gap, so a brief disconnect costs only a partial resync.
559    PartialResync { resume_lsn: u64 },
560    /// The slot is past the retention cap (or otherwise invalidated);
561    /// the replica must discard and re-bootstrap from a fresh snapshot.
562    FullRebootstrap {
563        cause: ReplicationSlotInvalidationCause,
564    },
565}
566
567const REBOOTSTRAP_PROOF_TOKEN_BYTES_MAX: usize = 1024;
568
569#[derive(Debug)]
570struct ReplicaRebootstrapProof {
571    replica_id: String,
572    timeline: u64,
573    checkpoint_lsn: u64,
574    token: String,
575}
576
577impl ReplicaRebootstrapProof {
578    const MAGIC: &'static [u8; 5] = b"RBRP1";
579
580    fn encode(&self) -> io::Result<Vec<u8>> {
581        let replica_id_bytes = self.replica_id.as_bytes();
582        let token_bytes = self.token.as_bytes();
583        let replica_id_len = u16::try_from(replica_id_bytes.len()).map_err(|_| {
584            io::Error::new(io::ErrorKind::InvalidInput, "replica identity is too long")
585        })?;
586        let token_len = u16::try_from(token_bytes.len()).map_err(|_| {
587            io::Error::new(io::ErrorKind::InvalidInput, "snapshot token is too long")
588        })?;
589        let mut bytes = Vec::with_capacity(25 + replica_id_bytes.len() + token_bytes.len());
590        bytes.extend_from_slice(Self::MAGIC);
591        bytes.extend_from_slice(&self.timeline.to_le_bytes());
592        bytes.extend_from_slice(&self.checkpoint_lsn.to_le_bytes());
593        bytes.extend_from_slice(&replica_id_len.to_le_bytes());
594        bytes.extend_from_slice(&token_len.to_le_bytes());
595        bytes.extend_from_slice(replica_id_bytes);
596        bytes.extend_from_slice(token_bytes);
597        Ok(bytes)
598    }
599
600    fn decode(bytes: &[u8]) -> io::Result<Self> {
601        if bytes.len() < 25 || &bytes[..5] != Self::MAGIC {
602            return Err(io::Error::new(
603                io::ErrorKind::InvalidData,
604                "invalid replica rebootstrap proof header",
605            ));
606        }
607        let timeline = u64::from_le_bytes(
608            bytes[5..13]
609                .try_into()
610                .expect("invariant: proof timeline slice has eight bytes"),
611        );
612        let checkpoint_lsn = u64::from_le_bytes(
613            bytes[13..21]
614                .try_into()
615                .expect("invariant: proof checkpoint slice has eight bytes"),
616        );
617        let replica_id_len = usize::from(u16::from_le_bytes(
618            bytes[21..23]
619                .try_into()
620                .expect("invariant: proof identity length slice has two bytes"),
621        ));
622        let token_len = usize::from(u16::from_le_bytes(
623            bytes[23..25]
624                .try_into()
625                .expect("invariant: proof token length slice has two bytes"),
626        ));
627        let expected_len = 25usize
628            .checked_add(replica_id_len)
629            .and_then(|len| len.checked_add(token_len))
630            .ok_or_else(|| {
631                io::Error::new(
632                    io::ErrorKind::InvalidData,
633                    "rebootstrap proof length overflow",
634                )
635            })?;
636        if bytes.len() != expected_len {
637            return Err(io::Error::new(
638                io::ErrorKind::InvalidData,
639                "invalid replica rebootstrap proof length",
640            ));
641        }
642        let replica_id_end = 25 + replica_id_len;
643        let replica_id = std::str::from_utf8(&bytes[25..replica_id_end])
644            .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?
645            .to_string();
646        let token = std::str::from_utf8(&bytes[replica_id_end..expected_len])
647            .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?
648            .to_string();
649        Ok(Self {
650            replica_id,
651            timeline,
652            checkpoint_lsn,
653            token,
654        })
655    }
656}
657
658fn replica_rebootstrap_proof_key(replica_id: &str) -> String {
659    format!("replication.rebootstrap.proof.{}", hex::encode(replica_id))
660}
661
662impl PrimaryReplication {
663    pub fn slot_path_for(data_path: &Path) -> PathBuf {
664        reddb_file::layout::legacy_logical_slots_path(data_path)
665    }
666
667    pub fn primary_replica_root_for(data_path: &Path) -> PathBuf {
668        reddb_file::layout::primary_replica_root(data_path)
669    }
670
671    pub fn slot_catalog_path_for(data_path: &Path) -> PathBuf {
672        Self::primary_replica_file_plan_for(data_path).slots_path()
673    }
674
675    fn primary_replica_file_plan_for(data_path: &Path) -> reddb_file::PrimaryReplicaFilePlan {
676        let root = Self::primary_replica_root_for(data_path);
677        let timeline =
678            Self::primary_replica_current_timeline_for_root(&root).unwrap_or_else(|err| {
679                warn!(
680                    target: "reddb::replication::primary",
681                    error = %err,
682                    "failed to read primary-replica timeline history; using initial timeline"
683                );
684                reddb_file::TimelineId::initial()
685            });
686        reddb_file::PrimaryReplicaFilePlan::new(root, timeline)
687    }
688
689    fn primary_replica_current_timeline_for_root(
690        root: &Path,
691    ) -> Result<reddb_file::TimelineId, reddb_file::RdbFileError> {
692        let path = reddb_file::PrimaryReplicaFilePlan::new(root, reddb_file::TimelineId::initial())
693            .timeline_history_path();
694        match reddb_file::TimelineHistory::read_from_path(&path) {
695            Ok(history) => Ok(history
696                .current()
697                .unwrap_or_else(reddb_file::TimelineId::initial)),
698            Err(reddb_file::RdbFileError::Io(err))
699                if err.kind() == std::io::ErrorKind::NotFound =>
700            {
701                Ok(reddb_file::TimelineId::initial())
702            }
703            Err(err) => Err(err),
704        }
705    }
706
707    pub fn new(data_path: Option<&Path>) -> Self {
708        Self::new_with_config(data_path, &crate::replication::ReplicationConfig::primary())
709    }
710
711    pub fn new_with_config(
712        data_path: Option<&Path>,
713        config: &crate::replication::ReplicationConfig,
714    ) -> Self {
715        let now_ms = crate::utils::now_unix_millis() as u128;
716        let slot_path = data_path.map(Self::slot_path_for);
717        let slot_catalog_path = data_path.map(Self::slot_catalog_path_for);
718        let primary_replica_file_plan = data_path.map(Self::primary_replica_file_plan_for);
719        let mut slots = load_replication_slot_catalog(slot_catalog_path.as_deref(), now_ms);
720        slots.extend(load_replication_slots(slot_path.as_deref(), now_ms));
721        let logical_wal_spool = data_path
722            .and_then(|path| LogicalWalSpool::open(path).ok())
723            .map(Arc::new);
724        let current_lsn = logical_wal_spool
725            .as_ref()
726            .map(|spool| spool.current_lsn())
727            .unwrap_or(0);
728        Self {
729            wal_buffer: Arc::new(WalBuffer::new(100_000)),
730            logical_wal_spool,
731            replicas: RwLock::new(Vec::new()),
732            routing_endpoints: RwLock::new(BTreeMap::new()),
733            wal_appended: (Mutex::new(current_lsn), Condvar::new()),
734            metadata_store: None,
735            metadata_error: Mutex::new(None),
736            slot_path,
737            slot_catalog_path,
738            primary_replica_file_plan,
739            primary_replica_wal_lock: Mutex::new(()),
740            slots: RwLock::new(slots),
741            // Legacy JSON cannot encode a timeline. Native catalogs are
742            // rebound to authoritative history by new_with_store instead.
743            slot_timeline: reddb_file::TimelineId::initial(),
744            slot_retention_max_lag_lsn: config.slot_retention_max_lag_lsn,
745            slot_idle_timeout_ms: config.slot_idle_timeout_ms,
746            commit_waiter: Arc::new(crate::replication::commit_waiter::CommitWaiter::new()),
747            topology_epoch: std::sync::atomic::AtomicU64::new(0),
748            partial_resync_count: std::sync::atomic::AtomicU64::new(0),
749            full_resync_count: std::sync::atomic::AtomicU64::new(0),
750        }
751    }
752
753    pub(crate) fn new_with_store(
754        data_path: Option<&Path>,
755        config: &crate::replication::ReplicationConfig,
756        store: Arc<crate::storage::unified::UnifiedStore>,
757    ) -> io::Result<Self> {
758        let mut primary = Self::new_with_config(data_path, config);
759        if let Some(path) = data_path.filter(|_| primary.logical_wal_spool.is_none()) {
760            // A persistent primary must not silently downgrade to an in-memory
761            // stream when its durable spool cannot be opened.
762            primary.logical_wal_spool = Some(Arc::new(LogicalWalSpool::open(path)?));
763        }
764        let stored_catalog = super::metadata::read(&store, super::metadata::SLOTS)?;
765        if let Some(bytes) = &stored_catalog {
766            let catalog =
767                reddb_file::ReplicationSlotCatalog::decode(&bytes).map_err(io::Error::other)?;
768            primary.slots = RwLock::new(
769                catalog
770                    .slots
771                    .into_iter()
772                    .map(|slot| (slot.replica_id.clone(), slot))
773                    .collect(),
774            );
775        } else {
776            // Validate legacy sources before importing. An unreadable source
777            // must not silently become an empty authoritative catalog.
778            let mut valid_source = false;
779            let mut import_error = None;
780            for path in [&primary.slot_catalog_path, &primary.slot_path]
781                .into_iter()
782                .flatten()
783            {
784                if path.try_exists()? {
785                    let result = if primary.slot_catalog_path.as_ref() == Some(path) {
786                        reddb_file::ReplicationSlotCatalog::read_from_path(path)
787                    } else {
788                        reddb_file::ReplicationSlotCatalog::read_legacy_json_from_path(
789                            path,
790                            crate::utils::now_unix_millis() as u128,
791                        )
792                    };
793                    match result {
794                        Ok(_) => valid_source = true,
795                        Err(error) => import_error = Some(error),
796                    }
797                }
798            }
799            if !valid_source {
800                if let Some(error) = import_error {
801                    return Err(io::Error::other(error));
802                }
803            }
804        }
805        {
806            let now_ms = crate::utils::now_unix_millis();
807            let history = match &primary.primary_replica_file_plan {
808                Some(plan) => super::metadata::timeline(&store, &plan.timeline_history_path())?,
809                None => reddb_file::TimelineHistory::new(now_ms),
810            };
811            let mut slots = primary
812                .slots
813                .write()
814                .unwrap_or_else(|error| error.into_inner());
815            primary.slot_timeline =
816                slots::align_slots_with_timeline(&mut slots, &history, u128::from(now_ms))?;
817            let bytes = slots::slot_catalog_from_map(&slots, primary.slot_timeline)
818                .and_then(|catalog| catalog.encode())
819                .map_err(io::Error::other)?;
820            if stored_catalog.as_deref() != Some(bytes.as_slice()) {
821                super::metadata::write(&store, super::metadata::SLOTS, &bytes)?;
822            }
823        }
824        for path in [&primary.slot_catalog_path, &primary.slot_path]
825            .into_iter()
826            .flatten()
827        {
828            super::metadata::retire_legacy(path)?;
829        }
830        primary.metadata_store = Some(store);
831        Ok(primary)
832    }
833
834    fn current_timeline(
835        &self,
836        root: &Path,
837    ) -> Result<reddb_file::TimelineId, reddb_file::RdbFileError> {
838        if let Some(store) = &self.metadata_store {
839            let path =
840                reddb_file::PrimaryReplicaFilePlan::new(root, reddb_file::TimelineId::initial())
841                    .timeline_history_path();
842            return super::metadata::timeline(store, &path)
843                .map(|history| {
844                    history
845                        .current()
846                        .unwrap_or_else(reddb_file::TimelineId::initial)
847                })
848                .map_err(reddb_file::RdbFileError::Io);
849        }
850        Self::primary_replica_current_timeline_for_root(root)
851    }
852
853    pub fn append_logical_record(&self, lsn: u64, encoded: Vec<u8>) {
854        if let Err(error) = self.try_append_logical_record(lsn, encoded) {
855            warn!(%error, lsn, "failed to publish logical replication record");
856        }
857    }
858
859    /// Publish durable projections before making a record available to readers.
860    /// Replaying a committed outbox is idempotent, including a crash between the
861    /// segment append and the spool append.
862    pub(crate) fn try_append_logical_record(&self, lsn: u64, encoded: Vec<u8>) -> io::Result<()> {
863        let _guard = self
864            .primary_replica_wal_lock
865            .lock()
866            .unwrap_or_else(|error| error.into_inner());
867        if let Some(plan) = &self.primary_replica_file_plan {
868            let timeline = self
869                .current_timeline(&plan.root)
870                .map_err(io::Error::other)?;
871            let plan = reddb_file::PrimaryReplicaFilePlan::new(plan.root.clone(), timeline);
872            plan.append_wal_record(lsn, &encoded)
873                .map_err(io::Error::other)?;
874        }
875        if let Some(spool) = &self.logical_wal_spool {
876            if spool.current_lsn() < lsn {
877                spool.append(lsn, &encoded)?;
878            }
879        }
880        if self.wal_buffer.current_lsn() < lsn {
881            self.wal_buffer.append(lsn, encoded);
882        }
883        let (lock, cvar) = &self.wal_appended;
884        let mut latest = lock.lock().unwrap_or_else(|error| error.into_inner());
885        *latest = (*latest).max(lsn);
886        cvar.notify_all();
887        Ok(())
888    }
889
890    pub fn wait_for_logical_lsn_after(&self, since_lsn: u64, timeout: Duration) -> bool {
891        if self.current_logical_lsn() > since_lsn {
892            return true;
893        }
894        let deadline = Instant::now() + timeout;
895        let (lock, cvar) = &self.wal_appended;
896        let mut latest = lock.lock().unwrap_or_else(|e| e.into_inner());
897        while *latest <= since_lsn {
898            let now = Instant::now();
899            if now >= deadline {
900                return false;
901            }
902            let remaining = deadline.saturating_duration_since(now);
903            let (guard, result) = cvar
904                .wait_timeout(latest, remaining)
905                .unwrap_or_else(|e| e.into_inner());
906            latest = guard;
907            if result.timed_out() && *latest <= since_lsn {
908                return false;
909            }
910        }
911        true
912    }
913
914    pub fn register_replica(&self, id: String) -> u64 {
915        self.register_replica_with_region(id, None)
916    }
917
918    /// Register a replica with an explicit region tag (Phase 2.6 multi-region).
919    ///
920    /// Preferred when the replica handshake declares a region — the quorum
921    /// coordinator uses this field to decide whether the replica counts
922    /// toward a `QuorumMode::Regions` commit.
923    ///
924    /// Idempotent on reconnect (issue #812): if a replica with `id` is
925    /// already registered, the existing entry is *updated in place* rather
926    /// than duplicated — progress LSNs (`last_acked_lsn`, `last_sent_lsn`,
927    /// `last_durable_lsn`) are preserved so a reconnecting replica is not
928    /// rewound, only `last_seen_at_unix_ms` is refreshed (and `region` when
929    /// a non-`None` value is supplied). A re-registration is not a
930    /// registry-shape change, so it does **not** bump the topology epoch.
931    /// Returns the slot `restart_lsn` the replica should resume streaming from:
932    /// the current WAL LSN for a fresh registration, or the durable slot
933    /// restart point for a reconnect.
934    pub fn register_replica_with_region(&self, id: String, region: Option<String>) -> u64 {
935        self.register_replica_with_region_at(id, region, self.current_logical_lsn())
936    }
937
938    fn register_replica_with_region_at(
939        &self,
940        id: String,
941        region: Option<String>,
942        initial_lsn: u64,
943    ) -> u64 {
944        let now_ms = crate::utils::now_unix_millis() as u128;
945        let resume_lsn = self.ensure_slot(&id, initial_lsn);
946        let mut replicas = self.replicas.write().unwrap_or_else(|e| e.into_inner());
947        if let Some(existing) = replicas.iter_mut().find(|r| r.id == id) {
948            existing.last_seen_at_unix_ms = now_ms;
949            if region.is_some() {
950                existing.region = region;
951            }
952            return resume_lsn;
953        }
954        replicas.push(ReplicaState {
955            id,
956            last_acked_lsn: resume_lsn,
957            last_sent_lsn: resume_lsn,
958            last_durable_lsn: resume_lsn,
959            apply_error_count: 0,
960            divergence_count: 0,
961            connected_at_unix_ms: now_ms,
962            last_seen_at_unix_ms: now_ms,
963            region,
964            rebootstrapping: false,
965        });
966        drop(replicas);
967        self.bump_topology_epoch();
968        resume_lsn
969    }
970
971    /// Mark (or clear) a replica's re-bootstrap state (issue #837).
972    ///
973    /// While `rebootstrapping` is `true` the replica keeps serving
974    /// non-causal reads from its existing data, but the advertiser
975    /// surfaces the flag so causal (bookmark) reads route to a
976    /// caught-up peer instead — the rebuilding replica's applied
977    /// frontier describes data it is about to discard. The primary
978    /// flips this back to `false` when the replica reports its atomic
979    /// snapshot swap complete.
980    ///
981    /// A change to the flag is a registry-shape change for routing
982    /// purposes, so it bumps the topology epoch to force consumers to
983    /// re-read the advertisement. Returns `true` when a replica with
984    /// `id` was present and updated.
985    pub fn set_replica_rebootstrapping(&self, id: &str, rebootstrapping: bool) -> bool {
986        let mut replicas = self.replicas.write().unwrap_or_else(|e| e.into_inner());
987        let Some(state) = replicas.iter_mut().find(|r| r.id == id) else {
988            return false;
989        };
990        if state.rebootstrapping == rebootstrapping {
991            return true;
992        }
993        state.rebootstrapping = rebootstrapping;
994        drop(replicas);
995        self.bump_topology_epoch();
996        true
997    }
998
999    /// Ensure a replica identifying itself with `id` is present in the
1000    /// registry (issue #812). This is the production self-registration hook
1001    /// used by the `pull_wal_records` path: the first time a replica sends
1002    /// its `replica_id` on a pull, the primary registers it so it is no
1003    /// longer blind to that replica's existence; subsequent pulls are
1004    /// idempotent no-ops. Returns `true` when a new registration was
1005    /// created. A fresh slot starts at the replica's requested cursor, bounded
1006    /// by the primary's current LSN, so an empty replica does not skip WAL and
1007    /// a cursor from a different primary cannot publish future progress.
1008    /// Reconnects preserve existing progress and do not bump the topology epoch.
1009    pub fn ensure_replica_registered(&self, id: &str, requested_since_lsn: u64) -> bool {
1010        let already = self
1011            .replicas
1012            .read()
1013            .unwrap_or_else(|e| e.into_inner())
1014            .iter()
1015            .any(|r| r.id == id);
1016        if already {
1017            return false;
1018        }
1019        let initial_lsn = requested_since_lsn.min(self.current_logical_lsn());
1020        self.register_replica_with_region_at(id.to_string(), None, initial_lsn);
1021        true
1022    }
1023
1024    /// Unregister a replica by id. Returns `true` when the replica
1025    /// was present (and removed). Bumps the topology epoch so a
1026    /// pending advertisement reflects the new fleet size.
1027    pub fn unregister_replica(&self, id: &str) -> bool {
1028        let mut replicas = self.replicas.write().unwrap_or_else(|e| e.into_inner());
1029        let before = replicas.len();
1030        replicas.retain(|r| r.id != id);
1031        let removed = replicas.len() != before;
1032        drop(replicas);
1033        if removed {
1034            self.routing_endpoints
1035                .write()
1036                .expect("replica routing endpoint map lock must not be poisoned")
1037                .remove(id);
1038            self.commit_waiter.drop_replica(id);
1039            self.bump_topology_epoch();
1040        }
1041        removed
1042    }
1043
1044    /// Current topology epoch. Strictly monotonic, bumps on every
1045    /// registry-shape change consumed by `TopologyAdvertiser`.
1046    pub fn topology_epoch(&self) -> u64 {
1047        self.topology_epoch
1048            .load(std::sync::atomic::Ordering::Relaxed)
1049    }
1050
1051    /// Advance the topology epoch. Call sites: register, unregister,
1052    /// and the health-sweep tick that flips a replica between
1053    /// healthy/unhealthy. Wrapping is not a concern in practice
1054    /// (`u64::MAX` events would take centuries at any realistic ack
1055    /// rate) but `fetch_add` saturates implicitly via wrap-around;
1056    /// the consumer treats epoch as opaque so a wrap is still
1057    /// strictly "different" from the previous value.
1058    pub fn bump_topology_epoch(&self) {
1059        self.topology_epoch
1060            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1061    }
1062
1063    pub fn ack_replica(&self, id: &str, lsn: u64) {
1064        let now_ms = crate::utils::now_unix_millis() as u128;
1065        let mut replicas = self.replicas.write().unwrap_or_else(|e| e.into_inner());
1066        if let Some(r) = replicas.iter_mut().find(|r| r.id == id) {
1067            r.last_acked_lsn = r.last_acked_lsn.max(lsn);
1068            r.last_durable_lsn = r.last_durable_lsn.max(lsn);
1069            r.last_seen_at_unix_ms = now_ms;
1070        }
1071        drop(replicas);
1072        self.commit_waiter.record_replica_ack(id, lsn);
1073    }
1074
1075    /// PLAN.md Phase 11.4 — replica reports applied + durable LSN
1076    /// after persisting a batch. Idempotent: only advances LSNs
1077    /// monotonically. `last_seen_at_unix_ms` always refreshes.
1078    /// Also signals `commit_waiter` so any thread blocked on
1079    /// `ack_n` / `quorum` can wake and re-check its threshold.
1080    pub fn ack_replica_lsn(&self, id: &str, applied_lsn: u64, durable_lsn: u64) {
1081        self.ack_replica_lsn_with_observability(id, applied_lsn, durable_lsn, 0, 0);
1082    }
1083
1084    pub fn ack_replica_lsn_with_observability(
1085        &self,
1086        id: &str,
1087        applied_lsn: u64,
1088        durable_lsn: u64,
1089        apply_error_count: u64,
1090        divergence_count: u64,
1091    ) {
1092        let now_ms = crate::utils::now_unix_millis() as u128;
1093        self.advance_slot(id, applied_lsn, durable_lsn, now_ms);
1094        let mut replicas = self.replicas.write().unwrap_or_else(|e| e.into_inner());
1095        if let Some(r) = replicas.iter_mut().find(|r| r.id == id) {
1096            r.last_acked_lsn = r.last_acked_lsn.max(applied_lsn);
1097            r.last_durable_lsn = r.last_durable_lsn.max(durable_lsn);
1098            r.apply_error_count = r.apply_error_count.max(apply_error_count);
1099            r.divergence_count = r.divergence_count.max(divergence_count);
1100            r.last_seen_at_unix_ms = now_ms;
1101        }
1102        // Drop the write lock before signaling so a waiter that
1103        // wakes immediately can read replica state without
1104        // contending against us.
1105        drop(replicas);
1106        self.commit_waiter.record_replica_ack(id, durable_lsn);
1107    }
1108
1109    /// PLAN.md Phase 11.4 — primary records the LSN it last sent to a
1110    /// replica via pull_wal_records. Helpful for `lag_records =
1111    /// last_sent_lsn - last_acked_lsn` to distinguish pull-side delay
1112    /// from apply-side delay.
1113    pub fn note_replica_pull(&self, id: &str, last_sent_lsn: u64) {
1114        let now_ms = crate::utils::now_unix_millis() as u128;
1115        self.touch_slot(id, now_ms);
1116        let mut replicas = self.replicas.write().unwrap_or_else(|e| e.into_inner());
1117        if let Some(r) = replicas.iter_mut().find(|r| r.id == id) {
1118            r.last_sent_lsn = r.last_sent_lsn.max(last_sent_lsn);
1119            r.last_seen_at_unix_ms = now_ms;
1120        }
1121    }
1122
1123    /// Snapshot of all currently registered replicas, for /metrics +
1124    /// /admin/status. Returns owned clones so callers don't hold the
1125    /// lock during serialization.
1126    pub fn replica_snapshots(&self) -> Vec<ReplicaState> {
1127        self.replicas
1128            .read()
1129            .unwrap_or_else(|e| e.into_inner())
1130            .clone()
1131    }
1132
1133    pub fn replication_progress(&self) -> Option<ReplicationProgress> {
1134        let replicas = self.replicas.read().unwrap_or_else(|e| e.into_inner());
1135        ReplicationProgress::from_replicas(&replicas)
1136    }
1137
1138    pub fn slot_snapshots(&self) -> Vec<ReplicationSlot> {
1139        self.slots
1140            .read()
1141            .unwrap_or_else(|e| e.into_inner())
1142            .values()
1143            .cloned()
1144            .collect()
1145    }
1146
1147    pub fn retention_floor_lsn(&self) -> Option<u64> {
1148        // A slot update publishes its durability result while holding this lock.
1149        // Check the error only after joining that same publication boundary.
1150        let slots = self.slots.read().unwrap_or_else(|e| e.into_inner());
1151        if self.ensure_slot_metadata_durable().is_err() {
1152            return Some(0);
1153        }
1154        slots
1155            .values()
1156            .filter(|slot| slot.invalidation_reason.is_none())
1157            .map(|slot| slot.restart_lsn)
1158            .min()
1159    }
1160
1161    pub fn prune_retained_wal_through(&self, archived_lsn: u64) -> io::Result<u64> {
1162        self.enforce_retention_limits(crate::utils::now_unix_millis() as u128);
1163        let prune_lsn = self
1164            .retention_floor_lsn()
1165            .map(|floor| floor.min(archived_lsn))
1166            .unwrap_or(archived_lsn);
1167        if prune_lsn > 0 {
1168            if let Some(spool) = &self.logical_wal_spool {
1169                spool.prune_through(prune_lsn)?;
1170            }
1171            self.wal_buffer.prune_through(prune_lsn);
1172        }
1173        Ok(prune_lsn)
1174    }
1175
1176    pub fn replica_count(&self) -> usize {
1177        self.replicas
1178            .read()
1179            .unwrap_or_else(|e| e.into_inner())
1180            .len()
1181    }
1182
1183    /// Current primary write position (logical WAL LSN, falling back to
1184    /// the in-memory WAL buffer). Used as the reference point for
1185    /// per-replica lag — including issue #826 flow control.
1186    pub fn current_logical_lsn(&self) -> u64 {
1187        self.logical_wal_spool
1188            .as_ref()
1189            .map(|spool| spool.current_lsn())
1190            .unwrap_or_else(|| self.wal_buffer.current_lsn())
1191    }
1192
1193    fn ensure_slot(&self, id: &str, initial_lsn: u64) -> u64 {
1194        let now_ms = crate::utils::now_unix_millis() as u128;
1195        let _publication = self
1196            .metadata_store
1197            .as_ref()
1198            .map(|store| store.vault_publication_lock.lock());
1199        let mut slots = self.slots.write().unwrap_or_else(|e| e.into_inner());
1200        if let Some(slot) = slots.get_mut(id) {
1201            slot.last_seen_at_unix_ms = now_ms;
1202            let restart_lsn = slot.restart_lsn;
1203            self.persist_slots_locked(&slots);
1204            return restart_lsn;
1205        }
1206        let mut slot = ReplicationSlot::new(id.to_string(), self.slot_timeline, initial_lsn);
1207        slot.last_seen_at_unix_ms = now_ms;
1208        slots.insert(id.to_string(), slot);
1209        let restart_lsn = initial_lsn;
1210        self.persist_slots_locked(&slots);
1211        restart_lsn
1212    }
1213
1214    fn advance_slot(&self, id: &str, confirmed_lsn: u64, restart_lsn: u64, now_ms: u128) {
1215        let _publication = self
1216            .metadata_store
1217            .as_ref()
1218            .map(|store| store.vault_publication_lock.lock());
1219        let mut slots = self.slots.write().unwrap_or_else(|e| e.into_inner());
1220        let slot = slots.entry(id.to_string()).or_insert_with(|| {
1221            let mut slot = ReplicationSlot::new(id.to_string(), self.slot_timeline, 0);
1222            slot.last_seen_at_unix_ms = now_ms;
1223            slot
1224        });
1225        if slot.invalidation_reason.is_some() {
1226            return;
1227        }
1228        slot.confirmed_write_lsn = slot.confirmed_lsn().max(confirmed_lsn).max(restart_lsn);
1229        slot.restart_lsn = slot.restart_lsn.max(restart_lsn);
1230        slot.confirmed_flush_lsn = slot.confirmed_flush_lsn.max(slot.restart_lsn);
1231        slot.confirmed_apply_lsn = slot.confirmed_apply_lsn.max(slot.restart_lsn);
1232        slot.last_seen_at_unix_ms = now_ms;
1233        self.persist_slots_locked(&slots);
1234    }
1235
1236    pub fn touch_slot(&self, id: &str, now_ms: u128) {
1237        let _publication = self
1238            .metadata_store
1239            .as_ref()
1240            .map(|store| store.vault_publication_lock.lock());
1241        let mut slots = self.slots.write().unwrap_or_else(|e| e.into_inner());
1242        let mut changed = false;
1243        if let Some(slot) = slots.get_mut(id) {
1244            if slot.invalidation_reason.is_none() {
1245                slot.last_seen_at_unix_ms = now_ms;
1246                changed = true;
1247            }
1248        }
1249        if changed {
1250            self.persist_slots_locked(&slots);
1251        }
1252    }
1253
1254    pub fn enforce_retention_limits(
1255        &self,
1256        now_ms: u128,
1257    ) -> Vec<(String, ReplicationSlotInvalidationCause)> {
1258        let current_lsn = self.current_logical_lsn();
1259        let mut invalidated = Vec::new();
1260        let _publication = self
1261            .metadata_store
1262            .as_ref()
1263            .map(|store| store.vault_publication_lock.lock());
1264        let mut slots = self.slots.write().unwrap_or_else(|e| e.into_inner());
1265        for slot in slots.values_mut() {
1266            if slot.invalidation_reason.is_some() {
1267                continue;
1268            }
1269            let reason = if self.slot_retention_max_lag_lsn > 0
1270                && current_lsn.saturating_sub(slot.restart_lsn) > self.slot_retention_max_lag_lsn
1271            {
1272                Some(ReplicationSlotInvalidationCause::Horizon)
1273            } else if self.slot_idle_timeout_ms > 0
1274                && now_ms.saturating_sub(slot.last_seen_at_unix_ms)
1275                    > u128::from(self.slot_idle_timeout_ms)
1276            {
1277                Some(ReplicationSlotInvalidationCause::IdleTimeout)
1278            } else {
1279                None
1280            };
1281            if let Some(reason) = reason {
1282                slot.invalidation_reason = Some(reason);
1283                slot.invalidated_at_unix_ms = Some(now_ms);
1284                invalidated.push((slot.replica_id.clone(), reason));
1285            }
1286        }
1287        if !invalidated.is_empty() {
1288            self.persist_slots_locked(&slots);
1289        }
1290        invalidated
1291    }
1292
1293    pub fn slot_rebootstrap_reason(
1294        &self,
1295        id: &str,
1296        requested_since_lsn: u64,
1297        oldest_available_lsn: Option<u64>,
1298    ) -> Option<ReplicationSlotInvalidationCause> {
1299        let now_ms = crate::utils::now_unix_millis() as u128;
1300        let _publication = self
1301            .metadata_store
1302            .as_ref()
1303            .map(|store| store.vault_publication_lock.lock());
1304        let mut slots = self.slots.write().unwrap_or_else(|e| e.into_inner());
1305        let slot = slots.get_mut(id)?;
1306        if let Some(reason) = slot.invalidation_reason {
1307            return Some(reason);
1308        }
1309        let slot_floor = slot.restart_lsn.max(requested_since_lsn);
1310        if oldest_available_lsn
1311            .map(|oldest| oldest > slot_floor.saturating_add(1))
1312            .unwrap_or(false)
1313        {
1314            slot.invalidation_reason = Some(ReplicationSlotInvalidationCause::WalRemoved);
1315            slot.invalidated_at_unix_ms = Some(now_ms);
1316            self.persist_slots_locked(&slots);
1317            return Some(ReplicationSlotInvalidationCause::WalRemoved);
1318        }
1319        None
1320    }
1321
1322    pub fn record_replica_rebootstrap_snapshot(
1323        &self,
1324        replica_id: &str,
1325        token: &str,
1326        timeline: reddb_file::TimelineId,
1327        checkpoint_lsn: u64,
1328        resumed_transfer: bool,
1329    ) -> io::Result<bool> {
1330        if token.is_empty() || token.len() > REBOOTSTRAP_PROOF_TOKEN_BYTES_MAX {
1331            return Err(io::Error::new(
1332                io::ErrorKind::InvalidInput,
1333                "invalid replica rebootstrap snapshot token",
1334            ));
1335        }
1336        if timeline != self.slot_timeline || checkpoint_lsn > self.current_logical_lsn() {
1337            return Err(io::Error::new(
1338                io::ErrorKind::InvalidInput,
1339                "replica rebootstrap snapshot is outside the primary frontier",
1340            ));
1341        }
1342        let _publication = self
1343            .metadata_store
1344            .as_ref()
1345            .map(|store| store.vault_publication_lock.lock());
1346        let slots = self.slots.read().unwrap_or_else(|error| error.into_inner());
1347        let Some(slot) = slots.get(replica_id) else {
1348            return Err(io::Error::new(
1349                io::ErrorKind::NotFound,
1350                "replica rebootstrap slot is not registered",
1351            ));
1352        };
1353        if slot.invalidation_reason.is_none() {
1354            return Ok(false);
1355        }
1356        let store = self.metadata_store.as_ref().ok_or_else(|| {
1357            io::Error::new(
1358                io::ErrorKind::Unsupported,
1359                "replica rebootstrap proof requires native metadata",
1360            )
1361        })?;
1362        let key = replica_rebootstrap_proof_key(replica_id);
1363        let proof = ReplicaRebootstrapProof {
1364            replica_id: replica_id.to_string(),
1365            timeline: timeline.0,
1366            checkpoint_lsn,
1367            token: token.to_string(),
1368        };
1369        if resumed_transfer {
1370            let bytes = super::metadata::read(store, &key)?
1371                .filter(|bytes| !bytes.is_empty())
1372                .ok_or_else(|| {
1373                    io::Error::new(
1374                        io::ErrorKind::InvalidData,
1375                        "replica rebootstrap snapshot token is no longer active",
1376                    )
1377                })?;
1378            let recorded = ReplicaRebootstrapProof::decode(&bytes)?;
1379            if recorded.replica_id != proof.replica_id
1380                || recorded.timeline != proof.timeline
1381                || recorded.checkpoint_lsn != proof.checkpoint_lsn
1382                || !crate::crypto::constant_time_eq(
1383                    recorded.token.as_bytes(),
1384                    proof.token.as_bytes(),
1385                )
1386            {
1387                return Err(io::Error::new(
1388                    io::ErrorKind::PermissionDenied,
1389                    "replica rebootstrap snapshot token does not match the issued snapshot",
1390                ));
1391            }
1392            return Ok(true);
1393        }
1394        let bytes = proof.encode()?;
1395        super::metadata::write(store, &key, &bytes)?;
1396        Ok(true)
1397    }
1398
1399    pub fn accept_replica_rebootstrap_snapshot(
1400        &self,
1401        replica_id: &str,
1402        token: Option<&str>,
1403        installed_lsn: u64,
1404    ) -> io::Result<bool> {
1405        let Some(token) = token else {
1406            return Ok(false);
1407        };
1408        if token.is_empty() || token.len() > REBOOTSTRAP_PROOF_TOKEN_BYTES_MAX {
1409            return Err(io::Error::new(
1410                io::ErrorKind::InvalidInput,
1411                "invalid installed replica snapshot token",
1412            ));
1413        }
1414        let current_lsn = self.current_logical_lsn();
1415        let _publication = self
1416            .metadata_store
1417            .as_ref()
1418            .map(|store| store.vault_publication_lock.lock());
1419        let mut slots = self
1420            .slots
1421            .write()
1422            .unwrap_or_else(|error| error.into_inner());
1423        let Some(slot) = slots.get(replica_id) else {
1424            return Err(io::Error::new(
1425                io::ErrorKind::NotFound,
1426                "installed replica snapshot has no registered slot",
1427            ));
1428        };
1429        if slot.invalidation_reason.is_none() {
1430            return Ok(false);
1431        }
1432        let store = self.metadata_store.as_ref().ok_or_else(|| {
1433            io::Error::new(
1434                io::ErrorKind::Unsupported,
1435                "installed replica snapshot proof requires native metadata",
1436            )
1437        })?;
1438        let key = replica_rebootstrap_proof_key(replica_id);
1439        let Some(bytes) = super::metadata::read(store, &key)?.filter(|bytes| !bytes.is_empty())
1440        else {
1441            // The proof is consumed before the repaired slot is published. If
1442            // the primary crashed in that interval, keep the slot invalidated
1443            // and let resume planning issue a new snapshot.
1444            return Ok(false);
1445        };
1446        let proof = ReplicaRebootstrapProof::decode(&bytes)?;
1447        if proof.replica_id != replica_id
1448            || proof.timeline != self.slot_timeline.0
1449            || slot.timeline != self.slot_timeline
1450            || proof.checkpoint_lsn != installed_lsn
1451            || installed_lsn > current_lsn
1452            || !crate::crypto::constant_time_eq(proof.token.as_bytes(), token.as_bytes())
1453        {
1454            return Err(io::Error::new(
1455                io::ErrorKind::PermissionDenied,
1456                "installed replica snapshot does not match the issued checkpoint",
1457            ));
1458        }
1459
1460        // Consume the proof before publishing the repaired slot. A crash in
1461        // between forces another snapshot instead of admitting an unproved
1462        // cursor on the next boot.
1463        super::metadata::write(store, &key, &[])?;
1464        let mut repaired = slots.clone();
1465        let repaired_slot = repaired
1466            .get_mut(replica_id)
1467            .expect("invariant: repaired slot was cloned from the registered slot map");
1468        repaired_slot.timeline = self.slot_timeline;
1469        repaired_slot.restart_lsn = installed_lsn;
1470        repaired_slot.confirmed_write_lsn = installed_lsn;
1471        repaired_slot.confirmed_flush_lsn = installed_lsn;
1472        repaired_slot.confirmed_apply_lsn = installed_lsn;
1473        repaired_slot.active = true;
1474        repaired_slot.last_seen_at_unix_ms = crate::utils::now_unix_millis() as u128;
1475        repaired_slot.invalidation_reason = None;
1476        repaired_slot.invalidated_at_unix_ms = None;
1477        let bytes = slots::slot_catalog_from_map(&repaired, self.slot_timeline)
1478            .and_then(|catalog| catalog.encode())
1479            .map_err(io::Error::other)?;
1480        super::metadata::write(store, super::metadata::SLOTS, &bytes)?;
1481        *slots = repaired;
1482        drop(slots);
1483        self.set_replica_rebootstrapping(replica_id, false);
1484        Ok(true)
1485    }
1486
1487    /// Decide how a reconnecting replica's pull should be served
1488    /// (issue #832). If the slot is invalidated or the requested
1489    /// position has fallen behind the retained WAL floor, the replica
1490    /// must re-bootstrap; otherwise it resumes via a partial resync
1491    /// from its slot position (never rewound behind it). Every
1492    /// partial-resync decision bumps the `partial_resync_count` metric
1493    /// so a brief disconnect that recovers without a full re-bootstrap
1494    /// is observable.
1495    pub fn plan_replica_resume(
1496        &self,
1497        id: &str,
1498        requested_since_lsn: u64,
1499        oldest_available_lsn: Option<u64>,
1500    ) -> ResumeMode {
1501        if let Some(cause) =
1502            self.slot_rebootstrap_reason(id, requested_since_lsn, oldest_available_lsn)
1503        {
1504            self.full_resync_count
1505                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1506            return ResumeMode::FullRebootstrap { cause };
1507        }
1508        let resume_lsn = self
1509            .slot_snapshots()
1510            .into_iter()
1511            .find(|slot| slot.replica_id == id)
1512            .map(|slot| requested_since_lsn.max(slot.restart_lsn))
1513            .unwrap_or(requested_since_lsn);
1514        self.partial_resync_count
1515            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1516        ResumeMode::PartialResync { resume_lsn }
1517    }
1518
1519    /// Number of pulls served as a partial resync since process start.
1520    /// Surfaced in the replication metrics/status payload (issue #832).
1521    pub fn partial_resync_count(&self) -> u64 {
1522        self.partial_resync_count
1523            .load(std::sync::atomic::Ordering::Relaxed)
1524    }
1525
1526    /// Number of pulls that forced a full re-bootstrap since process
1527    /// start (issue #839). Surfaced as `reddb_replication_full_resync_total`
1528    /// and in `/replication/status` — the primary operator alert signal.
1529    pub fn full_resync_count(&self) -> u64 {
1530        self.full_resync_count
1531            .load(std::sync::atomic::Ordering::Relaxed)
1532    }
1533
1534    fn persist_slots_locked(&self, slots: &BTreeMap<String, ReplicationSlot>) {
1535        if let Some(store) = &self.metadata_store {
1536            let result = slots::slot_catalog_from_map(slots, self.slot_timeline)
1537                .and_then(|catalog| catalog.encode())
1538                .map_err(io::Error::other)
1539                .and_then(|bytes| super::metadata::write(store, super::metadata::SLOTS, &bytes));
1540            let mut last_error = self
1541                .metadata_error
1542                .lock()
1543                .unwrap_or_else(|error| error.into_inner());
1544            if let Err(error) = result {
1545                *last_error = Some(error.to_string());
1546                warn!(target: "reddb::replication::slots", %error,
1547                    "failed to persist replication slots inside database");
1548            } else {
1549                *last_error = None;
1550            }
1551            return;
1552        }
1553        if let Err(err) =
1554            persist_replication_slots(self.slot_path.as_deref(), slots, self.slot_timeline)
1555        {
1556            warn!(
1557                target: "reddb::replication::slots",
1558                error = %err,
1559                "failed to persist replication slots"
1560            );
1561        }
1562        if let Err(err) = persist_replication_slot_catalog(
1563            self.slot_catalog_path.as_deref(),
1564            slots,
1565            self.slot_timeline,
1566        ) {
1567            warn!(
1568                target: "reddb::replication::slots",
1569                error = %err,
1570                "failed to persist binary replication slot catalog"
1571            );
1572        }
1573    }
1574
1575    pub(crate) fn ensure_slot_metadata_durable(&self) -> io::Result<()> {
1576        match self
1577            .metadata_error
1578            .lock()
1579            .unwrap_or_else(|error| error.into_inner())
1580            .as_ref()
1581        {
1582            Some(error) => Err(io::Error::other(error.clone())),
1583            None => Ok(()),
1584        }
1585    }
1586}
1587
1588#[cfg(test)]
1589mod tests;