1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum PgMode {
29 ReadWrite,
30 ReadOnly,
33 Fenced(i64),
36}
37
38pub(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
50pub(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
70fn 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 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 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 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}