cairn_journal_postgres/
lib.rs1use std::time::Duration;
9
10use async_trait::async_trait;
11use cairn_runtime::{Event, Journal, JournalError, LeaseHolder};
12use sqlx::postgres::{PgPool, PgPoolOptions};
13
14const SCHEMA: &str = r#"
15CREATE TABLE IF NOT EXISTS cairn_events (
16 run_id TEXT NOT NULL,
17 seq BIGINT NOT NULL,
18 at TIMESTAMPTZ NOT NULL,
19 kind TEXT NOT NULL,
20 event JSONB NOT NULL,
21 PRIMARY KEY (run_id, seq)
22);
23CREATE INDEX IF NOT EXISTS cairn_events_kind_at ON cairn_events (kind, at);
24CREATE TABLE IF NOT EXISTS cairn_leases (
25 run_id TEXT PRIMARY KEY,
26 owner TEXT NOT NULL,
27 expires_at TIMESTAMPTZ NOT NULL
28);
29"#;
30
31const SCHEMA_LOCK: i64 = 0x6361_6972_6e00_0001;
33
34#[derive(Clone)]
35pub struct PostgresJournal {
36 pool: PgPool,
37}
38
39fn backend(e: sqlx::Error) -> JournalError {
40 JournalError::Backend(e.to_string())
41}
42
43impl PostgresJournal {
44 pub async fn connect(url: &str) -> Result<Self, JournalError> {
46 let pool = PgPoolOptions::new().max_connections(16).connect(url).await.map_err(backend)?;
47 Self::from_pool(pool).await
48 }
49
50 pub async fn from_pool(pool: PgPool) -> Result<Self, JournalError> {
52 let mut tx = pool.begin().await.map_err(backend)?;
55 sqlx::query("SELECT pg_advisory_xact_lock($1)").bind(SCHEMA_LOCK).execute(&mut *tx).await.map_err(backend)?;
56 sqlx::raw_sql(SCHEMA).execute(&mut *tx).await.map_err(backend)?;
57 tx.commit().await.map_err(backend)?;
58 Ok(Self { pool })
59 }
60
61 pub fn pool(&self) -> &PgPool {
62 &self.pool
63 }
64
65 pub async fn runs_awaiting_approval(&self) -> Result<Vec<String>, JournalError> {
67 sqlx::query_scalar(
70 r#"
71 SELECT DISTINCT r.run_id FROM cairn_events r
72 WHERE r.kind = 'approval_requested'
73 AND NOT EXISTS (
74 SELECT 1 FROM cairn_events d
75 WHERE d.run_id = r.run_id AND d.kind = 'approval_decided'
76 AND d.event->'kind'->>'call_id' = r.event->'kind'->>'call_id')
77 AND NOT EXISTS (
78 SELECT 1 FROM cairn_events t
79 WHERE t.run_id = r.run_id
80 AND t.kind IN ('run_completed', 'run_failed', 'budget_exceeded'))
81 ORDER BY r.run_id
82 "#,
83 )
84 .fetch_all(&self.pool)
85 .await
86 .map_err(backend)
87 }
88}
89
90#[async_trait]
91impl Journal for PostgresJournal {
92 async fn append(&self, event: &Event) -> Result<(), JournalError> {
93 let json = serde_json::to_value(event).map_err(|e| JournalError::Corrupt(e.to_string()))?;
94 let seq = i64::try_from(event.seq).map_err(|_| JournalError::Corrupt("seq out of range".into()))?;
95
96 let result = sqlx::query(
99 r#"
100 INSERT INTO cairn_events (run_id, seq, at, kind, event)
101 SELECT $1, $2, $3, $4, $5
102 WHERE (SELECT COALESCE(MAX(seq) + 1, 0) FROM cairn_events WHERE run_id = $1) = $2
103 "#,
104 )
105 .bind(&event.run_id)
106 .bind(seq)
107 .bind(event.at)
108 .bind(event.kind.name())
109 .bind(json)
110 .execute(&self.pool)
111 .await;
112
113 let inserted = match result {
114 Ok(r) => r.rows_affected() == 1,
115 Err(sqlx::Error::Database(e)) if e.is_unique_violation() => false,
116 Err(e) => return Err(backend(e)),
117 };
118 if inserted {
119 return Ok(());
120 }
121 let expected: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(seq) + 1, 0) FROM cairn_events WHERE run_id = $1")
122 .bind(&event.run_id)
123 .fetch_one(&self.pool)
124 .await
125 .map_err(backend)?;
126 Err(JournalError::Conflict { run_id: event.run_id.clone(), expected: expected as u64, got: event.seq })
127 }
128
129 async fn acquire_lease(
131 &self,
132 run_id: &str,
133 owner: &str,
134 ttl: Duration,
135 ) -> Result<Result<(), LeaseHolder>, JournalError> {
136 let taken: Option<String> = sqlx::query_scalar(
137 r#"
138 INSERT INTO cairn_leases (run_id, owner, expires_at)
139 VALUES ($1, $2, now() + make_interval(secs => $3))
140 ON CONFLICT (run_id) DO UPDATE SET owner = EXCLUDED.owner, expires_at = EXCLUDED.expires_at
141 WHERE cairn_leases.expires_at < now() OR cairn_leases.owner = EXCLUDED.owner
142 RETURNING owner
143 "#,
144 )
145 .bind(run_id)
146 .bind(owner)
147 .bind(ttl.as_secs_f64())
148 .fetch_optional(&self.pool)
149 .await
150 .map_err(backend)?;
151 if taken.is_some() {
152 return Ok(Ok(()));
153 }
154 let holder: Option<(String, chrono::DateTime<chrono::Utc>)> =
155 sqlx::query_as("SELECT owner, expires_at FROM cairn_leases WHERE run_id = $1")
156 .bind(run_id)
157 .fetch_optional(&self.pool)
158 .await
159 .map_err(backend)?;
160 match holder {
161 Some((owner, expires_at)) => Ok(Err(LeaseHolder { owner, expires_at })),
162 None => Box::pin(self.acquire_lease(run_id, owner, ttl)).await,
164 }
165 }
166
167 async fn renew_lease(&self, run_id: &str, owner: &str, ttl: Duration) -> Result<bool, JournalError> {
168 let updated = sqlx::query(
169 "UPDATE cairn_leases SET expires_at = now() + make_interval(secs => $3) WHERE run_id = $1 AND owner = $2",
170 )
171 .bind(run_id)
172 .bind(owner)
173 .bind(ttl.as_secs_f64())
174 .execute(&self.pool)
175 .await
176 .map_err(backend)?;
177 Ok(updated.rows_affected() == 1)
178 }
179
180 async fn release_lease(&self, run_id: &str, owner: &str) -> Result<(), JournalError> {
181 sqlx::query("DELETE FROM cairn_leases WHERE run_id = $1 AND owner = $2")
182 .bind(run_id)
183 .bind(owner)
184 .execute(&self.pool)
185 .await
186 .map_err(backend)?;
187 Ok(())
188 }
189
190 async fn load(&self, run_id: &str) -> Result<Vec<Event>, JournalError> {
191 let rows: Vec<serde_json::Value> =
192 sqlx::query_scalar("SELECT event FROM cairn_events WHERE run_id = $1 ORDER BY seq")
193 .bind(run_id)
194 .fetch_all(&self.pool)
195 .await
196 .map_err(backend)?;
197 rows.into_iter().map(|v| serde_json::from_value(v).map_err(|e| JournalError::Corrupt(e.to_string()))).collect()
198 }
199}