use std::ops::Bound;
use async_trait::async_trait;
use redb::{ReadableDatabase, ReadableTable, TableDefinition};
use crate::case::TimerStore;
use crate::core::{CaseId, EffectKey, Phase, RunId, StoreError, Timer, Timestamp};
use super::redb::{MAX_STR, RedbStore, be, begin_write, decoded, is_sealed};
fn phase_from(s: &str) -> Result<Phase, StoreError> {
decoded("step phase", s, Phase::parse(s))
}
type TimerRow<'a> = (&'a str, u8, u32, &'a str, i64, i64, u8);
const TIMERS: TableDefinition<(&str, &str, &str), TimerRow<'static>> =
TableDefinition::new("timers");
const TIMERS_DUE: TableDefinition<(&str, i64, &str, &str), ()> = TableDefinition::new("timers_due");
const CLAIM_LEASE: i64 = 60;
pub(super) fn create_tables(w: &redb::WriteTransaction) -> Result<(), StoreError> {
w.open_table(TIMERS).map_err(|e| be(&e))?;
w.open_table(TIMERS_DUE).map_err(|e| be(&e))?;
Ok(())
}
fn build(
run: &str,
effect: &str,
case: &str,
has_case: u8,
step: u32,
phase: &str,
fire_at: i64,
) -> Result<Timer, StoreError> {
Ok(Timer {
run: RunId::parse(run).map_err(|e| StoreError::Corrupt {
seq: 0,
detail: format!("bad run id '{run}': {e}"),
})?,
case: if has_case == 1 {
Some(CaseId::parse(case).map_err(|e| StoreError::Corrupt {
seq: 0,
detail: format!("bad case id '{case}': {e}"),
})?)
} else {
None
},
effect: EffectKey::from_hex(effect).map_err(|e| StoreError::Corrupt {
seq: 0,
detail: format!("bad effect key '{effect}': {e}"),
})?,
step: crate::core::StepId(step),
phase: phase_from(phase)?,
fire_at: Timestamp::from_unix_timestamp(fire_at).map_err(|e| StoreError::Corrupt {
seq: 0,
detail: format!("unrepresentable timestamp {fire_at}: {e}"),
})?,
})
}
fn retire(
w: &redb::WriteTransaction,
tenant: &str,
run: &str,
effect: &str,
) -> Result<(), StoreError> {
let mut t = w.open_table(TIMERS).map_err(|e| be(&e))?;
if let Some(v) = t.remove((tenant, run, effect)).map_err(|e| be(&e))? {
let fire_at = v.value().4;
drop(v);
w.open_table(TIMERS_DUE)
.map_err(|e| be(&e))?
.remove((tenant, fire_at, run, effect))
.map_err(|e| be(&e))?;
}
Ok(())
}
#[async_trait]
impl TimerStore for RedbStore {
fn tenant(&self) -> &str {
crate::journal::JournalStore::tenant(self)
}
async fn arm(&self, timer: &Timer) -> Result<(), StoreError> {
let tenant = self.tenant_name();
let (run, effect) = (timer.run.to_string(), timer.effect.to_hex());
let case = timer.case.map(|c| c.to_string()).unwrap_or_default();
let has_case = u8::from(timer.case.is_some());
let step = timer.step.0;
let phase = timer.phase.as_str();
let fire_at = timer.fire_at.unix_timestamp();
self.with_db(move |db| {
let w = begin_write(db)?;
{
let mut t = w.open_table(TIMERS).map_err(|e| be(&e))?;
if t.get((tenant.as_str(), run.as_str(), effect.as_str()))
.map_err(|e| be(&e))?
.is_none()
{
t.insert(
(tenant.as_str(), run.as_str(), effect.as_str()),
(case.as_str(), has_case, step, phase, fire_at, 0, 0),
)
.map_err(|e| be(&e))?;
w.open_table(TIMERS_DUE)
.map_err(|e| be(&e))?
.insert(
(tenant.as_str(), fire_at, run.as_str(), effect.as_str()),
(),
)
.map_err(|e| be(&e))?;
}
}
w.commit().map_err(|e| be(&e))?;
Ok(())
})
.await
}
async fn claim_due(&self, now: Timestamp, limit: usize) -> Result<Vec<Timer>, StoreError> {
let cutoff = now.unix_timestamp();
let tenant = self.tenant_name();
self.with_db(move |db| {
let w = begin_write(db)?;
let out = {
let chunk = limit.max(64);
let mut after: Option<(i64, String, String)> = None;
let mut out = Vec::new();
'scan: loop {
let candidates: Vec<(i64, String, String)> = {
let due = w.open_table(TIMERS_DUE).map_err(|e| be(&e))?;
let end = Bound::Included((tenant.as_str(), cutoff, MAX_STR, MAX_STR));
let start = match &after {
Some((at, run, effect)) => Bound::Excluded((
tenant.as_str(),
*at,
run.as_str(),
effect.as_str(),
)),
None => Bound::Included((tenant.as_str(), i64::MIN, "", "")),
};
due.range::<(&str, i64, &str, &str)>((start, end))
.map_err(|e| be(&e))?
.take(chunk)
.map(|e| {
e.map(|(k, _)| {
let (_, at, run, effect) = k.value();
(at, run.to_owned(), effect.to_owned())
})
.map_err(|e| be(&e))
})
.collect::<Result<_, _>>()?
};
let Some(last) = candidates.last().cloned() else {
break;
};
after = Some(last);
for (_, run, effect) in candidates {
if out.len() >= limit {
break 'scan;
}
if is_sealed(&w, &tenant, &run)? {
retire(&w, &tenant, &run, &effect)?;
continue;
}
let mut timers = w.open_table(TIMERS).map_err(|e| be(&e))?;
let Some(row) = timers
.get((tenant.as_str(), run.as_str(), effect.as_str()))
.map_err(|e| be(&e))?
.map(|v| {
let (c, hc, st, ph, fa, ca, hca) = v.value();
(c.to_owned(), hc, st, ph.to_owned(), fa, ca, hca)
})
else {
continue;
};
let (case, has_case, step, phase, fire_at, claimed_at, has_claim) = row;
if has_claim == 1 && claimed_at > cutoff - CLAIM_LEASE {
continue;
}
timers
.insert(
(tenant.as_str(), run.as_str(), effect.as_str()),
(
case.as_str(),
has_case,
step,
phase.as_str(),
fire_at,
cutoff,
1,
),
)
.map_err(|e| be(&e))?;
out.push(build(
&run, &effect, &case, has_case, step, &phase, fire_at,
)?);
}
}
out
};
w.commit().map_err(|e| be(&e))?;
Ok(out)
})
.await
}
async fn pending_count(&self) -> Result<u64, StoreError> {
let tenant = self.tenant_name();
self.with_db(move |db| {
let r = db.begin_read().map_err(|e| be(&e))?;
let t = r.open_table(TIMERS).map_err(|e| be(&e))?;
let mut n = 0u64;
for e in t
.range((tenant.as_str(), "", "")..=(tenant.as_str(), MAX_STR, MAX_STR))
.map_err(|e| be(&e))?
{
e.map_err(|e| be(&e))?;
n += 1;
}
Ok(n)
})
.await
}
async fn disarm(&self, run: RunId, effect: EffectKey) -> Result<(), StoreError> {
let tenant = self.tenant_name();
let (run, effect) = (run.to_string(), effect.to_hex());
self.with_db(move |db| {
let w = begin_write(db)?;
retire(&w, &tenant, &run, &effect)?;
w.commit().map_err(|e| be(&e))?;
Ok(())
})
.await
}
async fn disarm_run(&self, run: RunId) -> Result<usize, StoreError> {
let tenant = self.tenant_name();
let run = run.to_string();
self.with_db(move |db| {
let w = begin_write(db)?;
let effects: Vec<String> = {
let t = w.open_table(TIMERS).map_err(|e| be(&e))?;
t.range(
(tenant.as_str(), run.as_str(), "")..=(tenant.as_str(), run.as_str(), MAX_STR),
)
.map_err(|e| be(&e))?
.map(|e| e.map(|(k, _)| k.value().2.to_owned()).map_err(|e| be(&e)))
.collect::<Result<_, _>>()?
};
for effect in &effects {
retire(&w, &tenant, &run, effect)?;
}
w.commit().map_err(|e| be(&e))?;
Ok(effects.len())
})
.await
}
}
impl RedbStore {
pub async fn armed_timers(&self, run: RunId) -> Result<usize, StoreError> {
Ok(self.armed_timer_rows(run).await?.len())
}
pub async fn armed_timer_rows(&self, run: RunId) -> Result<Vec<Timer>, StoreError> {
let tenant = self.tenant_name();
let run = run.to_string();
self.with_db(move |db| {
let r = db.begin_read().map_err(|e| be(&e))?;
let t = r.open_table(TIMERS).map_err(|e| be(&e))?;
t.range((tenant.as_str(), run.as_str(), "")..=(tenant.as_str(), run.as_str(), MAX_STR))
.map_err(|e| be(&e))?
.map(|e| {
let (k, v) = e.map_err(|e| be(&e))?;
let (_, run, effect) = k.value();
let (case, has_case, step, phase, fire_at, _, _) = v.value();
build(run, effect, case, has_case, step, phase, fire_at)
})
.collect()
})
.await
}
}
#[cfg(test)]
mod codec_tests {
use super::*;
#[test]
fn every_written_phase_decodes_to_the_value_that_wrote_it() {
for phase in [Phase::Forward, Phase::Compensating] {
assert_eq!(phase_from(phase.as_str()).expect("round trip"), phase);
}
}
#[test]
fn an_unreadable_timer_phase_is_refused_rather_than_defaulted() {
for bad in ["", "Forward", "compensating "] {
assert!(
matches!(phase_from(bad).err(), Some(StoreError::Corrupt { .. })),
"phase '{bad}' decoded instead of refusing"
);
}
}
}