#![forbid(unsafe_code)]
use std::collections::BTreeMap;
use std::path::Path;
use std::sync::Mutex;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use rusqlite::{Connection, OptionalExtension, params};
use zerodds_durability_store::{
Contract, Cursor, DurabilitySample, DurabilityStore, Page, Result, Selector, StoreError,
StoreStats,
};
use zerodds_qos::policies::history::HistoryKind;
const DEFAULT_PAGE: usize = 1024;
const SCHEMA: &str = "
CREATE TABLE IF NOT EXISTS samples (
topic TEXT NOT NULL,
instance BLOB NOT NULL,
sequence INTEGER NOT NULL,
created_nanos INTEGER NOT NULL,
payload BLOB NOT NULL,
representation INTEGER NOT NULL DEFAULT 1,
big_endian INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (topic, instance, sequence)
) WITHOUT ROWID;
CREATE INDEX IF NOT EXISTS idx_samples_topic ON samples(topic, instance, sequence);
CREATE TABLE IF NOT EXISTS unregistered (
topic TEXT NOT NULL,
instance BLOB NOT NULL,
at_nanos INTEGER NOT NULL,
PRIMARY KEY (topic, instance)
);
";
pub struct SqliteStore {
conn: Mutex<Connection>,
contracts: Mutex<BTreeMap<String, Contract>>,
default_contract: Contract,
}
fn backend<E: core::fmt::Display>(ctx: &str, e: E) -> StoreError {
StoreError::Backend(format!("sqlite store: {ctx}: {e}"))
}
fn nanos_of(t: SystemTime) -> i64 {
t.duration_since(UNIX_EPOCH)
.map(|d| i64::try_from(d.as_nanos()).unwrap_or(i64::MAX))
.unwrap_or(0)
}
fn time_of(nanos: i64) -> SystemTime {
UNIX_EPOCH + Duration::from_nanos(nanos.max(0) as u64)
}
impl SqliteStore {
pub fn open<P: AsRef<Path>>(path: P, default_contract: Contract) -> Result<Self> {
let conn = Connection::open(path.as_ref()).map_err(|e| backend("open", e))?;
Self::init(conn, default_contract)
}
pub fn open_in_memory(default_contract: Contract) -> Result<Self> {
let conn = Connection::open_in_memory().map_err(|e| backend("open mem", e))?;
Self::init(conn, default_contract)
}
fn init(conn: Connection, default_contract: Contract) -> Result<Self> {
conn.pragma_update(None, "journal_mode", "WAL")
.map_err(|e| backend("pragma wal", e))?;
conn.pragma_update(None, "synchronous", "NORMAL")
.map_err(|e| backend("pragma synchronous", e))?;
conn.execute_batch(SCHEMA)
.map_err(|e| backend("schema", e))?;
Ok(Self {
conn: Mutex::new(conn),
contracts: Mutex::new(BTreeMap::new()),
default_contract,
})
}
fn lock_conn(&self) -> Result<std::sync::MutexGuard<'_, Connection>> {
self.conn
.lock()
.map_err(|_| StoreError::Poisoned("sqlite conn"))
}
fn contract_for(&self, topic: &str) -> Result<Contract> {
Ok(self
.contracts
.lock()
.map_err(|_| StoreError::Poisoned("sqlite contracts"))?
.get(topic)
.copied()
.unwrap_or(self.default_contract))
}
fn count(conn: &Connection, sql: &str, p: &[&dyn rusqlite::ToSql]) -> Result<i64> {
conn.query_row(sql, p, |r| r.get::<_, i64>(0))
.map_err(|e| backend("count", e))
}
}
impl DurabilityStore for SqliteStore {
fn set_contract(&self, topic: &str, contract: Contract) -> Result<()> {
self.contracts
.lock()
.map_err(|_| StoreError::Poisoned("sqlite contracts"))?
.insert(topic.to_string(), contract);
Ok(())
}
fn store(&self, sample: DurabilitySample) -> Result<()> {
let contract = self.contract_for(&sample.topic)?;
let conn = self.lock_conn()?;
let inst = &sample.instance_key[..];
let is_resend = Self::count(
&conn,
"SELECT COUNT(*) FROM samples WHERE topic=?1 AND instance=?2 AND sequence=?3",
params![sample.topic, inst, sample.sequence as i64],
)? > 0;
if !is_resend
&& contract.samples_bounded()
&& matches!(contract.history_kind, HistoryKind::KeepAll)
{
let n = Self::count(
&conn,
"SELECT COUNT(*) FROM samples WHERE topic=?1",
params![sample.topic],
)?;
if n >= i64::from(contract.max_samples) {
return Err(StoreError::OutOfResources("max_samples"));
}
}
if contract.instances_bounded() {
let exists = Self::count(
&conn,
"SELECT COUNT(*) FROM samples WHERE topic=?1 AND instance=?2",
params![sample.topic, inst],
)? > 0;
if !exists {
let insts = Self::count(
&conn,
"SELECT COUNT(DISTINCT instance) FROM samples WHERE topic=?1",
params![sample.topic],
)?;
if insts >= i64::from(contract.max_instances) {
return Err(StoreError::OutOfResources("max_instances"));
}
}
}
if !is_resend
&& contract.per_instance_bounded()
&& matches!(contract.history_kind, HistoryKind::KeepAll)
{
let n = Self::count(
&conn,
"SELECT COUNT(*) FROM samples WHERE topic=?1 AND instance=?2",
params![sample.topic, inst],
)?;
if n >= i64::from(contract.max_samples_per_instance) {
return Err(StoreError::OutOfResources("max_samples_per_instance"));
}
}
conn.execute(
"INSERT OR REPLACE INTO samples(topic,instance,sequence,created_nanos,payload,representation,big_endian) \
VALUES (?1,?2,?3,?4,?5,?6,?7)",
params![
sample.topic,
inst,
sample.sequence as i64,
nanos_of(sample.created_at),
sample.payload,
i64::from(sample.representation),
i64::from(sample.big_endian)
],
)
.map_err(|e| backend("insert", e))?;
if matches!(contract.history_kind, HistoryKind::KeepLast) {
conn.execute(
"DELETE FROM samples WHERE topic=?1 AND instance=?2 AND sequence NOT IN \
(SELECT sequence FROM samples WHERE topic=?1 AND instance=?2 \
ORDER BY sequence DESC LIMIT ?3)",
params![sample.topic, inst, contract.effective_depth() as i64],
)
.map_err(|e| backend("keep_last trim", e))?;
}
Ok(())
}
fn query(&self, topic: &str, selector: &Selector) -> Result<Page> {
let conn = self.lock_conn()?;
let limit = selector.limit.unwrap_or(DEFAULT_PAGE);
let mut sql = String::from(
"SELECT instance,sequence,created_nanos,payload,representation,big_endian \
FROM samples WHERE topic=?1",
);
let mut binds: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(topic.to_string())];
if let Some(k) = selector.instance_key {
sql.push_str(" AND instance=?");
sql.push_str(&(binds.len() + 1).to_string());
binds.push(Box::new(k.to_vec()));
}
if let Some(lo) = selector.seq_from {
sql.push_str(&format!(" AND sequence>=?{}", binds.len() + 1));
binds.push(Box::new(lo as i64));
}
if let Some(hi) = selector.seq_to {
sql.push_str(&format!(" AND sequence<=?{}", binds.len() + 1));
binds.push(Box::new(hi as i64));
}
if let Some(t0) = selector.time_from {
sql.push_str(&format!(" AND created_nanos>=?{}", binds.len() + 1));
binds.push(Box::new(nanos_of(t0)));
}
if let Some(t1) = selector.time_to {
sql.push_str(&format!(" AND created_nanos<=?{}", binds.len() + 1));
binds.push(Box::new(nanos_of(t1)));
}
if let Some((ck, cs)) = selector.after {
let i1 = binds.len() + 1;
let i2 = binds.len() + 2;
let i3 = binds.len() + 3;
sql.push_str(&format!(
" AND (instance>?{i1} OR (instance=?{i2} AND sequence>?{i3}))"
));
binds.push(Box::new(ck.to_vec()));
binds.push(Box::new(ck.to_vec()));
binds.push(Box::new(cs as i64));
}
sql.push_str(&format!(" ORDER BY instance,sequence LIMIT {}", limit + 1));
let mut stmt = conn.prepare(&sql).map_err(|e| backend("prepare", e))?;
let param_refs: Vec<&dyn rusqlite::ToSql> = binds.iter().map(|b| b.as_ref()).collect();
let topic_owned = topic.to_string();
let rows = stmt
.query_map(param_refs.as_slice(), |r| {
let inst: Vec<u8> = r.get(0)?;
let mut key = [0u8; 16];
if inst.len() == 16 {
key.copy_from_slice(&inst);
}
Ok(DurabilitySample {
topic: topic_owned.clone(),
instance_key: key,
sequence: r.get::<_, i64>(1)? as u64,
created_at: time_of(r.get::<_, i64>(2)?),
payload: r.get(3)?,
representation: r.get::<_, i64>(4)? as u8,
big_endian: r.get::<_, i64>(5)? != 0,
})
})
.map_err(|e| backend("query_map", e))?;
let mut samples = Vec::new();
for row in rows {
samples.push(row.map_err(|e| backend("row", e))?);
}
let exhausted = samples.len() <= limit;
samples.truncate(limit);
let next: Option<Cursor> = if exhausted {
None
} else {
samples.last().map(|s| (s.instance_key, s.sequence))
};
Ok(Page { samples, next })
}
fn unregister(&self, topic: &str, instance_key: &[u8; 16], now: SystemTime) -> Result<()> {
let conn = self.lock_conn()?;
conn.execute(
"INSERT OR REPLACE INTO unregistered(topic,instance,at_nanos) VALUES (?1,?2,?3)",
params![topic, &instance_key[..], nanos_of(now)],
)
.map_err(|e| backend("unregister", e))?;
Ok(())
}
fn cleanup(&self, now: SystemTime) -> Result<usize> {
let conn = self.lock_conn()?;
let mut stmt = conn
.prepare("SELECT topic,instance,at_nanos FROM unregistered")
.map_err(|e| backend("prepare cleanup", e))?;
let due: Vec<(String, Vec<u8>)> = stmt
.query_map([], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, Vec<u8>>(1)?,
r.get::<_, i64>(2)?,
))
})
.map_err(|e| backend("cleanup scan", e))?
.filter_map(|row| row.ok())
.filter_map(|(topic, inst, at)| {
let delay = self.contract_for(&topic).ok()?.cleanup_delay;
let deadline = time_of(at).checked_add(delay)?;
if now >= deadline {
Some((topic, inst))
} else {
None
}
})
.collect();
drop(stmt);
let mut removed = 0usize;
for (topic, inst) in due {
conn.execute(
"DELETE FROM samples WHERE topic=?1 AND instance=?2",
params![topic, inst],
)
.map_err(|e| backend("cleanup delete samples", e))?;
conn.execute(
"DELETE FROM unregistered WHERE topic=?1 AND instance=?2",
params![topic, inst],
)
.map_err(|e| backend("cleanup delete marker", e))?;
removed += 1;
}
Ok(removed)
}
fn stats(&self, topic: &str) -> Result<StoreStats> {
let conn = self.lock_conn()?;
let samples = Self::count(
&conn,
"SELECT COUNT(*) FROM samples WHERE topic=?1",
params![topic],
)? as usize;
let instances = Self::count(
&conn,
"SELECT COUNT(DISTINCT instance) FROM samples WHERE topic=?1",
params![topic],
)? as usize;
let bytes = conn
.query_row(
"SELECT COALESCE(SUM(LENGTH(payload)),0) FROM samples WHERE topic=?1",
params![topic],
|r| r.get::<_, i64>(0),
)
.optional()
.map_err(|e| backend("stats bytes", e))?
.unwrap_or(0) as u64;
Ok(StoreStats {
samples,
instances,
bytes,
})
}
}