Skip to main content

orbit_runtime/store/
postgres.rs

1//! PostgreSQL backend.
2//!
3//! The `postgres` crate drives its own async runtime internally and must not be
4//! called from inside another one, while the engine runs synchronously inside
5//! the HTTP server's tokio runtime. So every statement runs on one dedicated
6//! connection thread, fed through a channel. Calls stay synchronous for the
7//! engine, the connection is reconnected automatically if it drops, and each
8//! `append` is a single transaction, exactly like the SQLite backend.
9
10use super::{ExecutionSummary, PutDefinition, Result, StoreError, WorkerRecord, SCHEMA};
11use crate::model::{Event, ExecutionState, HistoryEvent};
12use orbit_ir::WorkflowIr;
13use postgres::Client;
14use std::sync::mpsc;
15use tokio_postgres_rustls::MakeRustlsConnect;
16
17type Job = Box<dyn FnOnce(&mut Client) + Send>;
18
19pub struct PgStore {
20    jobs: mpsc::Sender<Job>,
21}
22
23/// TLS for PostgreSQL connections, following the URL's `sslmode`: `prefer`
24/// (the default) uses TLS when the server offers it, `require` insists on it,
25/// `disable` never uses it. Certificates are verified against the public web
26/// PKI roots plus, if set, the PEM CA bundle in `ORBIT_DB_CA_CERT`.
27fn tls() -> Result<MakeRustlsConnect> {
28    let mut roots = rustls::RootCertStore::empty();
29    roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
30    if let Ok(path) = std::env::var("ORBIT_DB_CA_CERT") {
31        let pem = std::fs::read(&path)
32            .map_err(|e| StoreError(format!("cannot read ORBIT_DB_CA_CERT {path}: {e}")))?;
33        for cert in rustls_pemfile::certs(&mut pem.as_slice()) {
34            let cert =
35                cert.map_err(|e| StoreError(format!("invalid certificate in {path}: {e}")))?;
36            roots
37                .add(cert)
38                .map_err(|e| StoreError(format!("invalid CA certificate in {path}: {e}")))?;
39        }
40    }
41    let provider = std::sync::Arc::new(rustls::crypto::aws_lc_rs::default_provider());
42    let config = rustls::ClientConfig::builder_with_provider(provider)
43        .with_safe_default_protocol_versions()
44        .map_err(|e| StoreError(format!("TLS configuration: {e}")))?
45        .with_root_certificates(roots)
46        .with_no_client_auth();
47    Ok(MakeRustlsConnect::new(config))
48}
49
50fn connect_client(url: &str) -> Result<Client> {
51    let mut client = Client::connect(url, tls()?)?;
52    client.batch_execute(SCHEMA)?;
53    let rows = client.query_one("SELECT COUNT(*) FROM schema_version", &[])?;
54    if rows.get::<_, i64>(0) == 0 {
55        client.execute("INSERT INTO schema_version (version) VALUES (1)", &[])?;
56    }
57    Ok(client)
58}
59
60fn bad_payload(e: serde_json::Error) -> StoreError {
61    StoreError(format!("postgres: unreadable event payload: {e}"))
62}
63
64impl PgStore {
65    pub fn connect(url: &str) -> Result<PgStore> {
66        let url = url.to_string();
67        let (jobs, rx) = mpsc::channel::<Job>();
68        let (ready_tx, ready_rx) = mpsc::channel::<Result<()>>();
69        std::thread::Builder::new()
70            .name("orbit-postgres".into())
71            .spawn(move || {
72                let mut client = match connect_client(&url) {
73                    Ok(c) => {
74                        let _ = ready_tx.send(Ok(()));
75                        c
76                    }
77                    Err(e) => {
78                        let _ = ready_tx.send(Err(e));
79                        return;
80                    }
81                };
82                for job in rx {
83                    if client.is_closed() {
84                        match connect_client(&url) {
85                            Ok(c) => {
86                                tracing::warn!("reconnected to PostgreSQL");
87                                client = c;
88                            }
89                            Err(e) => tracing::error!(error = %e, "PostgreSQL reconnect failed"),
90                        }
91                    }
92                    job(&mut client);
93                }
94            })
95            .map_err(|e| StoreError(format!("cannot start PostgreSQL thread: {e}")))?;
96        ready_rx
97            .recv()
98            .map_err(|_| StoreError("PostgreSQL thread exited during startup".into()))??;
99        Ok(PgStore { jobs })
100    }
101
102    fn run<T: Send + 'static>(
103        &self,
104        f: impl FnOnce(&mut Client) -> Result<T> + Send + 'static,
105    ) -> Result<T> {
106        let (tx, rx) = mpsc::channel();
107        self.jobs
108            .send(Box::new(move |c| {
109                let _ = tx.send(f(c));
110            }))
111            .map_err(|_| StoreError("PostgreSQL connection thread has stopped".into()))?;
112        rx.recv()
113            .map_err(|_| StoreError("PostgreSQL connection thread has stopped".into()))?
114    }
115
116    pub fn put_definition(&self, ir: &WorkflowIr, now: u64) -> Result<PutDefinition> {
117        let (name, version, hash) = (
118            ir.name.clone(),
119            ir.version.clone(),
120            ir.definition_hash.clone(),
121        );
122        let text = serde_json::to_string(ir).expect("IR serialises");
123        self.run(move |c| {
124            let existing = c.query_opt(
125                "SELECT definition_hash FROM workflow_definitions WHERE name = $1 AND version = $2",
126                &[&name, &version],
127            )?;
128            match existing.map(|r| r.get::<_, String>(0)) {
129                Some(h) if h == hash => Ok(PutDefinition::Unchanged),
130                Some(h) => Ok(PutDefinition::Conflict { existing_hash: h }),
131                None => {
132                    // ON CONFLICT DO NOTHING closes the race with a concurrent registration.
133                    let n = c.execute(
134                        "INSERT INTO workflow_definitions (name, version, definition_hash, ir, created_at_ms)
135                         VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING",
136                        &[&name, &version, &hash, &text, &(now as i64)],
137                    )?;
138                    Ok(if n == 1 { PutDefinition::Created } else { PutDefinition::Unchanged })
139                }
140            }
141        })
142    }
143
144    pub fn definitions(&self) -> Result<Vec<WorkflowIr>> {
145        let rows = self.run(|c| {
146            Ok(c.query(
147                "SELECT ir FROM workflow_definitions ORDER BY name, created_at_ms",
148                &[],
149            )?
150            .into_iter()
151            .map(|r| r.get::<_, String>(0))
152            .collect::<Vec<_>>())
153        })?;
154        Ok(rows
155            .into_iter()
156            .filter_map(|t| match serde_json::from_str::<WorkflowIr>(&t) {
157                Ok(ir) => Some(ir),
158                Err(e) => {
159                    tracing::error!(error = %e, "skipping unreadable workflow definition");
160                    None
161                }
162            })
163            .collect())
164    }
165
166    pub fn execution_exists(&self, id: &str) -> Result<bool> {
167        let id = id.to_string();
168        self.run(move |c| {
169            Ok(c.query_opt(
170                "SELECT 1 FROM workflow_executions WHERE execution_id = $1",
171                &[&id],
172            )?
173            .is_some())
174        })
175    }
176
177    pub fn append(&mut self, state: &ExecutionState, events: &[HistoryEvent]) -> Result<()> {
178        let rows: Vec<(i64, &'static str, Option<String>, String, i64)> = events
179            .iter()
180            .map(|h| {
181                (
182                    h.seq as i64,
183                    h.event.name(),
184                    h.event.step().map(str::to_string),
185                    serde_json::to_string(&h.event).expect("event serialises"),
186                    h.timestamp_ms as i64,
187                )
188            })
189            .collect();
190        let id = state.id.clone();
191        let projection = (
192            state.workflow.clone(),
193            state.version.clone(),
194            state.definition_hash.clone(),
195            state.status.as_str(),
196            state.created_at_ms as i64,
197            state.updated_at_ms as i64,
198            state.last_seq as i64,
199        );
200        self.run(move |c| {
201            let mut tx = c.transaction()?;
202            let ins = tx.prepare(
203                "INSERT INTO execution_events (execution_id, seq, event_type, step, payload, timestamp_ms)
204                 VALUES ($1, $2, $3, $4, $5, $6)",
205            )?;
206            for (seq, ty, step, payload, ts) in &rows {
207                tx.execute(&ins, &[&id, seq, ty, step, payload, ts])?;
208            }
209            let (workflow, version, hash, status, created, updated, last_seq) = projection;
210            tx.execute(
211                "INSERT INTO workflow_executions (execution_id, workflow, version, definition_hash, status, created_at_ms, updated_at_ms, last_seq)
212                 VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
213                 ON CONFLICT (execution_id) DO UPDATE SET status = EXCLUDED.status, updated_at_ms = EXCLUDED.updated_at_ms, last_seq = EXCLUDED.last_seq",
214                &[&id, &workflow, &version, &hash, &status, &created, &updated, &last_seq],
215            )?;
216            tx.commit()?;
217            Ok(())
218        })
219    }
220
221    pub fn history(&self, id: &str) -> Result<Vec<HistoryEvent>> {
222        let id = id.to_string();
223        let rows = self.run(move |c| {
224            Ok(c.query(
225                "SELECT seq, timestamp_ms, payload FROM execution_events WHERE execution_id = $1 ORDER BY seq",
226                &[&id],
227            )?
228            .into_iter()
229            .map(|r| (r.get::<_, i64>(0), r.get::<_, i64>(1), r.get::<_, String>(2)))
230            .collect::<Vec<_>>())
231        })?;
232        rows.into_iter()
233            .map(|(seq, ts, payload)| {
234                let event: Event = serde_json::from_str(&payload).map_err(bad_payload)?;
235                Ok(HistoryEvent {
236                    seq: seq as u64,
237                    timestamp_ms: ts as u64,
238                    event,
239                })
240            })
241            .collect()
242    }
243
244    pub fn active_execution_ids(&self) -> Result<Vec<String>> {
245        self.run(|c| {
246            Ok(c.query(
247                "SELECT execution_id FROM workflow_executions WHERE status IN ('RUNNING', 'COMPENSATING') ORDER BY created_at_ms",
248                &[],
249            )?
250            .into_iter()
251            .map(|r| r.get(0))
252            .collect())
253        })
254    }
255
256    pub fn list_executions(
257        &self,
258        status: Option<&str>,
259        workflow: Option<&str>,
260        limit: u32,
261    ) -> Result<Vec<ExecutionSummary>> {
262        let (status, workflow) = (status.map(str::to_string), workflow.map(str::to_string));
263        self.run(move |c| {
264            Ok(c.query(
265                "SELECT execution_id, workflow, version, status, created_at_ms, updated_at_ms FROM workflow_executions
266                 WHERE ($1::TEXT IS NULL OR status = $1) AND ($2::TEXT IS NULL OR workflow = $2)
267                 ORDER BY created_at_ms DESC LIMIT $3",
268                &[&status, &workflow, &(limit as i64)],
269            )?
270            .into_iter()
271            .map(|r| ExecutionSummary {
272                execution_id: r.get(0),
273                workflow: r.get(1),
274                version: r.get(2),
275                status: r.get(3),
276                created_at_ms: r.get::<_, i64>(4) as u64,
277                updated_at_ms: r.get::<_, i64>(5) as u64,
278            })
279            .collect())
280        })
281    }
282
283    pub fn count_by_status(&self) -> Result<Vec<(String, u64)>> {
284        self.run(|c| {
285            Ok(c.query(
286                "SELECT status, COUNT(*) FROM workflow_executions GROUP BY status",
287                &[],
288            )?
289            .into_iter()
290            .map(|r| (r.get(0), r.get::<_, i64>(1) as u64))
291            .collect())
292        })
293    }
294
295    pub fn upsert_worker(&self, w: &WorkerRecord) -> Result<()> {
296        let w = w.clone();
297        self.run(move |c| {
298            c.execute(
299                "INSERT INTO worker_registry (worker_id, runtime, sdk, contracts, registered_at_ms, last_seen_ms) VALUES ($1, $2, $3, $4, $5, $6)
300                 ON CONFLICT (worker_id) DO UPDATE SET runtime = EXCLUDED.runtime, sdk = EXCLUDED.sdk, contracts = EXCLUDED.contracts,
301                 registered_at_ms = EXCLUDED.registered_at_ms, last_seen_ms = EXCLUDED.last_seen_ms",
302                &[&w.worker_id, &w.runtime, &w.sdk, &w.contracts.to_string(), &(w.registered_at_ms as i64), &(w.last_seen_ms as i64)],
303            )?;
304            Ok(())
305        })
306    }
307
308    pub fn touch_worker(&self, worker_id: &str, now: u64) -> Result<()> {
309        let id = worker_id.to_string();
310        self.run(move |c| {
311            c.execute(
312                "UPDATE worker_registry SET last_seen_ms = $2 WHERE worker_id = $1",
313                &[&id, &(now as i64)],
314            )?;
315            Ok(())
316        })
317    }
318
319    pub fn workers(&self) -> Result<Vec<WorkerRecord>> {
320        self.run(|c| {
321            Ok(c.query(
322                "SELECT worker_id, runtime, sdk, contracts, registered_at_ms, last_seen_ms FROM worker_registry ORDER BY worker_id",
323                &[],
324            )?
325            .into_iter()
326            .map(|r| WorkerRecord {
327                worker_id: r.get(0),
328                runtime: r.get(1),
329                sdk: r.get(2),
330                contracts: serde_json::from_str(&r.get::<_, String>(3)).unwrap_or_default(),
331                registered_at_ms: r.get::<_, i64>(4) as u64,
332                last_seen_ms: r.get::<_, i64>(5) as u64,
333            })
334            .collect())
335        })
336    }
337}