use std::collections::{BTreeMap, VecDeque};
use std::fs::{self, File, OpenOptions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Condvar, Mutex, RwLock};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use reddb_file::{ReplicationSlot, ReplicationSlotInvalidationCause};
use tracing::warn;
mod slots;
use slots::{
load_replication_slot_catalog, load_replication_slots, persist_replication_slot_catalog,
persist_replication_slots,
};
fn term_from_payload(payload: &[u8]) -> u64 {
crate::replication::cdc::ChangeRecord::decode(payload)
.map(|record| record.term)
.unwrap_or(crate::replication::DEFAULT_REPLICATION_TERM)
}
fn authority_from_payload(payload: &[u8]) -> (u64, Option<u64>) {
crate::replication::cdc::ChangeRecord::decode(payload)
.map(|record| (record.term, record.ownership_epoch))
.unwrap_or((crate::replication::DEFAULT_REPLICATION_TERM, None))
}
pub struct WalBuffer {
records: RwLock<VecDeque<(u64, Arc<[u8]>)>>,
current_lsn: RwLock<u64>,
}
impl WalBuffer {
pub fn new(max_size: usize) -> Self {
Self {
records: RwLock::new(VecDeque::with_capacity(max_size)),
current_lsn: RwLock::new(0),
}
}
pub fn append(&self, lsn: u64, data: Vec<u8>) {
let mut records = self.records.write().unwrap_or_else(|e| e.into_inner());
records.push_back((lsn, Arc::from(data.into_boxed_slice())));
let mut current = self.current_lsn.write().unwrap_or_else(|e| e.into_inner());
*current = (*current).max(lsn);
}
pub fn read_since(&self, since_lsn: u64, max_count: usize) -> Vec<(u64, Vec<u8>)> {
self.read_since_shared(since_lsn, max_count)
.into_iter()
.map(|(lsn, data)| (lsn, data.to_vec()))
.collect()
}
pub fn read_since_shared(&self, since_lsn: u64, max_count: usize) -> Vec<(u64, Arc<[u8]>)> {
let records = self.records.read().unwrap_or_else(|e| e.into_inner());
records
.iter()
.filter(|(lsn, _)| *lsn > since_lsn)
.take(max_count)
.cloned()
.collect()
}
pub fn read_since_shared_bounded(
&self,
since_lsn: u64,
max_count: usize,
max_payload_bytes: usize,
) -> Vec<(u64, Arc<[u8]>)> {
let records = self.records.read().unwrap_or_else(|e| e.into_inner());
let mut payload_bytes = 0usize;
records
.iter()
.filter(|(lsn, _)| *lsn > since_lsn)
.take(max_count)
.take_while(|(_, data)| {
let next_payload_bytes = payload_bytes.saturating_add(data.len());
let include = payload_bytes == 0 || next_payload_bytes <= max_payload_bytes;
if include {
payload_bytes = next_payload_bytes;
}
include
})
.cloned()
.collect()
}
pub fn current_lsn(&self) -> u64 {
*self.current_lsn.read().unwrap_or_else(|e| e.into_inner())
}
pub fn set_current_lsn(&self, lsn: u64) {
let mut current = self.current_lsn.write().unwrap_or_else(|e| e.into_inner());
*current = (*current).max(lsn);
}
pub fn prune_through(&self, upto_lsn: u64) {
let mut records = self.records.write().unwrap_or_else(|e| e.into_inner());
while records
.front()
.map(|(lsn, _)| *lsn <= upto_lsn)
.unwrap_or(false)
{
records.pop_front();
}
}
pub fn oldest_lsn(&self) -> Option<u64> {
let records = self.records.read().unwrap_or_else(|e| e.into_inner());
records.front().map(|(lsn, _)| *lsn)
}
}
fn logical_wal_entry_term(entry: &reddb_file::LogicalWalEntry) -> u64 {
if entry.term == 0 {
term_from_payload(&entry.data)
} else {
entry.term
}
}
fn logical_wal_data_with_framing_term(entry: &reddb_file::LogicalWalEntry) -> Vec<u8> {
let term = logical_wal_entry_term(entry);
match crate::replication::cdc::ChangeRecord::decode(&entry.data) {
Ok(mut record)
if record.term != term || record.ownership_epoch != entry.ownership_epoch =>
{
record.term = term;
if entry.ownership_epoch.is_some() {
record.ownership_epoch = entry.ownership_epoch;
}
record.encode()
}
_ => entry.data.clone(),
}
}
#[derive(Debug, Default)]
struct LogicalWalSpoolState {
current_lsn: u64,
seek_index: Vec<(u64, u64)>,
write_offset: u64,
record_count: u64,
}
impl LogicalWalSpoolState {
fn note_record(&mut self, ordinal: u64, lsn: u64, offset: u64) {
if ordinal.is_multiple_of(reddb_file::LOGICAL_WAL_SEEK_INDEX_INTERVAL) {
if self.seek_index.last().map(|(l, _)| *l) != Some(lsn) {
self.seek_index.push((lsn, offset));
}
}
}
fn seek_floor_offset(&self, since_lsn: u64) -> u64 {
match self
.seek_index
.binary_search_by(|(lsn, _)| lsn.cmp(&since_lsn))
{
Ok(idx) => self.seek_index[idx].1,
Err(0) => 0,
Err(idx) => self.seek_index[idx - 1].1,
}
}
}
pub struct LogicalWalSpool {
path: PathBuf,
state: Mutex<LogicalWalSpoolState>,
}
impl LogicalWalSpool {
pub fn path_for(data_path: &Path) -> PathBuf {
reddb_file::layout::logical_wal_path(data_path)
}
pub fn open(data_path: &Path) -> io::Result<Self> {
let path = Self::path_for(data_path);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
if !path.exists() {
File::create(&path)?;
}
let entries = reddb_file::read_and_repair_logical_wal_entries(&path)?;
let current_lsn = entries.last().map(|entry| entry.lsn).unwrap_or(0);
let (seek_index, write_offset, record_count) =
reddb_file::build_logical_wal_seek_index(&path)?;
Ok(Self {
path,
state: Mutex::new(LogicalWalSpoolState {
current_lsn,
seek_index,
write_offset,
record_count,
}),
})
}
pub fn append(&self, lsn: u64, data: &[u8]) -> io::Result<()> {
let timestamp_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
self.append_with_timestamp(lsn, timestamp_ms, data)
}
pub fn append_with_timestamp(
&self,
lsn: u64,
timestamp_ms: u64,
data: &[u8],
) -> io::Result<()> {
let (term, ownership_epoch) = authority_from_payload(data);
self.append_with_term_epoch_and_timestamp(term, ownership_epoch, lsn, timestamp_ms, data)
}
pub fn append_with_term_and_timestamp(
&self,
term: u64,
lsn: u64,
timestamp_ms: u64,
data: &[u8],
) -> io::Result<()> {
let (_, ownership_epoch) = authority_from_payload(data);
self.append_with_term_epoch_and_timestamp(term, ownership_epoch, lsn, timestamp_ms, data)
}
pub fn append_with_term_epoch_and_timestamp(
&self,
term: u64,
ownership_epoch: Option<u64>,
lsn: u64,
timestamp_ms: u64,
data: &[u8],
) -> io::Result<()> {
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)?;
let frame =
reddb_file::encode_logical_wal_v3(term, ownership_epoch, lsn, timestamp_ms, data)?;
file.write_all(&frame)?;
file.sync_all()?;
let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
state.current_lsn = state.current_lsn.max(lsn);
let record_start = state.write_offset;
let ordinal = state.record_count;
state.note_record(ordinal, lsn, record_start);
state.write_offset = record_start + frame.len() as u64;
state.record_count = ordinal + 1;
Ok(())
}
pub fn read_since(&self, since_lsn: u64, max_count: usize) -> io::Result<Vec<(u64, Vec<u8>)>> {
let start_offset = {
let state = self.state.lock().unwrap_or_else(|e| e.into_inner());
state.seek_floor_offset(since_lsn)
};
let entries = reddb_file::read_logical_wal_entries_from(&self.path, start_offset)?;
Ok(entries
.into_iter()
.filter(|entry| entry.lsn > since_lsn)
.take(max_count)
.map(|entry| (entry.lsn, logical_wal_data_with_framing_term(&entry)))
.collect())
}
pub fn read_since_bounded(
&self,
since_lsn: u64,
max_count: usize,
max_payload_bytes: usize,
) -> io::Result<Vec<(u64, Vec<u8>)>> {
let start_offset = {
let state = self.state.lock().unwrap_or_else(|e| e.into_inner());
state.seek_floor_offset(since_lsn)
};
Ok(reddb_file::read_logical_wal_entries_from_bounded(
&self.path,
start_offset,
since_lsn,
max_count,
max_payload_bytes,
)?
.into_iter()
.map(|entry| (entry.lsn, logical_wal_data_with_framing_term(&entry)))
.collect())
}
#[cfg(test)]
fn seek_floor_offset(&self, since_lsn: u64) -> u64 {
self.state
.lock()
.unwrap_or_else(|e| e.into_inner())
.seek_floor_offset(since_lsn)
}
pub fn current_lsn(&self) -> u64 {
self.state
.lock()
.unwrap_or_else(|e| e.into_inner())
.current_lsn
}
pub fn oldest_lsn(&self) -> io::Result<Option<u64>> {
Ok(reddb_file::read_and_repair_logical_wal_entries(&self.path)?
.into_iter()
.next()
.map(|entry| entry.lsn))
}
pub fn prune_through(&self, upto_lsn: u64) -> io::Result<()> {
let previous_lsn = self.current_lsn();
let mut retained: Vec<_> = reddb_file::read_and_repair_logical_wal_entries(&self.path)?
.into_iter()
.filter(|entry| entry.lsn > upto_lsn)
.collect();
for entry in &mut retained {
entry.term = logical_wal_entry_term(entry);
}
let temp_path = reddb_file::layout::logical_wal_temp_path(&self.path);
for entry in &mut retained {
let timestamp_ms = if entry.timestamp_ms > 0 {
entry.timestamp_ms
} else {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
};
entry.timestamp_ms = timestamp_ms;
}
let current_lsn =
reddb_file::rewrite_logical_wal_entries(&self.path, &temp_path, &retained)?;
let (seek_index, write_offset, record_count) =
reddb_file::build_logical_wal_seek_index(&self.path)?;
let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
state.current_lsn = previous_lsn.max(current_lsn).max(upto_lsn);
state.seek_index = seek_index;
state.write_offset = write_offset;
state.record_count = record_count;
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct ReplicaState {
pub id: String,
pub last_acked_lsn: u64,
pub last_sent_lsn: u64,
pub last_durable_lsn: u64,
pub apply_error_count: u64,
pub divergence_count: u64,
pub connected_at_unix_ms: u128,
pub last_seen_at_unix_ms: u128,
pub region: Option<String>,
pub rebootstrapping: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReplicationProgress {
pub lag_lsn: u64,
pub safe_replay_lsn: u64,
}
impl ReplicationProgress {
pub fn from_replicas(replicas: &[ReplicaState]) -> Option<Self> {
let max_sent_lsn = replicas.iter().map(|replica| replica.last_sent_lsn).max()?;
let min_acked_lsn = replicas
.iter()
.map(|replica| replica.last_acked_lsn)
.min()?;
let safe_replay_lsn = replicas
.iter()
.map(|replica| replica.last_durable_lsn)
.min()?;
Some(Self {
lag_lsn: max_sent_lsn.saturating_sub(min_acked_lsn),
safe_replay_lsn,
})
}
}
pub struct PrimaryReplication {
pub wal_buffer: Arc<WalBuffer>,
pub logical_wal_spool: Option<Arc<LogicalWalSpool>>,
pub replicas: RwLock<Vec<ReplicaState>>,
pub(crate) routing_endpoints: RwLock<BTreeMap<String, reddb_wire::routing::RoutingEndpoints>>,
wal_appended: (Mutex<u64>, Condvar),
metadata_store: Option<Arc<crate::storage::unified::UnifiedStore>>,
metadata_error: Mutex<Option<String>>,
slot_path: Option<PathBuf>,
slot_catalog_path: Option<PathBuf>,
primary_replica_file_plan: Option<reddb_file::PrimaryReplicaFilePlan>,
primary_replica_wal_lock: Mutex<()>,
slots: RwLock<BTreeMap<String, ReplicationSlot>>,
slot_retention_max_lag_lsn: u64,
slot_idle_timeout_ms: u64,
pub commit_waiter: Arc<crate::replication::commit_waiter::CommitWaiter>,
topology_epoch: std::sync::atomic::AtomicU64,
partial_resync_count: std::sync::atomic::AtomicU64,
full_resync_count: std::sync::atomic::AtomicU64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResumeMode {
PartialResync { resume_lsn: u64 },
FullRebootstrap {
cause: ReplicationSlotInvalidationCause,
},
}
impl PrimaryReplication {
pub fn slot_path_for(data_path: &Path) -> PathBuf {
reddb_file::layout::legacy_logical_slots_path(data_path)
}
pub fn primary_replica_root_for(data_path: &Path) -> PathBuf {
reddb_file::layout::primary_replica_root(data_path)
}
pub fn slot_catalog_path_for(data_path: &Path) -> PathBuf {
Self::primary_replica_file_plan_for(data_path).slots_path()
}
fn primary_replica_file_plan_for(data_path: &Path) -> reddb_file::PrimaryReplicaFilePlan {
let root = Self::primary_replica_root_for(data_path);
let timeline =
Self::primary_replica_current_timeline_for_root(&root).unwrap_or_else(|err| {
warn!(
target: "reddb::replication::primary",
error = %err,
"failed to read primary-replica timeline history; using initial timeline"
);
reddb_file::TimelineId::initial()
});
reddb_file::PrimaryReplicaFilePlan::new(root, timeline)
}
fn primary_replica_current_timeline_for_root(
root: &Path,
) -> Result<reddb_file::TimelineId, reddb_file::RdbFileError> {
let path = reddb_file::PrimaryReplicaFilePlan::new(root, reddb_file::TimelineId::initial())
.timeline_history_path();
match reddb_file::TimelineHistory::read_from_path(&path) {
Ok(history) => Ok(history
.current()
.unwrap_or_else(reddb_file::TimelineId::initial)),
Err(reddb_file::RdbFileError::Io(err))
if err.kind() == std::io::ErrorKind::NotFound =>
{
Ok(reddb_file::TimelineId::initial())
}
Err(err) => Err(err),
}
}
pub fn new(data_path: Option<&Path>) -> Self {
Self::new_with_config(data_path, &crate::replication::ReplicationConfig::primary())
}
pub fn new_with_config(
data_path: Option<&Path>,
config: &crate::replication::ReplicationConfig,
) -> Self {
let now_ms = crate::utils::now_unix_millis() as u128;
let slot_path = data_path.map(Self::slot_path_for);
let slot_catalog_path = data_path.map(Self::slot_catalog_path_for);
let primary_replica_file_plan = data_path.map(Self::primary_replica_file_plan_for);
let mut slots = load_replication_slot_catalog(slot_catalog_path.as_deref(), now_ms);
slots.extend(load_replication_slots(slot_path.as_deref(), now_ms));
let logical_wal_spool = data_path
.and_then(|path| LogicalWalSpool::open(path).ok())
.map(Arc::new);
let current_lsn = logical_wal_spool
.as_ref()
.map(|spool| spool.current_lsn())
.unwrap_or(0);
Self {
wal_buffer: Arc::new(WalBuffer::new(100_000)),
logical_wal_spool,
replicas: RwLock::new(Vec::new()),
routing_endpoints: RwLock::new(BTreeMap::new()),
wal_appended: (Mutex::new(current_lsn), Condvar::new()),
metadata_store: None,
metadata_error: Mutex::new(None),
slot_path,
slot_catalog_path,
primary_replica_file_plan,
primary_replica_wal_lock: Mutex::new(()),
slots: RwLock::new(slots),
slot_retention_max_lag_lsn: config.slot_retention_max_lag_lsn,
slot_idle_timeout_ms: config.slot_idle_timeout_ms,
commit_waiter: Arc::new(crate::replication::commit_waiter::CommitWaiter::new()),
topology_epoch: std::sync::atomic::AtomicU64::new(0),
partial_resync_count: std::sync::atomic::AtomicU64::new(0),
full_resync_count: std::sync::atomic::AtomicU64::new(0),
}
}
pub(crate) fn new_with_store(
data_path: Option<&Path>,
config: &crate::replication::ReplicationConfig,
store: Arc<crate::storage::unified::UnifiedStore>,
) -> io::Result<Self> {
let mut primary = Self::new_with_config(data_path, config);
if let Some(path) = data_path.filter(|_| primary.logical_wal_spool.is_none()) {
primary.logical_wal_spool = Some(Arc::new(LogicalWalSpool::open(path)?));
}
if let Some(bytes) = super::metadata::read(&store, super::metadata::SLOTS)? {
let catalog =
reddb_file::ReplicationSlotCatalog::decode(&bytes).map_err(io::Error::other)?;
primary.slots = RwLock::new(
catalog
.slots
.into_iter()
.map(|slot| (slot.replica_id.clone(), slot))
.collect(),
);
} else {
let mut valid_source = false;
let mut import_error = None;
for path in [&primary.slot_catalog_path, &primary.slot_path]
.into_iter()
.flatten()
{
if path.try_exists()? {
let result = if primary.slot_catalog_path.as_ref() == Some(path) {
reddb_file::ReplicationSlotCatalog::read_from_path(path)
} else {
reddb_file::ReplicationSlotCatalog::read_legacy_json_from_path(
path,
crate::utils::now_unix_millis() as u128,
)
};
match result {
Ok(_) => valid_source = true,
Err(error) => import_error = Some(error),
}
}
}
if !valid_source {
if let Some(error) = import_error {
return Err(io::Error::other(error));
}
}
let slots = primary
.slots
.read()
.unwrap_or_else(|error| error.into_inner());
let bytes = slots::slot_catalog_from_map(&slots)
.and_then(|catalog| catalog.encode())
.map_err(io::Error::other)?;
super::metadata::write(&store, super::metadata::SLOTS, &bytes)?;
}
for path in [&primary.slot_catalog_path, &primary.slot_path]
.into_iter()
.flatten()
{
super::metadata::retire_legacy(path)?;
}
primary.metadata_store = Some(store);
Ok(primary)
}
fn current_timeline(
&self,
root: &Path,
) -> Result<reddb_file::TimelineId, reddb_file::RdbFileError> {
if let Some(store) = &self.metadata_store {
let path =
reddb_file::PrimaryReplicaFilePlan::new(root, reddb_file::TimelineId::initial())
.timeline_history_path();
return super::metadata::timeline(store, &path)
.map(|history| {
history
.current()
.unwrap_or_else(reddb_file::TimelineId::initial)
})
.map_err(reddb_file::RdbFileError::Io);
}
Self::primary_replica_current_timeline_for_root(root)
}
pub fn append_logical_record(&self, lsn: u64, encoded: Vec<u8>) {
if let Err(error) = self.try_append_logical_record(lsn, encoded) {
warn!(%error, lsn, "failed to publish logical replication record");
}
}
pub(crate) fn try_append_logical_record(&self, lsn: u64, encoded: Vec<u8>) -> io::Result<()> {
let _guard = self
.primary_replica_wal_lock
.lock()
.unwrap_or_else(|error| error.into_inner());
if let Some(plan) = &self.primary_replica_file_plan {
let timeline = self
.current_timeline(&plan.root)
.map_err(io::Error::other)?;
let plan = reddb_file::PrimaryReplicaFilePlan::new(plan.root.clone(), timeline);
plan.append_wal_record(lsn, &encoded)
.map_err(io::Error::other)?;
}
if let Some(spool) = &self.logical_wal_spool {
if spool.current_lsn() < lsn {
spool.append(lsn, &encoded)?;
}
}
if self.wal_buffer.current_lsn() < lsn {
self.wal_buffer.append(lsn, encoded);
}
let (lock, cvar) = &self.wal_appended;
let mut latest = lock.lock().unwrap_or_else(|error| error.into_inner());
*latest = (*latest).max(lsn);
cvar.notify_all();
Ok(())
}
pub fn wait_for_logical_lsn_after(&self, since_lsn: u64, timeout: Duration) -> bool {
if self.current_logical_lsn() > since_lsn {
return true;
}
let deadline = Instant::now() + timeout;
let (lock, cvar) = &self.wal_appended;
let mut latest = lock.lock().unwrap_or_else(|e| e.into_inner());
while *latest <= since_lsn {
let now = Instant::now();
if now >= deadline {
return false;
}
let remaining = deadline.saturating_duration_since(now);
let (guard, result) = cvar
.wait_timeout(latest, remaining)
.unwrap_or_else(|e| e.into_inner());
latest = guard;
if result.timed_out() && *latest <= since_lsn {
return false;
}
}
true
}
pub fn register_replica(&self, id: String) -> u64 {
self.register_replica_with_region(id, None)
}
pub fn register_replica_with_region(&self, id: String, region: Option<String>) -> u64 {
self.register_replica_with_region_at(id, region, self.current_logical_lsn())
}
fn register_replica_with_region_at(
&self,
id: String,
region: Option<String>,
initial_lsn: u64,
) -> u64 {
let now_ms = crate::utils::now_unix_millis() as u128;
let resume_lsn = self.ensure_slot(&id, initial_lsn);
let mut replicas = self.replicas.write().unwrap_or_else(|e| e.into_inner());
if let Some(existing) = replicas.iter_mut().find(|r| r.id == id) {
existing.last_seen_at_unix_ms = now_ms;
if region.is_some() {
existing.region = region;
}
return resume_lsn;
}
replicas.push(ReplicaState {
id,
last_acked_lsn: resume_lsn,
last_sent_lsn: resume_lsn,
last_durable_lsn: resume_lsn,
apply_error_count: 0,
divergence_count: 0,
connected_at_unix_ms: now_ms,
last_seen_at_unix_ms: now_ms,
region,
rebootstrapping: false,
});
drop(replicas);
self.bump_topology_epoch();
resume_lsn
}
pub fn set_replica_rebootstrapping(&self, id: &str, rebootstrapping: bool) -> bool {
let mut replicas = self.replicas.write().unwrap_or_else(|e| e.into_inner());
let Some(state) = replicas.iter_mut().find(|r| r.id == id) else {
return false;
};
if state.rebootstrapping == rebootstrapping {
return true;
}
state.rebootstrapping = rebootstrapping;
drop(replicas);
self.bump_topology_epoch();
true
}
pub fn ensure_replica_registered(&self, id: &str, requested_since_lsn: u64) -> bool {
let already = self
.replicas
.read()
.unwrap_or_else(|e| e.into_inner())
.iter()
.any(|r| r.id == id);
if already {
return false;
}
let initial_lsn = requested_since_lsn.min(self.current_logical_lsn());
self.register_replica_with_region_at(id.to_string(), None, initial_lsn);
true
}
pub fn unregister_replica(&self, id: &str) -> bool {
let mut replicas = self.replicas.write().unwrap_or_else(|e| e.into_inner());
let before = replicas.len();
replicas.retain(|r| r.id != id);
let removed = replicas.len() != before;
drop(replicas);
if removed {
self.routing_endpoints
.write()
.expect("replica routing endpoint map lock must not be poisoned")
.remove(id);
self.commit_waiter.drop_replica(id);
self.bump_topology_epoch();
}
removed
}
pub fn topology_epoch(&self) -> u64 {
self.topology_epoch
.load(std::sync::atomic::Ordering::Relaxed)
}
pub fn bump_topology_epoch(&self) {
self.topology_epoch
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
pub fn ack_replica(&self, id: &str, lsn: u64) {
let now_ms = crate::utils::now_unix_millis() as u128;
let mut replicas = self.replicas.write().unwrap_or_else(|e| e.into_inner());
if let Some(r) = replicas.iter_mut().find(|r| r.id == id) {
r.last_acked_lsn = r.last_acked_lsn.max(lsn);
r.last_durable_lsn = r.last_durable_lsn.max(lsn);
r.last_seen_at_unix_ms = now_ms;
}
drop(replicas);
self.commit_waiter.record_replica_ack(id, lsn);
}
pub fn ack_replica_lsn(&self, id: &str, applied_lsn: u64, durable_lsn: u64) {
self.ack_replica_lsn_with_observability(id, applied_lsn, durable_lsn, 0, 0);
}
pub fn ack_replica_lsn_with_observability(
&self,
id: &str,
applied_lsn: u64,
durable_lsn: u64,
apply_error_count: u64,
divergence_count: u64,
) {
let now_ms = crate::utils::now_unix_millis() as u128;
self.advance_slot(id, applied_lsn, durable_lsn, now_ms);
let mut replicas = self.replicas.write().unwrap_or_else(|e| e.into_inner());
if let Some(r) = replicas.iter_mut().find(|r| r.id == id) {
r.last_acked_lsn = r.last_acked_lsn.max(applied_lsn);
r.last_durable_lsn = r.last_durable_lsn.max(durable_lsn);
r.apply_error_count = r.apply_error_count.max(apply_error_count);
r.divergence_count = r.divergence_count.max(divergence_count);
r.last_seen_at_unix_ms = now_ms;
}
drop(replicas);
self.commit_waiter.record_replica_ack(id, durable_lsn);
}
pub fn note_replica_pull(&self, id: &str, last_sent_lsn: u64) {
let now_ms = crate::utils::now_unix_millis() as u128;
self.touch_slot(id, now_ms);
let mut replicas = self.replicas.write().unwrap_or_else(|e| e.into_inner());
if let Some(r) = replicas.iter_mut().find(|r| r.id == id) {
r.last_sent_lsn = r.last_sent_lsn.max(last_sent_lsn);
r.last_seen_at_unix_ms = now_ms;
}
}
pub fn replica_snapshots(&self) -> Vec<ReplicaState> {
self.replicas
.read()
.unwrap_or_else(|e| e.into_inner())
.clone()
}
pub fn replication_progress(&self) -> Option<ReplicationProgress> {
let replicas = self.replicas.read().unwrap_or_else(|e| e.into_inner());
ReplicationProgress::from_replicas(&replicas)
}
pub fn slot_snapshots(&self) -> Vec<ReplicationSlot> {
self.slots
.read()
.unwrap_or_else(|e| e.into_inner())
.values()
.cloned()
.collect()
}
pub fn retention_floor_lsn(&self) -> Option<u64> {
let slots = self.slots.read().unwrap_or_else(|e| e.into_inner());
if self.ensure_slot_metadata_durable().is_err() {
return Some(0);
}
slots
.values()
.filter(|slot| slot.invalidation_reason.is_none())
.map(|slot| slot.restart_lsn)
.min()
}
pub fn prune_retained_wal_through(&self, archived_lsn: u64) -> io::Result<u64> {
self.enforce_retention_limits(crate::utils::now_unix_millis() as u128);
let prune_lsn = self
.retention_floor_lsn()
.map(|floor| floor.min(archived_lsn))
.unwrap_or(archived_lsn);
if prune_lsn > 0 {
if let Some(spool) = &self.logical_wal_spool {
spool.prune_through(prune_lsn)?;
}
self.wal_buffer.prune_through(prune_lsn);
}
Ok(prune_lsn)
}
pub fn replica_count(&self) -> usize {
self.replicas
.read()
.unwrap_or_else(|e| e.into_inner())
.len()
}
pub fn current_logical_lsn(&self) -> u64 {
self.logical_wal_spool
.as_ref()
.map(|spool| spool.current_lsn())
.unwrap_or_else(|| self.wal_buffer.current_lsn())
}
fn ensure_slot(&self, id: &str, initial_lsn: u64) -> u64 {
let now_ms = crate::utils::now_unix_millis() as u128;
let _publication = self
.metadata_store
.as_ref()
.map(|store| store.vault_publication_lock.lock());
let mut slots = self.slots.write().unwrap_or_else(|e| e.into_inner());
if let Some(slot) = slots.get_mut(id) {
slot.last_seen_at_unix_ms = now_ms;
let restart_lsn = slot.restart_lsn;
self.persist_slots_locked(&slots);
return restart_lsn;
}
let mut slot = ReplicationSlot::new(
id.to_string(),
reddb_file::TimelineId::initial(),
initial_lsn,
);
slot.last_seen_at_unix_ms = now_ms;
slots.insert(id.to_string(), slot);
let restart_lsn = initial_lsn;
self.persist_slots_locked(&slots);
restart_lsn
}
fn advance_slot(&self, id: &str, confirmed_lsn: u64, restart_lsn: u64, now_ms: u128) {
let _publication = self
.metadata_store
.as_ref()
.map(|store| store.vault_publication_lock.lock());
let mut slots = self.slots.write().unwrap_or_else(|e| e.into_inner());
let slot = slots.entry(id.to_string()).or_insert_with(|| {
let mut slot =
ReplicationSlot::new(id.to_string(), reddb_file::TimelineId::initial(), 0);
slot.last_seen_at_unix_ms = now_ms;
slot
});
if slot.invalidation_reason.is_some() {
return;
}
slot.confirmed_write_lsn = slot.confirmed_lsn().max(confirmed_lsn).max(restart_lsn);
slot.restart_lsn = slot.restart_lsn.max(restart_lsn);
slot.confirmed_flush_lsn = slot.confirmed_flush_lsn.max(slot.restart_lsn);
slot.confirmed_apply_lsn = slot.confirmed_apply_lsn.max(slot.restart_lsn);
slot.last_seen_at_unix_ms = now_ms;
self.persist_slots_locked(&slots);
}
pub fn touch_slot(&self, id: &str, now_ms: u128) {
let _publication = self
.metadata_store
.as_ref()
.map(|store| store.vault_publication_lock.lock());
let mut slots = self.slots.write().unwrap_or_else(|e| e.into_inner());
let mut changed = false;
if let Some(slot) = slots.get_mut(id) {
if slot.invalidation_reason.is_none() {
slot.last_seen_at_unix_ms = now_ms;
changed = true;
}
}
if changed {
self.persist_slots_locked(&slots);
}
}
pub fn enforce_retention_limits(
&self,
now_ms: u128,
) -> Vec<(String, ReplicationSlotInvalidationCause)> {
let current_lsn = self.current_logical_lsn();
let mut invalidated = Vec::new();
let _publication = self
.metadata_store
.as_ref()
.map(|store| store.vault_publication_lock.lock());
let mut slots = self.slots.write().unwrap_or_else(|e| e.into_inner());
for slot in slots.values_mut() {
if slot.invalidation_reason.is_some() {
continue;
}
let reason = if self.slot_retention_max_lag_lsn > 0
&& current_lsn.saturating_sub(slot.restart_lsn) > self.slot_retention_max_lag_lsn
{
Some(ReplicationSlotInvalidationCause::Horizon)
} else if self.slot_idle_timeout_ms > 0
&& now_ms.saturating_sub(slot.last_seen_at_unix_ms)
> u128::from(self.slot_idle_timeout_ms)
{
Some(ReplicationSlotInvalidationCause::IdleTimeout)
} else {
None
};
if let Some(reason) = reason {
slot.invalidation_reason = Some(reason);
slot.invalidated_at_unix_ms = Some(now_ms);
invalidated.push((slot.replica_id.clone(), reason));
}
}
if !invalidated.is_empty() {
self.persist_slots_locked(&slots);
}
invalidated
}
pub fn slot_rebootstrap_reason(
&self,
id: &str,
requested_since_lsn: u64,
oldest_available_lsn: Option<u64>,
) -> Option<ReplicationSlotInvalidationCause> {
let now_ms = crate::utils::now_unix_millis() as u128;
let _publication = self
.metadata_store
.as_ref()
.map(|store| store.vault_publication_lock.lock());
let mut slots = self.slots.write().unwrap_or_else(|e| e.into_inner());
let slot = slots.get_mut(id)?;
if let Some(reason) = slot.invalidation_reason {
return Some(reason);
}
let slot_floor = slot.restart_lsn.max(requested_since_lsn);
if oldest_available_lsn
.map(|oldest| oldest > slot_floor.saturating_add(1))
.unwrap_or(false)
{
slot.invalidation_reason = Some(ReplicationSlotInvalidationCause::WalRemoved);
slot.invalidated_at_unix_ms = Some(now_ms);
self.persist_slots_locked(&slots);
return Some(ReplicationSlotInvalidationCause::WalRemoved);
}
None
}
pub fn plan_replica_resume(
&self,
id: &str,
requested_since_lsn: u64,
oldest_available_lsn: Option<u64>,
) -> ResumeMode {
if let Some(cause) =
self.slot_rebootstrap_reason(id, requested_since_lsn, oldest_available_lsn)
{
self.full_resync_count
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
return ResumeMode::FullRebootstrap { cause };
}
let resume_lsn = self
.slot_snapshots()
.into_iter()
.find(|slot| slot.replica_id == id)
.map(|slot| requested_since_lsn.max(slot.restart_lsn))
.unwrap_or(requested_since_lsn);
self.partial_resync_count
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
ResumeMode::PartialResync { resume_lsn }
}
pub fn partial_resync_count(&self) -> u64 {
self.partial_resync_count
.load(std::sync::atomic::Ordering::Relaxed)
}
pub fn full_resync_count(&self) -> u64 {
self.full_resync_count
.load(std::sync::atomic::Ordering::Relaxed)
}
fn persist_slots_locked(&self, slots: &BTreeMap<String, ReplicationSlot>) {
if let Some(store) = &self.metadata_store {
let result = slots::slot_catalog_from_map(slots)
.and_then(|catalog| catalog.encode())
.map_err(io::Error::other)
.and_then(|bytes| super::metadata::write(store, super::metadata::SLOTS, &bytes));
let mut last_error = self
.metadata_error
.lock()
.unwrap_or_else(|error| error.into_inner());
if let Err(error) = result {
*last_error = Some(error.to_string());
warn!(target: "reddb::replication::slots", %error,
"failed to persist replication slots inside database");
} else {
*last_error = None;
}
return;
}
if let Err(err) = persist_replication_slots(self.slot_path.as_deref(), slots) {
warn!(
target: "reddb::replication::slots",
error = %err,
"failed to persist replication slots"
);
}
if let Err(err) = persist_replication_slot_catalog(self.slot_catalog_path.as_deref(), slots)
{
warn!(
target: "reddb::replication::slots",
error = %err,
"failed to persist binary replication slot catalog"
);
}
}
pub(crate) fn ensure_slot_metadata_durable(&self) -> io::Result<()> {
match self
.metadata_error
.lock()
.unwrap_or_else(|error| error.into_inner())
.as_ref()
{
Some(error) => Err(io::Error::other(error.clone())),
None => Ok(()),
}
}
}
#[cfg(test)]
mod tests;