pub mod ffi;
use std::path::{Path, PathBuf};
use std::sync::{
Arc,
atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering},
};
use hakodb::config::HakoConfig;
use hakodb::document::hako_doc::HakoDoc;
use hakodb::engine::Hako;
use hakodb::query::query::Query;
#[cfg(unix)]
use hakodb::socket_sync::SocketSync;
#[derive(Debug, Clone)]
pub enum StaggerPolicy {
StaggeredStart { offset_ms: u64 },
PerInstance(Vec<u64>),
ManualRotation,
}
impl Default for StaggerPolicy {
fn default() -> Self {
Self::StaggeredStart { offset_ms: 1 }
}
}
#[derive(Debug, Clone)]
pub struct ClusterConfig {
pub durability_mode: hakodb::config::DurabilityMode,
pub group_commit_interval_ms: u64,
pub stagger: StaggerPolicy,
pub max_replica_lag_versions: Option<u64>,
pub sock_dir: PathBuf,
}
impl Default for ClusterConfig {
fn default() -> Self {
Self {
durability_mode: hakodb::config::DurabilityMode::Interval,
group_commit_interval_ms: 5,
stagger: StaggerPolicy::StaggeredStart { offset_ms: 1 },
max_replica_lag_versions: Some(5_000_000),
sock_dir: PathBuf::from("socks"),
}
}
}
impl std::fmt::Debug for Cluster {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Cluster")
.field("instances", &self.instances.len())
.field("reads", &self.read_counts())
.finish()
}
}
pub struct Cluster {
instances: Vec<Instance>,
rr: AtomicUsize,
writer_index: AtomicUsize,
epoch: AtomicU64,
promotions: std::sync::Mutex<Vec<Promotion>>,
flush_rr: AtomicUsize,
max_lag: Option<u64>,
#[cfg(unix)]
_rt: Rt,
}
#[cfg(unix)]
enum Rt {
Owned(tokio::runtime::Runtime),
Shared(tokio::runtime::Handle),
}
struct Instance {
db: Arc<Hako>,
reads: AtomicU64,
healthy: AtomicBool,
#[cfg(unix)]
_sync: SocketSync,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Promotion {
pub epoch: u64,
pub writer: usize,
pub at_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReplicaHealth {
pub index: usize,
pub lag_versions: u64,
pub healthy: bool,
}
impl Cluster {
pub fn open(paths: &[&str]) -> Result<Self, String> {
let sock_dir = Path::new(paths.first().ok_or("need ≥1 path")?)
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."))
.join("socks");
Self::open_with_config(
paths,
ClusterConfig {
sock_dir,
..ClusterConfig::default()
},
)
}
pub fn open_with_config(paths: &[&str], cfg: ClusterConfig) -> Result<Self, String> {
if paths.is_empty() {
return Err("need ≥1 path".into());
}
#[cfg(not(unix))]
if paths.len() > 1 {
return Err(
"socket peering is unix-only: N>1 clusters need a unix host".into(),
);
}
let intervals: Vec<u64> = match &cfg.stagger {
StaggerPolicy::StaggeredStart { .. } => {
vec![cfg.group_commit_interval_ms; paths.len()]
}
StaggerPolicy::PerInstance(v) => {
if v.len() != paths.len() {
return Err(format!(
"PerInstance needs one interval per path (got {} for {})",
v.len(),
paths.len()
));
}
v.clone()
}
StaggerPolicy::ManualRotation => {
if cfg.durability_mode != hakodb::config::DurabilityMode::Manual {
return Err(
"ManualRotation needs durability_mode = Manual".into(),
);
}
vec![cfg.group_commit_interval_ms; paths.len()]
}
};
let stagger_gap =
match cfg.stagger {
StaggerPolicy::StaggeredStart { offset_ms } => offset_ms,
StaggerPolicy::PerInstance(_) | StaggerPolicy::ManualRotation => 0,
};
let mut dbs = Vec::with_capacity(paths.len());
for (n, p) in paths.iter().enumerate() {
if n > 0 && stagger_gap > 0 {
std::thread::sleep(std::time::Duration::from_millis(stagger_gap));
}
let mut hc = HakoConfig::default();
hc.durability_mode = cfg.durability_mode;
hc.group_commit_interval_ms = intervals[n];
dbs.push(Arc::new(
Hako::open(p, hc).map_err(|e| format!("open {p}: {e}"))?,
));
}
#[cfg(unix)]
{
let rt = match tokio::runtime::Handle::try_current() {
Ok(h) => Rt::Shared(h),
Err(_) => Rt::Owned(
tokio::runtime::Runtime::new()
.map_err(|e| format!("tokio runtime: {e}"))?,
),
};
std::fs::create_dir_all(&cfg.sock_dir)
.map_err(|e| format!("sock_dir: {e}"))?;
let socks: Vec<PathBuf> = (0..dbs.len())
.map(|i| cfg.sock_dir.join(format!("instance-{i}.sock")))
.collect();
let mut instances = Vec::with_capacity(dbs.len());
for db in &dbs {
instances.push(Instance {
db: db.clone(),
reads: AtomicU64::new(0),
healthy: AtomicBool::new(true),
_sync: SocketSync::new(db.clone(), vec![]),
});
}
let mesh = async {
for (inst, sock) in instances.iter().zip(socks.iter()) {
inst._sync
.serve(sock.to_string_lossy().as_ref())
.map_err(|e| format!("serve {}: {e}", sock.display()))?;
}
for i in 0..instances.len() {
for j in (i + 1)..instances.len() {
let path = socks[j].to_string_lossy().into_owned();
instances[i]
._sync
.dial(&path)
.await
.map_err(|e| format!("dial {}: {e}", socks[j].display()))?;
}
}
Ok::<(), String>(())
};
match &rt {
Rt::Owned(r) => r.block_on(mesh)?,
Rt::Shared(h) => h
.spawn(mesh)
.await
.map_err(|e| format!("mesh task: {e}"))??,
}
for (n, inst) in instances.iter().enumerate() {
inst.db.set_read_only(n != 0);
}
return Ok(Self {
instances,
rr: AtomicUsize::new(0),
writer_index: AtomicUsize::new(0),
epoch: AtomicU64::new(0),
promotions: std::sync::Mutex::new(Vec::new()),
flush_rr: AtomicUsize::new(0),
max_lag: cfg.max_replica_lag_versions,
_rt: rt,
});
}
#[cfg(not(unix))]
Ok(Self {
instances: dbs
.into_iter()
.enumerate()
.map(|(n, db)| {
db.set_read_only(n != 0);
Instance {
db,
reads: AtomicU64::new(0),
healthy: AtomicBool::new(true),
}
})
.collect(),
rr: AtomicUsize::new(0),
writer_index: AtomicUsize::new(0),
epoch: AtomicU64::new(0),
promotions: std::sync::Mutex::new(Vec::new()),
flush_rr: AtomicUsize::new(0),
max_lag: cfg.max_replica_lag_versions,
})
}
pub fn instances(&self) -> Vec<Arc<Hako>> {
self.instances.iter().map(|i| i.db.clone()).collect()
}
pub fn writer(&self) -> &Arc<Hako> {
&self.instances[self.writer_index.load(Ordering::Relaxed)].db
}
pub fn writer_index(&self) -> usize {
self.writer_index.load(Ordering::Relaxed)
}
pub fn promote(&self, index: usize) -> Result<u64, String> {
if index >= self.instances.len() {
return Err(format!(
"promote: index {index} out of range (n={})",
self.instances.len()
));
}
if index == self.writer_index.load(Ordering::Relaxed) {
return Ok(self.epoch.load(Ordering::Relaxed));
}
for (n, inst) in self.instances.iter().enumerate() {
inst.db.set_read_only(n != index);
}
self.writer_index.store(index, Ordering::Relaxed);
let epoch = self.epoch.fetch_add(1, Ordering::Relaxed) + 1;
let at_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
self.promotions
.lock()
.unwrap()
.push(Promotion { epoch, writer: index, at_ms });
Ok(epoch)
}
pub fn epoch(&self) -> u64 {
self.epoch.load(Ordering::Relaxed)
}
pub fn promotion_log(&self) -> Vec<Promotion> {
self.promotions.lock().unwrap().clone()
}
pub fn tick_flush(&self) -> Result<(), String> {
let n = self.instances.len();
let i = self.flush_rr.fetch_add(1, Ordering::Relaxed) % n;
self.instances[i]
.db
.flush()
.map_err(|e| format!("tick_flush instance {i}: {e}"))
}
pub fn instance_count(&self) -> usize {
self.instances.len()
}
pub fn read_index(&self) -> usize {
let n = self.instances.len();
let start = self.rr.fetch_add(1, Ordering::Relaxed);
for k in 0..n {
let i = (start + k) % n;
if self.instances[i].healthy.load(Ordering::Relaxed) {
return i;
}
}
self.writer_index.load(Ordering::Relaxed)
}
pub fn note_read(&self, index: usize) {
if let Some(inst) = self.instances.get(index) {
inst.reads.fetch_add(1, Ordering::Relaxed);
}
}
pub fn read_counts(&self) -> Vec<u64> {
self.instances
.iter()
.map(|i| i.reads.load(Ordering::Relaxed))
.collect()
}
pub fn peer_count(&self) -> usize {
#[cfg(unix)]
return self.instances.iter().map(|i| i._sync.peer_count()).sum();
#[cfg(not(unix))]
return 0;
}
pub fn refresh_health(&self) -> Vec<ReplicaHealth> {
let w = self.writer_index.load(Ordering::Relaxed);
let base = self.instances[w].db.get_version_map();
let mut out = Vec::with_capacity(self.instances.len());
for (n, inst) in self.instances.iter().enumerate() {
let lag = if n == w {
0
} else {
let m = inst.db.get_version_map();
base.iter()
.map(|(col, wv)| wv.saturating_sub(*m.get(col).unwrap_or(&0)) as u64)
.max()
.unwrap_or(0)
};
let healthy = match (n == w, self.max_lag) {
(true, _) => true,
(false, None) => true,
(false, Some(max)) => {
let cur = inst.healthy.load(Ordering::Relaxed);
if lag > max {
false
} else if lag <= max / 2 {
true
} else {
cur
}
}
};
inst.healthy.store(healthy, Ordering::Relaxed);
out.push(ReplicaHealth {
index: n,
lag_versions: lag,
healthy,
});
}
out
}
pub fn healthy_count(&self) -> usize {
self.instances
.iter()
.filter(|i| i.healthy.load(Ordering::Relaxed))
.count()
}
fn pick(&self) -> &Instance {
&self.instances[self.read_index()]
}
pub fn put(&self, col: &str, id: &str, doc: &HakoDoc) -> Result<String, String> {
self.writer()
.put(col, id, doc)
.map_err(|e| e.to_string())
}
pub fn put_owned(&self, col: &str, id: &str, doc: HakoDoc) -> Result<String, String> {
self.writer()
.put_owned(col, id, doc)
.map_err(|e| e.to_string())
}
pub fn delete(&self, col: &str, id: &str) -> Result<String, String> {
self.writer().delete(col, id).map_err(|e| e.to_string())
}
pub fn get(
&self,
collection: &str,
doc_id: &str,
) -> Result<Option<HakoDoc>, String> {
let inst = self.pick();
inst.reads.fetch_add(1, Ordering::Relaxed);
inst.db.get(collection, doc_id).map_err(|e| e.to_string())
}
pub fn query(&self, query: Query) -> Result<Vec<(String, HakoDoc)>, String> {
let inst = self.pick();
inst.reads.fetch_add(1, Ordering::Relaxed);
inst.db.query(query).map_err(|e| e.to_string())
}
}
#[cfg(unix)]
impl Drop for Cluster {
fn drop(&mut self) {
for i in &self.instances {
i._sync.stop();
}
}
}