use std::{
collections::HashMap,
fs::File,
io::{self, Read},
path::{Path, PathBuf},
sync::{Arc, Mutex, MutexGuard},
time::Duration,
};
use serde::{Deserialize, Serialize};
use crate::{
fs::{sidecar, sync_parent, temp_suffix, valid_name, write_atomic},
wal::{self, RawDb},
Error, Result,
};
const BUSY_TIMEOUT: Duration = Duration::from_secs(5);
const SQLITE_MAGIC: &[u8; 16] = b"SQLite format 3\0";
#[derive(Debug, Default, Serialize, Deserialize)]
struct Saved {
generation: u64,
}
struct Replica {
generation: u64,
db: Option<RawDb>,
pending: Option<u32>,
committed: u32,
present: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Status {
pub generation: u64,
pub frames: u32,
pub pending: bool,
pub bytes: u64,
}
#[derive(Debug, thiserror::Error)]
pub enum Reject {
#[error("needs a new base: {0}")]
NeedsBase(String),
#[error(
"batch doesn't continue the replica (it is at generation {generation}, frame {frames})"
)]
Mismatch { generation: u64, frames: u32 },
#[error("replica busy")]
Busy,
#[error("{0}")]
Bad(String),
}
#[derive(Debug, PartialEq, Eq)]
pub enum Take {
Moved {
generation: u64,
frames: u32,
bytes: u64,
},
Absent,
Busy,
}
pub struct Receiver {
dir: PathBuf,
replicas: Mutex<HashMap<String, Arc<Mutex<Replica>>>>,
}
impl std::fmt::Debug for Receiver {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Receiver")
.field("dir", &self.dir)
.finish_non_exhaustive()
}
}
fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
impl Receiver {
pub fn new(dir: impl Into<PathBuf>) -> Result<Self> {
let dir = dir.into();
std::fs::create_dir_all(&dir)?;
Ok(Self {
dir,
replicas: Mutex::default(),
})
}
pub fn dir(&self) -> &Path {
&self.dir
}
pub fn db_path(&self, name: &str) -> PathBuf {
self.dir.join(format!("{name}.db"))
}
fn state_path(&self, name: &str) -> PathBuf {
self.dir.join(format!("{name}.json"))
}
pub fn staging_path(&self, name: &str) -> PathBuf {
self.dir
.join(format!("{name}.db.base-{}.tmp", temp_suffix()))
}
fn entry(&self, name: &str) -> Result<Arc<Mutex<Replica>>> {
if !valid_name(name) {
return Err(Error::InvalidName(name.to_owned()));
}
let mut replicas = lock(&self.replicas);
if let Some(r) = replicas.get(name) {
return Ok(Arc::clone(r));
}
let saved: Saved = std::fs::read(self.state_path(name))
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default();
let replica = Arc::new(Mutex::new(Replica {
generation: saved.generation,
db: None,
pending: None,
committed: 0,
present: self.db_path(name).exists(),
}));
replicas.insert(name.to_owned(), Arc::clone(&replica));
Ok(replica)
}
fn existing(&self, name: &str) -> Result<Option<Arc<Mutex<Replica>>>> {
if !valid_name(name) {
return Err(Error::InvalidName(name.to_owned()));
}
if let Some(r) = lock(&self.replicas).get(name) {
return Ok(Some(Arc::clone(r)));
}
if !self.db_path(name).exists() && !self.state_path(name).exists() {
return Ok(None);
}
self.entry(name).map(Some)
}
fn save(&self, name: &str, r: &Replica) -> Result<()> {
let bytes = serde_json::to_vec(&Saved {
generation: r.generation,
})?;
write_atomic(&self.state_path(name), &bytes)?;
Ok(())
}
fn open<'a>(&self, name: &str, r: &'a mut Replica) -> Result<&'a RawDb> {
let db = if let Some(db) = r.db.take() {
db
} else {
let db = RawDb::open(&self.db_path(name), BUSY_TIMEOUT)?;
db.disable_checkpoint()?;
r.committed = db.head(BUSY_TIMEOUT)?.ok_or_else(|| Error::Sqlite {
context: format!("opening replica {name}"),
code: libsql_ffi::SQLITE_BUSY,
message: "held by a writer".into(),
})?;
db
};
Ok(r.db.insert(db))
}
pub fn install_base(&self, name: &str, staged: &Path, generation: u64) -> Result<Status> {
let result = self.install_base_inner(name, staged, generation);
if result.is_err() {
let _ = std::fs::remove_file(staged);
}
result
}
fn install_base_inner(&self, name: &str, staged: &Path, generation: u64) -> Result<Status> {
check_base(staged)?;
let entry = self.entry(name)?;
let mut r = lock(&entry);
r.db = None;
r.pending = None;
let target = self.db_path(name);
remove_sidecars(&target);
std::fs::rename(staged, &target)?;
sync_parent(&target)?;
r.generation = generation;
r.committed = 0;
r.present = true;
self.save(name, &r)?;
self.open(name, &mut r)?;
Ok(self.status_of(name, &r))
}
pub fn apply_frames(
&self,
name: &str,
generation: u64,
first: u32,
page_size: u32,
body: &[u8],
) -> Result<Status, Reject> {
if !(512..=65536).contains(&page_size) || !page_size.is_power_of_two() {
return Err(Reject::Bad(format!("{page_size} isn't a SQLite page size")));
}
let frame_len = wal::FRAME_HEADER + page_size as usize;
if body.is_empty() || !body.len().is_multiple_of(frame_len) {
return Err(Reject::Bad(format!(
"body is {} bytes, not a whole number of {frame_len}-byte frames",
body.len()
)));
}
if first == 0 {
return Err(Reject::Bad("frames are numbered from 1".into()));
}
let needs_base = |e: Error| Reject::NeedsBase(e.to_string());
let entry = self.entry(name).map_err(|e| Reject::Bad(e.to_string()))?;
let mut guard = lock(&entry);
let r: &mut Replica = &mut guard;
if !r.present {
return Err(Reject::NeedsBase("no base installed".into()));
}
let replica_page_size = self
.open(name, r)
.and_then(RawDb::page_size)
.map_err(needs_base)?;
if replica_page_size != page_size {
return Err(Reject::Bad(format!(
"replica page size is {replica_page_size}, batch says {page_size}"
)));
}
let db = r.db.as_ref().expect("opened above");
let continues_session = r.pending == Some(first) && r.generation == generation;
if !continues_session {
if r.pending.take().is_some() {
let _ = db.insert_end();
}
let committed = match db.head(BUSY_TIMEOUT) {
Ok(Some(n)) => n,
Ok(None) => return Err(Reject::Busy),
Err(e) => return Err(needs_base(e)),
};
r.committed = committed;
if generation == r.generation && first == committed + 1 {
} else if generation > r.generation && first == 1 {
match db.checkpoint_truncate() {
Ok(ck) if ck.complete() => {}
Ok(_) => return Err(Reject::Busy),
Err(e) => return Err(needs_base(e)),
}
r.generation = generation;
r.committed = 0;
self.save(name, r).map_err(needs_base)?;
} else {
return Err(Reject::Mismatch {
generation: r.generation,
frames: committed,
});
}
db.insert_begin().map_err(needs_base)?;
}
let mut no = first;
let mut in_session = true;
let mut committed = r.committed;
for frame in body.chunks_exact(frame_len) {
if !in_session {
db.insert_begin().map_err(needs_base)?;
in_session = true;
}
if let Err(e) = db.insert_frame(no, frame) {
let _ = db.insert_end();
r.pending = None;
r.committed = committed;
return Err(needs_base(e));
}
if wal::size_after(frame) > 0 {
if let Err(e) = db.insert_end() {
r.pending = None;
return Err(needs_base(e));
}
in_session = false;
committed = no;
}
no += 1;
}
r.pending = in_session.then_some(no);
r.committed = committed;
Ok(self.status_of(name, r))
}
fn status_of(&self, name: &str, r: &Replica) -> Status {
let db = self.db_path(name);
let len = |p: &Path| std::fs::metadata(p).map_or(0, |m| m.len());
Status {
generation: r.generation,
frames: r.committed,
pending: r.pending.is_some(),
bytes: len(&db) + len(&sidecar(&db, "-wal")),
}
}
pub fn status(&self, name: &str) -> Option<Status> {
let entry = self.existing(name).ok()??;
let mut r = lock(&entry);
if !r.present {
return None;
}
if r.pending.is_none() {
if let Ok(db) = self.open(name, &mut r) {
if let Ok(Some(n)) = db.head(Duration::from_millis(200)) {
r.committed = n;
}
}
}
Some(self.status_of(name, &r))
}
pub fn list(&self) -> Vec<(String, Status)> {
let Ok(entries) = std::fs::read_dir(&self.dir) else {
return Vec::new();
};
let mut names: Vec<String> = entries
.filter_map(|e| {
let path = e.ok()?.path();
if path.extension()? != "db" {
return None;
}
Some(path.file_stem()?.to_str()?.to_owned())
})
.collect();
names.sort_unstable();
names
.into_iter()
.filter_map(|name| self.status(&name).map(|s| (name, s)))
.collect()
}
pub fn take(&self, name: &str, dest: &Path) -> Result<Take> {
let entry = self.entry(name)?;
let mut r = lock(&entry);
if !r.present {
return Ok(Take::Absent);
}
r.db = None;
r.pending = None;
let db_path = self.db_path(name);
let db = RawDb::open(&db_path, BUSY_TIMEOUT)?;
let frames = db.head(BUSY_TIMEOUT)?.unwrap_or(0);
let ck = db.checkpoint_truncate()?;
drop(db);
if !ck.complete() {
self.open(name, &mut r)?;
return Ok(Take::Busy);
}
remove_sidecars(&db_path);
move_file(&db_path, dest)?;
let _ = std::fs::remove_file(self.state_path(name));
let generation = std::mem::take(&mut r.generation);
r.present = false;
r.committed = 0;
let bytes = std::fs::metadata(dest).map_or(0, |m| m.len());
Ok(Take::Moved {
generation,
frames,
bytes,
})
}
pub fn delete(&self, name: &str) -> Result<bool> {
let Some(entry) = self.existing(name)? else {
return Ok(false);
};
let mut r = lock(&entry);
r.db = None;
r.pending = None;
let db_path = self.db_path(name);
let _ = std::fs::remove_file(&db_path);
remove_sidecars(&db_path);
let _ = std::fs::remove_file(self.state_path(name));
r.generation = 0;
r.committed = 0;
Ok(std::mem::take(&mut r.present))
}
}
fn remove_sidecars(db: &Path) {
let _ = std::fs::remove_file(sidecar(db, "-wal"));
let _ = std::fs::remove_file(sidecar(db, "-shm"));
}
fn move_file(src: &Path, dst: &Path) -> io::Result<()> {
move_file_with(src, dst, |a, b| std::fs::rename(a, b))
}
fn move_file_with<R>(src: &Path, dst: &Path, rename: R) -> io::Result<()>
where
R: Fn(&Path, &Path) -> io::Result<()>,
{
match rename(src, dst) {
Err(e) if e.kind() == io::ErrorKind::CrossesDevices => {}
other => return other,
}
std::fs::copy(src, dst)?;
File::options().write(true).open(dst)?.sync_all()?;
let _ = sync_parent(dst);
std::fs::remove_file(src)
}
fn check_base(path: &Path) -> Result<()> {
let mut file = File::open(path)?;
let len = file.metadata()?.len();
if len == 0 {
return Ok(());
}
let mut header = [0u8; 100];
file.read_exact(&mut header)
.map_err(|_| Error::NotADatabase("shorter than the header".into()))?;
if &header[..16] != SQLITE_MAGIC {
return Err(Error::NotADatabase("bad magic".into()));
}
let page_size = match u16::from_be_bytes([header[16], header[17]]) {
1 => 65536,
n => u64::from(n),
};
if page_size < 512 || len % page_size != 0 {
return Err(Error::NotADatabase(format!(
"{len} bytes isn't a whole number of {page_size}-byte pages"
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("walship-rx-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn frames(db: &RawDb, from: u32, to: u32, page_size: u32) -> Vec<u8> {
(from..=to)
.flat_map(|n| db.get_frame(n, page_size).unwrap())
.collect()
}
fn read_only(path: &Path) -> RawDb {
let db = RawDb::open(path, BUSY_TIMEOUT).unwrap();
db.exec("PRAGMA query_only=1").unwrap();
db.disable_checkpoint().unwrap();
db
}
fn rows(db: &RawDb) -> u32 {
db.query_u32("SELECT count(*) FROM t").unwrap()
}
#[test]
fn follows_a_primary() {
let dir = scratch("follow");
let src = RawDb::open(&dir.join("src.db"), BUSY_TIMEOUT).unwrap();
src.disable_checkpoint().unwrap();
let ps = src.page_size().unwrap();
src.exec("CREATE TABLE t(id INTEGER PRIMARY KEY, v TEXT)")
.unwrap();
src.exec("INSERT INTO t(v) VALUES ('a'),('b')").unwrap();
let rx = Receiver::new(dir.join("replicas")).unwrap();
let head = src.head(BUSY_TIMEOUT).unwrap().unwrap();
assert!(matches!(
rx.apply_frames("x", 1, 1, ps, &frames(&src, 1, head, ps)),
Err(Reject::NeedsBase(_))
));
let staged = rx.staging_path("x");
std::fs::write(&staged, b"").unwrap();
let s = rx.install_base("x", &staged, 1).unwrap();
assert_eq!((s.generation, s.frames), (1, 0));
assert!(!staged.exists());
src.exec("BEGIN").unwrap();
for i in 0..1500 {
src.exec(&format!(
"INSERT INTO t(v) VALUES ('{}')",
"y".repeat(100 + i % 5)
))
.unwrap();
}
src.exec("COMMIT").unwrap();
let head = src.head(BUSY_TIMEOUT).unwrap().unwrap();
let cut = head / 2;
let s = rx
.apply_frames("x", 1, 1, ps, &frames(&src, 1, cut, ps))
.unwrap();
assert!(s.pending);
assert!(s.frames < cut);
let reader = read_only(&rx.db_path("x"));
assert_eq!(rows(&reader), 2, "half a transaction must stay invisible");
let s = rx
.apply_frames("x", 1, cut + 1, ps, &frames(&src, cut + 1, head, ps))
.unwrap();
assert_eq!((s.frames, s.pending), (head, false));
assert_eq!(rows(&reader), 1502);
assert!(matches!(
rx.apply_frames("x", 1, head + 5, ps, &frames(&src, 1, 1, ps)),
Err(Reject::Mismatch { generation: 1, frames }) if frames == head
));
assert!(matches!(
rx.apply_frames("x", 1, head, ps, &frames(&src, head, head, ps)),
Err(Reject::Mismatch { .. })
));
drop(rx);
let rx = Receiver::new(dir.join("replicas")).unwrap();
assert_eq!(rx.status("x").unwrap().frames, head);
src.exec("INSERT INTO t(v) VALUES ('after restart')")
.unwrap();
let head2 = src.head(BUSY_TIMEOUT).unwrap().unwrap();
let s = rx
.apply_frames("x", 1, head + 1, ps, &frames(&src, head + 1, head2, ps))
.unwrap();
assert_eq!(s.frames, head2);
assert_eq!(rows(&reader), 1503);
assert!(src.checkpoint_truncate().unwrap().complete());
src.exec("INSERT INTO t(v) VALUES ('gen2')").unwrap();
assert_eq!(src.head(BUSY_TIMEOUT).unwrap(), Some(1));
let s = rx
.apply_frames("x", 2, 1, ps, &frames(&src, 1, 1, ps))
.unwrap();
assert_eq!((s.generation, s.frames), (2, 1));
assert_eq!(rows(&reader), 1504);
assert_eq!(reader.query_text("PRAGMA integrity_check").unwrap(), "ok");
assert!(matches!(
rx.apply_frames("x", 1, 2, ps, &frames(&src, 1, 1, ps)),
Err(Reject::Mismatch { .. })
));
drop(reader);
assert!(src.checkpoint_truncate().unwrap().complete());
let staged = rx.staging_path("x");
src.copy_main_file(&staged).unwrap();
let s = rx.install_base("x", &staged, 3).unwrap();
assert_eq!((s.generation, s.frames), (3, 0));
src.exec("INSERT INTO t(v) VALUES ('gen3')").unwrap();
assert_eq!(
rx.apply_frames("x", 3, 1, ps, &frames(&src, 1, 1, ps))
.unwrap()
.frames,
1
);
assert_eq!(rows(&read_only(&rx.db_path("x"))), 1505);
assert_eq!(rx.list().len(), 1);
assert!(rx.delete("x").unwrap());
assert!(rx.status("x").is_none());
assert!(!rx.db_path("x").exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_base_must_be_a_database() {
let dir = scratch("base");
let rx = Receiver::new(&dir).unwrap();
let staged = rx.staging_path("y");
std::fs::write(&staged, b"hello, not a database").unwrap();
assert!(matches!(
rx.install_base("y", &staged, 1),
Err(Error::NotADatabase(_))
));
assert!(!staged.exists(), "a rejected base should be removed");
assert!(rx.status("y").is_none());
assert!(rx.status("../y").is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn asking_about_a_missing_replica_leaves_no_trace() {
let dir = scratch("missing");
let rx = Receiver::new(&dir).unwrap();
for i in 0..100 {
assert!(rx.status(&format!("nope{i}")).is_none());
assert!(!rx.delete(&format!("nope{i}")).unwrap());
}
assert!(lock(&rx.replicas).is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn take_promotes_the_replica() {
let dir = scratch("take");
let src = RawDb::open(&dir.join("src.db"), BUSY_TIMEOUT).unwrap();
src.disable_checkpoint().unwrap();
let ps = src.page_size().unwrap();
src.exec("CREATE TABLE t(id INTEGER PRIMARY KEY, v TEXT)")
.unwrap();
src.exec("INSERT INTO t(v) VALUES ('a'),('b'),('c')")
.unwrap();
let rx = Receiver::new(dir.join("replicas")).unwrap();
let staged = rx.staging_path("x");
std::fs::write(&staged, b"").unwrap();
rx.install_base("x", &staged, 4).unwrap();
let head = src.head(BUSY_TIMEOUT).unwrap().unwrap();
rx.apply_frames("x", 4, 1, ps, &frames(&src, 1, head, ps))
.unwrap();
let dest = dir.join("promoted.db");
let Take::Moved {
generation,
frames,
bytes,
} = rx.take("x", &dest).unwrap()
else {
panic!("expected the replica to move");
};
assert_eq!((generation, frames), (4, head));
assert_eq!(bytes, std::fs::metadata(&dest).unwrap().len());
let promoted = RawDb::open(&dest, BUSY_TIMEOUT).unwrap();
assert_eq!(rows(&promoted), 3);
assert_eq!(promoted.query_text("PRAGMA integrity_check").unwrap(), "ok");
for leftover in ["x.db", "x.db-wal", "x.db-shm", "x.json"] {
assert!(
!rx.dir().join(leftover).exists(),
"{leftover} is still there"
);
}
assert_eq!(rx.take("x", &dir.join("again.db")).unwrap(), Take::Absent);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn move_falls_back_to_copy_across_mounts() {
let dir = scratch("exdev");
let src = dir.join("from.db");
let dst = dir.join("to.db");
std::fs::write(&src, b"pages").unwrap();
let exdev = |_: &Path, _: &Path| Err(io::ErrorKind::CrossesDevices.into());
move_file_with(&src, &dst, exdev).unwrap();
assert_eq!(std::fs::read(&dst).unwrap(), b"pages");
assert!(!src.exists());
std::fs::write(&src, b"pages").unwrap();
let denied = |_: &Path, _: &Path| Err(io::ErrorKind::PermissionDenied.into());
assert_eq!(
move_file_with(&src, &dst, denied).unwrap_err().kind(),
io::ErrorKind::PermissionDenied
);
assert!(src.exists());
let renamed = dir.join("renamed.db");
move_file(&src, &renamed).unwrap();
assert_eq!(std::fs::read(&renamed).unwrap(), b"pages");
assert!(!src.exists());
let _ = std::fs::remove_dir_all(&dir);
}
}