blob: 7aa2147e81659f68c3b29dbd02dab5417c718e79 [file]
//! The SQLite backend: one connection, one file, the laptop's ledger.
//!
//! The connection sits behind a mutex so the ledger is `Sync`; the server holds the whole
//! ledger under an async lock, so the mutex is never contended.
use crate::{
claimable, parse_time, Error, Event, PromotionRecord, PromotionSpec, RawPromotion, RawTask,
Redaction, Status, Task, TaskSpec, PROMOTION_COLUMNS, TASK_COLUMNS,
};
use dm_core::Label;
use rusqlite::{params, Connection, OptionalExtension, Row};
use std::path::Path;
use std::sync::{Mutex, MutexGuard, PoisonError};
use time::OffsetDateTime;
const SCHEMA: &str = "
CREATE TABLE IF NOT EXISTS events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL,
kind TEXT NOT NULL,
at TEXT NOT NULL,
payload TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS events_task ON events (task_id, seq);
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
domain TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT NOT NULL,
requirements TEXT NOT NULL,
depends_on TEXT NOT NULL,
origin TEXT,
upstream_event TEXT,
created_by TEXT NOT NULL,
label_rank INTEGER NOT NULL,
priority INTEGER NOT NULL,
created TEXT NOT NULL,
status TEXT NOT NULL,
holder TEXT,
lease_until TEXT,
attempts INTEGER NOT NULL DEFAULT 0,
handoff TEXT,
reason TEXT,
superseded_by TEXT,
posted_to TEXT
);
CREATE INDEX IF NOT EXISTS tasks_queue ON tasks (domain, status, priority DESC, created);
DROP INDEX IF EXISTS tasks_origin;
CREATE UNIQUE INDEX IF NOT EXISTS tasks_domain_origin ON tasks (domain, origin, upstream_event)
WHERE origin IS NOT NULL AND upstream_event IS NOT NULL;
CREATE TABLE IF NOT EXISTS promotions (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
domain TEXT NOT NULL,
entry TEXT NOT NULL,
tier TEXT NOT NULL,
promoter TEXT NOT NULL,
author TEXT NOT NULL,
why TEXT NOT NULL,
label_rank INTEGER NOT NULL,
digest TEXT NOT NULL,
new_digest TEXT NOT NULL,
at TEXT NOT NULL,
why_erased INTEGER NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX IF NOT EXISTS promotions_version
ON promotions (domain, entry, tier, digest, promoter);
";
fn task_from_row(row: &Row<'_>) -> Result<Task, Error> {
RawTask {
id: row.get(0)?,
domain: row.get(1)?,
title: row.get(2)?,
body: row.get(3)?,
requirements: row.get(4)?,
depends_on: row.get(5)?,
origin: row.get(6)?,
upstream_event: row.get(7)?,
created_by: row.get(8)?,
label_rank: row.get(9)?,
priority: row.get(10)?,
created: row.get(11)?,
status: row.get(12)?,
holder: row.get(13)?,
lease_until: row.get(14)?,
attempts: row.get(15)?,
handoff: row.get(16)?,
reason: row.get(17)?,
superseded_by: row.get(18)?,
posted_to: row.get(19)?,
}
.into_task()
}
/// The ledger's tables in one SQLite file.
pub(crate) struct Db {
conn: Mutex<Connection>,
}
impl Db {
pub(crate) fn open(path: &Path) -> Result<Db, Error> {
Db::init(Connection::open(path)?)
}
pub(crate) fn in_memory() -> Result<Db, Error> {
Db::init(Connection::open_in_memory()?)
}
fn init(conn: Connection) -> Result<Db, Error> {
conn.execute_batch("PRAGMA journal_mode = WAL;")?;
conn.execute_batch(SCHEMA)?;
// a ledger from before handoffs were posted back has no column for it
let has_posted: i64 = conn.query_row(
"SELECT COUNT(*) FROM pragma_table_info('tasks') WHERE name = 'posted_to'",
[],
|r| r.get(0),
)?;
if has_posted == 0 {
conn.execute_batch("ALTER TABLE tasks ADD COLUMN posted_to TEXT;")?;
}
migrate_promotions(&conn)?;
Ok(Db {
conn: Mutex::new(conn),
})
}
fn conn(&self) -> MutexGuard<'_, Connection> {
self.conn.lock().unwrap_or_else(PoisonError::into_inner)
}
pub(crate) fn get(&self, id: &str) -> Result<Task, Error> {
get_in(&self.conn(), id)
}
pub(crate) fn find_origin(
&self,
domain: &str,
origin: &str,
upstream_event: &str,
) -> Result<Option<String>, Error> {
Ok(self
.conn()
.query_row(
"SELECT id FROM tasks WHERE domain = ?1 AND origin = ?2 AND upstream_event = ?3",
params![domain, origin, upstream_event],
|r| r.get(0),
)
.optional()?)
}
/// Append the event and project it, in one transaction.
pub(crate) fn append(
&mut self,
id: &str,
at: OffsetDateTime,
event: &Event,
) -> Result<(), Error> {
let mut conn = self.conn();
let tx = conn.transaction()?;
apply(&tx, id, at, event)?;
tx.commit()?;
Ok(())
}
/// Record a promotion unless that promoter promoted that version of the entry to that
/// tier already. The check and the append are one transaction, taken for writing first.
pub(crate) fn record_promotion(
&mut self,
at: OffsetDateTime,
spec: &PromotionSpec,
why_erased: bool,
) -> Result<bool, Error> {
let mut conn = self.conn();
let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
let seen = tx
.query_row(
"SELECT 1 FROM promotions
WHERE domain = ?1 AND entry = ?2 AND tier = ?3 AND digest = ?4
AND promoter = ?5",
params![
spec.domain,
spec.entry,
spec.tier.to_string(),
spec.digest,
spec.by
],
|_| Ok(()),
)
.optional()?
.is_some();
if seen {
return Ok(false);
}
let key = crate::promotion_key(&spec.entry);
apply(
&tx,
&key,
at,
&Event::Promoted {
spec: spec.clone(),
why_erased,
},
)?;
tx.commit()?;
Ok(true)
}
/// Remove the why from an entry's promotion events and rows in one transaction, with
/// the space they leave overwritten, then rebuild the file without that space and
/// fold the log into it, so the old text is in neither. Returns how many records of
/// the entry hold no why now.
pub(crate) fn scrub_promotions(&mut self, domain: &str, entry: &str) -> Result<usize, Error> {
self.scrubbing(|tx| scrub_promotions_in(tx, domain, entry))
}
/// Rewrite the tasks filed from `origin` to the redaction, in their events and their
/// rows, and invalidate the open ones, in one transaction with the space the old text
/// leaves overwritten; then rebuild the file and fold the log into it. Returns how
/// many tasks from that origin there are.
pub(crate) fn scrub_tasks(
&mut self,
at: OffsetDateTime,
domain: &str,
origin: &str,
redaction: &Redaction,
) -> Result<usize, Error> {
self.scrubbing(|tx| scrub_tasks_in(tx, at, domain, origin, redaction))
}
/// Both scrubs of an erasure in one transaction, and one rebuild of the file after
/// them. Returns the records that hold no why now and the tasks from the origin.
pub(crate) fn scrub_entry(
&mut self,
at: OffsetDateTime,
domain: &str,
entry: &str,
origin: &str,
redaction: &Redaction,
) -> Result<(usize, usize), Error> {
self.scrubbing(|tx| {
let (records, changed_records) = scrub_promotions_in(tx, domain, entry)?;
let (tasks, changed_tasks) = scrub_tasks_in(tx, at, domain, origin, redaction)?;
Ok(((records, tasks), changed_records + changed_tasks))
})
}
/// Run `scrub` in one transaction taken for writing, with what an update frees
/// overwritten with zeros, here and nowhere else: the rest of the ledger's writes need
/// not pay for it. Then rebuild the file and fold the log into it, whether or not
/// `scrub` rewrote anything: a scrub that rewrote rows and then found the log held
/// committed the rows and left the old text in the log, and the run after it finds
/// nothing to rewrite and still has to fold that log. Returns what `scrub` returned,
/// without the count of rows it rewrote.
fn scrubbing<T>(
&mut self,
scrub: impl FnOnce(&rusqlite::Transaction<'_>) -> Result<(T, usize), Error>,
) -> Result<T, Error> {
let mut conn = self.conn();
conn.pragma_update(None, "secure_delete", true)?;
let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
let (out, _rewritten) = scrub(&tx)?;
tx.commit()?;
conn.pragma_update(None, "secure_delete", false)?;
compact(&conn)?;
Ok(out)
}
pub(crate) fn promotions(
&self,
domain: &str,
entry: &str,
) -> Result<Vec<PromotionRecord>, Error> {
let conn = self.conn();
let mut stmt = conn.prepare(&format!(
"SELECT {PROMOTION_COLUMNS} FROM promotions WHERE domain = ?1 AND entry = ?2
ORDER BY seq"
))?;
let rows = stmt.query_map(params![domain, entry], |r| {
Ok(RawPromotion {
domain: r.get(0)?,
entry: r.get(1)?,
tier: r.get(2)?,
promoter: r.get(3)?,
author: r.get(4)?,
why: r.get(5)?,
label_rank: r.get(6)?,
digest: r.get(7)?,
new_digest: r.get(8)?,
at: r.get(9)?,
why_erased: r.get(10)?,
})
})?;
rows.map(|r| r?.into_record()).collect()
}
/// The id of the task claimed, if any candidate qualified.
pub(crate) fn claim_next(
&mut self,
domain: &str,
now: OffsetDateTime,
holder: &str,
until: OffsetDateTime,
manifest: &[String],
handling: Label,
) -> Result<Option<String>, Error> {
let mut conn = self.conn();
let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
let candidates: Vec<Task> = {
let mut stmt = tx.prepare(&format!(
"SELECT {TASK_COLUMNS} FROM tasks
WHERE domain = ?1 AND (status = 'admitted' OR (status = 'claimed' AND lease_until < ?2))
ORDER BY priority DESC, created"
))?;
let rows = stmt.query_map(params![domain, crate::fmt_time(now)], |r| {
Ok(task_from_row(r))
})?;
rows.map(|r| r?).collect::<Result<_, _>>()?
};
for task in candidates {
if !claimable(&task, now, manifest, handling)
|| !deps_complete(&tx, &task.spec.depends_on)?
|| awaits_continuation(&tx, &task)?
{
continue;
}
apply(
&tx,
&task.id,
now,
&Event::Claimed {
holder: holder.to_string(),
until,
},
)?;
tx.commit()?;
return Ok(Some(task.id));
}
Ok(None)
}
pub(crate) fn claim(
&mut self,
id: &str,
now: OffsetDateTime,
holder: &str,
until: OffsetDateTime,
manifest: &[String],
handling: Label,
) -> Result<(), Error> {
let mut conn = self.conn();
let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
let task = get_in(&tx, id)?;
if !claimable(&task, now, manifest, handling)
|| !deps_complete(&tx, &task.spec.depends_on)?
|| awaits_continuation(&tx, &task)?
{
return Err(Error::WrongState {
id: id.to_string(),
status: task.status,
op: "claim",
});
}
apply(
&tx,
id,
now,
&Event::Claimed {
holder: holder.to_string(),
until,
},
)?;
tx.commit()?;
Ok(())
}
fn select(&self, sql: &str, args: &[&dyn rusqlite::ToSql]) -> Result<Vec<Task>, Error> {
let conn = self.conn();
let mut stmt = conn.prepare(&format!("SELECT {TASK_COLUMNS} FROM tasks {sql}"))?;
let rows = stmt.query_map(args, |r| Ok(task_from_row(r)))?;
rows.map(|r| r?).collect()
}
pub(crate) fn filed_from(&self, parent: &str) -> Result<Vec<Task>, Error> {
self.select(
"WHERE origin IN (?1, ?2, ?3) ORDER BY created",
&[
&format!("task:{parent}"),
&format!("continues:{parent}"),
&format!("retries:{parent}"),
],
)
}
pub(crate) fn with_origin(&self, domain: &str, origin: &str) -> Result<Vec<Task>, Error> {
self.select(
"WHERE domain = ?1 AND origin = ?2 ORDER BY created",
&[&domain, &origin],
)
}
pub(crate) fn list(&self, domain: &str, status: Status) -> Result<Vec<Task>, Error> {
self.select(
"WHERE domain = ?1 AND status = ?2 ORDER BY priority DESC, created",
&[&domain, &status.as_str()],
)
}
pub(crate) fn unposted(&self, domain: &str) -> Result<Vec<Task>, Error> {
self.select(
"WHERE domain = ?1 AND status = 'completed' AND origin IS NOT NULL
AND posted_to IS NULL
ORDER BY created",
&[&domain],
)
}
pub(crate) fn dependents(&self, id: &str) -> Result<Vec<Task>, Error> {
self.select(
"WHERE id IN (
SELECT t.id FROM tasks t, json_each(t.depends_on) AS d WHERE d.value = ?1)
ORDER BY created",
&[&id],
)
}
pub(crate) fn created_payload(&self, id: &str) -> Result<String, Error> {
self.conn()
.query_row(
"SELECT payload FROM events WHERE task_id = ?1 AND kind = 'created' ORDER BY seq LIMIT 1",
[id],
|r| r.get(0),
)
.optional()?
.ok_or_else(|| Error::NotFound(id.to_string()))
}
pub(crate) fn event_count(&self) -> Result<u64, Error> {
let n: i64 = self
.conn()
.query_row("SELECT COUNT(*) FROM events", [], |r| r.get(0))?;
Ok(u64::try_from(n).unwrap_or(0))
}
pub(crate) fn rebuild(&mut self) -> Result<u64, Error> {
let mut conn = self.conn();
let tx = conn.transaction()?;
tx.execute_batch("DELETE FROM tasks; DELETE FROM promotions;")?;
let events: Vec<(String, String, String)> = {
let mut stmt = tx.prepare("SELECT task_id, at, payload FROM events ORDER BY seq")?;
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?;
rows.collect::<Result<_, _>>()?
};
let mut n = 0;
for (task_id, at, payload) in events {
let at = parse_time(&task_id, &at)?;
let event = Event::parse(&task_id, &payload)?;
project(&tx, &task_id, at, &event)?;
n += 1;
}
tx.commit()?;
Ok(n)
}
}
/// A ledger whose promotions table 41e0bc5 made has neither `new_digest` nor
/// `why_erased`, and its index lacks the promoter, so a second promoter of a version would
/// be refused; one 088f890 made has `new_digest` and the promoter, and no `why_erased`.
/// Each column that is missing is added, empty or false, and the index is made again
/// when it lacks the promoter.
fn migrate_promotions(conn: &Connection) -> Result<(), Error> {
let has = |column: &str| -> Result<bool, Error> {
let n: i64 = conn.query_row(
"SELECT COUNT(*) FROM pragma_table_info('promotions') WHERE name = ?1",
[column],
|r| r.get(0),
)?;
Ok(n > 0)
};
// each column on its own: a ledger from between the two shapes has one and not the
// other
if !has("new_digest")? {
conn.execute_batch(
"ALTER TABLE promotions ADD COLUMN new_digest TEXT NOT NULL DEFAULT '';",
)?;
}
if !has("why_erased")? {
conn.execute_batch(
"ALTER TABLE promotions ADD COLUMN why_erased INTEGER NOT NULL DEFAULT 0;",
)?;
}
let keyed_by_promoter: i64 = conn.query_row(
"SELECT COUNT(*) FROM pragma_index_info('promotions_version') WHERE name = 'promoter'",
[],
|r| r.get(0),
)?;
if keyed_by_promoter == 0 {
conn.execute_batch(
"BEGIN;
DROP INDEX IF EXISTS promotions_version;
CREATE UNIQUE INDEX promotions_version
ON promotions (domain, entry, tier, digest, promoter);
COMMIT;",
)?;
}
Ok(())
}
/// The scrub of an entry's promotions, in the caller's transaction: the why leaves its
/// events and its rows. Returns how many records of the entry hold no why now, and how
/// many rows this call rewrote.
fn scrub_promotions_in(
tx: &Connection,
domain: &str,
entry: &str,
) -> Result<(usize, usize), Error> {
let key = crate::promotion_key(entry);
let events: Vec<(i64, String)> = {
let mut stmt =
tx.prepare("SELECT seq, payload FROM events WHERE task_id = ?1 AND kind = 'promoted'")?;
let rows = stmt.query_map(params![key], |r| Ok((r.get(0)?, r.get(1)?)))?;
rows.collect::<Result<_, _>>()?
};
let mut changed = 0;
for (seq, payload) in events {
if let Some(scrubbed) = crate::scrub_payload(&key, domain, &payload)? {
changed += tx.execute(
"UPDATE events SET payload = ?2 WHERE seq = ?1",
params![seq, scrubbed],
)?;
}
}
changed += tx.execute(
"UPDATE promotions SET why = '', why_erased = 1
WHERE domain = ?1 AND entry = ?2 AND (why_erased = 0 OR why != '')",
params![domain, entry],
)?;
let n: i64 = tx.query_row(
"SELECT COUNT(*) FROM promotions WHERE domain = ?1 AND entry = ?2 AND why_erased = 1",
params![domain, entry],
|r| r.get(0),
)?;
Ok((usize::try_from(n).unwrap_or(0), changed))
}
/// The scrub of the tasks filed from `origin`, in the caller's transaction: each is
/// rewritten to the redaction and the open ones are invalidated. Returns how many tasks
/// there are from that origin, and how many rows this call rewrote.
fn scrub_tasks_in(
tx: &Connection,
at: OffsetDateTime,
domain: &str,
origin: &str,
redaction: &Redaction,
) -> Result<(usize, usize), Error> {
let tasks: Vec<(String, bool)> = {
let mut stmt = tx.prepare(
"SELECT id, status IN ('completed', 'invalidated') FROM tasks
WHERE domain = ?1 AND origin = ?2 ORDER BY created",
)?;
let rows = stmt.query_map(params![domain, origin], |r| Ok((r.get(0)?, r.get(1)?)))?;
rows.collect::<Result<_, _>>()?
};
let mut changed = 0;
for (id, terminal) in &tasks {
if !terminal {
let closed = Event::Invalidated {
reason: redaction.reason.clone(),
superseded_by: None,
};
apply(tx, id, at, &closed)?;
changed += 1;
}
changed += redact_task(tx, id, redaction)?;
}
Ok((tasks.len(), changed))
}
/// One task's events and row, rewritten to the redaction: the title and body it was
/// filed with, the reason it was invalidated for, and the handoffs left on it or that
/// closed it, each free text an approver may have put the entry's text in. Returns how
/// many rows changed.
fn redact_task(conn: &Connection, id: &str, redaction: &Redaction) -> Result<usize, Error> {
let events: Vec<(i64, String)> = {
let mut stmt = conn.prepare("SELECT seq, payload FROM events WHERE task_id = ?1")?;
let rows = stmt.query_map(params![id], |r| Ok((r.get(0)?, r.get(1)?)))?;
rows.collect::<Result<_, _>>()?
};
let mut changed = 0;
for (seq, payload) in events {
if let Some(redacted) = crate::redact_event(id, &payload, redaction)? {
changed += conn.execute(
"UPDATE events SET payload = ?2 WHERE seq = ?1",
params![seq, redacted],
)?;
}
}
changed += conn.execute(
"UPDATE tasks SET title = ?2, body = ?3,
reason = CASE WHEN reason IS NULL THEN NULL ELSE ?4 END,
handoff = CASE WHEN handoff IS NULL THEN NULL ELSE ?4 END
WHERE id = ?1 AND (title != ?2 OR body != ?3
OR reason IS NOT ?4 AND reason IS NOT NULL
OR handoff IS NOT ?4 AND handoff IS NOT NULL)",
params![id, redaction.title, redaction.body, redaction.reason],
)?;
Ok(changed)
}
/// Rebuild the file without the free space old text sat in, and fold the log into it:
/// the log first, so the rebuild reads the tables as they stand; then again, since the
/// rebuild itself went through the log. `secure_delete` alone is not enough: a why long
/// enough for overflow pages, with records written after it, left its text in the file.
fn compact(conn: &Connection) -> Result<(), Error> {
checkpoint(conn)?;
conn.execute_batch("VACUUM;")?;
checkpoint(conn)
}
/// Fold the write-ahead log into the file and truncate it, waiting a little for a reader
/// mid-query; [`Error::Held`] when it still could not, since the log then still holds
/// what the tables no longer do.
fn checkpoint(conn: &Connection) -> Result<(), Error> {
conn.busy_timeout(std::time::Duration::from_millis(200))?;
let mut held = true;
for _ in 0..5 {
let busy: i64 = conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |r| r.get(0))?;
if busy == 0 {
held = false;
break;
}
}
// rusqlite's own default, for every other statement on this connection
conn.busy_timeout(std::time::Duration::from_secs(5))?;
if held {
return Err(Error::Held("the write-ahead log".into()));
}
Ok(())
}
fn get_in(conn: &Connection, id: &str) -> Result<Task, Error> {
conn.query_row(
&format!("SELECT {TASK_COLUMNS} FROM tasks WHERE id = ?1"),
params![id],
|r| Ok(task_from_row(r)),
)
.optional()?
.ok_or_else(|| Error::NotFound(id.to_string()))?
}
fn deps_complete(conn: &Connection, deps: &[String]) -> Result<bool, Error> {
for d in deps {
let status: Option<String> = conn
.query_row("SELECT status FROM tasks WHERE id = ?1", params![d], |r| {
r.get(0)
})
.optional()?;
if status.as_deref() != Some("completed") {
return Ok(false);
}
}
Ok(true)
}
/// Whether a continuation of `task` is still open: its work goes on there, and the
/// continuation's completion closes it, so it is nobody's to claim meanwhile.
fn awaits_continuation(conn: &Connection, task: &Task) -> Result<bool, Error> {
Ok(conn
.query_row(
"SELECT 1 FROM tasks WHERE domain = ?1 AND origin = ?2
AND status NOT IN ('completed', 'invalidated') LIMIT 1",
params![task.spec.domain, crate::continues(&task.id)],
|_| Ok(()),
)
.optional()?
.is_some())
}
/// Append the event and project it, in the caller's transaction.
fn apply(conn: &Connection, id: &str, at: OffsetDateTime, event: &Event) -> Result<(), Error> {
conn.execute(
"INSERT INTO events (task_id, kind, at, payload) VALUES (?1, ?2, ?3, ?4)",
params![id, event.kind(), crate::fmt_time(at), event.payload(id)?],
)?;
project(conn, id, at, event)
}
/// The row a `Created` event projects to: a task born proposed.
fn insert_task(
conn: &Connection,
id: &str,
at: OffsetDateTime,
spec: &TaskSpec,
) -> Result<(), Error> {
let requirements = serde_json::to_string(&spec.requirements).unwrap_or_default();
let depends_on = serde_json::to_string(&spec.depends_on).unwrap_or_default();
conn.execute(
"INSERT INTO tasks (id, domain, title, body, requirements, depends_on, origin,
upstream_event, created_by, label_rank, priority, created, status)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 'proposed')",
params![
id,
spec.domain,
spec.title,
spec.body,
requirements,
depends_on,
spec.origin,
spec.upstream_event,
spec.created_by,
spec.classification as i64,
spec.priority,
crate::fmt_time(at),
],
)?;
Ok(())
}
/// The row a `Promoted` event projects to.
fn insert_promotion(
conn: &Connection,
at: OffsetDateTime,
p: &PromotionSpec,
why_erased: bool,
) -> Result<(), Error> {
conn.execute(
"INSERT INTO promotions (domain, entry, tier, promoter, author, why, label_rank,
digest, new_digest, at, why_erased)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
params![
p.domain,
p.entry,
p.tier.to_string(),
p.by,
p.author,
p.why,
p.classification as i64,
p.digest,
p.new_digest,
crate::fmt_time(at),
i64::from(why_erased),
],
)?;
Ok(())
}
/// Update the projection for one event. Replay calls this and nothing else.
fn project(conn: &Connection, id: &str, at: OffsetDateTime, event: &Event) -> Result<(), Error> {
match event {
Event::Promoted { spec, why_erased } => insert_promotion(conn, at, spec, *why_erased)?,
Event::Created { id: new_id, spec } => insert_task(conn, new_id, at, spec)?,
Event::Admitted => {
conn.execute(
"UPDATE tasks SET status = 'admitted' WHERE id = ?1",
params![id],
)?;
}
Event::Claimed { holder, until } => {
conn.execute(
"UPDATE tasks SET status = 'claimed', holder = ?2, lease_until = ?3,
attempts = attempts + 1 WHERE id = ?1",
params![id, holder, crate::fmt_time(*until)],
)?;
}
Event::Heartbeat { until } => {
conn.execute(
"UPDATE tasks SET lease_until = ?2 WHERE id = ?1",
params![id, crate::fmt_time(*until)],
)?;
}
Event::Released => {
conn.execute(
"UPDATE tasks SET status = 'admitted', holder = NULL, lease_until = NULL
WHERE id = ?1",
params![id],
)?;
}
Event::HandoffLeft { handoff } => {
conn.execute(
"UPDATE tasks SET handoff = ?2 WHERE id = ?1",
params![id, handoff],
)?;
}
Event::Completed { handoff } => {
conn.execute(
"UPDATE tasks SET status = 'completed', handoff = ?2, lease_until = NULL
WHERE id = ?1",
params![id, handoff],
)?;
}
Event::Invalidated {
reason,
superseded_by,
} => {
conn.execute(
"UPDATE tasks SET status = 'invalidated', reason = ?2, superseded_by = ?3,
lease_until = NULL WHERE id = ?1",
params![id, reason, superseded_by],
)?;
}
Event::Posted { to } => {
conn.execute(
"UPDATE tasks SET posted_to = ?2 WHERE id = ?1",
params![id, to],
)?;
}
Event::Rescored { priority, .. } => {
conn.execute(
"UPDATE tasks SET priority = ?2 WHERE id = ?1",
params![id, priority],
)?;
}
}
Ok(())
}