Skip to main content

turnframe_store_postgres/
journal.rs

1//! The command journal over `tf_command_journal`: idempotency admission and
2//! persisted outcomes (spec §16.2, I14).
3//!
4//! # Admission is atomic, not optimistic
5//!
6//! `UNIQUE (account_id, idempotency_key)` is the rule, and
7//! `INSERT … ON CONFLICT DO NOTHING` is how this adapter asks the database to
8//! apply it. Two callers arriving with the same key at the same instant do not
9//! both read "absent" and both insert: the second one's insert waits on the
10//! first one's speculative row, then does nothing, and the re-read that follows
11//! sees the committed entry and replays it. There is exactly one `Fresh` per
12//! key, whatever the interleaving, and a repeat always carries the outcome the
13//! first attempt persisted.
14//!
15//! That re-read depends on each statement taking a fresh snapshot, which is
16//! true at `READ COMMITTED` — PostgreSQL's default and what this adapter
17//! assumes throughout. Under `REPEATABLE READ` the statement would instead
18//! raise a serialization failure, which this adapter reports as `Conflict`;
19//! correct, but it turns a routine retry into a caller-visible refusal.
20
21use async_trait::async_trait;
22use chrono::{DateTime, Utc};
23use sqlx::PgConnection;
24use sqlx::postgres::PgRow;
25use turnframe_core::case::CaseRef;
26use turnframe_core::command::IdempotencyKey;
27use turnframe_core::ids::{AccountId, CommandId, TurnId};
28use turnframe_store::error::StoreError;
29use turnframe_store::journal::{
30    CommandJournalEntry, CommandJournalReader, CommandJournalStatus, CommandJournalWriter,
31    JournalAdmission, JournalOutcome,
32};
33use uuid::Uuid;
34
35use crate::codec::{
36    column, from_json, from_label, label, now, revision_from_sql, revision_to_sql, to_json,
37};
38use crate::error::store_error;
39use crate::store::{PgStores, commit};
40
41/// Everything a read needs to rebuild a [`CommandJournalEntry`].
42const ENTRY_COLUMNS: &str = "account_id, command_id, idempotency_key, turn_id, workflow_key, \
43     case_id, expected_revision, command_type, command_payload, origin, status, result, \
44     created_at, completed_at";
45
46/// The statuses a crashed turn is resumed from.
47const PENDING_STATUSES: &str = "('pending', 'executing')";
48
49/// Admits a command under `UNIQUE (account_id, idempotency_key)`.
50pub(crate) async fn begin(
51    conn: &mut PgConnection,
52    entry: CommandJournalEntry,
53) -> Result<JournalAdmission, StoreError> {
54    if let Some(existing) =
55        read_by_key(&mut *conn, &entry.account_id, &entry.idempotency_key).await?
56    {
57        return Ok(JournalAdmission::replay(existing));
58    }
59    let inserted = sqlx::query(
60        "INSERT INTO tf_command_journal (
61             account_id, command_id, idempotency_key, turn_id, workflow_key, case_id,
62             expected_revision, command_type, command_payload, origin, status, result,
63             created_at, completed_at
64         ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
65         ON CONFLICT ON CONSTRAINT tf_command_journal_idempotency_key DO NOTHING",
66    )
67    .bind(entry.account_id.as_str())
68    .bind(entry.command_id.as_uuid())
69    .bind(entry.idempotency_key.as_str())
70    .bind(entry.turn_id.as_uuid())
71    .bind(entry.case_ref.workflow.as_str())
72    .bind(entry.case_ref.case_id.as_str())
73    .bind(revision_to_sql(entry.case_ref.expected_revision)?)
74    .bind(entry.command_type.as_str())
75    .bind(entry.command_payload.clone())
76    .bind(to_json(&entry.origin)?)
77    .bind(label(&entry.status)?)
78    .bind(entry.result.as_ref().map(to_json).transpose()?)
79    .bind(entry.created_at)
80    .bind(entry.completed_at)
81    .execute(&mut *conn)
82    .await
83    .map_err(|error| store_error(&error))?;
84    if inserted.rows_affected() == 1 {
85        return Ok(JournalAdmission::Fresh);
86    }
87    // Another caller took the key between the read and the insert. Its entry is
88    // committed by now, and it is the one that must be replayed.
89    read_by_key(conn, &entry.account_id, &entry.idempotency_key)
90        .await?
91        .map(JournalAdmission::replay)
92        .ok_or(StoreError::Corrupt)
93}
94
95/// Reads the entry holding an idempotency key, if any.
96async fn read_by_key(
97    conn: &mut PgConnection,
98    account: &AccountId,
99    key: &IdempotencyKey,
100) -> Result<Option<CommandJournalEntry>, StoreError> {
101    let statement = format!(
102        "SELECT {ENTRY_COLUMNS} FROM tf_command_journal
103         WHERE account_id = $1 AND idempotency_key = $2"
104    );
105    let row = sqlx::query(&statement)
106        .bind(account.as_str())
107        .bind(key.as_str())
108        .fetch_optional(conn)
109        .await
110        .map_err(|error| store_error(&error))?;
111    row.as_ref().map(decode_entry).transpose()
112}
113
114/// Moves `Pending` or `AwaitingConfirmation` to `Executing`; repeating it on an
115/// `Executing` entry changes nothing.
116pub(crate) async fn mark_executing(
117    conn: &mut PgConnection,
118    account: &AccountId,
119    command_id: &CommandId,
120) -> Result<(), StoreError> {
121    let updated = sqlx::query(
122        "UPDATE tf_command_journal SET status = 'executing'
123         WHERE account_id = $1 AND command_id = $2
124           AND status IN ('pending', 'awaiting_confirmation')",
125    )
126    .bind(account.as_str())
127    .bind(command_id.as_uuid())
128    .execute(&mut *conn)
129    .await
130    .map_err(|error| store_error(&error))?;
131    if updated.rows_affected() == 1 {
132        return Ok(());
133    }
134    match read_entry(conn, account, command_id).await? {
135        None => Err(StoreError::NotFound),
136        Some(entry) if entry.status == CommandJournalStatus::Executing => Ok(()),
137        Some(_) => Err(StoreError::Conflict),
138    }
139}
140
141/// Records the outcome of a command.
142///
143/// The legal source statuses are computed from the contract's transition table
144/// and go into the same statement as the write, so an entry that moved on
145/// between a caller's read and its write is refused rather than overwritten.
146/// Recording an outcome an entry already carries is accepted; recording a
147/// different one is a `Conflict`.
148pub(crate) async fn complete(
149    conn: &mut PgConnection,
150    account: &AccountId,
151    command_id: &CommandId,
152    outcome: JournalOutcome,
153    at: DateTime<Utc>,
154) -> Result<(), StoreError> {
155    let target = outcome.status();
156    let sources = legal_sources(target)?;
157    let updated = sqlx::query(
158        "UPDATE tf_command_journal SET status = $3, result = $4, completed_at = $5
159         WHERE account_id = $1 AND command_id = $2 AND status = ANY($6)",
160    )
161    .bind(account.as_str())
162    .bind(command_id.as_uuid())
163    .bind(label(&target)?)
164    .bind(to_json(&outcome)?)
165    .bind(at)
166    .bind(sources)
167    .execute(&mut *conn)
168    .await
169    .map_err(|error| store_error(&error))?;
170    if updated.rows_affected() == 1 {
171        return Ok(());
172    }
173    match read_entry(conn, account, command_id).await? {
174        None => Err(StoreError::NotFound),
175        Some(entry) if entry.status == target && entry.result.as_ref() == Some(&outcome) => Ok(()),
176        Some(_) => Err(StoreError::Conflict),
177    }
178}
179
180/// The statuses `target` may be reached from, as their column labels.
181fn legal_sources(target: CommandJournalStatus) -> Result<Vec<String>, StoreError> {
182    CommandJournalStatus::ALL
183        .into_iter()
184        .filter(|from| CommandJournalStatus::can_transition(*from, target))
185        .map(|from| label(&from))
186        .collect()
187}
188
189/// Loads one entry.
190pub(crate) async fn get(
191    conn: &mut PgConnection,
192    account: &AccountId,
193    command_id: &CommandId,
194) -> Result<CommandJournalEntry, StoreError> {
195    read_entry(conn, account, command_id)
196        .await?
197        .ok_or(StoreError::NotFound)
198}
199
200/// Reads an entry without deciding what its absence means.
201async fn read_entry(
202    conn: &mut PgConnection,
203    account: &AccountId,
204    command_id: &CommandId,
205) -> Result<Option<CommandJournalEntry>, StoreError> {
206    let statement = format!(
207        "SELECT {ENTRY_COLUMNS} FROM tf_command_journal
208         WHERE account_id = $1 AND command_id = $2"
209    );
210    let row = sqlx::query(&statement)
211        .bind(account.as_str())
212        .bind(command_id.as_uuid())
213        .fetch_optional(conn)
214        .await
215        .map_err(|error| store_error(&error))?;
216    row.as_ref().map(decode_entry).transpose()
217}
218
219/// The entries of a turn, in creation order; `only_pending` keeps the ones a
220/// crash left to resume.
221pub(crate) async fn for_turn(
222    conn: &mut PgConnection,
223    account: &AccountId,
224    turn_id: &TurnId,
225    only_pending: bool,
226) -> Result<Vec<CommandJournalEntry>, StoreError> {
227    let filter = if only_pending {
228        format!("AND status IN {PENDING_STATUSES}")
229    } else {
230        String::new()
231    };
232    let statement = format!(
233        "SELECT {ENTRY_COLUMNS} FROM tf_command_journal
234         WHERE account_id = $1 AND turn_id = $2 {filter}
235         ORDER BY created_at, command_id"
236    );
237    let rows = sqlx::query(&statement)
238        .bind(account.as_str())
239        .bind(turn_id.as_uuid())
240        .fetch_all(conn)
241        .await
242        .map_err(|error| store_error(&error))?;
243    rows.iter().map(decode_entry).collect()
244}
245
246/// Rebuilds an entry from its row.
247fn decode_entry(row: &PgRow) -> Result<CommandJournalEntry, StoreError> {
248    let account: String = column(row, "account_id")?;
249    let command_id: Uuid = column(row, "command_id")?;
250    let idempotency_key: String = column(row, "idempotency_key")?;
251    let turn_id: Uuid = column(row, "turn_id")?;
252    let workflow_key: String = column(row, "workflow_key")?;
253    let case_id: String = column(row, "case_id")?;
254    let revision: i64 = column(row, "expected_revision")?;
255    let status: String = column(row, "status")?;
256    let result: Option<serde_json::Value> = column(row, "result")?;
257    Ok(CommandJournalEntry {
258        command_id: CommandId::from(command_id),
259        account_id: AccountId::from(account),
260        idempotency_key: IdempotencyKey::new(idempotency_key),
261        turn_id: TurnId::from(turn_id),
262        case_ref: CaseRef::new(workflow_key, case_id, revision_from_sql(revision)?),
263        command_type: column(row, "command_type")?,
264        command_payload: column(row, "command_payload")?,
265        origin: from_json(column(row, "origin")?)?,
266        status: from_label(&status)?,
267        result: result.map(from_json).transpose()?,
268        created_at: column(row, "created_at")?,
269        completed_at: column(row, "completed_at")?,
270    })
271}
272
273#[async_trait]
274impl CommandJournalReader for PgStores {
275    async fn get(
276        &self,
277        account: &AccountId,
278        command_id: &CommandId,
279    ) -> Result<CommandJournalEntry, StoreError> {
280        let mut conn = self.connection().await?;
281        get(&mut conn, account, command_id).await
282    }
283
284    async fn for_turn(
285        &self,
286        account: &AccountId,
287        turn_id: &TurnId,
288    ) -> Result<Vec<CommandJournalEntry>, StoreError> {
289        let mut conn = self.connection().await?;
290        for_turn(&mut conn, account, turn_id, false).await
291    }
292
293    async fn pending_for_turn(
294        &self,
295        account: &AccountId,
296        turn_id: &TurnId,
297    ) -> Result<Vec<CommandJournalEntry>, StoreError> {
298        let mut conn = self.connection().await?;
299        for_turn(&mut conn, account, turn_id, true).await
300    }
301}
302
303#[async_trait]
304impl CommandJournalWriter for PgStores {
305    async fn begin(&self, entry: CommandJournalEntry) -> Result<JournalAdmission, StoreError> {
306        let mut transaction = self.transaction().await?;
307        let admission = begin(&mut transaction, entry).await?;
308        commit(transaction).await?;
309        Ok(admission)
310    }
311
312    async fn mark_executing(
313        &self,
314        account: &AccountId,
315        command_id: &CommandId,
316    ) -> Result<(), StoreError> {
317        let mut transaction = self.transaction().await?;
318        mark_executing(&mut transaction, account, command_id).await?;
319        commit(transaction).await
320    }
321
322    async fn complete(
323        &self,
324        account: &AccountId,
325        command_id: &CommandId,
326        outcome: JournalOutcome,
327    ) -> Result<(), StoreError> {
328        let mut transaction = self.transaction().await?;
329        complete(&mut transaction, account, command_id, outcome, now()).await?;
330        commit(transaction).await
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    #[test]
339    fn the_legal_sources_are_the_contract_transition_table() {
340        // A settled command must never be resumed, so `Committed` is reachable
341        // from the three unsettled statuses and from nothing else.
342        assert_eq!(
343            legal_sources(CommandJournalStatus::Committed).unwrap(),
344            vec![
345                "pending",
346                "awaiting_confirmation",
347                "executing",
348                "outcome_unknown"
349            ]
350        );
351        assert_eq!(
352            legal_sources(CommandJournalStatus::Failed).unwrap(),
353            vec![
354                "pending",
355                "awaiting_confirmation",
356                "executing",
357                "outcome_unknown"
358            ]
359        );
360        assert_eq!(
361            legal_sources(CommandJournalStatus::OutcomeUnknown).unwrap(),
362            vec!["pending", "awaiting_confirmation", "executing"]
363        );
364        assert_eq!(
365            legal_sources(CommandJournalStatus::Executing).unwrap(),
366            vec!["pending", "awaiting_confirmation"]
367        );
368    }
369}