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}
22
23fn 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 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}