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    mode: PgMode,
22}
23
24/// How a [`PgStore`] may write. Single-node servers use `ReadWrite`; the
25/// cluster mode (`orbit server --cluster`, see `crate::cluster`) gives a
26/// standby a `ReadOnly` store and the leader a `Fenced` one.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum PgMode {
29    ReadWrite,
30    /// Reads only: writes fail and no execution counts as active, so an
31    /// engine opened on it recovers nothing and never appends.
32    ReadOnly,
33    /// Every history append verifies, in its own transaction, that the
34    /// cluster term is still this one, so a deposed leader cannot write.
35    Fenced(i64),
36}
37
38/// Table holding the cluster's leadership term (created by `crate::cluster`).
39pub(crate) const CLUSTER_SCHEMA: &str = r#"
40CREATE TABLE IF NOT EXISTS orbit_cluster (
41    id INT PRIMARY KEY,
42    term BIGINT NOT NULL,
43    leader_node TEXT,
44    leader_addr TEXT,
45    since_ms BIGINT NOT NULL
46);
47INSERT INTO orbit_cluster (id, term, since_ms) VALUES (1, 0, 0) ON CONFLICT DO NOTHING;
48"#;
49
50/// A plain session for the cluster's leader-election connection: same TLS
51/// rules as the store, plus timeouts so that a dead network path is noticed
52/// within seconds rather than minutes.
53pub(crate) fn session_client(url: &str, application_name: &str) -> Result<Client> {
54    use std::str::FromStr;
55    use std::time::Duration;
56    let mut config = postgres::Config::from_str(url)?;
57    config
58        .application_name(application_name)
59        .connect_timeout(Duration::from_secs(5))
60        .keepalives(true)
61        .keepalives_idle(Duration::from_secs(5))
62        .tcp_user_timeout(Duration::from_secs(5));
63    Ok(config.connect(tls()?)?)
64}
65
66fn read_only() -> StoreError {
67    StoreError("this server is a standby: the store is read-only".into())
68}
69
70/// TLS for PostgreSQL connections, following the URL's `sslmode`: `prefer`
71/// (the default) uses TLS when the server offers it, `require` insists on it,
72/// `disable` never uses it. Certificates are verified against the public web
73/// PKI roots plus, if set, the PEM CA bundle in `ORBIT_DB_CA_CERT`.
74fn tls() -> Result<MakeRustlsConnect> {
75    let mut roots = rustls::RootCertStore::empty();
76    roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
77    if let Ok(path) = std::env::var("ORBIT_DB_CA_CERT") {
78        let pem = std::fs::read(&path)
79            .map_err(|e| StoreError(format!("cannot read ORBIT_DB_CA_CERT {path}: {e}")))?;
80        for cert in rustls_pemfile::certs(&mut pem.as_slice()) {
81            let cert =
82                cert.map_err(|e| StoreError(format!("invalid certificate in {path}: {e}")))?;
83            roots
84                .add(cert)
85                .map_err(|e| StoreError(format!("invalid CA certificate in {path}: {e}")))?;
86        }
87    }
88    let provider = std::sync::Arc::new(rustls::crypto::aws_lc_rs::default_provider());
89    let config = rustls::ClientConfig::builder_with_provider(provider)
90        .with_safe_default_protocol_versions()
91        .map_err(|e| StoreError(format!("TLS configuration: {e}")))?
92        .with_root_certificates(roots)
93        .with_no_client_auth();
94    Ok(MakeRustlsConnect::new(config))
95}
96
97fn connect_client(url: &str) -> Result<Client> {
98    let mut client = Client::connect(url, tls()?)?;
99    client.batch_execute(SCHEMA)?;
100    let rows = client.query_one("SELECT COUNT(*) FROM schema_version", &[])?;
101    if rows.get::<_, i64>(0) == 0 {
102        client.execute("INSERT INTO schema_version (version) VALUES (1)", &[])?;
103    }
104    Ok(client)
105}
106
107fn bad_payload(e: serde_json::Error) -> StoreError {
108    StoreError(format!("postgres: unreadable event payload: {e}"))
109}
110
111impl PgStore {
112    pub fn connect(url: &str) -> Result<PgStore> {
113        PgStore::connect_with(url, PgMode::ReadWrite)
114    }
115
116    /// Connect with a write mode (see [`PgMode`]).
117    pub fn connect_with(url: &str, mode: PgMode) -> Result<PgStore> {
118        let url = url.to_string();
119        let (jobs, rx) = mpsc::channel::<Job>();
120        let (ready_tx, ready_rx) = mpsc::channel::<Result<()>>();
121        std::thread::Builder::new()
122            .name("orbit-postgres".into())
123            .spawn(move || {
124                let mut client = match connect_client(&url) {
125                    Ok(c) => {
126                        let _ = ready_tx.send(Ok(()));
127                        c
128                    }
129                    Err(e) => {
130                        let _ = ready_tx.send(Err(e));
131                        return;
132                    }
133                };
134                for job in rx {
135                    if client.is_closed() {
136                        match connect_client(&url) {
137                            Ok(c) => {
138                                tracing::warn!("reconnected to PostgreSQL");
139                                client = c;
140                            }
141                            Err(e) => tracing::error!(error = %e, "PostgreSQL reconnect failed"),
142                        }
143                    }
144                    job(&mut client);
145                }
146            })
147            .map_err(|e| StoreError(format!("cannot start PostgreSQL thread: {e}")))?;
148        ready_rx
149            .recv()
150            .map_err(|_| StoreError("PostgreSQL thread exited during startup".into()))??;
151        Ok(PgStore { jobs, mode })
152    }
153
154    fn run<T: Send + 'static>(
155        &self,
156        f: impl FnOnce(&mut Client) -> Result<T> + Send + 'static,
157    ) -> Result<T> {
158        let (tx, rx) = mpsc::channel();
159        self.jobs
160            .send(Box::new(move |c| {
161                let _ = tx.send(f(c));
162            }))
163            .map_err(|_| StoreError("PostgreSQL connection thread has stopped".into()))?;
164        rx.recv()
165            .map_err(|_| StoreError("PostgreSQL connection thread has stopped".into()))?
166    }
167
168    pub fn mode(&self) -> PgMode {
169        self.mode
170    }
171
172    pub fn put_definition(&self, ir: &WorkflowIr, now: u64) -> Result<PutDefinition> {
173        if self.mode == PgMode::ReadOnly {
174            return Err(read_only());
175        }
176        let (name, version, hash) = (
177            ir.name.clone(),
178            ir.version.clone(),
179            ir.definition_hash.clone(),
180        );
181        let text = serde_json::to_string(ir).expect("IR serialises");
182        self.run(move |c| {
183            let existing = c.query_opt(
184                "SELECT definition_hash FROM workflow_definitions WHERE name = $1 AND version = $2",
185                &[&name, &version],
186            )?;
187            match existing.map(|r| r.get::<_, String>(0)) {
188                Some(h) if h == hash => Ok(PutDefinition::Unchanged),
189                Some(h) => Ok(PutDefinition::Conflict { existing_hash: h }),
190                None => {
191                    // ON CONFLICT DO NOTHING closes the race with a concurrent registration.
192                    let n = c.execute(
193                        "INSERT INTO workflow_definitions (name, version, definition_hash, ir, created_at_ms)
194                         VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING",
195                        &[&name, &version, &hash, &text, &(now as i64)],
196                    )?;
197                    Ok(if n == 1 { PutDefinition::Created } else { PutDefinition::Unchanged })
198                }
199            }
200        })
201    }
202
203    pub fn definitions(&self) -> Result<Vec<WorkflowIr>> {
204        let rows = self.run(|c| {
205            Ok(c.query(
206                "SELECT ir FROM workflow_definitions ORDER BY name, created_at_ms",
207                &[],
208            )?
209            .into_iter()
210            .map(|r| r.get::<_, String>(0))
211            .collect::<Vec<_>>())
212        })?;
213        Ok(rows
214            .into_iter()
215            .filter_map(|t| match serde_json::from_str::<WorkflowIr>(&t) {
216                Ok(ir) => Some(ir),
217                Err(e) => {
218                    tracing::error!(error = %e, "skipping unreadable workflow definition");
219                    None
220                }
221            })
222            .collect())
223    }
224
225    pub fn execution_exists(&self, id: &str) -> Result<bool> {
226        let id = id.to_string();
227        self.run(move |c| {
228            Ok(c.query_opt(
229                "SELECT 1 FROM workflow_executions WHERE execution_id = $1",
230                &[&id],
231            )?
232            .is_some())
233        })
234    }
235
236    pub fn append(&mut self, state: &ExecutionState, events: &[HistoryEvent]) -> Result<()> {
237        let fence = match self.mode {
238            PgMode::ReadWrite => None,
239            PgMode::ReadOnly => return Err(read_only()),
240            PgMode::Fenced(term) => Some(term),
241        };
242        let rows: Vec<(i64, &'static str, Option<String>, String, i64)> = events
243            .iter()
244            .map(|h| {
245                (
246                    h.seq as i64,
247                    h.event.name(),
248                    h.event.step().map(str::to_string),
249                    serde_json::to_string(&h.event).expect("event serialises"),
250                    h.timestamp_ms as i64,
251                )
252            })
253            .collect();
254        let id = state.id.clone();
255        let projection = (
256            state.workflow.clone(),
257            state.version.clone(),
258            state.definition_hash.clone(),
259            state.status.as_str(),
260            state.created_at_ms as i64,
261            state.updated_at_ms as i64,
262            state.last_seq as i64,
263        );
264        self.run(move |c| {
265            let mut tx = c.transaction()?;
266            if let Some(term) = fence {
267                // FOR SHARE: a new leader's term increment waits for this
268                // transaction, and this read sees any increment committed first.
269                let current: Option<i64> = tx
270                    .query_opt("SELECT term FROM orbit_cluster WHERE id = 1 FOR SHARE", &[])?
271                    .map(|r| r.get(0));
272                if current != Some(term) {
273                    return Err(StoreError(format!(
274                        "fenced: this server led term {term}, but the cluster is at term {}; another server is the leader",
275                        current.map_or("unknown".to_string(), |t| t.to_string())
276                    )));
277                }
278            }
279            let ins = tx.prepare(
280                "INSERT INTO execution_events (execution_id, seq, event_type, step, payload, timestamp_ms)
281                 VALUES ($1, $2, $3, $4, $5, $6)",
282            )?;
283            for (seq, ty, step, payload, ts) in &rows {
284                tx.execute(&ins, &[&id, seq, ty, step, payload, ts])?;
285            }
286            let (workflow, version, hash, status, created, updated, last_seq) = projection;
287            tx.execute(
288                "INSERT INTO workflow_executions (execution_id, workflow, version, definition_hash, status, created_at_ms, updated_at_ms, last_seq)
289                 VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
290                 ON CONFLICT (execution_id) DO UPDATE SET status = EXCLUDED.status, updated_at_ms = EXCLUDED.updated_at_ms, last_seq = EXCLUDED.last_seq",
291                &[&id, &workflow, &version, &hash, &status, &created, &updated, &last_seq],
292            )?;
293            tx.commit()?;
294            Ok(())
295        })
296    }
297
298    pub fn history(&self, id: &str) -> Result<Vec<HistoryEvent>> {
299        let id = id.to_string();
300        let rows = self.run(move |c| {
301            Ok(c.query(
302                "SELECT seq, timestamp_ms, payload FROM execution_events WHERE execution_id = $1 ORDER BY seq",
303                &[&id],
304            )?
305            .into_iter()
306            .map(|r| (r.get::<_, i64>(0), r.get::<_, i64>(1), r.get::<_, String>(2)))
307            .collect::<Vec<_>>())
308        })?;
309        rows.into_iter()
310            .map(|(seq, ts, payload)| {
311                let event: Event = serde_json::from_str(&payload).map_err(bad_payload)?;
312                Ok(HistoryEvent {
313                    seq: seq as u64,
314                    timestamp_ms: ts as u64,
315                    event,
316                })
317            })
318            .collect()
319    }
320
321    pub fn active_execution_ids(&self) -> Result<Vec<String>> {
322        if self.mode == PgMode::ReadOnly {
323            return Ok(vec![]);
324        }
325        self.run(|c| {
326            Ok(c.query(
327                "SELECT execution_id FROM workflow_executions WHERE status IN ('RUNNING', 'COMPENSATING') ORDER BY created_at_ms",
328                &[],
329            )?
330            .into_iter()
331            .map(|r| r.get(0))
332            .collect())
333        })
334    }
335
336    pub fn list_executions(
337        &self,
338        status: Option<&str>,
339        workflow: Option<&str>,
340        limit: u32,
341    ) -> Result<Vec<ExecutionSummary>> {
342        let (status, workflow) = (status.map(str::to_string), workflow.map(str::to_string));
343        self.run(move |c| {
344            Ok(c.query(
345                "SELECT execution_id, workflow, version, status, created_at_ms, updated_at_ms FROM workflow_executions
346                 WHERE ($1::TEXT IS NULL OR status = $1) AND ($2::TEXT IS NULL OR workflow = $2)
347                 ORDER BY created_at_ms DESC LIMIT $3",
348                &[&status, &workflow, &(limit as i64)],
349            )?
350            .into_iter()
351            .map(|r| ExecutionSummary {
352                execution_id: r.get(0),
353                workflow: r.get(1),
354                version: r.get(2),
355                status: r.get(3),
356                created_at_ms: r.get::<_, i64>(4) as u64,
357                updated_at_ms: r.get::<_, i64>(5) as u64,
358            })
359            .collect())
360        })
361    }
362
363    pub fn count_by_status(&self) -> Result<Vec<(String, u64)>> {
364        self.run(|c| {
365            Ok(c.query(
366                "SELECT status, COUNT(*) FROM workflow_executions GROUP BY status",
367                &[],
368            )?
369            .into_iter()
370            .map(|r| (r.get(0), r.get::<_, i64>(1) as u64))
371            .collect())
372        })
373    }
374
375    pub fn upsert_worker(&self, w: &WorkerRecord) -> Result<()> {
376        if self.mode == PgMode::ReadOnly {
377            return Err(read_only());
378        }
379        let w = w.clone();
380        self.run(move |c| {
381            c.execute(
382                "INSERT INTO worker_registry (worker_id, runtime, sdk, contracts, registered_at_ms, last_seen_ms) VALUES ($1, $2, $3, $4, $5, $6)
383                 ON CONFLICT (worker_id) DO UPDATE SET runtime = EXCLUDED.runtime, sdk = EXCLUDED.sdk, contracts = EXCLUDED.contracts,
384                 registered_at_ms = EXCLUDED.registered_at_ms, last_seen_ms = EXCLUDED.last_seen_ms",
385                &[&w.worker_id, &w.runtime, &w.sdk, &w.contracts.to_string(), &(w.registered_at_ms as i64), &(w.last_seen_ms as i64)],
386            )?;
387            Ok(())
388        })
389    }
390
391    pub fn touch_worker(&self, worker_id: &str, now: u64) -> Result<()> {
392        if self.mode == PgMode::ReadOnly {
393            return Err(read_only());
394        }
395        let id = worker_id.to_string();
396        self.run(move |c| {
397            c.execute(
398                "UPDATE worker_registry SET last_seen_ms = $2 WHERE worker_id = $1",
399                &[&id, &(now as i64)],
400            )?;
401            Ok(())
402        })
403    }
404
405    pub fn workers(&self) -> Result<Vec<WorkerRecord>> {
406        self.run(|c| {
407            Ok(c.query(
408                "SELECT worker_id, runtime, sdk, contracts, registered_at_ms, last_seen_ms FROM worker_registry ORDER BY worker_id",
409                &[],
410            )?
411            .into_iter()
412            .map(|r| WorkerRecord {
413                worker_id: r.get(0),
414                runtime: r.get(1),
415                sdk: r.get(2),
416                contracts: serde_json::from_str(&r.get::<_, String>(3)).unwrap_or_default(),
417                registered_at_ms: r.get::<_, i64>(4) as u64,
418                last_seen_ms: r.get::<_, i64>(5) as u64,
419            })
420            .collect())
421        })
422    }
423}