| //! The PostgreSQL backend: the same tables and the same events, on a server several | |
| //! processes can share. One connection, driven by a task of its own. | |
| //! | |
| //! One schema per domain, as the reference stack partitions everything by domain: a | |
| //! domain's `events`, `tasks` and `promotions` live in a schema of their own, created | |
| //! when the domain files its first task or records its first promotion, so a domain's | |
| //! queue is a table no other domain's claims touch and a domain can be moved, backed up | |
| //! or dropped as a unit. Two tables in `public` route the rest: `ledger_domains`, each | |
| //! domain and its schema, and `ledger_tasks`, each task id and its domain, written in | |
| //! the transaction that files the task, so an operation by id finds its schema in one | |
| //! lookup. A promotion is no task and has no route there: it names its domain, and is | |
| //! read by domain. | |
| //! | |
| //! Times are stored as the RFC 3339 text the SQLite backend stores, so a row reads back | |
| //! exactly as it was written and a replay produces the same projection on either side. | |
| //! A claim takes its candidates `FOR UPDATE SKIP LOCKED`, so two claimers on two | |
| //! connections cannot both win the same task. | |
| use crate::{ | |
| claimable, parse_time, Error, Event, PromotionRecord, PromotionSpec, RawPromotion, RawTask, | |
| Redaction, Status, Task, TaskSpec, PROMOTION_COLUMNS, TASK_COLUMNS, | |
| }; | |
| use dm_core::Label; | |
| use std::collections::HashMap; | |
| use std::sync::Mutex; | |
| use time::OffsetDateTime; | |
| use tokio_postgres::error::SqlState; | |
| use tokio_postgres::types::ToSql; | |
| use tokio_postgres::{Client, GenericClient, NoTls, Row}; | |
| /// The routing tables, in `public`. | |
| const DIRECTORY: &str = " | |
| CREATE TABLE IF NOT EXISTS public.ledger_domains ( | |
| domain TEXT PRIMARY KEY, | |
| schema TEXT NOT NULL UNIQUE | |
| ); | |
| CREATE TABLE IF NOT EXISTS public.ledger_tasks ( | |
| id TEXT PRIMARY KEY, | |
| domain TEXT NOT NULL REFERENCES public.ledger_domains (domain) | |
| ); | |
| "; | |
| /// One domain's tables, created in its schema. `{s}` is the quoted schema name. | |
| const DOMAIN_TABLES: &str = " | |
| CREATE TABLE IF NOT EXISTS {s}.events ( | |
| seq BIGSERIAL PRIMARY KEY, | |
| 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 {s}.events (task_id, seq); | |
| CREATE TABLE IF NOT EXISTS {s}.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 BIGINT NOT NULL, | |
| priority BIGINT NOT NULL, | |
| created TEXT NOT NULL, | |
| status TEXT NOT NULL, | |
| holder TEXT, | |
| lease_until TEXT, | |
| attempts BIGINT NOT NULL DEFAULT 0, | |
| handoff TEXT, | |
| reason TEXT, | |
| superseded_by TEXT, | |
| posted_to TEXT | |
| ); | |
| CREATE INDEX IF NOT EXISTS tasks_queue ON {s}.tasks (status, priority DESC, created); | |
| CREATE UNIQUE INDEX IF NOT EXISTS tasks_origin ON {s}.tasks (origin, upstream_event) | |
| WHERE origin IS NOT NULL AND upstream_event IS NOT NULL; | |
| "; | |
| /// One domain's promotions, created in its schema with the rest, and added at connect to | |
| /// a schema made before promotions were recorded. A table an earlier shape made gets | |
| /// each column it lacks, so the statements hold for every shape; the index is | |
| /// [`PROMOTION_INDEX`], made again when the one there lacks the promoter. `{s}` is the | |
| /// quoted schema name. | |
| const PROMOTION_TABLE: &str = " | |
| CREATE TABLE IF NOT EXISTS {s}.promotions ( | |
| seq BIGSERIAL PRIMARY KEY, | |
| 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 BIGINT NOT NULL, | |
| digest TEXT NOT NULL, | |
| new_digest TEXT NOT NULL DEFAULT '', | |
| at TEXT NOT NULL, | |
| why_erased BIGINT NOT NULL DEFAULT 0 | |
| ); | |
| ALTER TABLE {s}.promotions ADD COLUMN IF NOT EXISTS new_digest TEXT NOT NULL DEFAULT ''; | |
| ALTER TABLE {s}.promotions ADD COLUMN IF NOT EXISTS why_erased BIGINT NOT NULL DEFAULT 0; | |
| "; | |
| /// The key a promotion is kept once under. `{s}` is the quoted schema name. | |
| const PROMOTION_INDEX: &str = " | |
| CREATE UNIQUE INDEX IF NOT EXISTS promotions_version | |
| ON {s}.promotions (entry, tier, digest, promoter); | |
| "; | |
| /// Whether the promotions table `$1` names (`<schema>.promotions`, quoted) has an index | |
| /// `promotions_version` that the promoter is part of. | |
| const INDEX_HAS_PROMOTER: &str = " | |
| SELECT EXISTS ( | |
| SELECT 1 FROM pg_index i | |
| JOIN pg_class ic ON ic.oid = i.indexrelid | |
| JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY (i.indkey) | |
| WHERE i.indrelid = to_regclass($1) AND ic.relname = 'promotions_version' | |
| AND a.attname = 'promoter') | |
| "; | |
| /// The domains whose promotions table is missing, lacks a column, or is keyed without | |
| /// the promoter: what a connect migrates, and nothing else. | |
| const DOMAINS_TO_MIGRATE: &str = " | |
| SELECT d.domain FROM public.ledger_domains d | |
| LEFT JOIN pg_class c ON c.oid = to_regclass(d.schema || '.promotions') | |
| WHERE c.oid IS NULL | |
| OR NOT EXISTS (SELECT 1 FROM pg_attribute a WHERE a.attrelid = c.oid | |
| AND a.attname = 'new_digest' AND NOT a.attisdropped) | |
| OR NOT EXISTS (SELECT 1 FROM pg_attribute a WHERE a.attrelid = c.oid | |
| AND a.attname = 'why_erased' AND NOT a.attisdropped) | |
| OR NOT EXISTS (SELECT 1 FROM pg_index i | |
| JOIN pg_class ic ON ic.oid = i.indexrelid | |
| JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY (i.indkey) | |
| WHERE i.indrelid = c.oid AND ic.relname = 'promotions_version' | |
| AND a.attname = 'promoter') | |
| ORDER BY d.domain | |
| "; | |
| /// The schema a domain's tables live in: `ledger_` and the domain with anything that is | |
| /// not a lowercase letter or digit made an underscore, then eight hex digits of the | |
| /// domain's digest, so two domains that read alike never share a schema. Quoted. | |
| pub(crate) fn schema_for(domain: &str) -> String { | |
| let readable: String = domain | |
| .chars() | |
| .map(|c| { | |
| if c.is_ascii_alphanumeric() { | |
| c.to_ascii_lowercase() | |
| } else { | |
| '_' | |
| } | |
| }) | |
| .take(40) | |
| .collect(); | |
| format!("\"ledger_{readable}_{}\"", &dm_core::digest(domain)[..8]) | |
| } | |
| fn task_from_row(row: &Row) -> Result<Task, Error> { | |
| RawTask { | |
| id: row.try_get(0)?, | |
| domain: row.try_get(1)?, | |
| title: row.try_get(2)?, | |
| body: row.try_get(3)?, | |
| requirements: row.try_get(4)?, | |
| depends_on: row.try_get(5)?, | |
| origin: row.try_get(6)?, | |
| upstream_event: row.try_get(7)?, | |
| created_by: row.try_get(8)?, | |
| label_rank: row.try_get(9)?, | |
| priority: row.try_get(10)?, | |
| created: row.try_get(11)?, | |
| status: row.try_get(12)?, | |
| holder: row.try_get(13)?, | |
| lease_until: row.try_get(14)?, | |
| attempts: row.try_get(15)?, | |
| handoff: row.try_get(16)?, | |
| reason: row.try_get(17)?, | |
| superseded_by: row.try_get(18)?, | |
| posted_to: row.try_get(19)?, | |
| } | |
| .into_task() | |
| } | |
| /// The ledger's tables on one PostgreSQL connection. | |
| pub(crate) struct Db { | |
| client: Client, | |
| /// Domains whose schema is known to exist, and the schema. | |
| known: Mutex<HashMap<String, String>>, | |
| } | |
| impl Db { | |
| /// Connect, spawn the task that drives the connection, create the routing tables, | |
| /// and move a ledger from before domains had schemas into them. | |
| pub(crate) async fn connect(url: &str) -> Result<Db, Error> { | |
| let (client, connection) = tokio_postgres::connect(url, NoTls).await?; | |
| // The connection ends when the client is dropped; an error on the way surfaces | |
| // on the next query as its own error, so nothing is lost by not logging it here. | |
| tokio::spawn(async move { | |
| let _ = connection.await; | |
| }); | |
| client.batch_execute(DIRECTORY).await?; | |
| let mut db = Db { | |
| client, | |
| known: Mutex::new(HashMap::new()), | |
| }; | |
| db.migrate_shared_tables().await?; | |
| db.add_promotion_tables().await?; | |
| Ok(db) | |
| } | |
| /// Give a promotions table to each domain schema made before promotions were | |
| /// recorded, and its missing columns and key to one an earlier shape made: one | |
| /// catalog query finds them, and only those are locked and migrated, so a connect | |
| /// costs nothing per domain whose table is as it should be. | |
| async fn add_promotion_tables(&mut self) -> Result<(), Error> { | |
| let rows = self.client.query(DOMAINS_TO_MIGRATE, &[]).await?; | |
| if rows.is_empty() { | |
| return Ok(()); | |
| } | |
| let tx = self.client.transaction().await?; | |
| for row in &rows { | |
| let domain: String = row.try_get(0)?; | |
| ensure_promotions(&tx, &domain).await?; | |
| } | |
| tx.commit().await?; | |
| Ok(()) | |
| } | |
| fn cached(&self, domain: &str) -> Option<String> { | |
| self.known | |
| .lock() | |
| .unwrap_or_else(std::sync::PoisonError::into_inner) | |
| .get(domain) | |
| .cloned() | |
| } | |
| fn remember(&self, domain: &str, schema: &str) { | |
| self.known | |
| .lock() | |
| .unwrap_or_else(std::sync::PoisonError::into_inner) | |
| .insert(domain.to_string(), schema.to_string()); | |
| } | |
| /// The schema of a domain that has filed a task, or `None`. | |
| async fn schema(&self, domain: &str) -> Result<Option<String>, Error> { | |
| if let Some(s) = self.cached(domain) { | |
| return Ok(Some(s)); | |
| } | |
| let row = self | |
| .client | |
| .query_opt( | |
| "SELECT schema FROM public.ledger_domains WHERE domain = $1", | |
| &[&domain], | |
| ) | |
| .await?; | |
| let schema: Option<String> = row.map(|r| r.try_get(0)).transpose()?; | |
| if let Some(s) = &schema { | |
| self.remember(domain, s); | |
| } | |
| Ok(schema) | |
| } | |
| /// The schema of the domain a task belongs to. | |
| async fn schema_of_task(&self, id: &str) -> Result<String, Error> { | |
| schema_of_task_in(&self.client, id) | |
| .await? | |
| .ok_or_else(|| Error::NotFound(id.to_string())) | |
| } | |
| pub(crate) async fn get(&self, id: &str) -> Result<Task, Error> { | |
| let s = self.schema_of_task(id).await?; | |
| get_in(&self.client, &s, id).await | |
| } | |
| pub(crate) async fn created_payload(&self, id: &str) -> Result<String, Error> { | |
| let s = self.schema_of_task(id).await?; | |
| let row = self | |
| .client | |
| .query_opt( | |
| &format!( | |
| "SELECT payload FROM {s}.events WHERE task_id = $1 AND kind = 'created' | |
| ORDER BY seq LIMIT 1" | |
| ), | |
| &[&id], | |
| ) | |
| .await? | |
| .ok_or_else(|| Error::NotFound(id.to_string()))?; | |
| Ok(row.try_get(0)?) | |
| } | |
| pub(crate) async fn find_origin( | |
| &self, | |
| domain: &str, | |
| origin: &str, | |
| upstream_event: &str, | |
| ) -> Result<Option<String>, Error> { | |
| let Some(s) = self.schema(domain).await? else { | |
| return Ok(None); | |
| }; | |
| let row = self | |
| .client | |
| .query_opt( | |
| &format!("SELECT id FROM {s}.tasks WHERE origin = $1 AND upstream_event = $2"), | |
| &[&origin, &upstream_event], | |
| ) | |
| .await?; | |
| Ok(row.map(|r| r.try_get(0)).transpose()?) | |
| } | |
| /// Append the event and project it, in one transaction. A task's first event makes | |
| /// its domain's schema if this is the domain's first task. | |
| pub(crate) async fn append( | |
| &mut self, | |
| id: &str, | |
| at: OffsetDateTime, | |
| event: &Event, | |
| ) -> Result<(), Error> { | |
| let tx = self.client.transaction().await?; | |
| let s = match event { | |
| Event::Created { spec, .. } => ensure_domain(&tx, &spec.domain).await?, | |
| _ => schema_of_task_in(&tx, id) | |
| .await? | |
| .ok_or_else(|| Error::NotFound(id.to_string()))?, | |
| }; | |
| apply(&tx, &s, id, at, event).await?; | |
| tx.commit().await?; | |
| if let Event::Created { spec, .. } = event { | |
| self.remember(&spec.domain, &s); | |
| } | |
| Ok(()) | |
| } | |
| /// Record a promotion unless that promoter promoted that version of the entry to that | |
| /// tier already. The domain's lock is held from the check to the commit, so two | |
| /// processes recording the same promotion record it once. | |
| pub(crate) async fn record_promotion( | |
| &mut self, | |
| at: OffsetDateTime, | |
| spec: &PromotionSpec, | |
| why_erased: bool, | |
| ) -> Result<bool, Error> { | |
| let tx = self.client.transaction().await?; | |
| let s = ensure_domain(&tx, &spec.domain).await?; | |
| // a schema an older binary made has no table, or one of an earlier shape | |
| ensure_promotion_table(&tx, &s).await?; | |
| let seen = tx | |
| .query_opt( | |
| &format!( | |
| "SELECT 1 FROM {s}.promotions | |
| WHERE entry = $1 AND tier = $2 AND digest = $3 AND promoter = $4" | |
| ), | |
| &[&spec.entry, &spec.tier.to_string(), &spec.digest, &spec.by], | |
| ) | |
| .await? | |
| .is_some(); | |
| if seen { | |
| return Ok(false); | |
| } | |
| let key = crate::promotion_key(&spec.entry); | |
| apply( | |
| &tx, | |
| &s, | |
| &key, | |
| at, | |
| &Event::Promoted { | |
| spec: spec.clone(), | |
| why_erased, | |
| }, | |
| ) | |
| .await?; | |
| tx.commit().await?; | |
| self.remember(&spec.domain, &s); | |
| Ok(true) | |
| } | |
| /// Remove the why from an entry's promotion events and rows, in one transaction under | |
| /// the domain's lock. Returns how many records of the entry hold no why now. | |
| pub(crate) async fn scrub_promotions( | |
| &mut self, | |
| domain: &str, | |
| entry: &str, | |
| ) -> Result<usize, Error> { | |
| let Some(s) = self.schema(domain).await? else { | |
| return Ok(0); | |
| }; | |
| let tx = self.client.transaction().await?; | |
| ensure_promotions(&tx, domain).await?; | |
| let n = scrub_promotions_in(&tx, &s, domain, entry).await?; | |
| tx.commit().await?; | |
| Ok(n) | |
| } | |
| /// Rewrite the tasks filed from `origin` to the redaction, in their events and their | |
| /// rows, and invalidate the open ones, in one transaction under the domain's lock. | |
| /// Returns how many tasks from that origin there are. | |
| pub(crate) async fn scrub_tasks( | |
| &mut self, | |
| at: OffsetDateTime, | |
| domain: &str, | |
| origin: &str, | |
| redaction: &Redaction, | |
| ) -> Result<usize, Error> { | |
| let Some(s) = self.schema(domain).await? else { | |
| return Ok(0); | |
| }; | |
| let tx = self.client.transaction().await?; | |
| lock_domain(&tx, domain).await?; | |
| let n = scrub_tasks_in(&tx, &s, at, origin, redaction).await?; | |
| tx.commit().await?; | |
| Ok(n) | |
| } | |
| /// Both scrubs of an erasure in one transaction under the domain's lock. Returns the | |
| /// records that hold no why now and the tasks from the origin. | |
| pub(crate) async fn scrub_entry( | |
| &mut self, | |
| at: OffsetDateTime, | |
| domain: &str, | |
| entry: &str, | |
| origin: &str, | |
| redaction: &Redaction, | |
| ) -> Result<(usize, usize), Error> { | |
| let Some(s) = self.schema(domain).await? else { | |
| return Ok((0, 0)); | |
| }; | |
| let tx = self.client.transaction().await?; | |
| ensure_promotions(&tx, domain).await?; | |
| let records = scrub_promotions_in(&tx, &s, domain, entry).await?; | |
| let tasks = scrub_tasks_in(&tx, &s, at, origin, redaction).await?; | |
| tx.commit().await?; | |
| Ok((records, tasks)) | |
| } | |
| pub(crate) async fn promotions( | |
| &self, | |
| domain: &str, | |
| entry: &str, | |
| ) -> Result<Vec<PromotionRecord>, Error> { | |
| let Some(s) = self.schema(domain).await? else { | |
| return Ok(Vec::new()); | |
| }; | |
| let rows = match self | |
| .client | |
| .query( | |
| &format!( | |
| "SELECT {PROMOTION_COLUMNS} FROM {s}.promotions | |
| WHERE domain = $1 AND entry = $2 ORDER BY seq" | |
| ), | |
| &[&domain, &entry], | |
| ) | |
| .await | |
| { | |
| Ok(rows) => rows, | |
| // a schema an older binary made has no table, and so no promotions | |
| Err(e) if e.code() == Some(&SqlState::UNDEFINED_TABLE) => return Ok(Vec::new()), | |
| Err(e) => return Err(e.into()), | |
| }; | |
| rows.iter() | |
| .map(|r| { | |
| RawPromotion { | |
| domain: r.try_get(0)?, | |
| entry: r.try_get(1)?, | |
| tier: r.try_get(2)?, | |
| promoter: r.try_get(3)?, | |
| author: r.try_get(4)?, | |
| why: r.try_get(5)?, | |
| label_rank: r.try_get(6)?, | |
| digest: r.try_get(7)?, | |
| new_digest: r.try_get(8)?, | |
| at: r.try_get(9)?, | |
| why_erased: r.try_get(10)?, | |
| } | |
| .into_record() | |
| }) | |
| .collect() | |
| } | |
| /// The id of the task claimed, if any candidate qualified. The candidates are read | |
| /// in queue order; the first the manifest can take is then locked on its own, `FOR | |
| /// UPDATE SKIP LOCKED`, and read again as it now stands, so a row another claimer | |
| /// holds or has just taken is passed over rather than claimed twice. | |
| pub(crate) async fn claim_next( | |
| &mut self, | |
| domain: &str, | |
| now: OffsetDateTime, | |
| holder: &str, | |
| until: OffsetDateTime, | |
| manifest: &[String], | |
| handling: Label, | |
| ) -> Result<Option<String>, Error> { | |
| let Some(s) = self.schema(domain).await? else { | |
| return Ok(None); | |
| }; | |
| let claimable_now = "(status = 'admitted' OR (status = 'claimed' AND lease_until < $1))"; | |
| let now_text = crate::fmt_time(now); | |
| let tx = self.client.transaction().await?; | |
| let rows = tx | |
| .query( | |
| &format!( | |
| "SELECT {TASK_COLUMNS} FROM {s}.tasks WHERE {claimable_now} | |
| ORDER BY priority DESC, created" | |
| ), | |
| &[&now_text], | |
| ) | |
| .await?; | |
| for row in &rows { | |
| let seen = task_from_row(row)?; | |
| if !claimable(&seen, now, manifest, handling) { | |
| continue; | |
| } | |
| let Some(locked) = tx | |
| .query_opt( | |
| &format!( | |
| "SELECT {TASK_COLUMNS} FROM {s}.tasks WHERE id = $2 AND {claimable_now} | |
| FOR UPDATE SKIP LOCKED" | |
| ), | |
| &[&now_text, &seen.id], | |
| ) | |
| .await? | |
| else { | |
| continue; | |
| }; | |
| let task = task_from_row(&locked)?; | |
| if !claimable(&task, now, manifest, handling) | |
| || !deps_complete(&tx, &task.spec.depends_on).await? | |
| || awaits_continuation(&tx, &s, &task.id).await? | |
| { | |
| continue; | |
| } | |
| let claimed = Event::Claimed { | |
| holder: holder.to_string(), | |
| until, | |
| }; | |
| apply(&tx, &s, &task.id, now, &claimed).await?; | |
| tx.commit().await?; | |
| return Ok(Some(task.id)); | |
| } | |
| Ok(None) | |
| } | |
| pub(crate) async fn claim( | |
| &mut self, | |
| id: &str, | |
| now: OffsetDateTime, | |
| holder: &str, | |
| until: OffsetDateTime, | |
| manifest: &[String], | |
| handling: Label, | |
| ) -> Result<(), Error> { | |
| let s = self.schema_of_task(id).await?; | |
| let tx = self.client.transaction().await?; | |
| let row = tx | |
| .query_opt( | |
| &format!("SELECT {TASK_COLUMNS} FROM {s}.tasks WHERE id = $1 FOR UPDATE"), | |
| &[&id], | |
| ) | |
| .await? | |
| .ok_or_else(|| Error::NotFound(id.to_string()))?; | |
| let task = task_from_row(&row)?; | |
| if !claimable(&task, now, manifest, handling) | |
| || !deps_complete(&tx, &task.spec.depends_on).await? | |
| || awaits_continuation(&tx, &s, &task.id).await? | |
| { | |
| return Err(Error::WrongState { | |
| id: id.to_string(), | |
| status: task.status, | |
| op: "claim", | |
| }); | |
| } | |
| let claimed = Event::Claimed { | |
| holder: holder.to_string(), | |
| until, | |
| }; | |
| apply(&tx, &s, id, now, &claimed).await?; | |
| tx.commit().await?; | |
| Ok(()) | |
| } | |
| async fn select( | |
| &self, | |
| s: &str, | |
| sql: &str, | |
| args: &[&(dyn ToSql + Sync)], | |
| ) -> Result<Vec<Task>, Error> { | |
| let rows = self | |
| .client | |
| .query(&format!("SELECT {TASK_COLUMNS} FROM {s}.tasks {sql}"), args) | |
| .await?; | |
| rows.iter().map(task_from_row).collect() | |
| } | |
| /// What was filed from a task is filed in its domain: its children and its | |
| /// continuation carry its domain, and its retries its root's. | |
| pub(crate) async fn filed_from(&self, parent: &str) -> Result<Vec<Task>, Error> { | |
| let Some(s) = schema_of_task_in(&self.client, parent).await? else { | |
| return Ok(Vec::new()); | |
| }; | |
| self.select( | |
| &s, | |
| "WHERE origin IN ($1, $2, $3) ORDER BY created", | |
| &[ | |
| &format!("task:{parent}"), | |
| &format!("continues:{parent}"), | |
| &format!("retries:{parent}"), | |
| ], | |
| ) | |
| .await | |
| } | |
| pub(crate) async fn with_origin(&self, domain: &str, origin: &str) -> Result<Vec<Task>, Error> { | |
| let Some(s) = self.schema(domain).await? else { | |
| return Ok(Vec::new()); | |
| }; | |
| self.select(&s, "WHERE origin = $1 ORDER BY created", &[&origin]) | |
| .await | |
| } | |
| pub(crate) async fn list(&self, domain: &str, status: Status) -> Result<Vec<Task>, Error> { | |
| let Some(s) = self.schema(domain).await? else { | |
| return Ok(Vec::new()); | |
| }; | |
| self.select( | |
| &s, | |
| "WHERE status = $1 ORDER BY priority DESC, created", | |
| &[&status.as_str()], | |
| ) | |
| .await | |
| } | |
| pub(crate) async fn unposted(&self, domain: &str) -> Result<Vec<Task>, Error> { | |
| let Some(s) = self.schema(domain).await? else { | |
| return Ok(Vec::new()); | |
| }; | |
| self.select( | |
| &s, | |
| "WHERE status = 'completed' AND origin IS NOT NULL AND posted_to IS NULL | |
| ORDER BY created", | |
| &[], | |
| ) | |
| .await | |
| } | |
| pub(crate) async fn dependents(&self, id: &str) -> Result<Vec<Task>, Error> { | |
| let Some(s) = schema_of_task_in(&self.client, id).await? else { | |
| return Ok(Vec::new()); | |
| }; | |
| self.select( | |
| &s, | |
| &format!( | |
| "WHERE id IN ( | |
| SELECT t.id FROM {s}.tasks t, | |
| jsonb_array_elements_text(t.depends_on::jsonb) AS d(value) | |
| WHERE d.value = $1) | |
| ORDER BY created" | |
| ), | |
| &[&id], | |
| ) | |
| .await | |
| } | |
| /// Every domain's schema. | |
| async fn schemas(&self) -> Result<Vec<(String, String)>, Error> { | |
| let rows = self | |
| .client | |
| .query( | |
| "SELECT domain, schema FROM public.ledger_domains ORDER BY domain", | |
| &[], | |
| ) | |
| .await?; | |
| rows.iter() | |
| .map(|r| Ok((r.try_get(0)?, r.try_get(1)?))) | |
| .collect() | |
| } | |
| pub(crate) async fn event_count(&self) -> Result<u64, Error> { | |
| let mut n = 0u64; | |
| for (_, s) in self.schemas().await? { | |
| let row = self | |
| .client | |
| .query_one(&format!("SELECT COUNT(*) FROM {s}.events"), &[]) | |
| .await?; | |
| let c: i64 = row.try_get(0)?; | |
| n += u64::try_from(c).unwrap_or(0); | |
| } | |
| Ok(n) | |
| } | |
| /// Replay every domain's events into a fresh projection, one transaction for all. | |
| pub(crate) async fn rebuild(&mut self) -> Result<u64, Error> { | |
| let schemas = self.schemas().await?; | |
| let tx = self.client.transaction().await?; | |
| let mut n = 0; | |
| for (domain, s) in &schemas { | |
| n += rebuild_domain(&tx, domain, s).await?; | |
| } | |
| tx.commit().await?; | |
| Ok(n) | |
| } | |
| /// A ledger from before domains had schemas kept everything in `public.events` and | |
| /// `public.tasks`. Each domain's events move to its schema in sequence, its | |
| /// projection is replayed there, and the old tables are renamed, kept rather than | |
| /// dropped, in one transaction. | |
| async fn migrate_shared_tables(&mut self) -> Result<(), Error> { | |
| let row = self | |
| .client | |
| .query_one("SELECT to_regclass('public.tasks') IS NOT NULL", &[]) | |
| .await?; | |
| let shared: bool = row.try_get(0)?; | |
| if !shared { | |
| return Ok(()); | |
| } | |
| let tx = self.client.transaction().await?; | |
| let domains = tx | |
| .query( | |
| "SELECT DISTINCT domain FROM public.tasks ORDER BY domain", | |
| &[], | |
| ) | |
| .await?; | |
| for row in &domains { | |
| let domain: String = row.try_get(0)?; | |
| let s = ensure_domain(&tx, &domain).await?; | |
| tx.execute( | |
| &format!( | |
| "INSERT INTO {s}.events (task_id, kind, at, payload) | |
| SELECT e.task_id, e.kind, e.at, e.payload | |
| FROM public.events e JOIN public.tasks t ON t.id = e.task_id | |
| WHERE t.domain = $1 ORDER BY e.seq" | |
| ), | |
| &[&domain], | |
| ) | |
| .await?; | |
| rebuild_domain(&tx, &domain, &s).await?; | |
| } | |
| tx.batch_execute( | |
| "ALTER TABLE public.tasks RENAME TO tasks_before_domain_schemas; | |
| ALTER TABLE public.events RENAME TO events_before_domain_schemas;", | |
| ) | |
| .await?; | |
| tx.commit().await?; | |
| Ok(()) | |
| } | |
| } | |
| /// Take a domain's lock until the transaction ends. | |
| async fn lock_domain<C: GenericClient>(conn: &C, domain: &str) -> Result<(), Error> { | |
| conn.execute( | |
| "SELECT pg_advisory_xact_lock(hashtext('ledger_domain:' || $1))", | |
| &[&domain], | |
| ) | |
| .await?; | |
| Ok(()) | |
| } | |
| /// Give a domain's schema its promotions table if it has none, under the domain's lock, | |
| /// held until the caller's transaction ends. The schema's name comes from the routing | |
| /// table, read under the lock. | |
| async fn ensure_promotions<C: GenericClient>(conn: &C, domain: &str) -> Result<(), Error> { | |
| lock_domain(conn, domain).await?; | |
| let row = conn | |
| .query_one( | |
| "SELECT schema FROM public.ledger_domains WHERE domain = $1", | |
| &[&domain], | |
| ) | |
| .await?; | |
| let s: String = row.try_get(0)?; | |
| ensure_promotion_table(conn, &s).await | |
| } | |
| /// Make schema `s`'s promotions table if it is not there, give it each column it lacks, | |
| /// and make its key again when the one there lacks the promoter. The caller holds the | |
| /// domain's lock. | |
| async fn ensure_promotion_table<C: GenericClient>(conn: &C, s: &str) -> Result<(), Error> { | |
| conn.batch_execute(&PROMOTION_TABLE.replace("{s}", s)) | |
| .await?; | |
| let row = conn | |
| .query_one(INDEX_HAS_PROMOTER, &[&format!("{s}.promotions")]) | |
| .await?; | |
| let keyed_by_promoter: bool = row.try_get(0)?; | |
| if !keyed_by_promoter { | |
| conn.batch_execute(&format!( | |
| "DROP INDEX IF EXISTS {s}.promotions_version;{}", | |
| PROMOTION_INDEX.replace("{s}", s) | |
| )) | |
| .await?; | |
| } | |
| Ok(()) | |
| } | |
| /// Make a domain's schema and tables if they are not there, under a lock on the domain | |
| /// so two processes filing a domain's first task do not race to create it. The lock is | |
| /// held until the caller's transaction ends. | |
| async fn ensure_domain<C: GenericClient>(conn: &C, domain: &str) -> Result<String, Error> { | |
| lock_domain(conn, domain).await?; | |
| let known = conn | |
| .query_opt( | |
| "SELECT schema FROM public.ledger_domains WHERE domain = $1", | |
| &[&domain], | |
| ) | |
| .await?; | |
| if let Some(row) = known { | |
| return Ok(row.try_get(0)?); | |
| } | |
| let s = schema_for(domain); | |
| conn.batch_execute(&format!( | |
| "CREATE SCHEMA IF NOT EXISTS {s};{}{}{}", | |
| DOMAIN_TABLES.replace("{s}", &s), | |
| PROMOTION_TABLE.replace("{s}", &s), | |
| PROMOTION_INDEX.replace("{s}", &s) | |
| )) | |
| .await?; | |
| conn.execute( | |
| "INSERT INTO public.ledger_domains (domain, schema) VALUES ($1, $2)", | |
| &[&domain, &s], | |
| ) | |
| .await?; | |
| Ok(s) | |
| } | |
| async fn schema_of_task_in<C: GenericClient>(conn: &C, id: &str) -> Result<Option<String>, Error> { | |
| let row = conn | |
| .query_opt( | |
| "SELECT d.schema FROM public.ledger_tasks t | |
| JOIN public.ledger_domains d ON d.domain = t.domain WHERE t.id = $1", | |
| &[&id], | |
| ) | |
| .await?; | |
| Ok(row.map(|r| r.try_get(0)).transpose()?) | |
| } | |
| async fn get_in<C: GenericClient>(conn: &C, s: &str, id: &str) -> Result<Task, Error> { | |
| let row = conn | |
| .query_opt( | |
| &format!("SELECT {TASK_COLUMNS} FROM {s}.tasks WHERE id = $1"), | |
| &[&id], | |
| ) | |
| .await? | |
| .ok_or_else(|| Error::NotFound(id.to_string()))?; | |
| task_from_row(&row) | |
| } | |
| /// Whether every dependency is complete, wherever its domain is. | |
| async fn deps_complete<C: GenericClient>(conn: &C, deps: &[String]) -> Result<bool, Error> { | |
| for d in deps { | |
| let Some(s) = schema_of_task_in(conn, d).await? else { | |
| return Ok(false); | |
| }; | |
| let row = conn | |
| .query_opt( | |
| &format!("SELECT status FROM {s}.tasks WHERE id = $1"), | |
| &[&d], | |
| ) | |
| .await?; | |
| let status: Option<String> = row.map(|r| r.try_get(0)).transpose()?; | |
| if status.as_deref() != Some("completed") { | |
| return Ok(false); | |
| } | |
| } | |
| Ok(true) | |
| } | |
| /// Whether a continuation of the task is still open, in its domain's schema: its work | |
| /// goes on there, and the continuation's completion closes it. | |
| async fn awaits_continuation<C: GenericClient>(conn: &C, s: &str, id: &str) -> Result<bool, Error> { | |
| Ok(conn | |
| .query_opt( | |
| &format!( | |
| "SELECT 1 FROM {s}.tasks WHERE origin = $1 | |
| AND status NOT IN ('completed', 'invalidated') LIMIT 1" | |
| ), | |
| &[&crate::continues(id)], | |
| ) | |
| .await? | |
| .is_some()) | |
| } | |
| /// Drop one domain's projection and replay its events into it. | |
| async fn rebuild_domain<C: GenericClient>(conn: &C, domain: &str, s: &str) -> Result<u64, Error> { | |
| // a schema an older binary made after this process connected has no promotions table | |
| ensure_promotions(conn, domain).await?; | |
| conn.execute(&format!("DELETE FROM {s}.tasks"), &[]).await?; | |
| conn.execute(&format!("DELETE FROM {s}.promotions"), &[]) | |
| .await?; | |
| conn.execute( | |
| "DELETE FROM public.ledger_tasks WHERE domain = $1", | |
| &[&domain], | |
| ) | |
| .await?; | |
| let rows = conn | |
| .query( | |
| &format!("SELECT task_id, at, payload FROM {s}.events ORDER BY seq"), | |
| &[], | |
| ) | |
| .await?; | |
| let mut n = 0; | |
| for row in &rows { | |
| let task_id: String = row.try_get(0)?; | |
| let at: String = row.try_get(1)?; | |
| let payload: String = row.try_get(2)?; | |
| let at = parse_time(&task_id, &at)?; | |
| let event = Event::parse(&task_id, &payload)?; | |
| project(conn, s, &task_id, at, &event).await?; | |
| n += 1; | |
| } | |
| Ok(n) | |
| } | |
| /// The scrub of an entry's promotions in schema `s`, in the caller's transaction: the why | |
| /// leaves its events and its rows. Returns how many records of the entry hold no why now. | |
| async fn scrub_promotions_in<C: GenericClient>( | |
| conn: &C, | |
| s: &str, | |
| domain: &str, | |
| entry: &str, | |
| ) -> Result<usize, Error> { | |
| let key = crate::promotion_key(entry); | |
| let rows = conn | |
| .query( | |
| &format!( | |
| "SELECT seq, payload FROM {s}.events | |
| WHERE task_id = $1 AND kind = 'promoted' FOR UPDATE" | |
| ), | |
| &[&key], | |
| ) | |
| .await?; | |
| for row in &rows { | |
| let seq: i64 = row.try_get(0)?; | |
| let payload: String = row.try_get(1)?; | |
| if let Some(scrubbed) = crate::scrub_payload(&key, domain, &payload)? { | |
| conn.execute( | |
| &format!("UPDATE {s}.events SET payload = $2 WHERE seq = $1"), | |
| &[&seq, &scrubbed], | |
| ) | |
| .await?; | |
| } | |
| } | |
| conn.execute( | |
| &format!( | |
| "UPDATE {s}.promotions SET why = '', why_erased = 1 | |
| WHERE domain = $1 AND entry = $2 AND (why_erased = 0 OR why != '')" | |
| ), | |
| &[&domain, &entry], | |
| ) | |
| .await?; | |
| let row = conn | |
| .query_one( | |
| &format!( | |
| "SELECT COUNT(*) FROM {s}.promotions | |
| WHERE domain = $1 AND entry = $2 AND why_erased = 1" | |
| ), | |
| &[&domain, &entry], | |
| ) | |
| .await?; | |
| let n: i64 = row.try_get(0)?; | |
| Ok(usize::try_from(n).unwrap_or(0)) | |
| } | |
| /// The scrub of the tasks filed from `origin` in schema `s`, 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. | |
| async fn scrub_tasks_in<C: GenericClient>( | |
| conn: &C, | |
| s: &str, | |
| at: OffsetDateTime, | |
| origin: &str, | |
| redaction: &Redaction, | |
| ) -> Result<usize, Error> { | |
| let rows = conn | |
| .query( | |
| &format!( | |
| "SELECT id, status IN ('completed', 'invalidated') FROM {s}.tasks | |
| WHERE origin = $1 ORDER BY created FOR UPDATE" | |
| ), | |
| &[&origin], | |
| ) | |
| .await?; | |
| for row in &rows { | |
| let id: String = row.try_get(0)?; | |
| let terminal: bool = row.try_get(1)?; | |
| if !terminal { | |
| let closed = Event::Invalidated { | |
| reason: redaction.reason.clone(), | |
| superseded_by: None, | |
| }; | |
| apply(conn, s, &id, at, &closed).await?; | |
| } | |
| redact_task(conn, s, &id, redaction).await?; | |
| } | |
| Ok(rows.len()) | |
| } | |
| /// 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. | |
| async fn redact_task<C: GenericClient>( | |
| conn: &C, | |
| s: &str, | |
| id: &str, | |
| redaction: &Redaction, | |
| ) -> Result<(), Error> { | |
| let rows = conn | |
| .query( | |
| &format!("SELECT seq, payload FROM {s}.events WHERE task_id = $1 FOR UPDATE"), | |
| &[&id], | |
| ) | |
| .await?; | |
| for row in &rows { | |
| let seq: i64 = row.try_get(0)?; | |
| let payload: String = row.try_get(1)?; | |
| if let Some(redacted) = crate::redact_event(id, &payload, redaction)? { | |
| conn.execute( | |
| &format!("UPDATE {s}.events SET payload = $2 WHERE seq = $1"), | |
| &[&seq, &redacted], | |
| ) | |
| .await?; | |
| } | |
| } | |
| conn.execute( | |
| &format!( | |
| "UPDATE {s}.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" | |
| ), | |
| &[&id, &redaction.title, &redaction.body, &redaction.reason], | |
| ) | |
| .await?; | |
| Ok(()) | |
| } | |
| /// Append the event and project it, in the caller's transaction. | |
| async fn apply<C: GenericClient>( | |
| conn: &C, | |
| s: &str, | |
| id: &str, | |
| at: OffsetDateTime, | |
| event: &Event, | |
| ) -> Result<(), Error> { | |
| conn.execute( | |
| &format!("INSERT INTO {s}.events (task_id, kind, at, payload) VALUES ($1, $2, $3, $4)"), | |
| &[ | |
| &id, | |
| &event.kind(), | |
| &crate::fmt_time(at), | |
| &event.payload(id)?, | |
| ], | |
| ) | |
| .await?; | |
| project(conn, s, id, at, event).await | |
| } | |
| /// The row a `Created` event projects to: a task born proposed, and its route. | |
| async fn insert_task<C: GenericClient>( | |
| conn: &C, | |
| s: &str, | |
| 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( | |
| &format!( | |
| "INSERT INTO {s}.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')" | |
| ), | |
| &[ | |
| &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), | |
| ], | |
| ) | |
| .await?; | |
| conn.execute( | |
| "INSERT INTO public.ledger_tasks (id, domain) VALUES ($1, $2)", | |
| &[&id, &spec.domain], | |
| ) | |
| .await?; | |
| Ok(()) | |
| } | |
| /// The row a `Promoted` event projects to. No route: a promotion is read by its domain. | |
| async fn insert_promotion<C: GenericClient>( | |
| conn: &C, | |
| s: &str, | |
| at: OffsetDateTime, | |
| p: &PromotionSpec, | |
| why_erased: bool, | |
| ) -> Result<(), Error> { | |
| conn.execute( | |
| &format!( | |
| "INSERT INTO {s}.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)" | |
| ), | |
| &[ | |
| &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), | |
| ], | |
| ) | |
| .await?; | |
| Ok(()) | |
| } | |
| /// Update the projection for one event. Replay calls this and nothing else. | |
| async fn project<C: GenericClient>( | |
| conn: &C, | |
| s: &str, | |
| id: &str, | |
| at: OffsetDateTime, | |
| event: &Event, | |
| ) -> Result<(), Error> { | |
| // the one time an event carries, formatted once so the arms can borrow it | |
| let until_text = match event { | |
| Event::Claimed { until, .. } | Event::Heartbeat { until } => crate::fmt_time(*until), | |
| _ => String::new(), | |
| }; | |
| let (sql, args): (&str, Vec<&(dyn ToSql + Sync)>) = match event { | |
| Event::Created { id: new_id, spec } => return insert_task(conn, s, new_id, at, spec).await, | |
| Event::Promoted { spec, why_erased } => { | |
| return insert_promotion(conn, s, at, spec, *why_erased).await | |
| } | |
| Event::Admitted => ("SET status = 'admitted' WHERE id = $1", vec![&id]), | |
| Event::Claimed { holder, .. } => ( | |
| "SET status = 'claimed', holder = $2, lease_until = $3, | |
| attempts = attempts + 1 WHERE id = $1", | |
| vec![&id, holder, &until_text], | |
| ), | |
| Event::Heartbeat { .. } => ("SET lease_until = $2 WHERE id = $1", vec![&id, &until_text]), | |
| Event::Released => ( | |
| "SET status = 'admitted', holder = NULL, lease_until = NULL WHERE id = $1", | |
| vec![&id], | |
| ), | |
| Event::HandoffLeft { handoff } => ("SET handoff = $2 WHERE id = $1", vec![&id, handoff]), | |
| Event::Completed { handoff } => ( | |
| "SET status = 'completed', handoff = $2, lease_until = NULL WHERE id = $1", | |
| vec![&id, handoff], | |
| ), | |
| Event::Invalidated { | |
| reason, | |
| superseded_by, | |
| } => ( | |
| "SET status = 'invalidated', reason = $2, superseded_by = $3, | |
| lease_until = NULL WHERE id = $1", | |
| vec![&id, reason, superseded_by], | |
| ), | |
| Event::Posted { to } => ("SET posted_to = $2 WHERE id = $1", vec![&id, to]), | |
| Event::Rescored { priority, .. } => { | |
| ("SET priority = $2 WHERE id = $1", vec![&id, priority]) | |
| } | |
| }; | |
| conn.execute(&format!("UPDATE {s}.tasks {sql}"), &args) | |
| .await?; | |
| Ok(()) | |
| } |