| //! The task ledger: tasks, leases, handoffs, origin dedup, and the record of why each | |
| //! entry was promoted. | |
| //! | |
| //! The ledger is event-sourced. Every operation appends one event to an append-only | |
| //! table and updates a projection of current task state in the same transaction. The | |
| //! projection is a cache: [`Ledger::rebuild`] drops it and replays the events, and a test | |
| //! shows the result identical. That is the paper's "projection plus lease cache, | |
| //! rebuildable from the log", with a database standing in for the log. | |
| //! | |
| //! One type, two backends, chosen when the ledger is opened: SQLite, one file on a laptop | |
| //! ([`Ledger::open`], [`Ledger::in_memory`]), and PostgreSQL, a server several processes | |
| //! share ([`Ledger::connect`]). The rules, the events and the projection are the same on | |
| //! both; only the SQL that holds them differs, and the same tests run against each. | |
| //! | |
| //! Three rules hold: | |
| //! | |
| //! - **A task is born proposed and becomes claimable only once admitted.** Anything may | |
| //! file a task; admission is a separate act. | |
| //! - **One holder at a time.** A claim is one transaction over the ledger, so two callers | |
| //! racing for the same task cannot both win. On PostgreSQL the candidate rows are taken | |
| //! `FOR UPDATE SKIP LOCKED`, so that holds across connections too. | |
| //! - **A lease that cannot be renewed is lost at its expiry**, for the holder and the | |
| //! ledger alike. An expired lease makes the task claimable again; the stale holder's | |
| //! heartbeat, release, and complete are refused. | |
| //! | |
| //! A promotion is recorded on the same log, as an event of its own under the key | |
| //! `promotion:<entry>`, and projected into a table of its own. It is no task: nothing | |
| //! claims it, lists it, or reads it as delivered work. It is kept once per version of the | |
| //! entry, and a rebuild replays it with the rest. | |
| //! | |
| //! Time comes from a clock the ledger owns, so tests can move it. | |
| mod postgres; | |
| mod sqlite; | |
| #[cfg(any(test, feature = "pg-test"))] | |
| pub mod testing; | |
| use dm_core::{Label, TrustTier}; | |
| use serde::{Deserialize, Serialize}; | |
| use std::path::Path; | |
| use time::{format_description::well_known::Rfc3339, Duration, OffsetDateTime}; | |
| /// What can go wrong at the ledger. | |
| #[derive(Debug, thiserror::Error)] | |
| pub enum Error { | |
| /// SQLite refused or failed. | |
| #[error("ledger: {0}")] | |
| Db(#[from] rusqlite::Error), | |
| /// PostgreSQL refused or failed, or could not be reached. | |
| #[error("ledger: {0}")] | |
| Postgres(#[from] tokio_postgres::Error), | |
| /// The runtime a PostgreSQL ledger needs could not be started. | |
| #[error("ledger: {0}")] | |
| Runtime(String), | |
| /// No task with that id. | |
| #[error("no task {0}")] | |
| NotFound(String), | |
| /// The task is not in a state where the operation applies. | |
| #[error("task {id} is {status:?}; cannot {op}")] | |
| WrongState { | |
| /// The task. | |
| id: String, | |
| /// Its current status. | |
| status: Status, | |
| /// What was attempted. | |
| op: &'static str, | |
| }, | |
| /// The caller does not hold the task, or held it and the lease expired. | |
| #[error("task {id} is not held by {holder}")] | |
| NotHolder { | |
| /// The task. | |
| id: String, | |
| /// Who asked. | |
| holder: String, | |
| }, | |
| /// Another connection holds the write-ahead log, so text just removed may still be | |
| /// in it. Run the operation again. | |
| #[error("ledger: another connection holds the log: {0}")] | |
| Held(String), | |
| /// A row holds something the ledger did not write. Rebuild it. | |
| #[error("ledger row for {id}: {what}")] | |
| BadRow { | |
| /// The task. | |
| id: String, | |
| /// What was wrong. | |
| what: String, | |
| }, | |
| } | |
| /// Where a task is in its life. | |
| #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] | |
| #[serde(rename_all = "lowercase")] | |
| pub enum Status { | |
| /// Filed, not yet admitted. Not claimable. | |
| Proposed, | |
| /// Admitted and waiting for a holder. | |
| Admitted, | |
| /// Held under a lease. | |
| Claimed, | |
| /// Done, with a handoff. | |
| Completed, | |
| /// Closed without being done: obsolete, invalid, or a duplicate. | |
| Invalidated, | |
| } | |
| impl Status { | |
| /// The status as the ledger stores it. | |
| #[must_use] | |
| pub fn as_str(self) -> &'static str { | |
| match self { | |
| Status::Proposed => "proposed", | |
| Status::Admitted => "admitted", | |
| Status::Claimed => "claimed", | |
| Status::Completed => "completed", | |
| Status::Invalidated => "invalidated", | |
| } | |
| } | |
| fn parse(id: &str, s: &str) -> Result<Status, Error> { | |
| Ok(match s { | |
| "proposed" => Status::Proposed, | |
| "admitted" => Status::Admitted, | |
| "claimed" => Status::Claimed, | |
| "completed" => Status::Completed, | |
| "invalidated" => Status::Invalidated, | |
| other => { | |
| return Err(Error::BadRow { | |
| id: id.to_string(), | |
| what: format!("status {other:?}"), | |
| }) | |
| } | |
| }) | |
| } | |
| /// Whether no further operation applies. | |
| #[must_use] | |
| pub fn is_terminal(self) -> bool { | |
| matches!(self, Status::Completed | Status::Invalidated) | |
| } | |
| } | |
| /// What a caller files. Everything else on a [`Task`] the ledger fills in. | |
| #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| pub struct TaskSpec { | |
| /// The domain whose ledger partition holds it. | |
| pub domain: String, | |
| /// One line. | |
| pub title: String, | |
| /// What is to be done, in Markdown. | |
| pub body: String, | |
| /// Capabilities a session's manifest must carry to claim it, such as `tool:github`. | |
| #[serde(default)] | |
| pub requirements: Vec<String>, | |
| /// Tasks that must be completed first. | |
| #[serde(default)] | |
| pub depends_on: Vec<String>, | |
| /// Where the task came from, when a tap or an import filed it. | |
| #[serde(default)] | |
| pub origin: Option<String>, | |
| /// The upstream event that produced it, for dedup with `origin`. | |
| #[serde(default)] | |
| pub upstream_event: Option<String>, | |
| /// Who filed it, system-qualified. | |
| pub created_by: String, | |
| /// How sensitive the task and its handoff are. | |
| #[serde(default)] | |
| pub classification: Label, | |
| /// Higher is claimed first. | |
| #[serde(default)] | |
| pub priority: i64, | |
| } | |
| /// A task as the ledger holds it. | |
| #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| pub struct Task { | |
| /// Stable, permanent, meaningless. | |
| pub id: String, | |
| /// What was filed. | |
| #[serde(flatten)] | |
| pub spec: TaskSpec, | |
| /// When it was filed. | |
| #[serde(with = "time::serde::rfc3339")] | |
| pub created: OffsetDateTime, | |
| /// Where it is. | |
| pub status: Status, | |
| /// Who holds it, while claimed. | |
| pub holder: Option<String>, | |
| /// When the current lease runs out, while claimed. | |
| #[serde(with = "time::serde::rfc3339::option")] | |
| pub lease_until: Option<OffsetDateTime>, | |
| /// How many times it has been claimed. | |
| pub attempts: u32, | |
| /// The handoff entry that closed it, or the last one a lost lease left behind. | |
| pub handoff: Option<String>, | |
| /// Why it was invalidated. | |
| pub reason: Option<String>, | |
| /// The task that replaced it, when invalidated as a duplicate. | |
| pub superseded_by: Option<String>, | |
| /// Where its handoff was posted back upstream, once a tap has done so. | |
| #[serde(default)] | |
| pub posted_to: Option<String>, | |
| } | |
| /// What an erasure did to an entry's promotion records and approval tasks. | |
| #[derive(Clone, Debug, Default, PartialEq, Eq)] | |
| pub struct Scrubbed { | |
| /// How many records of the entry hold no why now. | |
| pub records: usize, | |
| /// How many tasks filed from the entry's origin hold the redaction now. | |
| pub tasks: usize, | |
| /// Copies of the text the ledger could not remove, named so nobody believes it did. | |
| pub beyond_reach: Vec<String>, | |
| } | |
| /// What an erasure rewrites a task to when the task is about the erased entry: a title | |
| /// and a body that name the entry by its id and nothing else of it. | |
| #[derive(Clone, Debug, PartialEq, Eq)] | |
| pub struct Redaction { | |
| /// The title every task gets. | |
| pub title: String, | |
| /// The body every task gets. | |
| pub body: String, | |
| /// Why an open task is invalidated. | |
| pub reason: String, | |
| } | |
| /// The result of filing a task. | |
| #[derive(Clone, Debug, PartialEq, Eq)] | |
| pub struct Created { | |
| /// The task, new or already there. | |
| pub task: Task, | |
| /// True when the same origin and upstream event had already been filed and that | |
| /// task is returned instead of a second one. | |
| pub deduplicated: bool, | |
| } | |
| /// A granted claim. | |
| #[derive(Clone, Debug, PartialEq, Eq)] | |
| pub struct Claim { | |
| /// The task. | |
| pub task: Task, | |
| /// When to heartbeat by. | |
| pub lease_until: OffsetDateTime, | |
| } | |
| /// What a promotion records: which version of an entry was moved to which tier, by | |
| /// whom, and why. A record of a change already judged, not work: it is no task, and no | |
| /// query over tasks returns it. | |
| #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| pub struct PromotionSpec { | |
| /// The domain the entry lives in, whose ledger keeps the record. | |
| pub domain: String, | |
| /// The entry promoted. | |
| pub entry: String, | |
| /// The tier it was promoted to. | |
| pub tier: TrustTier, | |
| /// Who promoted it, system-qualified. | |
| pub by: String, | |
| /// Who wrote the entry. | |
| pub author: String, | |
| /// Why: what was checked, and against what. | |
| pub why: String, | |
| /// How sensitive the record is: at least the entry's classification. | |
| pub classification: Label, | |
| /// The digest of the version of the entry that was promoted. | |
| pub digest: String, | |
| /// The digest of the version the promotion writes, so a record whose version never | |
| /// reached the record can be told from one whose version did. Empty in a record from | |
| /// before it was kept. | |
| #[serde(default)] | |
| pub new_digest: String, | |
| } | |
| /// A promotion as the ledger holds it. | |
| #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| pub struct PromotionRecord { | |
| /// What was recorded. | |
| #[serde(flatten)] | |
| pub spec: PromotionSpec, | |
| /// The why was removed by an erasure of the entry: `why` is then empty, and the rest | |
| /// is kept as the least an audit needs. | |
| #[serde(default)] | |
| pub why_erased: bool, | |
| /// When. | |
| #[serde(with = "time::serde::rfc3339")] | |
| pub at: OffsetDateTime, | |
| } | |
| type Clock = Box<dyn Fn() -> OffsetDateTime + Send + Sync>; | |
| /// One appended event, as replay reads it. | |
| #[derive(Serialize, Deserialize)] | |
| #[serde(tag = "kind", rename_all = "lowercase")] | |
| enum Event { | |
| Created { | |
| id: String, | |
| spec: TaskSpec, | |
| }, | |
| Admitted, | |
| Claimed { | |
| holder: String, | |
| #[serde(with = "time::serde::rfc3339")] | |
| until: OffsetDateTime, | |
| }, | |
| Heartbeat { | |
| #[serde(with = "time::serde::rfc3339")] | |
| until: OffsetDateTime, | |
| }, | |
| Released, | |
| /// A handoff left on a task that is going back to the pool, so the next claimant | |
| /// starts where this one stopped. Appended before `Released`. | |
| HandoffLeft { | |
| handoff: String, | |
| }, | |
| Completed { | |
| handoff: String, | |
| }, | |
| Invalidated { | |
| reason: String, | |
| superseded_by: Option<String>, | |
| }, | |
| /// The handoff of a completed task was posted back where the task came from. | |
| Posted { | |
| to: String, | |
| }, | |
| /// The rubric ranked an open task again: its place in the queue, and why. The | |
| /// priority it was filed with stays in its `Created` event. | |
| Rescored { | |
| priority: i64, | |
| why: String, | |
| }, | |
| /// An entry was promoted, and why. Appended under [`promotion_key`], never a task id. | |
| /// An erasure of the entry rewrites it with the why removed and `why_erased` set. | |
| Promoted { | |
| #[serde(flatten)] | |
| spec: PromotionSpec, | |
| #[serde(default, skip_serializing_if = "is_false")] | |
| why_erased: bool, | |
| }, | |
| } | |
| impl Event { | |
| /// The `kind` column: what the event is, readable without the payload. | |
| fn kind(&self) -> &'static str { | |
| match self { | |
| Event::Created { .. } => "created", | |
| Event::Admitted => "admitted", | |
| Event::Claimed { .. } => "claimed", | |
| Event::Heartbeat { .. } => "heartbeat", | |
| Event::Released => "released", | |
| Event::HandoffLeft { .. } => "handoff_left", | |
| Event::Completed { .. } => "completed", | |
| Event::Invalidated { .. } => "invalidated", | |
| Event::Posted { .. } => "posted", | |
| Event::Rescored { .. } => "rescored", | |
| Event::Promoted { .. } => "promoted", | |
| } | |
| } | |
| /// The `payload` column: the event as JSON. | |
| fn payload(&self, id: &str) -> Result<String, Error> { | |
| serde_json::to_string(self).map_err(|e| Error::BadRow { | |
| id: id.to_string(), | |
| what: format!("event serialises: {e}"), | |
| }) | |
| } | |
| fn parse(id: &str, payload: &str) -> Result<Event, Error> { | |
| serde_json::from_str(payload).map_err(|e| Error::BadRow { | |
| id: id.to_string(), | |
| what: format!("event payload: {e}"), | |
| }) | |
| } | |
| } | |
| /// What a PostgreSQL ledger keeps of a why after the rows are rewritten: the server, not | |
| /// the ledger, decides when old row versions and its log are gone. | |
| const POSTGRES_RESIDUE: &str = "the ledger's PostgreSQL server: old row versions and its write-ahead log keep removed text until the server vacuums and recycles them"; | |
| /// A task event's payload with the redaction in place of its free text: a `created` | |
| /// event's title and body, an `invalidated` event's reason, and the handoff of a | |
| /// `handoff_left` or `completed` event, each text a person may have put the entry's text | |
| /// in. `None` when it holds the redaction already, or carries no text. | |
| fn redact_event(id: &str, payload: &str, redaction: &Redaction) -> Result<Option<String>, Error> { | |
| let redacted = match Event::parse(id, payload)? { | |
| Event::Created { | |
| id: new_id, | |
| mut spec, | |
| } if spec.title != redaction.title || spec.body != redaction.body => { | |
| spec.title.clone_from(&redaction.title); | |
| spec.body.clone_from(&redaction.body); | |
| Event::Created { id: new_id, spec } | |
| } | |
| Event::Invalidated { | |
| reason, | |
| superseded_by, | |
| } if reason != redaction.reason => Event::Invalidated { | |
| reason: redaction.reason.clone(), | |
| superseded_by, | |
| }, | |
| Event::HandoffLeft { handoff } if handoff != redaction.reason => Event::HandoffLeft { | |
| handoff: redaction.reason.clone(), | |
| }, | |
| Event::Completed { handoff } if handoff != redaction.reason => Event::Completed { | |
| handoff: redaction.reason.clone(), | |
| }, | |
| _ => return Ok(None), | |
| }; | |
| Ok(Some(redacted.payload(id)?)) | |
| } | |
| /// A `promoted` event's payload with its why removed, when the event is that domain's | |
| /// and holds a why; `None` when there is nothing to rewrite. | |
| fn scrub_payload(key: &str, domain: &str, payload: &str) -> Result<Option<String>, Error> { | |
| match Event::parse(key, payload)? { | |
| Event::Promoted { | |
| mut spec, | |
| why_erased, | |
| } if spec.domain == domain && !(why_erased && spec.why.is_empty()) => { | |
| spec.why.clear(); | |
| let scrubbed = Event::Promoted { | |
| spec, | |
| why_erased: true, | |
| }; | |
| Ok(Some(scrubbed.payload(key)?)) | |
| } | |
| _ => Ok(None), | |
| } | |
| } | |
| fn is_false(b: &bool) -> bool { | |
| !*b | |
| } | |
| fn fmt_time(t: OffsetDateTime) -> String { | |
| t.format(&Rfc3339) | |
| .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()) | |
| } | |
| fn parse_time(id: &str, s: &str) -> Result<OffsetDateTime, Error> { | |
| OffsetDateTime::parse(s, &Rfc3339).map_err(|_| Error::BadRow { | |
| id: id.to_string(), | |
| what: format!("time {s:?}"), | |
| }) | |
| } | |
| fn label_from_rank(id: &str, rank: i64) -> Result<Label, Error> { | |
| match rank { | |
| 0 => Ok(Label::Public), | |
| 1 => Ok(Label::Internal), | |
| 2 => Ok(Label::Restricted), | |
| other => Err(Error::BadRow { | |
| id: id.to_string(), | |
| what: format!("label rank {other}"), | |
| }), | |
| } | |
| } | |
| /// The key a promotion's events are appended under. A task id starts `task-`, so the two | |
| /// never meet. | |
| fn promotion_key(entry: &str) -> String { | |
| format!("promotion:{entry}") | |
| } | |
| /// The columns of a promotion row, in the order both backends read them. | |
| const PROMOTION_COLUMNS: &str = | |
| "domain, entry, tier, promoter, author, why, label_rank, digest, new_digest, at, why_erased"; | |
| /// A promotion row as the database returns it, before the ledger has checked it. | |
| struct RawPromotion { | |
| domain: String, | |
| entry: String, | |
| tier: String, | |
| promoter: String, | |
| author: String, | |
| why: String, | |
| label_rank: i64, | |
| digest: String, | |
| new_digest: String, | |
| at: String, | |
| why_erased: i64, | |
| } | |
| impl RawPromotion { | |
| fn into_record(self) -> Result<PromotionRecord, Error> { | |
| let key = promotion_key(&self.entry); | |
| let tier = self.tier.parse::<TrustTier>().map_err(|_| Error::BadRow { | |
| id: key.clone(), | |
| what: format!("tier {:?}", self.tier), | |
| })?; | |
| Ok(PromotionRecord { | |
| spec: PromotionSpec { | |
| domain: self.domain, | |
| entry: self.entry, | |
| tier, | |
| by: self.promoter, | |
| author: self.author, | |
| why: self.why, | |
| classification: label_from_rank(&key, self.label_rank)?, | |
| digest: self.digest, | |
| new_digest: self.new_digest, | |
| }, | |
| at: parse_time(&key, &self.at)?, | |
| why_erased: self.why_erased != 0, | |
| }) | |
| } | |
| } | |
| /// The columns of a task row, in the order both backends read them. | |
| const TASK_COLUMNS: &str = | |
| "id, domain, title, body, requirements, depends_on, origin, upstream_event, | |
| created_by, label_rank, priority, created, status, holder, lease_until, attempts, handoff, | |
| reason, superseded_by, posted_to"; | |
| /// A task row as the database returns it, before the ledger has checked it. | |
| struct RawTask { | |
| id: String, | |
| domain: String, | |
| title: String, | |
| body: String, | |
| requirements: String, | |
| depends_on: String, | |
| origin: Option<String>, | |
| upstream_event: Option<String>, | |
| created_by: String, | |
| label_rank: i64, | |
| priority: i64, | |
| created: String, | |
| status: String, | |
| holder: Option<String>, | |
| lease_until: Option<String>, | |
| attempts: i64, | |
| handoff: Option<String>, | |
| reason: Option<String>, | |
| superseded_by: Option<String>, | |
| posted_to: Option<String>, | |
| } | |
| impl RawTask { | |
| fn into_task(self) -> Result<Task, Error> { | |
| let id = self.id; | |
| let json_list = |s: &str, what: &str| -> Result<Vec<String>, Error> { | |
| serde_json::from_str(s).map_err(|_| Error::BadRow { | |
| id: id.clone(), | |
| what: format!("{what} {s:?}"), | |
| }) | |
| }; | |
| let requirements = json_list(&self.requirements, "requirements")?; | |
| let depends_on = json_list(&self.depends_on, "depends_on")?; | |
| Ok(Task { | |
| spec: TaskSpec { | |
| domain: self.domain, | |
| title: self.title, | |
| body: self.body, | |
| requirements, | |
| depends_on, | |
| origin: self.origin, | |
| upstream_event: self.upstream_event, | |
| created_by: self.created_by, | |
| classification: label_from_rank(&id, self.label_rank)?, | |
| priority: self.priority, | |
| }, | |
| created: parse_time(&id, &self.created)?, | |
| status: Status::parse(&id, &self.status)?, | |
| holder: self.holder, | |
| lease_until: self.lease_until.map(|s| parse_time(&id, &s)).transpose()?, | |
| attempts: u32::try_from(self.attempts).unwrap_or(u32::MAX), | |
| handoff: self.handoff, | |
| reason: self.reason, | |
| superseded_by: self.superseded_by, | |
| posted_to: self.posted_to, | |
| id, | |
| }) | |
| } | |
| } | |
| /// The `origin` a continuation of `parent` carries: the task that resumes it once the | |
| /// subagent tasks its run filed are done. | |
| #[must_use] | |
| pub fn continues(parent: &str) -> String { | |
| format!("continues:{parent}") | |
| } | |
| /// Whether a session may claim `task` now: it is admitted, or claimed under a lease | |
| /// that has run out; the session's manifest carries every requirement; and the | |
| /// session's handling covers the task's classification. Whether its dependencies are | |
| /// complete is the backend's question, asked inside the transaction. | |
| fn claimable(task: &Task, now: OffsetDateTime, manifest: &[String], handling: Label) -> bool { | |
| let lease_live = task.lease_until.is_some_and(|t| t >= now); | |
| let state = task.status == Status::Admitted || (task.status == Status::Claimed && !lease_live); | |
| state | |
| && task.spec.classification <= handling | |
| && task.spec.requirements.iter().all(|r| manifest.contains(r)) | |
| } | |
| fn check_holder(task: &Task, holder: &str, now: OffsetDateTime) -> Result<(), Error> { | |
| let held = task.holder.as_deref() == Some(holder); | |
| let live = task.lease_until.is_some_and(|t| t >= now); | |
| if held && live { | |
| Ok(()) | |
| } else { | |
| Err(Error::NotHolder { | |
| id: task.id.clone(), | |
| holder: holder.to_string(), | |
| }) | |
| } | |
| } | |
| /// Where the tables live. | |
| enum Backend { | |
| Sqlite(sqlite::Db), | |
| Postgres(postgres::Db), | |
| } | |
| /// Run one backend call: the SQLite arm is synchronous, the PostgreSQL arm awaits. | |
| macro_rules! on_backend { | |
| ($backend:expr, $db:ident => $call:expr) => { | |
| match $backend { | |
| Backend::Sqlite($db) => $call, | |
| Backend::Postgres($db) => $call.await, | |
| } | |
| }; | |
| } | |
| /// The ledger, over one SQLite file or one PostgreSQL connection. | |
| pub struct Ledger { | |
| backend: Backend, | |
| clock: Clock, | |
| /// Held while a thread of our own drives the connection; dropping it ends the thread. | |
| keepalive: Option<tokio::sync::oneshot::Sender<()>>, | |
| } | |
| impl Ledger { | |
| /// Open or create the SQLite ledger file. | |
| /// | |
| /// # Errors | |
| /// | |
| /// When the file cannot be opened or the schema cannot be created. | |
| pub fn open(path: &Path) -> Result<Ledger, Error> { | |
| Ok(Ledger::new(Backend::Sqlite(sqlite::Db::open(path)?))) | |
| } | |
| /// A SQLite ledger that lives only in memory. | |
| /// | |
| /// # Errors | |
| /// | |
| /// When SQLite cannot create the schema, which it can always do in memory. | |
| pub fn in_memory() -> Result<Ledger, Error> { | |
| Ok(Ledger::new(Backend::Sqlite(sqlite::Db::in_memory()?))) | |
| } | |
| /// Connect to a PostgreSQL ledger at `url` (`postgres://user:pass@host:port/db`) | |
| /// and create its tables if they are not there. The connection is driven by a task | |
| /// spawned on the current runtime. TLS is not spoken: the server is on loopback or | |
| /// a private network, as the sidecar's would be. | |
| /// | |
| /// # Errors | |
| /// | |
| /// When the server cannot be reached, refuses the credentials, or the schema cannot | |
| /// be created. | |
| pub async fn connect(url: &str) -> Result<Ledger, Error> { | |
| Ok(Ledger::new(Backend::Postgres( | |
| postgres::Db::connect(url).await?, | |
| ))) | |
| } | |
| /// [`Ledger::connect`] from synchronous code, for a caller that opens the ledger | |
| /// before it has a runtime of its own, or on one it cannot block. On a | |
| /// multi-threaded runtime the connection is driven there; otherwise a thread of | |
| /// its own drives it for as long as the ledger lives. | |
| /// | |
| /// # Errors | |
| /// | |
| /// As [`Ledger::connect`], or [`Error::Runtime`] when no runtime can be had. | |
| pub fn connect_blocking(url: &str) -> Result<Ledger, Error> { | |
| use tokio::runtime::{Handle, RuntimeFlavor}; | |
| if let Ok(handle) = Handle::try_current() { | |
| if handle.runtime_flavor() == RuntimeFlavor::MultiThread { | |
| return tokio::task::block_in_place(|| handle.block_on(Ledger::connect(url))); | |
| } | |
| } | |
| let url = url.to_string(); | |
| let (tx, rx) = std::sync::mpsc::channel(); | |
| std::thread::Builder::new() | |
| .name("ledger-postgres".into()) | |
| .spawn(move || { | |
| let rt = match tokio::runtime::Builder::new_current_thread() | |
| .enable_all() | |
| .build() | |
| { | |
| Ok(rt) => rt, | |
| Err(e) => { | |
| let _ = tx.send(Err(Error::Runtime(e.to_string()))); | |
| return; | |
| } | |
| }; | |
| rt.block_on(async move { | |
| let (done_tx, done_rx) = tokio::sync::oneshot::channel::<()>(); | |
| let ledger = postgres::Db::connect(&url).await.map(|db| { | |
| let mut l = Ledger::new(Backend::Postgres(db)); | |
| l.keepalive = Some(done_tx); | |
| l | |
| }); | |
| let _ = tx.send(ledger); | |
| // the connection task lives on this runtime; stay until the | |
| // ledger is dropped, which closes the other end | |
| let _ = done_rx.await; | |
| }); | |
| }) | |
| .map_err(|e| Error::Runtime(e.to_string()))?; | |
| rx.recv() | |
| .unwrap_or_else(|_| Err(Error::Runtime("connection thread ended".into()))) | |
| } | |
| fn new(backend: Backend) -> Ledger { | |
| Ledger { | |
| backend, | |
| clock: Box::new(OffsetDateTime::now_utc), | |
| keepalive: None, | |
| } | |
| } | |
| /// Replace the clock. Tests use this to let leases expire without waiting. | |
| pub fn set_clock(&mut self, clock: impl Fn() -> OffsetDateTime + Send + Sync + 'static) { | |
| self.clock = Box::new(clock); | |
| } | |
| fn now(&self) -> OffsetDateTime { | |
| (self.clock)() | |
| } | |
| /// File a task. It is born proposed. If `origin` and `upstream_event` are both set and | |
| /// the domain already has a task with that pair, that task is returned and nothing is | |
| /// filed; another domain's task with the same pair is not this domain's. | |
| /// | |
| /// # Errors | |
| /// | |
| /// A database error. | |
| pub async fn create(&mut self, spec: TaskSpec) -> Result<Created, Error> { | |
| if let (Some(o), Some(u)) = (&spec.origin, &spec.upstream_event) { | |
| let existing = on_backend!(&self.backend, db => db.find_origin(&spec.domain, o, u))?; | |
| if let Some(id) = existing { | |
| return Ok(Created { | |
| task: self.get(&id).await?, | |
| deduplicated: true, | |
| }); | |
| } | |
| } | |
| let id = dm_core::id::new_task_id(&spec.domain); | |
| let now = self.now(); | |
| let event = Event::Created { | |
| id: id.clone(), | |
| spec, | |
| }; | |
| self.append_at(&id, now, &event).await?; | |
| Ok(Created { | |
| task: self.get(&id).await?, | |
| deduplicated: false, | |
| }) | |
| } | |
| /// Admit a proposed task, making it claimable. | |
| /// | |
| /// # Errors | |
| /// | |
| /// [`Error::NotFound`], or [`Error::WrongState`] if it is not proposed. | |
| pub async fn admit(&mut self, id: &str) -> Result<Task, Error> { | |
| let task = self.get(id).await?; | |
| if task.status != Status::Proposed { | |
| return Err(Error::WrongState { | |
| id: id.to_string(), | |
| status: task.status, | |
| op: "admit", | |
| }); | |
| } | |
| self.append(id, &Event::Admitted).await?; | |
| self.get(id).await | |
| } | |
| /// Claim the highest-ranked claimable task in a domain whose requirements the | |
| /// manifest satisfies, whose classification `handling` covers, and whose | |
| /// dependencies are complete. Claimable means admitted, or claimed under a lease | |
| /// that has expired. One transaction, so one holder; a task above `handling` is | |
| /// passed over inside it, so it is never claimed and then given back. | |
| /// | |
| /// # Errors | |
| /// | |
| /// A database error. | |
| pub async fn claim_next( | |
| &mut self, | |
| domain: &str, | |
| holder: &str, | |
| lease: Duration, | |
| manifest: &[String], | |
| handling: Label, | |
| ) -> Result<Option<Claim>, Error> { | |
| let now = self.now(); | |
| let until = now + lease; | |
| let claimed = on_backend!(&mut self.backend, db => db.claim_next(domain, now, holder, until, manifest, handling))?; | |
| if let Some(id) = &claimed { | |
| self.trace("claimed", id).await; | |
| } | |
| match claimed { | |
| Some(id) => Ok(Some(Claim { | |
| task: self.get(&id).await?, | |
| lease_until: until, | |
| })), | |
| None => Ok(None), | |
| } | |
| } | |
| /// Claim one named task, under the same rules as [`Ledger::claim_next`]. | |
| /// | |
| /// # Errors | |
| /// | |
| /// [`Error::NotFound`]; [`Error::WrongState`] when it is not claimable, including when | |
| /// another holder's lease is still live, a dependency is open, or the task is above | |
| /// `handling`. | |
| pub async fn claim( | |
| &mut self, | |
| id: &str, | |
| holder: &str, | |
| lease: Duration, | |
| manifest: &[String], | |
| handling: Label, | |
| ) -> Result<Claim, Error> { | |
| let now = self.now(); | |
| let until = now + lease; | |
| on_backend!(&mut self.backend, db => db.claim(id, now, holder, until, manifest, handling))?; | |
| self.trace("claimed", id).await; | |
| Ok(Claim { | |
| task: self.get(id).await?, | |
| lease_until: until, | |
| }) | |
| } | |
| /// Renew a lease. Refused when the caller does not hold the task or the lease has | |
| /// already expired, in which case the task is back in the pool and the caller must | |
| /// stop. Returns the task so a caller can see an invalidation. | |
| /// | |
| /// # Errors | |
| /// | |
| /// [`Error::NotFound`], [`Error::NotHolder`], or [`Error::WrongState`] when terminal. | |
| pub async fn heartbeat( | |
| &mut self, | |
| id: &str, | |
| holder: &str, | |
| lease: Duration, | |
| ) -> Result<Task, Error> { | |
| let now = self.now(); | |
| let task = self.get(id).await?; | |
| if task.status.is_terminal() { | |
| return Err(Error::WrongState { | |
| id: id.to_string(), | |
| status: task.status, | |
| op: "heartbeat", | |
| }); | |
| } | |
| check_holder(&task, holder, now)?; | |
| self.append_at(id, now, &Event::Heartbeat { until: now + lease }) | |
| .await?; | |
| self.get(id).await | |
| } | |
| /// Close a task with the handoff entry that records what happened. | |
| /// | |
| /// # Errors | |
| /// | |
| /// [`Error::NotFound`], [`Error::NotHolder`], or [`Error::WrongState`]. | |
| pub async fn complete(&mut self, id: &str, holder: &str, handoff: &str) -> Result<Task, Error> { | |
| let now = self.now(); | |
| let task = self.get(id).await?; | |
| if task.status != Status::Claimed { | |
| return Err(Error::WrongState { | |
| id: id.to_string(), | |
| status: task.status, | |
| op: "complete", | |
| }); | |
| } | |
| check_holder(&task, holder, now)?; | |
| self.append_at( | |
| id, | |
| now, | |
| &Event::Completed { | |
| handoff: handoff.to_string(), | |
| }, | |
| ) | |
| .await?; | |
| self.get(id).await | |
| } | |
| /// Close a task its continuation finished: the continuation `by` resumed it and is | |
| /// complete with `handoff`, so the task is too, with the same handoff. Only a task | |
| /// nobody holds: one released when its run delegated, or whose lease ran out. A | |
| /// holder with a live lease finishes it themselves. | |
| /// | |
| /// # Errors | |
| /// | |
| /// [`Error::NotFound`]; [`Error::WrongState`] when it is terminal, held under a live | |
| /// lease, or `by` is not a completed continuation of it. | |
| pub async fn complete_by_continuation( | |
| &mut self, | |
| id: &str, | |
| by: &str, | |
| handoff: &str, | |
| ) -> Result<Task, Error> { | |
| let now = self.now(); | |
| let task = self.get(id).await?; | |
| let continuation = self.get(by).await?; | |
| let held = task.status == Status::Claimed && task.lease_until.is_some_and(|t| t >= now); | |
| let resumed_it = continuation.spec.origin.as_deref() == Some(continues(id).as_str()) | |
| && continuation.status == Status::Completed; | |
| if task.status.is_terminal() || held || !resumed_it { | |
| return Err(Error::WrongState { | |
| id: id.to_string(), | |
| status: task.status, | |
| op: "complete by continuation", | |
| }); | |
| } | |
| self.append_at( | |
| id, | |
| now, | |
| &Event::Completed { | |
| handoff: handoff.to_string(), | |
| }, | |
| ) | |
| .await?; | |
| self.get(id).await | |
| } | |
| /// Give a task back to the pool. | |
| /// | |
| /// # Errors | |
| /// | |
| /// [`Error::NotFound`], [`Error::NotHolder`], or [`Error::WrongState`]. | |
| pub async fn release( | |
| &mut self, | |
| id: &str, | |
| holder: &str, | |
| handoff: Option<&str>, | |
| ) -> Result<Task, Error> { | |
| let now = self.now(); | |
| let task = self.get(id).await?; | |
| if task.status != Status::Claimed { | |
| return Err(Error::WrongState { | |
| id: id.to_string(), | |
| status: task.status, | |
| op: "release", | |
| }); | |
| } | |
| check_holder(&task, holder, now)?; | |
| if let Some(handoff) = handoff { | |
| self.append_at( | |
| id, | |
| now, | |
| &Event::HandoffLeft { | |
| handoff: handoff.to_string(), | |
| }, | |
| ) | |
| .await?; | |
| } | |
| self.append_at(id, now, &Event::Released).await?; | |
| self.get(id).await | |
| } | |
| /// Close a task without doing it. A live holder learns of it at the next heartbeat. | |
| /// | |
| /// # Errors | |
| /// | |
| /// [`Error::NotFound`], or [`Error::WrongState`] when already terminal. | |
| pub async fn invalidate( | |
| &mut self, | |
| id: &str, | |
| reason: &str, | |
| superseded_by: Option<&str>, | |
| ) -> Result<Task, Error> { | |
| let task = self.get(id).await?; | |
| if task.status.is_terminal() { | |
| return Err(Error::WrongState { | |
| id: id.to_string(), | |
| status: task.status, | |
| op: "invalidate", | |
| }); | |
| } | |
| self.append( | |
| id, | |
| &Event::Invalidated { | |
| reason: reason.to_string(), | |
| superseded_by: superseded_by.map(str::to_string), | |
| }, | |
| ) | |
| .await?; | |
| self.get(id).await | |
| } | |
| /// One task. | |
| /// | |
| /// # Errors | |
| /// | |
| /// [`Error::NotFound`], or a row the ledger did not write. | |
| pub async fn get(&self, id: &str) -> Result<Task, Error> { | |
| on_backend!(&self.backend, db => db.get(id)) | |
| } | |
| /// The domain's task for an `origin` and `upstream_event` pair, in any status: the | |
| /// one [`Ledger::create`] returns in place of filing the same pair again. | |
| /// | |
| /// # Errors | |
| /// | |
| /// A database error, or a row the ledger did not write. | |
| pub async fn by_origin( | |
| &self, | |
| domain: &str, | |
| origin: &str, | |
| upstream_event: &str, | |
| ) -> Result<Option<Task>, Error> { | |
| let found = | |
| on_backend!(&self.backend, db => db.find_origin(domain, origin, upstream_event))?; | |
| match found { | |
| Some(id) => Ok(Some(self.get(&id).await?)), | |
| None => Ok(None), | |
| } | |
| } | |
| /// The domain's tasks with an `origin`, of every upstream event and status, oldest | |
| /// first. | |
| /// | |
| /// # Errors | |
| /// | |
| /// A database error, or a row the ledger did not write. | |
| pub async fn with_origin(&self, domain: &str, origin: &str) -> Result<Vec<Task>, Error> { | |
| on_backend!(&self.backend, db => db.with_origin(domain, origin)) | |
| } | |
| /// The tasks filed from `parent`: the subagent tasks a run of it asked for | |
| /// (`origin` `task:<parent>`), its continuations (`continues:<parent>`) and its | |
| /// retries (`retries:<parent>`), oldest first. | |
| /// | |
| /// # Errors | |
| /// | |
| /// A database error, or a row the ledger did not write. | |
| pub async fn filed_from(&self, parent: &str) -> Result<Vec<Task>, Error> { | |
| on_backend!(&self.backend, db => db.filed_from(parent)) | |
| } | |
| /// Tasks in a domain with the given status, ranked as the queue ranks them. | |
| /// | |
| /// # Errors | |
| /// | |
| /// A database error, or a row the ledger did not write. | |
| pub async fn list(&self, domain: &str, status: Status) -> Result<Vec<Task>, Error> { | |
| on_backend!(&self.backend, db => db.list(domain, status)) | |
| } | |
| /// Record that a completed task's handoff was posted back where the task came from, | |
| /// at `to`, so it is not posted twice. | |
| /// | |
| /// # Errors | |
| /// | |
| /// [`Error::NotFound`], or [`Error::WrongState`] when the task is not completed or | |
| /// was posted already. | |
| pub async fn post(&mut self, id: &str, to: &str) -> Result<Task, Error> { | |
| let task = self.get(id).await?; | |
| if task.status != Status::Completed || task.posted_to.is_some() { | |
| return Err(Error::WrongState { | |
| id: id.to_string(), | |
| status: task.status, | |
| op: "post", | |
| }); | |
| } | |
| self.append(id, &Event::Posted { to: to.to_string() }) | |
| .await?; | |
| self.get(id).await | |
| } | |
| /// Rank an open task again: its place in the queue changes, the priority it was filed | |
| /// with does not, and a claimed task keeps its lease whatever the rubric says. No | |
| /// event when the rank is what it was. | |
| /// | |
| /// # Errors | |
| /// | |
| /// [`Error::NotFound`], or [`Error::WrongState`] when it is terminal. | |
| pub async fn rescore(&mut self, id: &str, priority: i64, why: &str) -> Result<Task, Error> { | |
| let task = self.get(id).await?; | |
| if task.status.is_terminal() { | |
| return Err(Error::WrongState { | |
| id: id.to_string(), | |
| status: task.status, | |
| op: "rescore", | |
| }); | |
| } | |
| if task.spec.priority == priority { | |
| return Ok(task); | |
| } | |
| self.append( | |
| id, | |
| &Event::Rescored { | |
| priority, | |
| why: why.to_string(), | |
| }, | |
| ) | |
| .await?; | |
| self.get(id).await | |
| } | |
| /// The priority a task was filed with, before any rescore: from its `Created` event. | |
| /// | |
| /// # Errors | |
| /// | |
| /// [`Error::NotFound`], or a payload the ledger did not write. | |
| pub async fn filed_priority(&self, id: &str) -> Result<i64, Error> { | |
| let payload = on_backend!(&self.backend, db => db.created_payload(id))?; | |
| match Event::parse(id, &payload)? { | |
| Event::Created { spec, .. } => Ok(spec.priority), | |
| _ => Err(Error::BadRow { | |
| id: id.to_string(), | |
| what: "first event is not its creation".into(), | |
| }), | |
| } | |
| } | |
| /// Completed tasks in a domain that came from upstream and whose handoff has not | |
| /// been posted back, oldest first. | |
| /// | |
| /// # Errors | |
| /// | |
| /// A database error, or a row the ledger did not write. | |
| pub async fn unposted(&self, domain: &str) -> Result<Vec<Task>, Error> { | |
| on_backend!(&self.backend, db => db.unposted(domain)) | |
| } | |
| /// Tasks that depend on this one. | |
| /// | |
| /// # Errors | |
| /// | |
| /// A database error, or a row the ledger did not write. | |
| pub async fn dependents(&self, id: &str) -> Result<Vec<Task>, Error> { | |
| on_backend!(&self.backend, db => db.dependents(id)) | |
| } | |
| /// Record a promotion: one event, projected into the promotions and nowhere else. | |
| /// Once per promoter and version: a second record of the same entry, tier, digest | |
| /// and promoter appends nothing and returns false. Another version, or another | |
| /// promoter of the same version, is a record of its own, so a record whose promotion | |
| /// failed never stands in for a later one that did not. | |
| /// | |
| /// # Errors | |
| /// | |
| /// A database error. | |
| pub async fn record_promotion(&mut self, spec: &PromotionSpec) -> Result<bool, Error> { | |
| let now = self.now(); | |
| on_backend!(&mut self.backend, db => db.record_promotion(now, spec, false)) | |
| } | |
| /// Record a promotion another ledger kept, as it was there: its time, and whether its | |
| /// why was erased. An import of a bundle the same owner exported calls this, after | |
| /// moving the record to the entry as it landed here. Once, as | |
| /// [`Ledger::record_promotion`] is. | |
| /// | |
| /// # Errors | |
| /// | |
| /// A database error. | |
| pub async fn restore_promotion(&mut self, record: &PromotionRecord) -> Result<bool, Error> { | |
| on_backend!(&mut self.backend, db => db.record_promotion(record.at, &record.spec, record.why_erased)) | |
| } | |
| /// The promotions of an entry in a domain, oldest first. | |
| /// | |
| /// # Errors | |
| /// | |
| /// A database error, or a row the ledger did not write. | |
| pub async fn promotions( | |
| &self, | |
| domain: &str, | |
| entry: &str, | |
| ) -> Result<Vec<PromotionRecord>, Error> { | |
| on_backend!(&self.backend, db => db.promotions(domain, entry)) | |
| } | |
| /// Remove the why from every promotion record of an entry, for its erasure: the | |
| /// events that hold it are rewritten and the projection with them, so no replay | |
| /// brings it back. Who promoted which version to which tier, and when, is kept. | |
| /// Running it again changes nothing. | |
| /// | |
| /// # Errors | |
| /// | |
| /// A database error, or [`Error::Held`] when another connection holds the log the | |
| /// old text is in. | |
| pub async fn scrub_promotions(&mut self, domain: &str, entry: &str) -> Result<Scrubbed, Error> { | |
| let records = on_backend!(&mut self.backend, db => db.scrub_promotions(domain, entry))?; | |
| Ok(Scrubbed { | |
| records, | |
| tasks: 0, | |
| beyond_reach: self.beyond_reach(), | |
| }) | |
| } | |
| /// Rewrite the tasks filed from `origin` in `domain` for an erasure of what they are | |
| /// about: each gets `redaction`'s title and body in its `created` event and in the | |
| /// projection, its reason for being invalidated and the handoffs left on it or that | |
| /// closed it become the redaction's reason, so no replay brings the old text back; | |
| /// each still open is invalidated. The task's id, origin, who filed it and its history | |
| /// stay. Returns how many tasks from that origin now hold the redaction. Running it | |
| /// again changes nothing. | |
| /// | |
| /// # Errors | |
| /// | |
| /// A database error, or [`Error::Held`] when another connection holds the log the | |
| /// old text is in. | |
| pub async fn scrub_tasks( | |
| &mut self, | |
| domain: &str, | |
| origin: &str, | |
| redaction: &Redaction, | |
| ) -> Result<usize, Error> { | |
| let now = self.now(); | |
| on_backend!(&mut self.backend, db => db.scrub_tasks(now, domain, origin, redaction)) | |
| } | |
| /// Both scrubs of an erasure, [`Ledger::scrub_promotions`] of `entry` and | |
| /// [`Ledger::scrub_tasks`] of `origin`, in one transaction, and on SQLite one rebuild | |
| /// of the file after them, also when nothing was left to rewrite: an earlier run that | |
| /// a reader held up left the old text in the write-ahead log. | |
| /// | |
| /// # Errors | |
| /// | |
| /// A database error, or [`Error::Held`] when another connection holds the log the | |
| /// old text is in. | |
| pub async fn scrub_entry( | |
| &mut self, | |
| domain: &str, | |
| entry: &str, | |
| origin: &str, | |
| redaction: &Redaction, | |
| ) -> Result<Scrubbed, Error> { | |
| let now = self.now(); | |
| let (records, tasks) = on_backend!( | |
| &mut self.backend, | |
| db => db.scrub_entry(now, domain, entry, origin, redaction) | |
| )?; | |
| Ok(Scrubbed { | |
| records, | |
| tasks, | |
| beyond_reach: self.beyond_reach(), | |
| }) | |
| } | |
| /// What a scrub on this backend cannot remove. | |
| fn beyond_reach(&self) -> Vec<String> { | |
| match self.backend { | |
| Backend::Sqlite(_) => Vec::new(), | |
| Backend::Postgres(_) => vec![POSTGRES_RESIDUE.to_string()], | |
| } | |
| } | |
| /// How many events the ledger holds. | |
| /// | |
| /// # Errors | |
| /// | |
| /// A database error. | |
| pub async fn event_count(&self) -> Result<u64, Error> { | |
| on_backend!(&self.backend, db => db.event_count()) | |
| } | |
| /// Drop the projection and replay every event into a fresh one. Returns the number | |
| /// of events replayed. | |
| /// | |
| /// # Errors | |
| /// | |
| /// A database error; the projection is left as it was. | |
| pub async fn rebuild(&mut self) -> Result<u64, Error> { | |
| on_backend!(&mut self.backend, db => db.rebuild()) | |
| } | |
| async fn append(&mut self, id: &str, event: &Event) -> Result<(), Error> { | |
| let now = self.now(); | |
| self.append_at(id, now, event).await | |
| } | |
| /// Append one event and project it, in one transaction. | |
| async fn append_at( | |
| &mut self, | |
| id: &str, | |
| at: OffsetDateTime, | |
| event: &Event, | |
| ) -> Result<(), Error> { | |
| on_backend!(&mut self.backend, db => db.append(id, at, event))?; | |
| let task_id = match event { | |
| Event::Created { id: new_id, .. } => new_id.as_str(), | |
| _ => id, | |
| }; | |
| self.trace(event.kind(), task_id).await; | |
| Ok(()) | |
| } | |
| /// A `task.<kind>` span for a transition that took effect, with the task as it now | |
| /// stands. Only when a collector is configured, since it reads the task back. | |
| async fn trace(&self, kind: &str, id: &str) { | |
| if !dm_otel::enabled() { | |
| return; | |
| } | |
| let Ok(t) = self.get(id).await else { | |
| return; | |
| }; | |
| let mut attrs: Vec<(&'static str, dm_otel::Value<'_>)> = vec![ | |
| ("task.id", t.id.as_str().into()), | |
| ("memory.domain", t.spec.domain.as_str().into()), | |
| ("task.status", t.status.as_str().into()), | |
| ("task.priority", t.spec.priority.into()), | |
| ("task.attempts", i64::from(t.attempts).into()), | |
| ("task.created_by", t.spec.created_by.as_str().into()), | |
| ]; | |
| if let Some(h) = &t.holder { | |
| attrs.push(("task.holder", h.as_str().into())); | |
| } | |
| if let Some(o) = &t.spec.origin { | |
| attrs.push(("task.origin", o.as_str().into())); | |
| } | |
| if let Some(r) = &t.reason { | |
| attrs.push(("task.reason", r.as_str().into())); | |
| } | |
| dm_otel::event(&format!("task.{kind}"), &attrs); | |
| } | |
| } | |
| #[cfg(test)] | |
| mod tests { | |
| use super::testing::PgServer; | |
| use super::*; | |
| use std::sync::{Arc, Mutex}; | |
| use time::macros::datetime; | |
| const T0: OffsetDateTime = datetime!(2026-10-01 10:00 UTC); | |
| /// Where a test's ledger lives, so it can be opened again. | |
| enum Site { | |
| Sqlite(tempfile::TempDir), | |
| Postgres(Box<PgServer>), | |
| } | |
| impl Site { | |
| async fn open(&self) -> Ledger { | |
| match self { | |
| Site::Sqlite(dir) => Ledger::open(&dir.path().join("ledger.db")).unwrap(), | |
| Site::Postgres(server) => Ledger::connect(server.url()).await.unwrap(), | |
| } | |
| } | |
| /// Every event's payload, as the log holds it. | |
| async fn payloads(&self) -> Vec<String> { | |
| match self { | |
| Site::Sqlite(dir) => { | |
| let conn = rusqlite::Connection::open(dir.path().join("ledger.db")).unwrap(); | |
| let mut stmt = conn.prepare("SELECT payload FROM events").unwrap(); | |
| let rows = stmt.query_map([], |r| r.get(0)).unwrap(); | |
| rows.map(Result::unwrap).collect() | |
| } | |
| Site::Postgres(server) => { | |
| let c = pg_client(server).await; | |
| let mut out = Vec::new(); | |
| for row in c | |
| .query("SELECT schema FROM public.ledger_domains", &[]) | |
| .await | |
| .unwrap() | |
| { | |
| let s: String = row.get(0); | |
| for p in c | |
| .query(&format!("SELECT payload FROM {s}.events"), &[]) | |
| .await | |
| .unwrap() | |
| { | |
| out.push(p.get(0)); | |
| } | |
| } | |
| out | |
| } | |
| } | |
| } | |
| /// A ledger on a clock the test moves. | |
| async fn ledger(&self) -> (Ledger, Arc<Mutex<OffsetDateTime>>) { | |
| let now = Arc::new(Mutex::new(T0)); | |
| let mut l = self.open().await; | |
| let c = now.clone(); | |
| l.set_clock(move || *c.lock().unwrap()); | |
| (l, now) | |
| } | |
| } | |
| /// Each test body once per backend, as `sqlite::<name>` and `postgres::<name>`; the | |
| /// PostgreSQL one is skipped, saying so, where no server can be had. | |
| macro_rules! on_both { | |
| ($($name:ident),* $(,)?) => { | |
| mod sqlite { | |
| $( | |
| #[tokio::test] | |
| async fn $name() { | |
| let b = super::Site::Sqlite(tempfile::tempdir().unwrap()); | |
| super::$name(&b).await; | |
| } | |
| )* | |
| } | |
| mod postgres { | |
| $( | |
| #[tokio::test] | |
| async fn $name() { | |
| let Some(server) = super::PgServer::start().await.unwrap() else { | |
| return; | |
| }; | |
| super::$name(&super::Site::Postgres(Box::new(server))).await; | |
| } | |
| )* | |
| } | |
| }; | |
| } | |
| on_both! { | |
| a_task_is_proposed_until_admitted_and_then_claimable_once, | |
| heartbeat_renews_only_for_the_holder_and_complete_closes, | |
| an_unrenewed_lease_is_lost_at_expiry_for_both_sides, | |
| requirements_match_the_manifest_and_priority_orders_the_queue, | |
| a_task_goes_only_to_a_session_whose_handling_covers_it, | |
| dependencies_block_until_complete, | |
| release_returns_to_pool_and_invalidate_is_terminal, | |
| origin_and_upstream_event_dedup_a_second_filing, | |
| what_was_filed_from_a_task_is_found_by_its_origin, | |
| every_operation_appends_one_event_and_replay_rebuilds_the_projection, | |
| not_found_and_persistence, | |
| a_completed_task_from_upstream_is_posted_back_once, | |
| two_claimers_on_two_connections_get_two_tasks, | |
| a_domain_claims_only_its_own_queue, | |
| a_rescore_moves_the_queue_and_keeps_the_filed_priority, | |
| a_task_waits_on_its_continuation, | |
| a_task_closes_with_its_continuation, | |
| a_continuation_given_up_on_gives_its_task_back, | |
| a_promotion_is_recorded_once_per_version, | |
| a_promotion_record_is_no_task, | |
| replay_keeps_the_promotions, | |
| a_second_promoter_of_the_same_version_is_recorded, | |
| an_erasure_takes_the_why_and_a_replay_keeps_it_out, | |
| a_restored_record_keeps_its_time_and_its_erased_why, | |
| an_erasure_rewrites_the_tasks_filed_from_an_origin, | |
| an_erasure_takes_the_reason_and_the_handoff_of_the_tasks_too, | |
| } | |
| fn spec(title: &str) -> TaskSpec { | |
| TaskSpec { | |
| domain: "dom-a".into(), | |
| title: title.into(), | |
| body: "Do it.\n".into(), | |
| requirements: vec![], | |
| depends_on: vec![], | |
| origin: None, | |
| upstream_event: None, | |
| created_by: "human:test:me".into(), | |
| classification: Label::Public, | |
| priority: 0, | |
| } | |
| } | |
| async fn admitted(l: &mut Ledger, s: TaskSpec) -> Task { | |
| let t = l.create(s).await.unwrap().task; | |
| l.admit(&t.id).await.unwrap() | |
| } | |
| fn mins(n: i64) -> Duration { | |
| Duration::minutes(n) | |
| } | |
| /// `claim_next` in `dom-a` with an empty manifest and the highest handling. | |
| async fn next(l: &mut Ledger, holder: &str, lease: Duration) -> Option<Claim> { | |
| l.claim_next("dom-a", holder, lease, &[], Label::Restricted) | |
| .await | |
| .unwrap() | |
| } | |
| /// `claim` with an empty manifest and the highest handling. | |
| async fn claim( | |
| l: &mut Ledger, | |
| id: &str, | |
| holder: &str, | |
| lease: Duration, | |
| ) -> Result<Claim, Error> { | |
| l.claim(id, holder, lease, &[], Label::Restricted).await | |
| } | |
| fn is_wrong_state<T>(r: &Result<T, Error>) -> bool { | |
| matches!(r, Err(Error::WrongState { .. })) | |
| } | |
| fn is_not_holder<T>(r: &Result<T, Error>) -> bool { | |
| matches!(r, Err(Error::NotHolder { .. })) | |
| } | |
| async fn get_all(l: &Ledger, ids: &[&str]) -> Vec<Task> { | |
| let mut out = Vec::new(); | |
| for id in ids { | |
| out.push(l.get(id).await.unwrap()); | |
| } | |
| out | |
| } | |
| async fn a_task_is_proposed_until_admitted_and_then_claimable_once(b: &Site) { | |
| let (mut l, _) = b.ledger().await; | |
| let t = l.create(spec("Fix the flaky test")).await.unwrap().task; | |
| assert_eq!(t.status, Status::Proposed); | |
| assert!(next(&mut l, "s1", mins(30)).await.is_none()); | |
| l.admit(&t.id).await.unwrap(); | |
| assert!(is_wrong_state(&l.admit(&t.id).await)); | |
| claimable_once(&mut l, &t).await; | |
| } | |
| async fn claimable_once(l: &mut Ledger, t: &Task) { | |
| let c = next(l, "s1", mins(30)).await.unwrap(); | |
| assert_eq!(c.task.id, t.id); | |
| assert_eq!(c.task.status, Status::Claimed); | |
| assert_eq!(c.task.holder.as_deref(), Some("s1")); | |
| assert_eq!(c.lease_until, T0 + mins(30)); | |
| assert_eq!(c.task.attempts, 1); | |
| assert!(next(l, "s2", mins(30)).await.is_none(), "one holder"); | |
| assert!(is_wrong_state(&claim(l, &t.id, "s2", mins(30)).await)); | |
| } | |
| async fn heartbeat_renews_only_for_the_holder_and_complete_closes(b: &Site) { | |
| let (mut l, now) = b.ledger().await; | |
| let t = admitted(&mut l, spec("Work")).await; | |
| claim(&mut l, &t.id, "s1", mins(30)).await.unwrap(); | |
| *now.lock().unwrap() = T0 + mins(20); | |
| let t = l.heartbeat(&t.id, "s1", mins(30)).await.unwrap(); | |
| assert_eq!(t.lease_until, Some(T0 + mins(50))); | |
| assert!(is_not_holder(&l.heartbeat(&t.id, "s2", mins(30)).await)); | |
| assert!(is_not_holder(&l.complete(&t.id, "s2", "mem-a-h").await)); | |
| complete_closes(&mut l, &t).await; | |
| } | |
| async fn complete_closes(l: &mut Ledger, t: &Task) { | |
| let done = l.complete(&t.id, "s1", "mem-a-handoff").await.unwrap(); | |
| assert_eq!(done.status, Status::Completed); | |
| assert_eq!(done.handoff.as_deref(), Some("mem-a-handoff")); | |
| assert!(done.lease_until.is_none()); | |
| assert!(is_wrong_state(&l.heartbeat(&t.id, "s1", mins(30)).await)); | |
| assert!(is_wrong_state(&l.complete(&t.id, "s1", "again").await)); | |
| assert_eq!(l.list("dom-a", Status::Completed).await.unwrap().len(), 1); | |
| } | |
| async fn an_unrenewed_lease_is_lost_at_expiry_for_both_sides(b: &Site) { | |
| let (mut l, now) = b.ledger().await; | |
| let t = admitted(&mut l, spec("Long job")).await; | |
| claim(&mut l, &t.id, "s1", mins(30)).await.unwrap(); | |
| *now.lock().unwrap() = T0 + mins(29); | |
| assert!(next(&mut l, "s2", mins(30)).await.is_none(), "still held"); | |
| *now.lock().unwrap() = T0 + mins(31); | |
| let c = next(&mut l, "s2", mins(30)).await.unwrap(); | |
| assert_eq!(c.task.id, t.id); | |
| assert_eq!(c.task.holder.as_deref(), Some("s2")); | |
| assert_eq!(c.task.attempts, 2); | |
| stale_holder_is_refused(&mut l, &t, &now).await; | |
| } | |
| async fn stale_holder_is_refused(l: &mut Ledger, t: &Task, now: &Mutex<OffsetDateTime>) { | |
| assert!( | |
| is_not_holder(&l.heartbeat(&t.id, "s1", mins(30)).await), | |
| "stale holder" | |
| ); | |
| assert!(is_not_holder(&l.complete(&t.id, "s1", "mem-a-h").await)); | |
| assert!(is_not_holder(&l.release(&t.id, "s1", None).await)); | |
| // s2's own lease also expires if never renewed | |
| *now.lock().unwrap() = T0 + mins(62); | |
| assert!(is_not_holder(&l.heartbeat(&t.id, "s2", mins(30)).await)); | |
| } | |
| async fn requirements_match_the_manifest_and_priority_orders_the_queue(b: &Site) { | |
| let (mut l, _) = b.ledger().await; | |
| let mut needs_tool = spec("Needs github"); | |
| needs_tool.requirements = vec!["tool:github".into()]; | |
| needs_tool.priority = 10; | |
| let needs_tool = admitted(&mut l, needs_tool).await; | |
| let mut low = spec("Low"); | |
| low.priority = 1; | |
| let low = admitted(&mut l, low).await; | |
| let mut high = spec("High"); | |
| high.priority = 5; | |
| let high = admitted(&mut l, high).await; | |
| the_queue_is_ranked(&mut l, &needs_tool, &low, &high).await; | |
| } | |
| async fn the_queue_is_ranked(l: &mut Ledger, needs_tool: &Task, low: &Task, high: &Task) { | |
| let c = next(l, "s1", mins(5)).await.unwrap(); | |
| assert_eq!(c.task.id, high.id, "highest priority the manifest can take"); | |
| let c = l | |
| .claim_next( | |
| "dom-a", | |
| "s1", | |
| mins(5), | |
| &["tool:github".into()], | |
| Label::Restricted, | |
| ) | |
| .await | |
| .unwrap() | |
| .unwrap(); | |
| assert_eq!(c.task.id, needs_tool.id); | |
| let c = next(l, "s1", mins(5)).await.unwrap(); | |
| assert_eq!(c.task.id, low.id); | |
| assert!(is_wrong_state( | |
| &claim(l, &needs_tool.id, "s3", mins(5)).await | |
| )); | |
| } | |
| async fn a_task_goes_only_to_a_session_whose_handling_covers_it(b: &Site) { | |
| let (mut l, _) = b.ledger().await; | |
| let mut secret = spec("Rotate the signing key"); | |
| secret.classification = Label::Restricted; | |
| secret.priority = 10; | |
| let secret = admitted(&mut l, secret).await; | |
| let mut low = spec("Tidy the docs"); | |
| low.priority = 1; | |
| let low = admitted(&mut l, low).await; | |
| handling_bounds_the_claim(&mut l, &secret, &low).await; | |
| } | |
| /// `claim_next` in `dom-a` for a session whose handling is public. | |
| async fn public(l: &mut Ledger) -> Option<Claim> { | |
| l.claim_next("dom-a", "s1", mins(5), &[], Label::Public) | |
| .await | |
| .unwrap() | |
| } | |
| async fn handling_bounds_the_claim(l: &mut Ledger, secret: &Task, low: &Task) { | |
| let c = public(l).await.unwrap(); | |
| assert_eq!( | |
| c.task.id, low.id, | |
| "the top task a public session can handle" | |
| ); | |
| assert!(public(l).await.is_none(), "nothing it may handle is left"); | |
| assert!(is_wrong_state( | |
| &l.claim(&secret.id, "s1", mins(5), &[], Label::Internal) | |
| .await | |
| )); | |
| assert_eq!(l.get(&secret.id).await.unwrap().status, Status::Admitted); | |
| let c = next(l, "s2", mins(5)).await.unwrap(); | |
| assert_eq!(c.task.id, secret.id, "a restricted session takes it"); | |
| } | |
| async fn dependencies_block_until_complete(b: &Site) { | |
| let (mut l, _) = b.ledger().await; | |
| let parent = admitted(&mut l, spec("Parent")).await; | |
| let mut child_spec = spec("Child"); | |
| child_spec.depends_on = vec![parent.id.clone()]; | |
| child_spec.priority = 100; | |
| let child = admitted(&mut l, child_spec).await; | |
| assert_eq!(l.dependents(&parent.id).await.unwrap()[0].id, child.id); | |
| let c = next(&mut l, "s1", mins(5)).await.unwrap(); | |
| assert_eq!(c.task.id, parent.id, "the child outranks it but is blocked"); | |
| assert!(is_wrong_state( | |
| &claim(&mut l, &child.id, "s2", mins(5)).await | |
| )); | |
| l.complete(&parent.id, "s1", "mem-a-h1").await.unwrap(); | |
| let c = next(&mut l, "s1", mins(5)).await.unwrap(); | |
| assert_eq!(c.task.id, child.id); | |
| } | |
| async fn release_returns_to_pool_and_invalidate_is_terminal(b: &Site) { | |
| let (mut l, _) = b.ledger().await; | |
| let t = admitted(&mut l, spec("Give back")).await; | |
| claim(&mut l, &t.id, "s1", mins(5)).await.unwrap(); | |
| let t = l.release(&t.id, "s1", Some("mem-x-left")).await.unwrap(); | |
| assert_eq!( | |
| t.handoff.as_deref(), | |
| Some("mem-x-left"), | |
| "the handoff rides with the released task" | |
| ); | |
| assert_eq!(t.status, Status::Admitted); | |
| assert!(t.holder.is_none()); | |
| let c = next(&mut l, "s2", mins(5)).await.unwrap(); | |
| assert_eq!(c.task.attempts, 2); | |
| invalidate_is_terminal(&mut l, &t).await; | |
| } | |
| /// A continuation of `parent`, filed by the run that delegated, waiting on `deps`. | |
| fn resume(parent: &Task, deps: &[&Task]) -> TaskSpec { | |
| TaskSpec { | |
| depends_on: deps.iter().map(|d| d.id.clone()).collect(), | |
| origin: Some(continues(&parent.id)), | |
| upstream_event: Some(parent.id.clone()), | |
| ..spec(&format!("Resume: {}", parent.spec.title)) | |
| } | |
| } | |
| /// A parent whose run delegated one child, with a continuation waiting on it, and | |
| /// was released: (parent, child, continuation). | |
| async fn delegated(l: &mut Ledger) -> (Task, Task, Task) { | |
| let parent = admitted(l, spec("Compare two decisions")).await; | |
| claim(l, &parent.id, "s1", mins(5)).await.unwrap(); | |
| let child = admitted(l, spec("Summarise one")).await; | |
| let cont = admitted(l, resume(&parent, &[&child])).await; | |
| l.release(&parent.id, "s1", Some("mem-a-left")) | |
| .await | |
| .unwrap(); | |
| (parent, child, cont) | |
| } | |
| async fn a_task_waits_on_its_continuation(b: &Site) { | |
| let (mut l, _) = b.ledger().await; | |
| let (parent, child, cont) = delegated(&mut l).await; | |
| // back on the board, but nobody's to claim while its work goes on | |
| let c = next(&mut l, "s2", mins(5)).await.unwrap(); | |
| assert_eq!(c.task.id, child.id, "the child, not the parent"); | |
| assert!(is_wrong_state( | |
| &claim(&mut l, &parent.id, "s2", mins(5)).await | |
| )); | |
| let early = l | |
| .complete_by_continuation(&parent.id, &cont.id, "mem-a-h") | |
| .await; | |
| assert!( | |
| is_wrong_state(&early), | |
| "not before the continuation is complete" | |
| ); | |
| } | |
| async fn a_task_closes_with_its_continuation(b: &Site) { | |
| let (mut l, _) = b.ledger().await; | |
| let (parent, child, cont) = delegated(&mut l).await; | |
| claim(&mut l, &child.id, "s2", mins(5)).await.unwrap(); | |
| l.complete(&child.id, "s2", "mem-a-child").await.unwrap(); | |
| claim(&mut l, &cont.id, "s3", mins(5)).await.unwrap(); | |
| l.complete(&cont.id, "s3", "mem-a-done").await.unwrap(); | |
| let by_child = l | |
| .complete_by_continuation(&parent.id, &child.id, "mem-a-h") | |
| .await; | |
| assert!(is_wrong_state(&by_child), "only by a continuation of it"); | |
| let closed = l | |
| .complete_by_continuation(&parent.id, &cont.id, "mem-a-done") | |
| .await | |
| .unwrap(); | |
| assert_eq!(closed.status, Status::Completed); | |
| assert_eq!(closed.handoff.as_deref(), Some("mem-a-done")); | |
| let twice = l | |
| .complete_by_continuation(&parent.id, &cont.id, "mem-a-done") | |
| .await; | |
| assert!(is_wrong_state(&twice)); | |
| } | |
| async fn a_continuation_given_up_on_gives_its_task_back(b: &Site) { | |
| let (mut l, _) = b.ledger().await; | |
| let parent = admitted(&mut l, spec("Compare again")).await; | |
| let dead = admitted(&mut l, resume(&parent, &[])).await; | |
| assert!(is_wrong_state( | |
| &claim(&mut l, &parent.id, "s1", mins(5)).await | |
| )); | |
| l.invalidate(&dead.id, "a dead end", None).await.unwrap(); | |
| claim(&mut l, &parent.id, "s1", mins(5)).await.unwrap(); | |
| } | |
| fn promotion(entry: &str, digest: &str) -> PromotionSpec { | |
| PromotionSpec { | |
| domain: "dom-a".into(), | |
| entry: entry.into(), | |
| tier: TrustTier::Curated, | |
| by: "agent:librarian".into(), | |
| author: "human:test:me".into(), | |
| why: "checked against the tree".into(), | |
| classification: Label::Internal, | |
| digest: digest.into(), | |
| new_digest: format!("{digest}-curated"), | |
| } | |
| } | |
| async fn a_promotion_is_recorded_once_per_version(b: &Site) { | |
| let (mut l, now) = b.ledger().await; | |
| let first = promotion("mem-a-1", "d1"); | |
| assert!(l.record_promotion(&first).await.unwrap(), "a new record"); | |
| let events = l.event_count().await.unwrap(); | |
| assert_eq!(events, 1, "one event for one record"); | |
| assert!( | |
| !l.record_promotion(&first).await.unwrap(), | |
| "the same version again is not new" | |
| ); | |
| assert_eq!( | |
| l.event_count().await.unwrap(), | |
| events, | |
| "and appends nothing" | |
| ); | |
| *now.lock().unwrap() = T0 + mins(1); | |
| a_new_version_is_a_new_record(&mut l, first).await; | |
| } | |
| async fn a_new_version_is_a_new_record(l: &mut Ledger, first: PromotionSpec) { | |
| let again = PromotionSpec { | |
| digest: "d2".into(), | |
| why: "the id came back at raw and was checked again".into(), | |
| ..first.clone() | |
| }; | |
| assert!( | |
| l.record_promotion(&again).await.unwrap(), | |
| "a new version is a new record" | |
| ); | |
| let got = l.promotions("dom-a", "mem-a-1").await.unwrap(); | |
| assert_eq!( | |
| got, | |
| vec![ | |
| PromotionRecord { | |
| spec: first, | |
| why_erased: false, | |
| at: T0 | |
| }, | |
| PromotionRecord { | |
| spec: again, | |
| why_erased: false, | |
| at: T0 + mins(1) | |
| }, | |
| ], | |
| "every field kept, oldest first" | |
| ); | |
| assert!(l.promotions("dom-a", "mem-a-2").await.unwrap().is_empty()); | |
| assert!(l.promotions("dom-b", "mem-a-1").await.unwrap().is_empty()); | |
| } | |
| /// A promotion whose new version never landed leaves its record; a second promoter | |
| /// of the same version is not that record, and is kept with its own why. | |
| async fn a_second_promoter_of_the_same_version_is_recorded(b: &Site) { | |
| let (mut l, _) = b.ledger().await; | |
| let first = promotion("mem-a-1", "d1"); | |
| assert!(l.record_promotion(&first).await.unwrap()); | |
| let second = PromotionSpec { | |
| by: "agent:checker".into(), | |
| why: "read it against the release notes".into(), | |
| ..first.clone() | |
| }; | |
| assert!( | |
| l.record_promotion(&second).await.unwrap(), | |
| "another promoter is another record" | |
| ); | |
| let got: Vec<PromotionSpec> = l | |
| .promotions("dom-a", "mem-a-1") | |
| .await | |
| .unwrap() | |
| .into_iter() | |
| .map(|r| r.spec) | |
| .collect(); | |
| assert_eq!(got, vec![first, second.clone()]); | |
| assert!(!l.record_promotion(&second).await.unwrap(), "once each"); | |
| } | |
| const SECRET: &str = "passphrase-Qz7xx"; | |
| /// Two promotions of `mem-a-1` whose whys quote its secret, one of `mem-a-2`, and one | |
| /// of an entry with the same id in `dom-b`, which is another entry. | |
| async fn promotions_that_quote_a_secret(l: &mut Ledger) -> Vec<PromotionSpec> { | |
| let first = PromotionSpec { | |
| why: format!("the vault opens with {SECRET}, as it says"), | |
| ..promotion("mem-a-1", "d1") | |
| }; | |
| let second = PromotionSpec { | |
| by: "agent:checker".into(), | |
| why: format!("tried {SECRET} on the vault"), | |
| ..first.clone() | |
| }; | |
| let other = promotion("mem-a-2", "d9"); | |
| let elsewhere = PromotionSpec { | |
| domain: "dom-b".into(), | |
| why: "dom-b's own reading of its own entry".into(), | |
| ..promotion("mem-a-1", "d1") | |
| }; | |
| for p in [&first, &second, &other, &elsewhere] { | |
| assert!(l.record_promotion(p).await.unwrap()); | |
| } | |
| vec![first, second, other, elsewhere] | |
| } | |
| /// The records of `entry` in `domain` are exactly `spec`, why and all. | |
| async fn kept_whole(l: &Ledger, domain: &str, entry: &str, spec: &PromotionSpec) { | |
| let got = l.promotions(domain, entry).await.unwrap(); | |
| assert_eq!(got.len(), 1, "{domain}/{entry}: {got:?}"); | |
| assert_eq!(&got[0].spec, spec, "{domain}/{entry} keeps its why"); | |
| assert!(!got[0].why_erased); | |
| } | |
| async fn an_erasure_takes_the_why_and_a_replay_keeps_it_out(b: &Site) { | |
| let (mut l, _) = b.ledger().await; | |
| let specs = promotions_that_quote_a_secret(&mut l).await; | |
| let scrubbed = l.scrub_promotions("dom-a", "mem-a-1").await.unwrap(); | |
| assert_eq!(scrubbed.records, 2); | |
| let kept: Vec<PromotionRecord> = specs[..2] | |
| .iter() | |
| .map(|s| PromotionRecord { | |
| spec: PromotionSpec { | |
| why: String::new(), | |
| ..s.clone() | |
| }, | |
| why_erased: true, | |
| at: T0, | |
| }) | |
| .collect(); | |
| assert_eq!( | |
| l.promotions("dom-a", "mem-a-1").await.unwrap(), | |
| kept, | |
| "the why goes; who, which version, which tier and when stay" | |
| ); | |
| another_entry_and_the_log_and_a_replay(b, &mut l, &specs, &kept).await; | |
| } | |
| async fn another_entry_and_the_log_and_a_replay( | |
| b: &Site, | |
| l: &mut Ledger, | |
| specs: &[PromotionSpec], | |
| kept: &[PromotionRecord], | |
| ) { | |
| kept_whole(l, "dom-a", "mem-a-2", &specs[2]).await; | |
| kept_whole(l, "dom-b", "mem-a-1", &specs[3]).await; | |
| assert!( | |
| !b.payloads().await.iter().any(|p| p.contains(SECRET)), | |
| "the log no longer holds the why" | |
| ); | |
| l.rebuild().await.unwrap(); | |
| assert_eq!( | |
| l.promotions("dom-a", "mem-a-1").await.unwrap(), | |
| kept, | |
| "replay keeps it out" | |
| ); | |
| // and replay brings back every other why, in this domain and the other | |
| kept_whole(l, "dom-a", "mem-a-2", &specs[2]).await; | |
| kept_whole(l, "dom-b", "mem-a-1", &specs[3]).await; | |
| let again = l.scrub_promotions("dom-a", "mem-a-1").await.unwrap(); | |
| assert_eq!(again.records, 2, "running it again counts what is scrubbed"); | |
| } | |
| /// The redaction an erasure of `mem-a-1` gives its tasks. | |
| fn redaction() -> Redaction { | |
| Redaction { | |
| title: "Approve: verified for mem-a-1".into(), | |
| body: "`mem-a-1` was erased.".into(), | |
| reason: "the entry was erased".into(), | |
| } | |
| } | |
| /// A task filed from `origin` on `event` whose title and body quote the secret. | |
| fn task_quoting_the_secret(origin: &str, event: &str) -> TaskSpec { | |
| TaskSpec { | |
| title: format!("Approve: verified for The vault opens with {SECRET}"), | |
| body: format!("proposed because {SECRET} opens it"), | |
| origin: Some(origin.into()), | |
| upstream_event: Some(event.into()), | |
| ..spec("") | |
| } | |
| } | |
| /// Three tasks: an open one and a completed one filed from `promote:mem-a-1`, and an | |
| /// open one filed from `promote:mem-a-2`. | |
| async fn tasks_quoting_the_secret(l: &mut Ledger) -> (Task, Task, Task) { | |
| let open = admitted(l, task_quoting_the_secret("promote:mem-a-1", "verified")).await; | |
| let done = admitted(l, task_quoting_the_secret("promote:mem-a-1", "again")).await; | |
| claim(l, &done.id, "s1", mins(5)).await.unwrap(); | |
| l.complete(&done.id, "s1", "mem-a-1").await.unwrap(); | |
| let other = admitted(l, task_quoting_the_secret("promote:mem-a-2", "verified")).await; | |
| (open, done, other) | |
| } | |
| async fn an_erasure_rewrites_the_tasks_filed_from_an_origin(b: &Site) { | |
| let (mut l, _) = b.ledger().await; | |
| let (open, done, other) = tasks_quoting_the_secret(&mut l).await; | |
| let scrubbed = l | |
| .scrub_tasks("dom-a", "promote:mem-a-1", &redaction()) | |
| .await; | |
| assert_eq!(scrubbed.unwrap(), 2); | |
| the_tasks_hold_the_redaction(&l, &open, &done, &other).await; | |
| let holding = b | |
| .payloads() | |
| .await | |
| .iter() | |
| .filter(|p| p.contains(SECRET)) | |
| .count(); | |
| assert_eq!( | |
| holding, 1, | |
| "the log holds the text in the other entry's task alone" | |
| ); | |
| l.rebuild().await.unwrap(); | |
| the_tasks_hold_the_redaction(&l, &open, &done, &other).await; | |
| let again = l | |
| .scrub_tasks("dom-a", "promote:mem-a-1", &redaction()) | |
| .await; | |
| assert_eq!( | |
| again.unwrap(), | |
| 2, | |
| "running it again counts what holds the redaction" | |
| ); | |
| } | |
| async fn the_tasks_hold_the_redaction(l: &Ledger, open: &Task, done: &Task, other: &Task) { | |
| let open_now = l.get(&open.id).await.unwrap(); | |
| assert_eq!( | |
| open_now.status, | |
| Status::Invalidated, | |
| "an open task is closed" | |
| ); | |
| assert_eq!(open_now.reason.as_deref(), Some("the entry was erased")); | |
| redacted(&open_now); | |
| let done_now = l.get(&done.id).await.unwrap(); | |
| assert_eq!( | |
| done_now.status, | |
| Status::Completed, | |
| "a closed task keeps its status" | |
| ); | |
| assert_eq!( | |
| done_now.handoff.as_deref(), | |
| Some("the entry was erased"), | |
| "the handoff is free text, and goes with the rest" | |
| ); | |
| redacted(&done_now); | |
| let other_now = l.get(&other.id).await.unwrap(); | |
| assert_eq!( | |
| other_now.spec, other.spec, | |
| "another entry's task is untouched" | |
| ); | |
| assert_eq!(other_now.status, Status::Admitted); | |
| } | |
| /// The task holds the redaction and nothing else of the entry, and the rest of it | |
| /// is as it was filed. | |
| fn redacted(t: &Task) { | |
| assert_eq!(t.spec.title, "Approve: verified for mem-a-1"); | |
| assert_eq!(t.spec.body, "`mem-a-1` was erased."); | |
| assert_eq!( | |
| t.spec.origin.as_deref(), | |
| Some("promote:mem-a-1"), | |
| "the origin stays" | |
| ); | |
| assert_eq!(t.spec.created_by, "human:test:me", "who filed it stays"); | |
| assert_eq!(t.created, T0, "and when"); | |
| } | |
| /// The reason a proposal was declined for, and the handoffs left on or closing a | |
| /// task, are free text an approver can quote the entry in: the scrub takes them too, | |
| /// in the events, in the rows and after a replay. | |
| async fn an_erasure_takes_the_reason_and_the_handoff_of_the_tasks_too(b: &Site) { | |
| let (mut l, _) = b.ledger().await; | |
| let declined = admitted( | |
| &mut l, | |
| task_quoting_the_secret("promote:mem-a-1", "verified"), | |
| ) | |
| .await; | |
| l.invalidate( | |
| &declined.id, | |
| &format!("declined: {SECRET} no longer opens it"), | |
| None, | |
| ) | |
| .await | |
| .unwrap(); | |
| let left = admitted(&mut l, task_quoting_the_secret("promote:mem-a-1", "again")).await; | |
| claim(&mut l, &left.id, "s1", mins(5)).await.unwrap(); | |
| l.release(&left.id, "s1", Some(&format!("left: {SECRET}"))) | |
| .await | |
| .unwrap(); | |
| claim(&mut l, &left.id, "s2", mins(5)).await.unwrap(); | |
| l.complete(&left.id, "s2", &format!("done: {SECRET}")) | |
| .await | |
| .unwrap(); | |
| assert_eq!( | |
| l.scrub_tasks("dom-a", "promote:mem-a-1", &redaction()) | |
| .await | |
| .unwrap(), | |
| 2 | |
| ); | |
| the_reason_and_the_handoff_hold_the_redaction(b, &l, &declined, &left).await; | |
| l.rebuild().await.unwrap(); | |
| the_reason_and_the_handoff_hold_the_redaction(b, &l, &declined, &left).await; | |
| } | |
| async fn the_reason_and_the_handoff_hold_the_redaction( | |
| b: &Site, | |
| l: &Ledger, | |
| declined: &Task, | |
| left: &Task, | |
| ) { | |
| let declined_now = l.get(&declined.id).await.unwrap(); | |
| assert_eq!(declined_now.status, Status::Invalidated); | |
| assert_eq!( | |
| declined_now.reason.as_deref(), | |
| Some("the entry was erased"), | |
| "the reason" | |
| ); | |
| let left_now = l.get(&left.id).await.unwrap(); | |
| assert_eq!(left_now.status, Status::Completed); | |
| assert_eq!( | |
| left_now.handoff.as_deref(), | |
| Some("the entry was erased"), | |
| "the handoff" | |
| ); | |
| assert!( | |
| !b.payloads().await.iter().any(|p| p.contains(SECRET)), | |
| "no event holds the text" | |
| ); | |
| } | |
| /// The first ledger file under `dir` whose bytes hold `needle`. | |
| fn file_holding(dir: &Path, needle: &str) -> Option<std::path::PathBuf> { | |
| std::fs::read_dir(dir) | |
| .unwrap() | |
| .flatten() | |
| .map(|f| f.path()) | |
| .find(|p| { | |
| std::fs::read(p) | |
| .unwrap() | |
| .windows(needle.len()) | |
| .any(|w| w == needle.as_bytes()) | |
| }) | |
| } | |
| #[tokio::test] | |
| async fn on_sqlite_an_erased_why_leaves_no_bytes_in_the_files() { | |
| let dir = tempfile::tempdir().unwrap(); | |
| let file = dir.path().join("ledger.db"); | |
| let mut l = Ledger::open(&file).unwrap(); | |
| // long enough to spill onto pages of its own, which an update frees and does not | |
| // overwrite | |
| let why = format!("{SECRET} {}", "and so on ".repeat(900)); | |
| let long = PromotionSpec { | |
| why, | |
| ..promotion("mem-a-1", "d1") | |
| }; | |
| assert!(l.record_promotion(&long).await.unwrap()); | |
| // and buried under later records, so the pages it sat on are deep in the file | |
| for n in 0..40 { | |
| let later = promotion(&format!("mem-a-{}", n + 2), "d1"); | |
| assert!(l.record_promotion(&later).await.unwrap()); | |
| } | |
| // in the file itself, not only in the log | |
| rusqlite::Connection::open(&file) | |
| .unwrap() | |
| .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |r| { | |
| r.get::<_, i64>(0) | |
| }) | |
| .unwrap(); | |
| assert_eq!( | |
| file_holding(dir.path(), SECRET), | |
| Some(file.clone()), | |
| "the file held it" | |
| ); | |
| let scrubbed = l.scrub_promotions("dom-a", "mem-a-1").await.unwrap(); | |
| assert!(scrubbed.beyond_reach.is_empty()); | |
| if let Some(f) = file_holding(dir.path(), SECRET) { | |
| panic!("{} still holds the erased why", f.display()); | |
| } | |
| } | |
| #[test] | |
| fn a_promoted_payload_from_before_the_new_digest_still_parses() { | |
| let old = r#"{"kind":"promoted","domain":"dom-a","entry":"mem-a-1","tier":"curated","by":"agent:librarian","author":"human:test:me","why":"checked","classification":"internal","digest":"d1"}"#; | |
| let Event::Promoted { spec, why_erased } = Event::parse("promotion:mem-a-1", old).unwrap() | |
| else { | |
| panic!("not a promotion"); | |
| }; | |
| assert_eq!(spec.new_digest, ""); | |
| assert!(!why_erased); | |
| } | |
| /// The shapes the promotions table had before this one, each a commit of the branch: | |
| /// 41e0bc5 had neither `new_digest` nor `why_erased` and an index without the | |
| /// promoter; 088f890 and 9db9e03 had `new_digest` and the promoter in the index, and | |
| /// no `why_erased`. | |
| const OLD_SHAPES: [&str; 2] = ["41e0bc5", "088f890"]; | |
| /// The `promotions` table and its index as `shape` made them, in SQLite's dialect | |
| /// (`domain` leads the index) or PostgreSQL's (`{s}` the schema, no `domain`). | |
| fn old_promotions_table(shape: &str, s: Option<&str>) -> String { | |
| let (table, dot) = s.map_or(("promotions".to_string(), String::new()), |s| { | |
| (format!("{s}.promotions"), format!("{s}.")) | |
| }); | |
| let (new_digest, key) = match (shape, s) { | |
| ("41e0bc5", None) => ("", "domain, entry, tier, digest"), | |
| ("41e0bc5", Some(_)) => ("", "entry, tier, digest"), | |
| (_, None) => ( | |
| "new_digest TEXT NOT NULL,", | |
| "domain, entry, tier, digest, promoter", | |
| ), | |
| (_, Some(_)) => ("new_digest TEXT NOT NULL,", "entry, tier, digest, promoter"), | |
| }; | |
| let serial = if s.is_some() { | |
| "BIGSERIAL PRIMARY KEY" | |
| } else { | |
| "INTEGER PRIMARY KEY AUTOINCREMENT" | |
| }; | |
| let new_digest_value = if new_digest.is_empty() { "" } else { "'', " }; | |
| let new_digest_column = if new_digest.is_empty() { | |
| "" | |
| } else { | |
| "new_digest, " | |
| }; | |
| format!( | |
| "CREATE TABLE {table} (seq {serial}, | |
| 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} at TEXT NOT NULL); | |
| CREATE UNIQUE INDEX promotions_version ON {table} ({key}); | |
| INSERT INTO {dot}events (task_id, kind, at, payload) VALUES ('promotion:mem-a-1', | |
| 'promoted', '2026-10-01T10:00:00Z', | |
| '{{\"kind\":\"promoted\",\"domain\":\"dom-a\",\"entry\":\"mem-a-1\",\"tier\":\"curated\",\"by\":\"agent:librarian\",\"author\":\"human:test:me\",\"why\":\"checked\",\"classification\":\"internal\",\"digest\":\"d1\"}}'); | |
| INSERT INTO {table} (domain, entry, tier, promoter, author, why, label_rank, | |
| digest, {new_digest_column}at) VALUES ('dom-a', 'mem-a-1', 'curated', 'agent:librarian', | |
| 'human:test:me', 'checked', 1, 'd1', {new_digest_value}'2026-10-01T10:00:00Z');" | |
| ) | |
| } | |
| /// A ledger file whose promotions table is as `shape` made it, holding one record. | |
| fn ledger_of_shape(file: &Path, shape: &str) { | |
| let c = rusqlite::Connection::open(file).unwrap(); | |
| c.execute_batch(&format!( | |
| "CREATE TABLE events (seq INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL, | |
| kind TEXT NOT NULL, at TEXT NOT NULL, payload TEXT NOT NULL); | |
| {}", | |
| old_promotions_table(shape, None) | |
| )) | |
| .unwrap(); | |
| } | |
| #[tokio::test] | |
| async fn on_sqlite_a_ledger_of_each_old_shape_is_migrated() { | |
| for shape in OLD_SHAPES { | |
| let dir = tempfile::tempdir().unwrap(); | |
| let file = dir.path().join("ledger.db"); | |
| ledger_of_shape(&file, shape); | |
| let l = Ledger::open(&file).unwrap(); | |
| the_migrated_ledger_works(l, shape).await; | |
| } | |
| } | |
| /// Reading, a second promoter of the old version, a rebuild and a scrub all work on | |
| /// a ledger of an old shape once it is opened. | |
| async fn the_migrated_ledger_works(mut l: Ledger, shape: &str) { | |
| let old = l | |
| .promotions("dom-a", "mem-a-1") | |
| .await | |
| .unwrap_or_else(|e| panic!("{shape}: {e}")); | |
| assert_eq!(old.len(), 1, "{shape}"); | |
| assert_eq!(old[0].spec.new_digest, "", "{shape}"); | |
| assert!(!old[0].why_erased, "{shape}"); | |
| let second = PromotionSpec { | |
| by: "agent:checker".into(), | |
| ..promotion("mem-a-1", "d1") | |
| }; | |
| assert!( | |
| l.record_promotion(&second) | |
| .await | |
| .unwrap_or_else(|e| panic!("{shape}: {e}")), | |
| "{shape}: the index has the promoter now" | |
| ); | |
| assert_eq!(l.rebuild().await.unwrap(), 2, "{shape}"); | |
| assert_eq!( | |
| l.promotions("dom-a", "mem-a-1").await.unwrap().len(), | |
| 2, | |
| "{shape}" | |
| ); | |
| let scrubbed = l.scrub_promotions("dom-a", "mem-a-1").await; | |
| assert_eq!( | |
| scrubbed.unwrap_or_else(|e| panic!("{shape}: {e}")).records, | |
| 2, | |
| "{shape}" | |
| ); | |
| } | |
| async fn a_restored_record_keeps_its_time_and_its_erased_why(b: &Site) { | |
| let (mut l, _) = b.ledger().await; | |
| let then = T0 - Duration::days(30); | |
| let records = [ | |
| PromotionRecord { | |
| spec: promotion("mem-a-1", "d1"), | |
| why_erased: false, | |
| at: then, | |
| }, | |
| PromotionRecord { | |
| spec: PromotionSpec { | |
| why: String::new(), | |
| ..promotion("mem-a-1", "d0") | |
| }, | |
| why_erased: true, | |
| at: then - Duration::days(1), | |
| }, | |
| ]; | |
| for r in &records { | |
| assert!(l.restore_promotion(r).await.unwrap()); | |
| assert!(!l.restore_promotion(r).await.unwrap(), "once"); | |
| } | |
| assert_eq!(l.promotions("dom-a", "mem-a-1").await.unwrap(), records); | |
| l.rebuild().await.unwrap(); | |
| assert_eq!( | |
| l.promotions("dom-a", "mem-a-1").await.unwrap(), | |
| records, | |
| "a replay keeps both" | |
| ); | |
| } | |
| async fn a_promotion_record_is_no_task(b: &Site) { | |
| let mut l = b.open().await; | |
| let parent = admitted(&mut l, spec("A task beside it")).await; | |
| assert!(l | |
| .record_promotion(&promotion("mem-a-1", "d1")) | |
| .await | |
| .unwrap()); | |
| only_the_task_is_listed(&l, &parent).await; | |
| no_task_query_finds_the_record(&mut l, &parent).await; | |
| } | |
| async fn only_the_task_is_listed(l: &Ledger, parent: &Task) { | |
| for st in [ | |
| Status::Proposed, | |
| Status::Admitted, | |
| Status::Claimed, | |
| Status::Completed, | |
| Status::Invalidated, | |
| ] { | |
| let ids: Vec<String> = l | |
| .list("dom-a", st) | |
| .await | |
| .unwrap() | |
| .into_iter() | |
| .map(|t| t.id) | |
| .collect(); | |
| let expected = if st == Status::Admitted { | |
| vec![parent.id.clone()] | |
| } else { | |
| Vec::new() | |
| }; | |
| assert_eq!(ids, expected, "{st:?}"); | |
| } | |
| } | |
| async fn no_task_query_finds_the_record(l: &mut Ledger, parent: &Task) { | |
| assert!(l.unposted("dom-a").await.unwrap().is_empty()); | |
| assert!(l.dependents("promotion:mem-a-1").await.unwrap().is_empty()); | |
| assert!(l.filed_from("mem-a-1").await.unwrap().is_empty()); | |
| assert!(matches!( | |
| l.get("promotion:mem-a-1").await, | |
| Err(Error::NotFound(_)) | |
| )); | |
| let c = next(l, "s1", mins(5)).await.unwrap(); | |
| assert_eq!(c.task.id, parent.id); | |
| assert!( | |
| next(l, "s1", mins(5)).await.is_none(), | |
| "nothing else to claim" | |
| ); | |
| } | |
| async fn replay_keeps_the_promotions(b: &Site) { | |
| let mut l = b.open().await; | |
| admitted(&mut l, spec("A task beside it")).await; | |
| l.record_promotion(&promotion("mem-a-1", "d1")) | |
| .await | |
| .unwrap(); | |
| l.record_promotion(&promotion("mem-a-1", "d2")) | |
| .await | |
| .unwrap(); | |
| let before = l.promotions("dom-a", "mem-a-1").await.unwrap(); | |
| assert_eq!(before.len(), 2); | |
| assert_eq!(l.rebuild().await.unwrap(), 4); | |
| assert_eq!(l.promotions("dom-a", "mem-a-1").await.unwrap(), before); | |
| assert!( | |
| !l.record_promotion(&promotion("mem-a-1", "d1")) | |
| .await | |
| .unwrap(), | |
| "replay keeps what makes a record new" | |
| ); | |
| assert_eq!(l.list("dom-a", Status::Admitted).await.unwrap().len(), 1); | |
| } | |
| async fn invalidate_is_terminal(l: &mut Ledger, t: &Task) { | |
| let t = l | |
| .invalidate(&t.id, "duplicate", Some("task-a-other")) | |
| .await | |
| .unwrap(); | |
| assert_eq!(t.status, Status::Invalidated); | |
| assert_eq!(t.reason.as_deref(), Some("duplicate")); | |
| assert_eq!(t.superseded_by.as_deref(), Some("task-a-other")); | |
| assert!( | |
| is_wrong_state(&l.heartbeat(&t.id, "s2", mins(5)).await), | |
| "holder learns at heartbeat" | |
| ); | |
| assert!(is_wrong_state(&l.invalidate(&t.id, "again", None).await)); | |
| assert!(next(l, "s3", mins(5)).await.is_none()); | |
| } | |
| async fn origin_and_upstream_event_dedup_a_second_filing(b: &Site) { | |
| let (mut l, _) = b.ledger().await; | |
| let mut s = spec("From an issue"); | |
| s.origin = Some("github:apache/x#12".into()); | |
| s.upstream_event = Some("labeled:2026-10-01T09:00:00Z".into()); | |
| let first = l.create(s.clone()).await.unwrap(); | |
| assert!(!first.deduplicated); | |
| let again = l.create(s.clone()).await.unwrap(); | |
| assert!(again.deduplicated); | |
| assert_eq!(again.task.id, first.task.id); | |
| the_pair_finds_the_task(&l, &first.task).await; | |
| s.upstream_event = Some("labeled:2026-10-02T09:00:00Z".into()); | |
| let other = l.create(s).await.unwrap(); | |
| assert!(!other.deduplicated, "a new upstream event is a new task"); | |
| assert_ne!(other.task.id, first.task.id); | |
| assert_eq!( | |
| l.event_count().await.unwrap(), | |
| 2, | |
| "the duplicate appended nothing" | |
| ); | |
| dedup_is_scoped_to_the_domain(&mut l, &first.task).await; | |
| } | |
| /// The pair names the task create would return, and a pair never filed names none. | |
| async fn the_pair_finds_the_task(l: &Ledger, first: &Task) { | |
| let dom = first.spec.domain.as_str(); | |
| let found = l | |
| .by_origin(dom, "github:apache/x#12", "labeled:2026-10-01T09:00:00Z") | |
| .await | |
| .unwrap(); | |
| assert_eq!(found.map(|t| t.id), Some(first.id.clone())); | |
| let none = l | |
| .by_origin(dom, "github:apache/x#12", "labeled:2026-10-02T09:00:00Z") | |
| .await | |
| .unwrap(); | |
| assert!(none.is_none(), "{none:?}"); | |
| let all = l.with_origin(dom, "github:apache/x#12").await.unwrap(); | |
| assert_eq!( | |
| all.iter().map(|t| t.id.as_str()).collect::<Vec<_>>(), | |
| vec![first.id.as_str()] | |
| ); | |
| assert!(l | |
| .with_origin(dom, "github:apache/x#13") | |
| .await | |
| .unwrap() | |
| .is_empty()); | |
| } | |
| /// The same issue filed into another domain is that domain's task: dedup is scoped | |
| /// to the domain, since one domain's ledger says nothing about another's. | |
| async fn dedup_is_scoped_to_the_domain(l: &mut Ledger, first: &Task) { | |
| let mut elsewhere = spec("From the same issue, elsewhere"); | |
| elsewhere.domain = "dom-b".into(); | |
| elsewhere.origin = Some("github:apache/x#12".into()); | |
| elsewhere.upstream_event = Some("labeled:2026-10-01T09:00:00Z".into()); | |
| let theirs = l.create(elsewhere.clone()).await.unwrap(); | |
| assert!( | |
| !theirs.deduplicated, | |
| "another domain's filing is not a duplicate" | |
| ); | |
| assert_ne!(theirs.task.id, first.id); | |
| assert_eq!(theirs.task.spec.domain, "dom-b"); | |
| assert!(l.create(elsewhere).await.unwrap().deduplicated); | |
| assert_eq!(l.event_count().await.unwrap(), 3); | |
| } | |
| async fn what_was_filed_from_a_task_is_found_by_its_origin(b: &Site) { | |
| let mut l = b.open().await; | |
| let parent = l.create(spec("parent")).await.unwrap().task; | |
| let mut child = spec("child"); | |
| child.origin = Some(format!("task:{}", parent.id)); | |
| child.upstream_event = Some("h1".into()); | |
| let child = l.create(child).await.unwrap().task; | |
| let mut cont = spec("resume"); | |
| cont.origin = Some(format!("continues:{}", parent.id)); | |
| cont.upstream_event = Some("h2".into()); | |
| cont.depends_on = vec![child.id.clone()]; | |
| let cont = l.create(cont).await.unwrap().task; | |
| let mut other = spec("other"); | |
| other.origin = Some("task:elsewhere".into()); | |
| other.upstream_event = Some("h3".into()); | |
| l.create(other).await.unwrap(); | |
| let filed = l.filed_from(&parent.id).await.unwrap(); | |
| assert_eq!( | |
| filed.iter().map(|t| t.id.as_str()).collect::<Vec<_>>(), | |
| vec![child.id.as_str(), cont.id.as_str()] | |
| ); | |
| assert!(l.filed_from(&child.id).await.unwrap().is_empty()); | |
| } | |
| async fn every_operation_appends_one_event_and_replay_rebuilds_the_projection(b: &Site) { | |
| let (mut l, now) = b.ledger().await; | |
| let (a, b_task) = nine_events(&mut l, &now).await; | |
| let before = get_all(&l, &[&a.id, &b_task.id]).await; | |
| assert_eq!(l.rebuild().await.unwrap(), 9); | |
| let after = get_all(&l, &[&a.id, &b_task.id]).await; | |
| assert_eq!( | |
| before, after, | |
| "the projection is a pure function of the events" | |
| ); | |
| assert_eq!(after[0].attempts, 2); | |
| assert_eq!(after[0].status, Status::Completed); | |
| assert_eq!(after[1].status, Status::Invalidated); | |
| } | |
| /// One task through every operation and a second one invalidated: nine events. | |
| async fn nine_events(l: &mut Ledger, now: &Mutex<OffsetDateTime>) -> (Task, Task) { | |
| let a = l.create(spec("A")).await.unwrap().task; | |
| l.admit(&a.id).await.unwrap(); | |
| claim(l, &a.id, "s1", mins(5)).await.unwrap(); | |
| *now.lock().unwrap() = T0 + mins(2); | |
| l.heartbeat(&a.id, "s1", mins(5)).await.unwrap(); | |
| l.release(&a.id, "s1", None).await.unwrap(); | |
| claim(l, &a.id, "s2", mins(5)).await.unwrap(); | |
| l.complete(&a.id, "s2", "mem-a-h").await.unwrap(); | |
| let b = l.create(spec("B")).await.unwrap().task; | |
| l.invalidate(&b.id, "obsolete", None).await.unwrap(); | |
| assert_eq!(l.event_count().await.unwrap(), 9); | |
| (a, b) | |
| } | |
| async fn not_found_and_persistence(b: &Site) { | |
| let (l, _) = b.ledger().await; | |
| assert!(matches!( | |
| l.get("task-a-none").await, | |
| Err(Error::NotFound(_)) | |
| )); | |
| drop(l); | |
| let id = { | |
| let mut l = b.open().await; | |
| admitted(&mut l, spec("Persist")).await.id | |
| }; | |
| let l = b.open().await; | |
| assert_eq!(l.get(&id).await.unwrap().status, Status::Admitted); | |
| assert_eq!(l.list("dom-a", Status::Admitted).await.unwrap().len(), 1); | |
| } | |
| async fn a_completed_task_from_upstream_is_posted_back_once(b: &Site) { | |
| let (mut l, _) = b.ledger().await; | |
| let mut s = spec("From an issue"); | |
| s.origin = Some("github:acme/widgets#12".into()); | |
| s.upstream_event = Some("open:2026-10-01T09:00:00Z".into()); | |
| let from_upstream = l.create(s).await.unwrap().task; | |
| let local = l.create(spec("Filed by hand")).await.unwrap().task; | |
| for t in [&from_upstream, &local] { | |
| l.admit(&t.id).await.unwrap(); | |
| claim(&mut l, &t.id, "s1", mins(5)).await.unwrap(); | |
| } | |
| assert!( | |
| l.unposted("dom-a").await.unwrap().is_empty(), | |
| "nothing is done yet" | |
| ); | |
| assert!(is_wrong_state( | |
| &l.post(&from_upstream.id, "https://x/1").await | |
| )); | |
| let posted = only_upstream_is_due_and_posted_once(&mut l, &from_upstream, &local).await; | |
| replay_keeps(&mut l, &posted).await; | |
| } | |
| async fn replay_keeps(l: &mut Ledger, task: &Task) { | |
| let events = l.event_count().await.unwrap(); | |
| l.rebuild().await.unwrap(); | |
| assert_eq!(l.event_count().await.unwrap(), events); | |
| assert_eq!(&l.get(&task.id).await.unwrap(), task, "replay keeps it"); | |
| } | |
| async fn only_upstream_is_due_and_posted_once( | |
| l: &mut Ledger, | |
| from_upstream: &Task, | |
| local: &Task, | |
| ) -> Task { | |
| l.complete(&from_upstream.id, "s1", "mem-a-h1") | |
| .await | |
| .unwrap(); | |
| l.complete(&local.id, "s1", "mem-a-h2").await.unwrap(); | |
| let due: Vec<String> = l | |
| .unposted("dom-a") | |
| .await | |
| .unwrap() | |
| .into_iter() | |
| .map(|t| t.id) | |
| .collect(); | |
| assert_eq!( | |
| due, | |
| vec![from_upstream.id.clone()], | |
| "only what came from upstream" | |
| ); | |
| assert!(l.unposted("dom-b").await.unwrap().is_empty()); | |
| let posted = l | |
| .post( | |
| &from_upstream.id, | |
| "https://github.com/acme/widgets/issues/12#c1", | |
| ) | |
| .await | |
| .unwrap(); | |
| assert_eq!( | |
| posted.posted_to.as_deref(), | |
| Some("https://github.com/acme/widgets/issues/12#c1") | |
| ); | |
| assert!(l.unposted("dom-a").await.unwrap().is_empty()); | |
| assert!(is_wrong_state( | |
| &l.post(&from_upstream.id, "https://x/2").await | |
| )); | |
| posted | |
| } | |
| /// Two ledgers over the same tables, as two sidecars on one server would be, each | |
| /// claiming: one holder a task, and nothing claimed twice. | |
| async fn two_claimers_on_two_connections_get_two_tasks(b: &Site) { | |
| let (mut l1, _) = b.ledger().await; | |
| let (mut l2, _) = b.ledger().await; | |
| let first = admitted(&mut l1, spec("First")).await; | |
| let second = admitted(&mut l1, spec("Second")).await; | |
| let (c1, c2) = tokio::join!( | |
| l1.claim_next("dom-a", "s1", mins(5), &[], Label::Restricted), | |
| l2.claim_next("dom-a", "s2", mins(5), &[], Label::Restricted), | |
| ); | |
| let c1 = c1.unwrap().unwrap(); | |
| let c2 = c2.unwrap().unwrap(); | |
| assert_ne!(c1.task.id, c2.task.id, "nothing claimed twice"); | |
| let mut ids = vec![c1.task.id.clone(), c2.task.id.clone()]; | |
| ids.sort(); | |
| let mut expected = vec![first.id, second.id]; | |
| expected.sort(); | |
| assert_eq!(ids, expected); | |
| assert!(next(&mut l1, "s3", mins(5)).await.is_none()); | |
| } | |
| /// Two domains, one ledger: each claims only from its own queue, sees only its own | |
| /// tasks listed, and finds either task by id. | |
| async fn a_domain_claims_only_its_own_queue(b: &Site) { | |
| let (mut l, _) = b.ledger().await; | |
| let a = admitted(&mut l, spec("In a")).await; | |
| let other = admitted( | |
| &mut l, | |
| TaskSpec { | |
| domain: "dom-b".into(), | |
| ..spec("In b") | |
| }, | |
| ) | |
| .await; | |
| let listed = l.list("dom-b", Status::Admitted).await.unwrap(); | |
| assert_eq!( | |
| listed.iter().map(|t| &t.id).collect::<Vec<_>>(), | |
| vec![&other.id] | |
| ); | |
| let got = l | |
| .claim_next("dom-b", "s", mins(5), &[], Label::Restricted) | |
| .await | |
| .unwrap() | |
| .unwrap(); | |
| assert_eq!(got.task.id, other.id); | |
| assert!(l | |
| .claim_next("dom-b", "s", mins(5), &[], Label::Restricted) | |
| .await | |
| .unwrap() | |
| .is_none()); | |
| assert_eq!(l.get(&a.id).await.unwrap().status, Status::Admitted); | |
| a_domain_with_no_tasks_has_an_empty_queue(&mut l).await; | |
| } | |
| async fn a_domain_with_no_tasks_has_an_empty_queue(l: &mut Ledger) { | |
| assert!(l | |
| .list("dom-none", Status::Admitted) | |
| .await | |
| .unwrap() | |
| .is_empty()); | |
| assert!(l | |
| .claim_next("dom-none", "s", mins(5), &[], Label::Restricted) | |
| .await | |
| .unwrap() | |
| .is_none()); | |
| } | |
| async fn pg_client(server: &PgServer) -> tokio_postgres::Client { | |
| let (client, conn) = tokio_postgres::connect(server.url(), tokio_postgres::NoTls) | |
| .await | |
| .unwrap(); | |
| tokio::spawn(conn); | |
| client | |
| } | |
| #[tokio::test] | |
| async fn on_postgres_each_domain_has_a_schema_of_its_own() { | |
| let Some(server) = PgServer::start().await.unwrap() else { | |
| return; | |
| }; | |
| let mut l = Ledger::connect(server.url()).await.unwrap(); | |
| let a = admitted(&mut l, spec("In a")).await; | |
| let b = admitted( | |
| &mut l, | |
| TaskSpec { | |
| domain: "dom-b".into(), | |
| ..spec("In b") | |
| }, | |
| ) | |
| .await; | |
| let c = pg_client(&server).await; | |
| let count = |schema: String, id: String| { | |
| let c = &c; | |
| async move { | |
| let row = c | |
| .query_one( | |
| &format!("SELECT COUNT(*) FROM {schema}.tasks WHERE id = $1"), | |
| &[&id], | |
| ) | |
| .await | |
| .unwrap(); | |
| row.get::<_, i64>(0) | |
| } | |
| }; | |
| let sa = crate::postgres::schema_for("dom-a"); | |
| let sb = crate::postgres::schema_for("dom-b"); | |
| assert_ne!(sa, sb); | |
| assert_eq!(count(sa.clone(), a.id.clone()).await, 1); | |
| assert_eq!(count(sb.clone(), b.id.clone()).await, 1); | |
| assert_eq!( | |
| count(sa, b.id.clone()).await, | |
| 0, | |
| "dom-b's task is not in dom-a's schema" | |
| ); | |
| assert_eq!(count(sb, a.id.clone()).await, 0); | |
| assert!( | |
| c.query_one("SELECT to_regclass('public.tasks') IS NULL", &[]) | |
| .await | |
| .unwrap() | |
| .get::<_, bool>(0), | |
| "no shared tasks table" | |
| ); | |
| // a schema name is readable and does not collide for domains that read alike | |
| assert!(crate::postgres::schema_for("dom-a").starts_with("\"ledger_dom_a_")); | |
| assert_ne!( | |
| crate::postgres::schema_for("dom-a.b"), | |
| crate::postgres::schema_for("dom-a_b") | |
| ); | |
| } | |
| #[tokio::test] | |
| async fn on_postgres_a_ledger_from_before_domain_schemas_moves_into_them() { | |
| let Some(server) = PgServer::start().await.unwrap() else { | |
| return; | |
| }; | |
| let c = pg_client(&server).await; | |
| // the shared tables as the first PostgreSQL ledger made them, with one task in | |
| // each of two domains, one of them admitted | |
| c.batch_execute( | |
| "CREATE TABLE public.events (seq BIGSERIAL PRIMARY KEY, task_id TEXT NOT NULL, | |
| kind TEXT NOT NULL, at TEXT NOT NULL, payload TEXT NOT NULL); | |
| CREATE TABLE public.tasks (id TEXT PRIMARY KEY, domain TEXT NOT NULL);", | |
| ) | |
| .await | |
| .unwrap(); | |
| let mut ids = Vec::new(); | |
| for (domain, admit) in [("dom-a", true), ("dom-b", false)] { | |
| let id = dm_core::id::new_task_id(domain); | |
| let created = Event::Created { | |
| id: id.clone(), | |
| spec: TaskSpec { | |
| domain: domain.into(), | |
| ..spec("Old") | |
| }, | |
| }; | |
| let mut events = vec![created]; | |
| if admit { | |
| events.push(Event::Admitted); | |
| } | |
| for e in &events { | |
| c.execute( | |
| "INSERT INTO public.events (task_id, kind, at, payload) VALUES ($1, $2, $3, $4)", | |
| &[&id, &e.kind(), &fmt_time(T0), &e.payload(&id).unwrap()], | |
| ) | |
| .await | |
| .unwrap(); | |
| } | |
| c.execute( | |
| "INSERT INTO public.tasks (id, domain) VALUES ($1, $2)", | |
| &[&id, &domain], | |
| ) | |
| .await | |
| .unwrap(); | |
| ids.push(id); | |
| } | |
| let l = Ledger::connect(server.url()).await.unwrap(); | |
| assert_eq!(l.get(&ids[0]).await.unwrap().status, Status::Admitted); | |
| assert_eq!(l.get(&ids[1]).await.unwrap().status, Status::Proposed); | |
| assert_eq!(l.get(&ids[1]).await.unwrap().spec.domain, "dom-b"); | |
| assert_eq!(l.event_count().await.unwrap(), 3); | |
| let kept = c | |
| .query_one( | |
| "SELECT to_regclass('public.events_before_domain_schemas') IS NOT NULL | |
| AND to_regclass('public.tasks') IS NULL", | |
| &[], | |
| ) | |
| .await | |
| .unwrap(); | |
| assert!( | |
| kept.get::<_, bool>(0), | |
| "the old tables are renamed, not dropped" | |
| ); | |
| // and a second connect finds nothing left to move | |
| let again = Ledger::connect(server.url()).await.unwrap(); | |
| assert_eq!(again.event_count().await.unwrap(), 3); | |
| } | |
| /// A domain whose schema was made before promotions were recorded has no table for | |
| /// them; connecting adds it, and a promotion there is kept. | |
| #[tokio::test] | |
| async fn on_postgres_a_domain_from_before_promotions_gets_their_table() { | |
| let Some(server) = PgServer::start().await.unwrap() else { | |
| return; | |
| }; | |
| let mut l = Ledger::connect(server.url()).await.unwrap(); | |
| let t = admitted(&mut l, spec("Old")).await; | |
| drop(l); | |
| let s = crate::postgres::schema_for("dom-a"); | |
| let c = pg_client(&server).await; | |
| c.batch_execute(&format!("DROP TABLE IF EXISTS {s}.promotions")) | |
| .await | |
| .unwrap(); | |
| let mut l = Ledger::connect(server.url()).await.unwrap(); | |
| assert!(l | |
| .record_promotion(&promotion("mem-a-1", "d1")) | |
| .await | |
| .unwrap()); | |
| assert_eq!(l.promotions("dom-a", "mem-a-1").await.unwrap().len(), 1); | |
| assert_eq!(l.get(&t.id).await.unwrap().status, Status::Admitted); | |
| let routed = c | |
| .query_one( | |
| "SELECT COUNT(*) FROM public.ledger_tasks WHERE id LIKE 'promotion:%'", | |
| &[], | |
| ) | |
| .await | |
| .unwrap(); | |
| assert_eq!( | |
| routed.get::<_, i64>(0), | |
| 0, | |
| "a record is not routed as a task" | |
| ); | |
| } | |
| /// A connected ledger on a server whose `dom-a` schema has a promotions table of | |
| /// `shape`, holding the one old record and no other event, and a client on the server. | |
| async fn pg_domain_of_shape( | |
| server: &PgServer, | |
| shape: &str, | |
| ) -> (Ledger, tokio_postgres::Client) { | |
| let mut l = Ledger::connect(server.url()).await.unwrap(); | |
| admitted(&mut l, spec("Makes the schema")).await; | |
| drop(l); | |
| let s = crate::postgres::schema_for("dom-a"); | |
| let c = pg_client(server).await; | |
| c.batch_execute(&format!( | |
| "DROP TABLE {s}.promotions; DELETE FROM {s}.events; DELETE FROM {s}.tasks; | |
| DELETE FROM public.ledger_tasks; {}", | |
| old_promotions_table(shape, Some(&s)) | |
| )) | |
| .await | |
| .unwrap(); | |
| (Ledger::connect(server.url()).await.unwrap(), c) | |
| } | |
| /// A domain schema of each old shape is migrated when the ledger connects. | |
| #[tokio::test] | |
| async fn on_postgres_a_domain_of_each_old_shape_is_migrated_at_connect() { | |
| for shape in OLD_SHAPES { | |
| let Some(server) = PgServer::start().await.unwrap() else { | |
| return; | |
| }; | |
| let (l, _c) = pg_domain_of_shape(&server, shape).await; | |
| the_migrated_ledger_works(l, shape).await; | |
| } | |
| } | |
| /// A domain schema of an old shape made after this ledger connected is migrated where | |
| /// a record, a rebuild or a scrub reaches it. | |
| #[tokio::test] | |
| async fn on_postgres_a_domain_of_an_old_shape_made_after_connect_is_migrated() { | |
| for shape in OLD_SHAPES { | |
| let Some(server) = PgServer::start().await.unwrap() else { | |
| return; | |
| }; | |
| let (mut l, c) = pg_domain_of_shape(&server, shape).await; | |
| let s = crate::postgres::schema_for("dom-a"); | |
| let back_to_old = format!( | |
| "DROP TABLE {s}.promotions; DELETE FROM {s}.events; {}", | |
| old_promotions_table(shape, Some(&s)) | |
| ); | |
| c.batch_execute(&back_to_old).await.unwrap(); | |
| let second = PromotionSpec { | |
| by: "agent:checker".into(), | |
| ..promotion("mem-a-1", "d1") | |
| }; | |
| assert!( | |
| l.record_promotion(&second).await.unwrap(), | |
| "{shape}: record" | |
| ); | |
| assert_eq!( | |
| l.promotions("dom-a", "mem-a-1").await.unwrap().len(), | |
| 2, | |
| "{shape}" | |
| ); | |
| c.batch_execute(&back_to_old).await.unwrap(); | |
| assert_eq!(l.rebuild().await.unwrap(), 1, "{shape}: rebuild"); | |
| assert_eq!( | |
| l.promotions("dom-a", "mem-a-1").await.unwrap().len(), | |
| 1, | |
| "{shape}" | |
| ); | |
| c.batch_execute(&back_to_old).await.unwrap(); | |
| let scrubbed = l.scrub_promotions("dom-a", "mem-a-1").await; | |
| assert_eq!(scrubbed.unwrap().records, 1, "{shape}: scrub"); | |
| } | |
| } | |
| /// Connecting migrates only a schema that lacks the promotions table, so a domain | |
| /// another process holds does not hold up the connect. | |
| #[tokio::test] | |
| async fn on_postgres_connect_waits_on_no_domain_that_has_its_table() { | |
| let Some(server) = PgServer::start().await.unwrap() else { | |
| return; | |
| }; | |
| let mut l = Ledger::connect(server.url()).await.unwrap(); | |
| admitted(&mut l, spec("In a")).await; | |
| drop(l); | |
| let holder = pg_client(&server).await; | |
| holder | |
| .execute( | |
| "SELECT pg_advisory_lock(hashtext('ledger_domain:' || $1))", | |
| &[&"dom-a"], | |
| ) | |
| .await | |
| .unwrap(); | |
| let connected = tokio::time::timeout( | |
| std::time::Duration::from_secs(5), | |
| Ledger::connect(server.url()), | |
| ) | |
| .await; | |
| assert!( | |
| connected.is_ok(), | |
| "connect waited on a domain that needed nothing" | |
| ); | |
| } | |
| /// A domain schema an older binary made after this ledger connected has no | |
| /// promotions table; reading, recording and rebuilding there still work. | |
| #[tokio::test] | |
| async fn on_postgres_a_schema_without_the_table_made_after_connect_still_works() { | |
| let Some(server) = PgServer::start().await.unwrap() else { | |
| return; | |
| }; | |
| let mut l = Ledger::connect(server.url()).await.unwrap(); | |
| admitted(&mut l, spec("In a")).await; | |
| let c = pg_client(&server).await; | |
| let drop_table = format!( | |
| "DROP TABLE {}.promotions", | |
| crate::postgres::schema_for("dom-a") | |
| ); | |
| c.batch_execute(&drop_table).await.unwrap(); | |
| assert!(l.promotions("dom-a", "mem-a-1").await.unwrap().is_empty()); | |
| assert!(l | |
| .record_promotion(&promotion("mem-a-1", "d1")) | |
| .await | |
| .unwrap()); | |
| c.batch_execute(&drop_table).await.unwrap(); | |
| assert_eq!(l.rebuild().await.unwrap(), 3); | |
| assert_eq!(l.promotions("dom-a", "mem-a-1").await.unwrap().len(), 1); | |
| } | |
| #[tokio::test] | |
| async fn every_transition_is_a_span_with_the_task_as_it_stands() { | |
| use opentelemetry::Value; | |
| use opentelemetry_sdk::trace::{InMemorySpanExporter, SdkTracerProvider}; | |
| let exporter = InMemorySpanExporter::default(); | |
| let provider = SdkTracerProvider::builder() | |
| .with_simple_exporter(exporter.clone()) | |
| .build(); | |
| let _guard = dm_otel::install(provider); | |
| let mut l = Ledger::in_memory().unwrap(); | |
| let t = admitted(&mut l, spec("Traced")).await; | |
| let c = l | |
| .claim_next("dom-a", "agent:x", mins(5), &[], Label::Restricted) | |
| .await | |
| .unwrap() | |
| .unwrap(); | |
| l.complete(&c.task.id, "agent:x", "mem-a-handoff") | |
| .await | |
| .unwrap(); | |
| let spans: Vec<_> = exporter | |
| .get_finished_spans() | |
| .unwrap() | |
| .into_iter() | |
| .filter(|sp| { | |
| sp.attributes | |
| .iter() | |
| .any(|kv| kv.key.as_str() == "task.id" && kv.value.as_str() == t.id.as_str()) | |
| }) | |
| .collect(); | |
| let names: Vec<String> = spans.iter().map(|sp| sp.name.to_string()).collect(); | |
| assert_eq!( | |
| names, | |
| vec![ | |
| "task.created", | |
| "task.admitted", | |
| "task.claimed", | |
| "task.completed" | |
| ] | |
| ); | |
| let attr = |i: usize, k: &str| { | |
| spans[i] | |
| .attributes | |
| .iter() | |
| .find(|kv| kv.key.as_str() == k) | |
| .map(|kv| kv.value.clone()) | |
| }; | |
| assert_eq!(attr(0, "task.status"), Some(Value::from("proposed"))); | |
| assert_eq!(attr(2, "task.holder"), Some(Value::from("agent:x"))); | |
| assert_eq!(attr(2, "task.attempts"), Some(Value::I64(1))); | |
| assert_eq!(attr(3, "task.status"), Some(Value::from("completed"))); | |
| assert_eq!(attr(3, "memory.domain"), Some(Value::from("dom-a"))); | |
| } | |
| async fn a_rescore_moves_the_queue_and_keeps_the_filed_priority(b: &Site) { | |
| let (mut l, _) = b.ledger().await; | |
| let first = admitted(&mut l, spec("First")).await; | |
| let second = admitted(&mut l, spec("Second")).await; | |
| let moved = l | |
| .rescore(&second.id, 7, "unblocks a ranked task") | |
| .await | |
| .unwrap(); | |
| assert_eq!(moved.spec.priority, 7); | |
| assert_eq!( | |
| l.filed_priority(&second.id).await.unwrap(), | |
| 0, | |
| "the filer's say is kept" | |
| ); | |
| let events = l.event_count().await.unwrap(); | |
| l.rescore(&second.id, 7, "same").await.unwrap(); | |
| assert_eq!( | |
| l.event_count().await.unwrap(), | |
| events, | |
| "no event for no change" | |
| ); | |
| a_rescored_task_is_claimed_first_and_replayed(&mut l, &first, &second).await; | |
| } | |
| async fn a_rescored_task_is_claimed_first_and_replayed( | |
| l: &mut Ledger, | |
| first: &Task, | |
| second: &Task, | |
| ) { | |
| let c = next(l, "s", mins(5)).await.unwrap(); | |
| assert_eq!(c.task.id, second.id, "the rescored task is claimed first"); | |
| l.rebuild().await.unwrap(); | |
| assert_eq!( | |
| l.get(&second.id).await.unwrap().spec.priority, | |
| 7, | |
| "replay keeps it" | |
| ); | |
| l.complete(&second.id, "s", "h").await.unwrap(); | |
| assert!(is_wrong_state(&l.rescore(&second.id, 1, "late").await)); | |
| assert_eq!(l.get(&first.id).await.unwrap().spec.priority, 0); | |
| } | |
| } |