1use crate::engine::{ContractAnnouncement, Engine, EngineError, TaskAssignment, WorkerInfo};
31use crate::model::{is_cleanup_key, Event, ExecutionState, HistoryEvent, UnitStatus};
32use crate::server::{engine, Shared};
33use crate::store::{
34 session_client, PgMode, PgStore, SharedAppend, Store, StoreError, TaskOffer, UnitSync,
35 WorkerRecord, NOW_MS, SHARED_SCHEMA,
36};
37use axum::extract::{Request, State};
38use axum::http::{Method, StatusCode};
39use axum::middleware::{self, Next};
40use axum::response::{IntoResponse, Response};
41use axum::{Json, Router};
42use orbit_ir::{Invocation, WorkflowIr};
43use postgres::fallible_iterator::FallibleIterator;
44use serde_json::{json, Value};
45use std::collections::{BTreeMap, HashMap, HashSet};
46use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
47use std::sync::{mpsc, Arc, Mutex, MutexGuard};
48use std::time::{Duration, Instant};
49
50const SCHEMA_LOCK_KEY: i64 = 0x6f72_6269_7473_6873;
52const SESSION_LAG_MS: u64 = 1_000;
55const OPS_CONNECTIONS: usize = 4;
57const REPLY_TIMEOUT: Duration = Duration::from_secs(10);
59const REQUERY: Duration = Duration::from_secs(1);
62
63pub type EngineFactory = crate::cluster::EngineFactory;
65
66#[derive(Debug, Clone)]
67pub struct SharedConfig {
68 pub url: String,
70 pub node_id: String,
73 pub advertise_addr: Option<String>,
75 pub heartbeat_interval: Duration,
77 pub node_timeout: Duration,
80 pub takeover_batch: i64,
82}
83
84impl SharedConfig {
85 pub fn new(url: &str, node_id: &str) -> SharedConfig {
86 SharedConfig {
87 url: url.to_string(),
88 node_id: node_id.to_string(),
89 advertise_addr: None,
90 heartbeat_interval: Duration::from_secs(1),
91 node_timeout: Duration::from_secs(5),
92 takeover_batch: 100,
93 }
94 }
95}
96
97pub(crate) enum Claimed {
99 Nothing,
101 Stale,
103 Remote(TaskAssignment),
105 Local {
107 row_id: i64,
108 offer: u32,
109 assignment: TaskAssignment,
110 },
111}
112
113struct Staged {
114 seq: i64,
115 execution_id: String,
116 kind: String,
117 consumed: bool,
118}
119
120pub struct Ctx {
122 config: SharedConfig,
123 incarnation: String,
124 session_timeout_ms: u64,
125 pool: Vec<PgStore>,
128 next: std::sync::atomic::AtomicUsize,
129 epochs: Arc<Mutex<HashMap<String, i64>>>,
131 staged: Mutex<Vec<Staged>>,
134 done: Mutex<Vec<(String, String)>>,
136 fenced: Mutex<HashSet<String>>,
138 touched: Mutex<HashMap<String, Instant>>,
140 seen: Mutex<HashMap<String, Option<(u64, Instant)>>>,
143 lost: Mutex<HashSet<String>>,
146 queue_gen: AtomicU64,
148 empty: Mutex<HashMap<String, (u64, Instant)>>,
150 defs_dirty: AtomicBool,
151 applying: AtomicBool,
152 started: AtomicBool,
153 duplicate: AtomicBool,
154 last_beat: Mutex<Option<Instant>>,
155 nodes: Mutex<Value>,
156 tasks_claimed: AtomicU64,
157 stop: AtomicBool,
158 halt: AtomicBool,
159 spawned: AtomicBool,
161 exited: AtomicBool,
163}
164
165fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
166 m.lock().unwrap_or_else(|p| p.into_inner())
167}
168
169fn storage(e: StoreError) -> EngineError {
170 EngineError::new(500, "STORAGE_ERROR", e.to_string())
171}
172
173fn now_ms() -> i64 {
174 std::time::SystemTime::now()
175 .duration_since(std::time::UNIX_EPOCH)
176 .map(|d| d.as_millis() as i64)
177 .unwrap_or(0)
178}
179
180fn error_json(status: u16, code: &str, message: &str) -> String {
181 json!({ "error": { "status": status, "code": code, "message": message } }).to_string()
182}
183
184tokio::task_local! {
185 static FORWARDED: Arc<Mutex<Option<i64>>>;
186}
187
188pub(crate) fn note_forwarded(seq: i64) {
191 let _ = FORWARDED.try_with(|s| *lock(s) = Some(seq));
192}
193
194pub(crate) fn token_execution(token: &str) -> Option<&str> {
196 token
197 .rsplit_once('/')
198 .map(|(e, _)| e)
199 .filter(|e| !e.is_empty())
200}
201
202pub(crate) fn worker_invocation<'a>(def: &'a WorkflowIr, key: &str) -> Option<&'a Invocation> {
205 let inv = crate::decide::invocation(def, key)?;
206 match def.contracts.get(&inv.task) {
207 Some(c) if c.builtin || c.workflow_hash.is_some() => None,
208 _ => Some(inv),
209 }
210}
211
212pub(crate) fn offer_for(state: &ExecutionState, key: &str, def: &WorkflowIr) -> Option<TaskOffer> {
214 let inv = worker_invocation(def, key)?;
215 let u = state.units.get(key)?;
216 let a = TaskAssignment {
217 token: String::new(),
218 execution_id: state.id.clone(),
219 workflow: def.name.clone(),
220 workflow_version: def.version.clone(),
221 step_id: key.to_string(),
222 task_name: inv.task.clone(),
223 contract_version: inv.contract_version.clone(),
224 input: u.input.clone().unwrap_or(Value::Null),
225 idempotency_key: u.idempotency_key.clone().unwrap_or_default(),
226 attempt: u.attempt,
227 redelivery: u.redelivery,
228 deadline_ms: None,
229 timeout_ms: inv.timeout_ms,
230 };
231 Some(TaskOffer {
232 unit_key: key.to_string(),
233 attempt: u.attempt as i64,
234 offer: u.dispatch_count as i64,
235 task_name: inv.task.clone(),
236 contract_version: inv.contract_version.clone(),
237 language: inv.language.as_ref().map(|l| l.to_lowercase()),
238 payload: serde_json::to_string(&a).expect("assignment serialises"),
239 enqueued_ms: u.scheduled_at_ms.unwrap_or(0) as i64,
240 })
241}
242
243fn queue_changes(
246 state: &ExecutionState,
247 history: &[HistoryEvent],
248 def: &WorkflowIr,
249) -> (Vec<TaskOffer>, Vec<UnitSync>) {
250 let aborting = history.iter().any(|h| {
251 matches!(
252 h.event,
253 Event::CancelRequested { .. }
254 | Event::WorkflowTimeoutReached { .. }
255 | Event::StepFailed { .. }
256 | Event::LimitExceeded { .. }
257 | Event::CompensationStarted { .. }
258 | Event::HandlersStarted { .. }
259 )
260 });
261 let mut touched: BTreeMap<&str, (bool, bool)> = BTreeMap::new();
263 for h in history {
264 if let Some(step) = h.event.step() {
265 let e = touched.entry(step).or_default();
266 match h.event {
267 Event::StepScheduled { .. } => e.0 = true,
268 Event::StepDispatched { .. } => {}
269 _ => e.1 = true,
270 }
271 }
272 }
273 if aborting {
274 for (key, u) in &state.units {
275 if matches!(u.status(), UnitStatus::Scheduled | UnitStatus::Dispatched) {
276 touched.entry(key.as_str()).or_default().1 = true;
277 }
278 }
279 }
280 let mut offers = vec![];
281 let mut syncs = vec![];
282 for (key, (scheduled, check)) in touched {
283 if worker_invocation(def, key).is_none() {
284 continue;
285 }
286 let Some(u) = state.units.get(key) else {
287 continue;
288 };
289 if scheduled && u.status() == UnitStatus::Scheduled {
290 offers.extend(offer_for(state, key, def));
291 }
292 if check {
293 let keep = match u.status() {
294 UnitStatus::Scheduled => Some((u.attempt as i64, u.dispatch_count as i64)),
295 UnitStatus::Dispatched if state.abort.is_none() || is_cleanup_key(key) => {
296 Some((u.attempt as i64, u.dispatch_count as i64 - 1))
297 }
298 _ => None,
299 };
300 syncs.push(UnitSync {
301 unit_key: key.to_string(),
302 keep,
303 });
304 }
305 }
306 (offers, syncs)
307}
308
309impl Ctx {
310 fn ops(&self) -> &PgStore {
311 &self.pool[self.next.fetch_add(1, Ordering::Relaxed) % self.pool.len()]
312 }
313
314 pub fn node_id(&self) -> &str {
315 &self.config.node_id
316 }
317
318 pub fn owned_executions(&self) -> usize {
320 lock(&self.epochs).len()
321 }
322
323 pub fn tasks_claimed(&self) -> u64 {
325 self.tasks_claimed.load(Ordering::Relaxed)
326 }
327
328 pub fn stop(&self) {
331 self.stop.store(true, Ordering::SeqCst);
332 if !self.spawned.load(Ordering::SeqCst) {
333 return;
335 }
336 let deadline = Instant::now() + Duration::from_secs(5);
337 while !self.exited.load(Ordering::SeqCst) && Instant::now() < deadline {
338 std::thread::sleep(Duration::from_millis(10));
339 }
340 }
341
342 pub fn halt(&self) {
345 self.halt.store(true, Ordering::SeqCst);
346 }
347
348 fn stopped(&self) -> bool {
349 self.stop.load(Ordering::SeqCst)
350 }
351
352 fn halted(&self) -> bool {
353 self.halt.load(Ordering::SeqCst)
354 }
355
356 pub fn is_ready(&self) -> bool {
358 self.started.load(Ordering::SeqCst)
359 && !self.duplicate.load(Ordering::SeqCst)
360 && !self.halted()
361 && lock(&self.last_beat).is_some_and(|t| t.elapsed() < self.config.node_timeout)
362 }
363
364 pub(crate) fn applying_inbox(&self) -> bool {
365 self.applying.load(Ordering::SeqCst)
366 }
367
368 pub(crate) fn set_epoch(&self, id: &str, epoch: i64) {
369 lock(&self.epochs).insert(id.to_string(), epoch);
370 }
371
372 pub(crate) fn forget(&self, id: &str) {
373 lock(&self.epochs).remove(id);
374 }
375
376 pub(crate) fn stage_done(&self, execution_id: &str, token: &str) {
377 lock(&self.done).push((execution_id.to_string(), token.to_string()));
378 }
379
380 fn stage(&self, seq: i64, execution_id: &str, kind: &str) {
381 lock(&self.staged).push(Staged {
382 seq,
383 execution_id: execution_id.to_string(),
384 kind: kind.to_string(),
385 consumed: false,
386 });
387 }
388
389 fn unstage(&self, seq: i64) -> bool {
391 let mut s = lock(&self.staged);
392 let consumed = s.iter().any(|e| e.seq == seq && e.consumed);
393 s.retain(|e| e.seq != seq);
394 consumed
395 }
396
397 pub(crate) fn append(
400 &self,
401 store: &mut Store,
402 state: &ExecutionState,
403 history: &[HistoryEvent],
404 def: Option<&WorkflowIr>,
405 rollover: Option<(u32, Vec<HistoryEvent>)>,
406 ) -> crate::store::Result<()> {
407 let Store::Postgres(pg) = store else {
408 return Err(StoreError("shared mode needs a PostgreSQL store".into()));
409 };
410 let id = state.id.clone();
411 let created = rollover.is_none() && history.first().is_some_and(|h| h.seq == 1);
413 let epoch = if created {
414 None
415 } else {
416 match lock(&self.epochs).get(&id).copied() {
417 Some(e) => Some(e),
418 None => {
419 return Err(StoreError(format!(
420 "fenced: this server does not own execution `{id}`"
421 )))
422 }
423 }
424 };
425 let (offers, syncs) = match def {
426 Some(d) => queue_changes(state, history, d),
427 None => (vec![], vec![]),
428 };
429 let mut consumed = vec![];
430 {
431 let signal_seq = history
432 .iter()
433 .find_map(|h| matches!(h.event, Event::SignalReceived { .. }).then_some(h.seq));
434 for e in lock(&self.staged).iter_mut() {
435 if e.execution_id == id && !e.consumed {
436 e.consumed = true;
437 let reply = match e.kind.as_str() {
438 "signal" => Some(json!({ "seq": signal_seq }).to_string()),
439 "cancel" => Some(json!({ "ok": true, "pending": true }).to_string()),
442 _ => None,
443 };
444 consumed.push((e.seq, reply));
445 }
446 }
447 }
448 let done_tokens: Vec<String> = {
449 let mut d = lock(&self.done);
450 let (mine, rest): (Vec<_>, Vec<_>) = d.drain(..).partition(|(e, _)| *e == id);
451 *d = rest;
452 mine.into_iter().map(|(_, t)| t).collect()
453 };
454 let terminal = state.status.is_terminal();
455 let ext = SharedAppend {
456 node_id: self.config.node_id.clone(),
457 epoch,
458 parent: state.parent.as_ref().map(|p| p.execution_id.clone()),
459 offers,
460 syncs,
461 done_tokens,
462 consumed: consumed.clone(),
463 finished_reply: terminal.then(|| {
464 error_json(
465 409,
466 "EXECUTION_FINISHED",
467 &format!("execution is already {}", state.status.as_str()),
468 )
469 }),
470 rollover,
471 };
472 let result = pg.append_shared(state, history, ext);
473 match &result {
474 Ok(()) => {
475 let mut epochs = lock(&self.epochs);
476 if terminal {
477 epochs.remove(&id);
478 } else if created {
479 epochs.insert(id, 1);
480 }
481 }
482 Err(e) => {
483 let seqs: HashSet<i64> = consumed.iter().map(|(s, _)| *s).collect();
484 for e in lock(&self.staged).iter_mut() {
485 if seqs.contains(&e.seq) {
486 e.consumed = false;
487 }
488 }
489 if e.0.starts_with("fenced") && !created {
490 lock(&self.fenced).insert(id);
491 }
492 }
493 }
494 result
495 }
496
497 pub(crate) fn should_claim(&self, session: &str) -> bool {
499 let gen = self.queue_gen.load(Ordering::SeqCst);
500 lock(&self.empty)
501 .get(session)
502 .is_none_or(|(g, at)| *g != gen || at.elapsed() >= REQUERY)
503 }
504
505 pub(crate) fn claim(
507 &self,
508 session: &str,
509 worker_id: &str,
510 runtime: &str,
511 contracts: &HashSet<(String, String)>,
512 now: u64,
513 ) -> Result<Claimed, EngineError> {
514 let gen = self.queue_gen.load(Ordering::SeqCst);
515 let (names, versions): (Vec<String>, Vec<String>) = contracts.iter().cloned().unzip();
516 let suffix = uuid::Uuid::new_v4().to_string();
517 let me = self.config.node_id.clone();
518 let epochs = self.epochs.clone();
519 let incarnation = self.incarnation.clone();
520 let (session_id, worker, runtime) = (
521 session.to_string(),
522 worker_id.to_string(),
523 runtime.to_lowercase(),
524 );
525 let now = now as i64;
526 let claimed = self
527 .ops()
528 .run(move |c| {
529 let mut tx = c.transaction()?;
530 let Some(row) = tx.query_opt(
531 "WITH c AS (
532 SELECT id FROM orbit_tasks
533 WHERE state = 'queued'
534 AND (task_name, contract_version) IN (SELECT n, v FROM unnest($1::text[], $2::text[]) AS u(n, v))
535 AND (language IS NULL OR language = 'any' OR language = $3)
536 ORDER BY enqueued_ms, id
537 LIMIT 1
538 FOR UPDATE SKIP LOCKED)
539 UPDATE orbit_tasks t
540 SET state = 'claimed', token = t.execution_id || '/' || $4, claimed_by = $5, claimed_inc = $9,
541 claimed_session = $6, claimed_worker = $7, claimed_ms = $8, updated_ms = $8
542 FROM c WHERE t.id = c.id
543 RETURNING t.id, t.execution_id, t.unit_key, t.attempt, t.offer, t.payload, t.token",
544 &[&names, &versions, &runtime, &suffix, &me, &session_id, &worker, &now, &incarnation],
545 )?
546 else {
547 return Ok(None);
548 };
549 let (row_id, exec, unit, attempt, offer, payload, token): (
550 i64,
551 String,
552 String,
553 i64,
554 i64,
555 String,
556 String,
557 ) = (
558 row.get(0),
559 row.get(1),
560 row.get(2),
561 row.get(3),
562 row.get(4),
563 row.get(5),
564 row.get(6),
565 );
566 let mut a: TaskAssignment = serde_json::from_str(&payload)
567 .map_err(|e| StoreError(format!("unreadable queued task: {e}")))?;
568 a.token = token.clone();
569 a.deadline_ms = a.timeout_ms.map(|t| now as u64 + t);
570 let owner: Option<(String, i64)> = tx
571 .query_opt(
572 "SELECT node_id, epoch FROM orbit_owners WHERE execution_id = $1 FOR SHARE",
573 &[&exec],
574 )?
575 .map(|r| (r.get(0), r.get(1)));
576 let mine = lock(&epochs).get(&exec).copied();
577 let local = matches!((&owner, mine), (Some((n, e)), Some(m)) if *n == me && *e == m);
578 let outcome = match &owner {
579 _ if local => Claimed::Local {
580 row_id,
581 offer: offer as u32,
582 assignment: a,
583 },
584 None => {
585 tx.execute(
586 "UPDATE orbit_tasks SET state = 'cancelled', updated_ms = $2 WHERE id = $1",
587 &[&row_id, &now],
588 )?;
589 Claimed::Stale
590 }
591 Some((owner_node, _)) => {
592 let msg = json!({
593 "unit": unit,
594 "attempt": attempt,
595 "offer": offer,
596 "token": token,
597 "worker_id": worker,
598 "session": session_id,
599 "deadline_ms": a.deadline_ms,
600 });
601 tx.execute(
602 "INSERT INTO orbit_inbox (execution_id, kind, token, payload, created_ms) VALUES ($1, 'dispatched', $2, $3, $4)",
603 &[&exec, &token, &msg.to_string(), &now],
604 )?;
605 tx.execute("SELECT pg_notify('orbit_inbox', $1)", &[owner_node])?;
606 Claimed::Remote(a)
607 }
608 };
609 tx.commit()?;
610 Ok(Some(outcome))
611 })
612 .map_err(storage)?;
613 match claimed {
614 None => {
615 lock(&self.empty).insert(session.to_string(), (gen, Instant::now()));
616 Ok(Claimed::Nothing)
617 }
618 Some(c) => {
619 lock(&self.empty).remove(session);
620 if !matches!(c, Claimed::Stale) {
621 self.tasks_claimed.fetch_add(1, Ordering::Relaxed);
622 }
623 Ok(c)
624 }
625 }
626 }
627
628 pub(crate) fn retire(&self, row_id: i64, token: &str) -> Result<(), EngineError> {
630 let token = token.to_string();
631 self.ops()
632 .run(move |c| {
633 c.execute(
634 "UPDATE orbit_tasks SET state = 'cancelled', updated_ms = $3 WHERE id = $1 AND token = $2",
635 &[&row_id, &token, &now_ms()],
636 )?;
637 Ok(())
638 })
639 .map_err(storage)
640 }
641
642 pub(crate) fn release(&self, row_id: i64, token: &str) -> Result<(), EngineError> {
644 let token = token.to_string();
645 self.ops()
646 .run(move |c| {
647 c.execute(
648 "UPDATE orbit_tasks SET state = 'queued', token = NULL, claimed_by = NULL, claimed_inc = NULL,
649 claimed_session = NULL, claimed_worker = NULL, claimed_ms = NULL, updated_ms = $3
650 WHERE id = $1 AND token = $2 AND state = 'claimed'",
651 &[&row_id, &token, &now_ms()],
652 )?;
653 c.execute("SELECT pg_notify('orbit_tasks', '')", &[])?;
654 Ok(())
655 })
656 .map_err(storage)
657 }
658
659 pub(crate) fn forward(
664 &self,
665 execution_id: &str,
666 kind: &str,
667 payload: Value,
668 token: Option<&str>,
669 ) -> Result<Option<i64>, EngineError> {
670 let (exec, kind, token) = (
671 execution_id.to_string(),
672 kind.to_string(),
673 token.map(str::to_string),
674 );
675 let payload = payload.to_string();
676 self.ops()
677 .run(move |c| {
678 let mut tx = c.transaction()?;
679 let Some(owner) = tx
680 .query_opt(
681 "SELECT node_id FROM orbit_owners WHERE execution_id = $1",
682 &[&exec],
683 )?
684 .map(|r| r.get::<_, String>(0))
685 else {
686 return Ok(None);
687 };
688 let now = now_ms();
689 if let Some(t) = &token {
690 tx.execute(
691 "UPDATE orbit_tasks SET state = 'done', updated_ms = $2 WHERE token = $1 AND state IN ('claimed', 'cancelled')",
692 &[t, &now],
693 )?;
694 }
695 let seq: i64 = tx
696 .query_one(
697 "INSERT INTO orbit_inbox (execution_id, kind, token, payload, created_ms) VALUES ($1, $2, $3, $4, $5) RETURNING seq",
698 &[&exec, &kind, &token, &payload, &now],
699 )?
700 .get(0);
701 tx.execute("SELECT pg_notify('orbit_inbox', $1)", &[&owner])?;
702 tx.commit()?;
703 Ok(Some(seq))
704 })
705 .map_err(storage)
706 }
707
708 fn wait_reply(&self, seq: i64, timeout: Duration) -> Option<Value> {
711 let deadline = Instant::now() + timeout;
712 loop {
713 let r = self.ops().run(move |c| {
714 Ok(c.query_opt(
715 "SELECT done_ms IS NOT NULL, result FROM orbit_inbox WHERE seq = $1",
716 &[&seq],
717 )?
718 .map(|r| (r.get::<_, bool>(0), r.get::<_, Option<String>>(1))))
719 });
720 match r {
721 Ok(Some((true, result))) => {
722 let v: Value = result
723 .and_then(|r| serde_json::from_str(&r).ok())
724 .unwrap_or(Value::Null);
725 if v["pending"] != json!(true) || Instant::now() >= deadline {
728 return Some(v);
729 }
730 }
731 Ok(None) => return Some(Value::Null),
732 _ => {}
733 }
734 if Instant::now() >= deadline {
735 return None;
736 }
737 std::thread::sleep(Duration::from_millis(20));
738 }
739 }
740
741 pub(crate) fn save_session(
743 &self,
744 session_id: &str,
745 worker_id: &str,
746 runtime: &str,
747 contracts: &[ContractAnnouncement],
748 ) -> Result<(), EngineError> {
749 let (sid, wid, rt, node) = (
750 session_id.to_string(),
751 worker_id.to_string(),
752 runtime.to_lowercase(),
753 self.config.node_id.clone(),
754 );
755 let contracts = serde_json::to_string(contracts).expect("contracts serialise");
756 self.ops()
757 .run(move |c| {
758 c.execute(
759 &format!(
760 "INSERT INTO orbit_sessions (session_id, worker_id, runtime, contracts, node_id, registered_ms, last_seen_ms)
761 VALUES ($1, $2, $3, $4, $5, {NOW_MS}, {NOW_MS}) ON CONFLICT DO NOTHING"
762 ),
763 &[&sid, &wid, &rt, &contracts, &node],
764 )?;
765 Ok(())
766 })
767 .map_err(storage)
768 }
769
770 #[allow(clippy::type_complexity)]
772 pub(crate) fn load_session(
773 &self,
774 session_id: &str,
775 session_timeout_ms: u64,
776 ) -> Result<Option<(String, String, Vec<(String, String)>)>, EngineError> {
777 let sid = session_id.to_string();
778 let retain = (session_timeout_ms * 4) as i64;
779 let row = self
780 .ops()
781 .run(move |c| {
782 Ok(c.query_opt(
783 &format!(
784 "SELECT worker_id, runtime, contracts FROM orbit_sessions WHERE session_id = $1 AND {NOW_MS} - last_seen_ms < $2"
785 ),
786 &[&sid, &retain],
787 )?
788 .map(|r| (r.get::<_, String>(0), r.get::<_, String>(1), r.get::<_, String>(2))))
789 })
790 .map_err(storage)?;
791 Ok(row.map(|(w, rt, contracts)| {
792 let list: Vec<ContractAnnouncement> =
793 serde_json::from_str(&contracts).unwrap_or_default();
794 (
795 w,
796 rt,
797 list.into_iter()
798 .map(|c| (c.task_name, c.contract_version))
799 .collect(),
800 )
801 }))
802 }
803
804 pub(crate) fn drop_session(
806 &self,
807 session_id: &str,
808 ) -> Result<Vec<(String, String)>, EngineError> {
809 let sid = session_id.to_string();
810 lock(&self.touched).remove(session_id);
811 self.ops()
812 .run(move |c| {
813 let mut tx = c.transaction()?;
814 tx.execute("DELETE FROM orbit_sessions WHERE session_id = $1", &[&sid])?;
815 let held = tx
816 .query(
817 "SELECT execution_id, token FROM orbit_tasks WHERE claimed_session = $1 AND state = 'claimed'",
818 &[&sid],
819 )?
820 .into_iter()
821 .map(|r| (r.get(0), r.get(1)))
822 .collect();
823 tx.commit()?;
824 Ok(held)
825 })
826 .map_err(storage)
827 }
828
829 pub(crate) fn cancelled_for(&self, session: &str) -> Result<Vec<String>, EngineError> {
832 let sid = session.to_string();
833 self.ops()
834 .run(move |c| {
835 Ok(c.query(
836 "UPDATE orbit_tasks SET state = 'cancel_sent', updated_ms = $2
837 WHERE claimed_session = $1 AND state = 'cancelled' RETURNING token",
838 &[&sid, &now_ms()],
839 )?
840 .into_iter()
841 .filter_map(|r| r.get::<_, Option<String>>(0))
842 .collect())
843 })
844 .map_err(storage)
845 }
846
847 pub(crate) fn touched(&self, session: &str) {
848 lock(&self.touched).insert(session.to_string(), Instant::now());
849 }
850
851 pub(crate) fn session_alive(&self, session: &str, timeout_ms: u64) -> bool {
854 match lock(&self.seen).get(session) {
855 None => true,
856 Some(None) => false,
857 Some(Some((age, at))) => {
858 age + (at.elapsed().as_millis() as u64) < timeout_ms + SESSION_LAG_MS
859 }
860 }
861 }
862
863 pub(crate) fn claim_lost(&self, token: Option<&str>) -> bool {
866 token.is_some_and(|t| lock(&self.lost).contains(t))
867 }
868
869 pub(crate) fn token_worker(&self, token: &str) -> Option<String> {
871 let t = token.to_string();
872 self.ops()
873 .run(move |c| {
874 Ok(c.query_opt(
875 "SELECT claimed_worker FROM orbit_tasks WHERE token = $1",
876 &[&t],
877 )?
878 .and_then(|r| r.get::<_, Option<String>>(0)))
879 })
880 .ok()
881 .flatten()
882 }
883
884 pub(crate) fn workers(
886 &self,
887 records: Vec<WorkerRecord>,
888 session_timeout_ms: u64,
889 ) -> Result<Vec<WorkerInfo>, EngineError> {
890 let timeout = session_timeout_ms as i64;
891 let (sessions, inflight): (HashMap<String, i64>, HashMap<String, i64>) = self
892 .ops()
893 .run(move |c| {
894 let s = c
895 .query(
896 &format!(
897 "SELECT worker_id, count(*) FROM orbit_sessions WHERE {NOW_MS} - last_seen_ms < $1 GROUP BY worker_id"
898 ),
899 &[&timeout],
900 )?
901 .into_iter()
902 .map(|r| (r.get(0), r.get(1)))
903 .collect();
904 let i = c
905 .query(
906 "SELECT claimed_worker, count(*) FROM orbit_tasks WHERE state = 'claimed' AND claimed_worker IS NOT NULL GROUP BY claimed_worker",
907 &[],
908 )?
909 .into_iter()
910 .map(|r| (r.get(0), r.get(1)))
911 .collect();
912 Ok((s, i))
913 })
914 .map_err(storage)?;
915 Ok(records
916 .into_iter()
917 .map(|record| {
918 let n = sessions.get(&record.worker_id).copied().unwrap_or(0) as usize;
919 WorkerInfo {
920 online: n > 0,
921 sessions: n,
922 inflight: inflight.get(&record.worker_id).copied().unwrap_or(0) as usize,
923 record,
924 }
925 })
926 .collect())
927 }
928
929 pub(crate) fn republish(
933 &self,
934 offers: Vec<(String, TaskOffer)>,
935 previous_owner: &str,
936 ) -> Result<(), EngineError> {
937 if offers.is_empty() {
938 return Ok(());
939 }
940 let previous = previous_owner.to_string();
941 self.ops()
942 .run(move |c| {
943 let mut tx = c.transaction()?;
944 let now = now_ms();
945 let ins = tx.prepare(
946 "INSERT INTO orbit_tasks (execution_id, unit_key, attempt, offer, task_name, contract_version, language, payload, enqueued_ms, state, updated_ms)
947 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'queued', $10)
948 ON CONFLICT (execution_id, unit_key, attempt, offer) DO NOTHING",
949 )?;
950 for (id, o) in &offers {
951 tx.execute(
952 &ins,
953 &[id, &o.unit_key, &o.attempt, &o.offer, &o.task_name, &o.contract_version, &o.language, &o.payload, &o.enqueued_ms, &now],
954 )?;
955 }
956 let units: Vec<(String, String, i64, i64)> = offers
957 .iter()
958 .map(|(id, o)| (id.clone(), o.unit_key.clone(), o.attempt, o.offer))
959 .collect();
960 requeue(&mut tx, &units, Some(&previous), None)?;
961 tx.execute("SELECT pg_notify('orbit_tasks', '')", &[])?;
962 tx.commit()?;
963 Ok(())
964 })
965 .map_err(storage)
966 }
967}
968
969fn requeue(
973 c: &mut impl postgres::GenericClient,
974 units: &[(String, String, i64, i64)],
975 by: Option<&str>,
976 older_than: Option<i64>,
977) -> Result<u64, postgres::Error> {
978 if units.is_empty() {
979 return Ok(0);
980 }
981 let ids: Vec<String> = units.iter().map(|u| u.0.clone()).collect();
982 let keys: Vec<String> = units.iter().map(|u| u.1.clone()).collect();
983 let attempts: Vec<i64> = units.iter().map(|u| u.2).collect();
984 let offers: Vec<i64> = units.iter().map(|u| u.3).collect();
985 c.execute(
986 "UPDATE orbit_tasks t SET state = 'queued', token = NULL, claimed_by = NULL, claimed_inc = NULL,
987 claimed_session = NULL, claimed_worker = NULL, claimed_ms = NULL, updated_ms = $7
988 WHERE t.state = 'claimed'
989 AND ($5::text IS NULL OR t.claimed_by = $5)
990 AND ($6::bigint IS NULL OR t.claimed_ms < $6)
991 AND (t.execution_id, t.unit_key, t.attempt, t.offer) IN
992 (SELECT * FROM unnest($1::text[], $2::text[], $3::bigint[], $4::bigint[]))
993 AND NOT EXISTS (SELECT 1 FROM orbit_inbox i WHERE i.execution_id = t.execution_id AND i.kind = 'dispatched' AND i.token = t.token)",
994 &[&ids, &keys, &attempts, &offers, &by, &older_than, &now_ms()],
995 )
996}
997
998fn connect(config: &SharedConfig, purpose: &str) -> Result<postgres::Client, String> {
1001 let mut c = session_client(
1002 &config.url,
1003 &format!("orbit-shared-{purpose}:{}", config.node_id),
1004 )
1005 .map_err(|e| e.to_string())?;
1006 c.batch_execute("SET statement_timeout = '30s'")
1007 .map_err(|e| e.to_string())?;
1008 let mut tx = c.transaction().map_err(|e| e.to_string())?;
1009 tx.execute("SELECT pg_advisory_xact_lock($1)", &[&SCHEMA_LOCK_KEY])
1010 .map_err(|e| e.to_string())?;
1011 let exists: bool = tx
1012 .query_one("SELECT to_regclass('orbit_sessions') IS NOT NULL", &[])
1013 .map_err(|e| e.to_string())?
1014 .get(0);
1015 if !exists {
1016 tx.batch_execute(SHARED_SCHEMA).map_err(|e| e.to_string())?;
1017 }
1018 tx.commit().map_err(|e| e.to_string())?;
1019 Ok(c)
1020}
1021
1022fn register_node(
1026 c: &mut postgres::Client,
1027 config: &SharedConfig,
1028 incarnation: &str,
1029) -> Result<(), String> {
1030 let timeout = config.node_timeout.as_millis() as i64;
1031 let deadline = Instant::now() + config.node_timeout * 3;
1032 let mut warned = false;
1033 loop {
1034 let row = c
1035 .query_opt(
1036 &format!(
1037 "SELECT incarnation, {NOW_MS} - heartbeat_ms FROM orbit_nodes WHERE node_id = $1"
1038 ),
1039 &[&config.node_id],
1040 )
1041 .map_err(|e| e.to_string())?;
1042 if let Some((inc, age)) = row.map(|r| (r.get::<_, String>(0), r.get::<_, i64>(1))) {
1043 if inc != incarnation && age < timeout {
1044 if Instant::now() >= deadline {
1045 return Err(format!(
1046 "another server with node id `{}` is running (it heartbeated {age} ms ago); give every server its own --node-id",
1047 config.node_id
1048 ));
1049 }
1050 if !warned {
1051 tracing::warn!(node_id = %config.node_id, "shared: a server with this node id heartbeated recently; waiting for it to expire");
1052 warned = true;
1053 }
1054 std::thread::sleep(Duration::from_millis(250));
1055 continue;
1056 }
1057 }
1058 c.execute(
1059 &format!(
1060 "INSERT INTO orbit_nodes (node_id, incarnation, addr, heartbeat_ms, started_ms) VALUES ($1, $2, $3, {NOW_MS}, {NOW_MS})
1061 ON CONFLICT (node_id) DO UPDATE SET incarnation = EXCLUDED.incarnation, addr = EXCLUDED.addr,
1062 heartbeat_ms = EXCLUDED.heartbeat_ms, started_ms = EXCLUDED.started_ms"
1063 ),
1064 &[&config.node_id, &incarnation, &config.advertise_addr],
1065 )
1066 .map_err(|e| e.to_string())?;
1067 return Ok(());
1068 }
1069}
1070
1071pub fn open(
1074 config: SharedConfig,
1075 factory: impl Fn(Store) -> Result<Engine, EngineError>,
1076) -> Result<Engine, EngineError> {
1077 let store = PgStore::connect_with(&config.url, PgMode::Shared).map_err(storage)?;
1078 attach(factory(Store::Postgres(store))?, config)
1079}
1080
1081pub fn attach(mut engine: Engine, config: SharedConfig) -> Result<Engine, EngineError> {
1086 let unavailable = |e: String| EngineError::new(500, "STORAGE_ERROR", e);
1087 let incarnation = uuid::Uuid::new_v4().to_string();
1088 let mut c = connect(&config, "control").map_err(unavailable)?;
1089 register_node(&mut c, &config, &incarnation).map_err(unavailable)?;
1090 drop(c);
1091 let pool = (0..OPS_CONNECTIONS)
1092 .map(|_| PgStore::connect_with(&config.url, PgMode::Shared))
1093 .collect::<Result<Vec<_>, _>>()
1094 .map_err(storage)?;
1095 let ctx = Arc::new(Ctx {
1096 session_timeout_ms: engine.config.session_timeout_ms,
1097 config,
1098 incarnation,
1099 pool,
1100 next: std::sync::atomic::AtomicUsize::new(0),
1101 epochs: Arc::new(Mutex::new(HashMap::new())),
1102 staged: Mutex::new(vec![]),
1103 done: Mutex::new(vec![]),
1104 fenced: Mutex::new(HashSet::new()),
1105 touched: Mutex::new(HashMap::new()),
1106 seen: Mutex::new(HashMap::new()),
1107 lost: Mutex::new(HashSet::new()),
1108 queue_gen: AtomicU64::new(0),
1109 empty: Mutex::new(HashMap::new()),
1110 defs_dirty: AtomicBool::new(false),
1111 applying: AtomicBool::new(false),
1112 started: AtomicBool::new(false),
1113 duplicate: AtomicBool::new(false),
1114 last_beat: Mutex::new(Some(Instant::now())),
1115 nodes: Mutex::new(json!([])),
1116 tasks_claimed: AtomicU64::new(0),
1117 stop: AtomicBool::new(false),
1118 halt: AtomicBool::new(false),
1119 spawned: AtomicBool::new(false),
1120 exited: AtomicBool::new(false),
1121 });
1122 engine.shared = Some(ctx);
1123 Ok(engine)
1124}
1125
1126pub fn start(ctx: Arc<Ctx>, state: Shared) -> std::io::Result<()> {
1129 tracing::info!(
1130 node_id = %ctx.config.node_id,
1131 advertise_addr = ctx.config.advertise_addr.as_deref().unwrap_or(""),
1132 node_timeout_ms = ctx.config.node_timeout.as_millis() as u64,
1133 "cluster mode: shared (every server active)"
1134 );
1135 let (wake_tx, wake_rx) = mpsc::channel();
1136 let (c, s) = (ctx.clone(), state.clone());
1137 std::thread::Builder::new()
1138 .name("orbit-shared".into())
1139 .spawn(move || Control::new(c, s).run(wake_rx))?;
1140 ctx.spawned.store(true, Ordering::SeqCst);
1141 std::thread::Builder::new()
1142 .name("orbit-shared-listen".into())
1143 .spawn(move || listen_loop(&ctx, &state, wake_tx))?;
1144 Ok(())
1145}
1146
1147fn listen_loop(ctx: &Ctx, state: &Shared, wake: mpsc::Sender<()>) {
1151 while !ctx.stopped() {
1152 let mut c = match connect(&ctx.config, "listen") {
1153 Ok(c) => c,
1154 Err(e) => {
1155 tracing::warn!(error = %e, "shared: cannot open the notification session; retrying");
1156 std::thread::sleep(Duration::from_secs(1));
1157 continue;
1158 }
1159 };
1160 if let Err(e) = c.batch_execute(
1161 "LISTEN orbit_inbox; LISTEN orbit_tasks; LISTEN orbit_done; LISTEN orbit_defs",
1162 ) {
1163 tracing::warn!(error = %e, "shared: LISTEN failed; retrying");
1164 std::thread::sleep(Duration::from_secs(1));
1165 continue;
1166 }
1167 ctx.queue_gen.fetch_add(1, Ordering::SeqCst);
1169 ctx.defs_dirty.store(true, Ordering::SeqCst);
1170 let _ = wake.send(());
1171 state.changed.notify_waiters();
1172 'session: while !ctx.stopped() {
1173 {
1174 let mut n = c.notifications();
1175 let mut it = n.timeout_iter(Duration::from_millis(500));
1176 loop {
1177 match it.next() {
1178 Ok(None) => break,
1179 Ok(Some(note)) => match note.channel() {
1180 "orbit_inbox" => {
1181 if note.payload() == ctx.config.node_id {
1182 let _ = wake.send(());
1183 }
1184 }
1185 "orbit_tasks" => {
1186 ctx.queue_gen.fetch_add(1, Ordering::SeqCst);
1187 state.changed.notify_waiters();
1188 }
1189 "orbit_done" => state.changed.notify_waiters(),
1190 "orbit_defs" => ctx.defs_dirty.store(true, Ordering::SeqCst),
1191 _ => {}
1192 },
1193 Err(e) => {
1194 tracing::warn!(error = %e, "shared: notification session failed; reconnecting");
1195 break 'session;
1196 }
1197 }
1198 }
1199 }
1200 if c.is_closed() {
1201 break;
1202 }
1203 }
1204 }
1205}
1206
1207struct Control {
1210 ctx: Arc<Ctx>,
1211 state: Shared,
1212 resumed_own: bool,
1213 beat: Instant,
1214 flush: Instant,
1215 seen: Instant,
1216 notify: Instant,
1217 reconcile: Instant,
1218 cleanup: Instant,
1219}
1220
1221impl Control {
1222 fn new(ctx: Arc<Ctx>, state: Shared) -> Control {
1223 let past = Instant::now() - Duration::from_secs(3600);
1224 Control {
1225 ctx,
1226 state,
1227 resumed_own: false,
1228 beat: past,
1229 flush: past,
1230 seen: past,
1231 notify: past,
1232 reconcile: Instant::now(),
1233 cleanup: Instant::now(),
1234 }
1235 }
1236
1237 fn me(&self) -> &str {
1238 &self.ctx.config.node_id
1239 }
1240
1241 fn run(mut self, wake: mpsc::Receiver<()>) {
1242 let mut client: Option<postgres::Client> = None;
1243 while !self.ctx.stopped() {
1244 if self.ctx.halted() {
1245 std::thread::sleep(Duration::from_millis(100));
1246 continue;
1247 }
1248 if client.as_ref().is_none_or(|c| c.is_closed()) {
1249 match connect(&self.ctx.config, "control") {
1250 Ok(c) => client = Some(c),
1251 Err(e) => {
1252 tracing::warn!(error = %e, "shared: cannot reach PostgreSQL; retrying");
1253 std::thread::sleep(Duration::from_secs(1));
1254 continue;
1255 }
1256 }
1257 }
1258 let c = client.as_mut().expect("connected");
1259 if let Err(e) = self.cycle(c) {
1260 tracing::warn!(error = %e, "shared: control cycle failed");
1261 std::thread::sleep(Duration::from_millis(200));
1262 }
1263 let _ = wake.recv_timeout(Duration::from_millis(100));
1264 while wake.try_recv().is_ok() {}
1265 }
1266 if !self.ctx.halted() {
1267 if let Some(c) = client.as_mut() {
1268 let _ = c.execute(
1271 "UPDATE orbit_tasks SET claimed_inc = NULL WHERE claimed_inc = $1",
1272 &[&self.ctx.incarnation],
1273 );
1274 let _ = c.execute(
1275 "UPDATE orbit_nodes SET heartbeat_ms = 0 WHERE node_id = $1 AND incarnation = $2",
1276 &[&self.ctx.config.node_id, &self.ctx.incarnation],
1277 );
1278 tracing::info!(node_id = %self.ctx.config.node_id, "shared: left the cluster; other servers take over this server's executions");
1279 }
1280 }
1281 self.ctx.exited.store(true, Ordering::SeqCst);
1282 }
1283
1284 fn cycle(&mut self, c: &mut postgres::Client) -> Result<(), String> {
1285 if !self.resumed_own {
1286 let rows = c
1289 .query(
1290 &format!(
1291 "UPDATE orbit_owners SET epoch = epoch + 1, since_ms = {NOW_MS} WHERE node_id = $1 RETURNING execution_id, epoch"
1292 ),
1293 &[&self.ctx.config.node_id],
1294 )
1295 .map_err(|e| e.to_string())?;
1296 let items: Vec<(String, i64)> = rows.iter().map(|r| (r.get(0), r.get(1))).collect();
1297 if !items.is_empty() {
1298 tracing::info!(
1299 executions = items.len(),
1300 "shared: resuming the executions this node owned before it restarted"
1301 );
1302 let me = self.me().to_string();
1303 engine(&self.state)
1304 .adopt(&items, &me)
1305 .map_err(|e| e.to_string())?;
1306 }
1307 self.resumed_own = true;
1308 *lock(&self.ctx.last_beat) = Some(Instant::now());
1309 self.ctx.started.store(true, Ordering::SeqCst);
1310 tracing::info!(node_id = %self.me(), resumed = items.len(), "shared: node is serving");
1311 }
1312 if self.beat.elapsed() >= self.ctx.config.heartbeat_interval {
1313 self.beat = Instant::now();
1314 self.heartbeat(c)?;
1315 self.take_over(c)?;
1316 self.evict_fenced();
1317 }
1318 self.consume_inbox(c)?;
1319 if self.flush.elapsed() >= Duration::from_millis(250) {
1320 self.flush = Instant::now();
1321 self.flush_touches(c)?;
1322 }
1323 if self.seen.elapsed() >= Duration::from_millis(500) {
1324 self.seen = Instant::now();
1325 self.refresh_seen(c)?;
1326 }
1327 if self.notify.elapsed() >= Duration::from_secs(1) {
1328 self.notify = Instant::now();
1329 if self.ctx.defs_dirty.swap(false, Ordering::SeqCst) {
1330 let _ = engine(&self.state).refresh_definitions();
1331 }
1332 self.state.changed.notify_waiters();
1334 }
1335 if self.reconcile.elapsed() >= Duration::from_secs(10) {
1336 self.reconcile = Instant::now();
1337 self.reconcile_ownership(c)?;
1338 self.sweep_claims(c)?;
1339 }
1340 if self.cleanup.elapsed() >= Duration::from_secs(30) {
1341 self.cleanup = Instant::now();
1342 self.cleanup(c)?;
1343 }
1344 Ok(())
1345 }
1346
1347 fn heartbeat(&mut self, c: &mut postgres::Client) -> Result<(), String> {
1348 let ctx = self.ctx.clone();
1349 let cfg = &ctx.config;
1350 let n = c
1351 .execute(
1352 &format!(
1353 "UPDATE orbit_nodes SET heartbeat_ms = {NOW_MS}, addr = $3 WHERE node_id = $1 AND incarnation = $2"
1354 ),
1355 &[&cfg.node_id, &ctx.incarnation, &cfg.advertise_addr],
1356 )
1357 .map_err(|e| e.to_string())?;
1358 let previous = *lock(&ctx.last_beat);
1359 if n == 0 {
1360 let row = c
1361 .query_opt(
1362 &format!("SELECT incarnation, {NOW_MS} - heartbeat_ms FROM orbit_nodes WHERE node_id = $1"),
1363 &[&cfg.node_id],
1364 )
1365 .map_err(|e| e.to_string())?
1366 .map(|r| (r.get::<_, String>(0), r.get::<_, i64>(1)));
1367 if let Some((_, age)) = row.filter(|(inc, age)| {
1368 *inc != ctx.incarnation && *age < cfg.node_timeout.as_millis() as i64
1369 }) {
1370 if !ctx.duplicate.swap(true, Ordering::SeqCst) {
1371 tracing::error!(node_id = %cfg.node_id, age_ms = age, "shared: another server took this node id; this server stops serving until it is gone");
1372 }
1373 let mut e = engine(&self.state);
1374 for id in e.owned_ids() {
1375 e.evict(&id);
1376 }
1377 return Ok(());
1378 }
1379 register_node(c, cfg, &ctx.incarnation)?;
1380 }
1381 ctx.duplicate.store(false, Ordering::SeqCst);
1382 *lock(&ctx.last_beat) = Some(Instant::now());
1383 if n == 0 || previous.is_none_or(|p| p.elapsed() >= cfg.node_timeout) {
1384 self.reconcile_ownership(c)?;
1386 }
1387 let rows = c
1388 .query(
1389 &format!(
1390 "SELECT n.node_id, n.addr, {NOW_MS} - n.heartbeat_ms,
1391 (SELECT count(*) FROM orbit_owners o WHERE o.node_id = n.node_id)
1392 FROM orbit_nodes n ORDER BY n.node_id"
1393 ),
1394 &[],
1395 )
1396 .map_err(|e| e.to_string())?;
1397 let timeout = cfg.node_timeout.as_millis() as i64;
1398 let nodes: Vec<Value> = rows
1399 .iter()
1400 .map(|r| {
1401 let age: i64 = r.get(2);
1402 json!({
1403 "node_id": r.get::<_, String>(0),
1404 "addr": r.get::<_, Option<String>>(1),
1405 "live": age < timeout,
1406 "heartbeat_age_ms": age.max(0),
1407 "owned_executions": r.get::<_, i64>(3),
1408 })
1409 })
1410 .filter(|n| n["live"] == json!(true) || n["owned_executions"] != json!(0))
1411 .collect();
1412 *lock(&ctx.nodes) = Value::Array(nodes);
1413 Ok(())
1414 }
1415
1416 fn take_over(&mut self, c: &mut postgres::Client) -> Result<(), String> {
1419 let timeout = self.ctx.config.node_timeout.as_millis() as i64;
1420 let batch = self.ctx.config.takeover_batch.max(1);
1421 let me = self.me().to_string();
1422 loop {
1423 let picks = c
1424 .query(
1425 &format!(
1426 "WITH live AS (SELECT node_id FROM orbit_nodes WHERE {NOW_MS} - heartbeat_ms < $1),
1427 orphan AS (
1428 SELECT DISTINCT ON (o.root_id) o.root_id, o.node_id AS old
1429 FROM orbit_owners o
1430 WHERE NOT EXISTS (SELECT 1 FROM live l WHERE l.node_id = o.node_id)
1431 ORDER BY o.root_id),
1432 pick AS (
1433 SELECT DISTINCT ON (orphan.root_id) orphan.root_id, orphan.old, l.node_id AS taker
1434 FROM orphan CROSS JOIN live l
1435 ORDER BY orphan.root_id, md5(orphan.root_id || '/' || l.node_id) DESC)
1436 SELECT root_id, old FROM pick WHERE taker = $2 LIMIT $3"
1437 ),
1438 &[&timeout, &me, &batch],
1439 )
1440 .map_err(|e| e.to_string())?;
1441 if picks.is_empty() {
1442 return Ok(());
1443 }
1444 let mut by_old: BTreeMap<String, Vec<(String, i64)>> = BTreeMap::new();
1445 for p in &picks {
1446 let (root, old): (String, String) = (p.get(0), p.get(1));
1447 let won = take_over_root(c, &me, &root, &old).map_err(|e| e.to_string())?;
1448 by_old.entry(old).or_default().extend(won);
1449 }
1450 for (old, items) in by_old {
1451 if items.is_empty() {
1452 continue;
1453 }
1454 tracing::warn!(from = %old, executions = items.len(), "shared: taking over executions of a node that stopped heartbeating");
1455 engine(&self.state)
1456 .adopt(&items, &old)
1457 .map_err(|e| e.to_string())?;
1458 }
1459 if (picks.len() as i64) < batch {
1460 return Ok(());
1461 }
1462 }
1463 }
1464
1465 fn evict_fenced(&mut self) {
1466 let fenced: Vec<String> = lock(&self.ctx.fenced).drain().collect();
1467 if fenced.is_empty() {
1468 return;
1469 }
1470 let mut e = engine(&self.state);
1471 for id in fenced {
1472 e.evict(&id);
1473 }
1474 }
1475
1476 fn reconcile_ownership(&mut self, c: &mut postgres::Client) -> Result<(), String> {
1479 let mut e = engine(&self.state);
1480 let rows = c
1481 .query(
1482 "SELECT execution_id, epoch FROM orbit_owners WHERE node_id = $1",
1483 &[&self.ctx.config.node_id],
1484 )
1485 .map_err(|e| e.to_string())?;
1486 let db: HashMap<String, i64> = rows.iter().map(|r| (r.get(0), r.get(1))).collect();
1487 let held = lock(&self.ctx.epochs).clone();
1488 for id in e.owned_ids() {
1489 if db.get(&id) != held.get(&id) {
1490 e.evict(&id);
1491 }
1492 }
1493 let missing: Vec<(String, i64)> = db.into_iter().filter(|(id, _)| !e.owns(id)).collect();
1494 if !missing.is_empty() {
1495 let me = self.me().to_string();
1496 e.adopt(&missing, &me).map_err(|e| e.to_string())?;
1497 }
1498 Ok(())
1499 }
1500
1501 fn sweep_claims(&mut self, c: &mut postgres::Client) -> Result<(), String> {
1504 let units = engine(&self.state).queued_units();
1505 let n = requeue(c, &units, None, Some(now_ms() - 30_000)).map_err(|e| e.to_string())?;
1506 if n > 0 {
1507 tracing::warn!(
1508 tasks = n,
1509 "shared: requeued tasks whose claim never reached this owner"
1510 );
1511 let _ = c.execute("SELECT pg_notify('orbit_tasks', '')", &[]);
1512 }
1513 Ok(())
1514 }
1515
1516 fn consume_inbox(&mut self, c: &mut postgres::Client) -> Result<(), String> {
1518 let rows = c
1519 .query(
1520 "SELECT i.seq, i.execution_id, i.kind, i.payload FROM orbit_inbox i
1521 JOIN orbit_owners o ON o.execution_id = i.execution_id
1522 WHERE o.node_id = $1 AND i.done_ms IS NULL
1523 ORDER BY i.seq LIMIT 500",
1524 &[&self.ctx.config.node_id],
1525 )
1526 .map_err(|e| e.to_string())?;
1527 let mut blocked: HashSet<String> = HashSet::new();
1528 for row in rows {
1529 let (seq, exec, kind, payload): (i64, String, String, String) =
1530 (row.get(0), row.get(1), row.get(2), row.get(3));
1531 if blocked.contains(&exec) {
1532 continue;
1533 }
1534 let p: Value = serde_json::from_str(&payload).unwrap_or(Value::Null);
1535 let mut e = engine(&self.state);
1536 if !e.owns(&exec) {
1537 blocked.insert(exec);
1538 continue;
1539 }
1540 self.ctx.stage(seq, &exec, &kind);
1541 self.ctx.applying.store(true, Ordering::SeqCst);
1542 let r = apply_entry(&mut e, &exec, &kind, &p);
1543 self.ctx.applying.store(false, Ordering::SeqCst);
1544 let cancelled_state = (kind == "cancel" && r.is_ok())
1547 .then(|| e.execution(&exec).ok())
1548 .flatten()
1549 .map(|st| json!({ "ok": true, "state": st }).to_string());
1550 drop(e);
1551 if self.ctx.unstage(seq) {
1552 if let Some(final_reply) = cancelled_state {
1553 let _ = c.execute(
1554 "UPDATE orbit_inbox SET result = $2 WHERE seq = $1",
1555 &[&seq, &final_reply],
1556 );
1557 }
1558 continue;
1559 }
1560 let reply = |v: String| matches!(kind.as_str(), "signal" | "cancel").then_some(v);
1561 let finish = match r {
1562 Err(err) if err.code == "STORAGE_ERROR" => {
1563 tracing::warn!(execution_id = %exec, kind = %kind, error = %err, "shared: applying an inbox entry failed");
1565 blocked.insert(exec);
1566 continue;
1567 }
1568 Err(err) => reply(error_json(err.status, err.code, &err.message)),
1569 Ok(()) => {
1570 reply(cancelled_state.unwrap_or_else(|| json!({ "ok": true }).to_string()))
1571 }
1572 };
1573 match finish {
1574 None => c.execute("DELETE FROM orbit_inbox WHERE seq = $1", &[&seq]),
1575 Some(r) => c.execute(
1576 "UPDATE orbit_inbox SET done_ms = $2, result = $3 WHERE seq = $1",
1577 &[&seq, &now_ms(), &r],
1578 ),
1579 }
1580 .map_err(|e| e.to_string())?;
1581 }
1582 Ok(())
1583 }
1584
1585 fn flush_touches(&mut self, c: &mut postgres::Client) -> Result<(), String> {
1587 let touched: Vec<(String, Instant)> = lock(&self.ctx.touched).drain().collect();
1588 if touched.is_empty() {
1589 return Ok(());
1590 }
1591 let ids: Vec<String> = touched.iter().map(|t| t.0.clone()).collect();
1592 let ago: Vec<i64> = touched
1593 .iter()
1594 .map(|t| t.1.elapsed().as_millis() as i64)
1595 .collect();
1596 let found: HashSet<String> = c
1597 .query(
1598 &format!(
1599 "UPDATE orbit_sessions s SET last_seen_ms = GREATEST(s.last_seen_ms, {NOW_MS} - v.ago)
1600 FROM unnest($1::text[], $2::bigint[]) AS v(id, ago)
1601 WHERE s.session_id = v.id RETURNING s.session_id"
1602 ),
1603 &[&ids, &ago],
1604 )
1605 .map_err(|e| e.to_string())?
1606 .into_iter()
1607 .map(|r| r.get(0))
1608 .collect();
1609 let gone: Vec<String> = ids.into_iter().filter(|i| !found.contains(i)).collect();
1610 if !gone.is_empty() {
1611 engine(&self.state).forget_sessions(&gone);
1612 }
1613 Ok(())
1614 }
1615
1616 fn refresh_seen(&mut self, c: &mut postgres::Client) -> Result<(), String> {
1619 let attempts = engine(&self.state).dispatched_attempts();
1620 let mut sessions: Vec<String> = attempts.iter().map(|a| a.0.clone()).collect();
1621 sessions.sort();
1622 sessions.dedup();
1623 let tokens: Vec<String> = attempts.into_iter().map(|a| a.1).collect();
1624 let mut lost = HashSet::new();
1625 if !tokens.is_empty() {
1626 let session_timeout = self.ctx.session_timeout_ms as i64;
1627 let grace = self.ctx.config.node_timeout.as_millis() as i64 + session_timeout;
1628 for r in c
1629 .query(
1630 &format!(
1631 "SELECT t.token FROM orbit_tasks t LEFT JOIN orbit_nodes n ON n.node_id = t.claimed_by
1632 WHERE t.token = ANY($1) AND t.state <> 'done' AND t.claimed_inc IS NOT NULL
1633 AND (n.node_id IS NULL
1634 OR {NOW_MS} - n.heartbeat_ms > $2
1635 OR (n.incarnation <> t.claimed_inc AND {NOW_MS} - n.started_ms > $3))"
1636 ),
1637 &[&tokens, &grace, &session_timeout],
1638 )
1639 .map_err(|e| e.to_string())?
1640 {
1641 lost.insert(r.get::<_, String>(0));
1642 }
1643 }
1644 *lock(&self.ctx.lost) = lost;
1645 let mut seen: HashMap<String, Option<(u64, Instant)>> =
1646 sessions.iter().map(|s| (s.clone(), None)).collect();
1647 if !sessions.is_empty() {
1648 let at = Instant::now();
1649 for r in c
1650 .query(
1651 &format!(
1652 "SELECT session_id, {NOW_MS} - last_seen_ms FROM orbit_sessions WHERE session_id = ANY($1)"
1653 ),
1654 &[&sessions],
1655 )
1656 .map_err(|e| e.to_string())?
1657 {
1658 let age: i64 = r.get(1);
1659 seen.insert(r.get(0), Some((age.max(0) as u64, at)));
1660 }
1661 }
1662 *lock(&self.ctx.seen) = seen;
1663 Ok(())
1664 }
1665
1666 fn cleanup(&mut self, c: &mut postgres::Client) -> Result<(), String> {
1667 let retain = (self.ctx.session_timeout_ms * 4).max(60_000) as i64;
1668 let cutoff = now_ms() - retain;
1669 let e = |e: postgres::Error| e.to_string();
1670 c.execute(
1671 &format!("DELETE FROM orbit_sessions WHERE {NOW_MS} - last_seen_ms > $1"),
1672 &[&((self.ctx.session_timeout_ms * 4) as i64)],
1673 )
1674 .map_err(e)?;
1675 c.execute(
1676 "DELETE FROM orbit_tasks t WHERE t.state NOT IN ('queued', 'claimed') AND t.updated_ms < $1
1677 AND NOT EXISTS (SELECT 1 FROM orbit_owners o WHERE o.execution_id = t.execution_id)",
1678 &[&cutoff],
1679 )
1680 .map_err(e)?;
1681 c.execute(
1682 "DELETE FROM orbit_inbox i WHERE i.created_ms < $1
1683 AND (i.done_ms IS NOT NULL OR NOT EXISTS (SELECT 1 FROM orbit_owners o WHERE o.execution_id = i.execution_id))",
1684 &[&(now_ms() - 60_000)],
1685 )
1686 .map_err(e)?;
1687 c.execute(
1688 &format!(
1689 "DELETE FROM orbit_nodes n WHERE {NOW_MS} - n.heartbeat_ms > 3600000
1690 AND NOT EXISTS (SELECT 1 FROM orbit_owners o WHERE o.node_id = n.node_id)"
1691 ),
1692 &[],
1693 )
1694 .map_err(e)?;
1695 Ok(())
1696 }
1697}
1698
1699pub fn take_over_root(
1704 c: &mut postgres::Client,
1705 me: &str,
1706 root: &str,
1707 old: &str,
1708) -> Result<Vec<(String, i64)>, postgres::Error> {
1709 Ok(c.query(
1710 &format!(
1711 "UPDATE orbit_owners SET node_id = $1, epoch = epoch + 1, since_ms = {NOW_MS}
1712 WHERE root_id = $2 AND node_id = $3 RETURNING execution_id, epoch"
1713 ),
1714 &[&me, &root, &old],
1715 )?
1716 .iter()
1717 .map(|r| (r.get(0), r.get(1)))
1718 .collect())
1719}
1720
1721pub(crate) fn poll(state: &Shared, session: &str) -> Result<Option<TaskAssignment>, EngineError> {
1725 let (ctx, worker, runtime, contracts) = {
1726 let mut e = engine(state);
1727 let Some(ctx) = e.shared_context() else {
1728 return e.poll(session);
1729 };
1730 match e.prepare_claim(session)? {
1731 Some((w, r, c)) => (ctx, w, r, c),
1732 None => return Ok(None),
1733 }
1734 };
1735 for _ in 0..16 {
1736 let now = engine(state).now();
1737 match ctx.claim(session, &worker, &runtime, &contracts, now)? {
1738 Claimed::Nothing => return Ok(None),
1739 Claimed::Stale => continue,
1740 Claimed::Remote(a) => return Ok(Some(a)),
1741 Claimed::Local {
1742 row_id,
1743 offer,
1744 assignment: a,
1745 } => {
1746 let applied = engine(state).apply_dispatch(
1747 &a.execution_id,
1748 &a.step_id,
1749 a.attempt,
1750 offer,
1751 &a.token,
1752 &worker,
1753 session,
1754 a.deadline_ms,
1755 );
1756 match applied {
1757 Ok(true) => return Ok(Some(a)),
1758 Ok(false) => ctx.retire(row_id, &a.token)?,
1759 Err(e) => {
1760 let _ = ctx.release(row_id, &a.token);
1761 return Err(e);
1762 }
1763 }
1764 }
1765 }
1766 }
1767 Ok(None)
1768}
1769
1770fn apply_entry(e: &mut Engine, exec: &str, kind: &str, p: &Value) -> Result<(), EngineError> {
1772 let s = |k: &str| p[k].as_str().unwrap_or("").to_string();
1773 match kind {
1774 "dispatched" => {
1775 e.apply_dispatch(
1776 exec,
1777 &s("unit"),
1778 p["attempt"].as_u64().unwrap_or(0) as u32,
1779 p["offer"].as_u64().unwrap_or(0) as u32,
1780 &s("token"),
1781 &s("worker_id"),
1782 &s("session"),
1783 p["deadline_ms"].as_u64(),
1784 )?;
1785 }
1786 "complete" => {
1787 e.complete(&s("token"), p["output"].clone())?;
1788 }
1789 "fail" => {
1790 let error = serde_json::from_value(p["error"].clone()).unwrap_or_else(|_| {
1791 crate::model::TaskError::new("UNKNOWN", "unreadable failure", true)
1792 });
1793 e.fail(&s("token"), error)?;
1794 }
1795 "uncertain" => {
1796 e.mark_uncertain(&s("token"), &s("reason"))?;
1797 }
1798 "signal" => {
1799 e.signal(exec, &s("name"), p["payload"].clone())?;
1800 }
1801 "cancel" => {
1802 e.cancel(exec, &s("reason"))?;
1803 }
1804 other => tracing::warn!(kind = other, "shared: unknown inbox entry"),
1805 }
1806 Ok(())
1807}
1808
1809#[derive(Clone)]
1812struct Gate {
1813 ctx: Arc<Ctx>,
1814 state: Shared,
1815}
1816
1817pub fn layer(app: Router, ctx: Arc<Ctx>, state: Shared) -> Router {
1821 app.layer(middleware::from_fn_with_state(Gate { ctx, state }, gate))
1822}
1823
1824fn health_fields(ctx: &Ctx) -> Value {
1825 json!({
1826 "role": "shared",
1827 "node_id": ctx.config.node_id,
1828 "term": null,
1829 "leader": null,
1830 "owned_executions": ctx.owned_executions(),
1831 "tasks_claimed": ctx.tasks_claimed(),
1832 "nodes": lock(&ctx.nodes).clone(),
1833 })
1834}
1835
1836async fn gate(State(g): State<Gate>, req: Request, next: Next) -> Response {
1837 let path = req.uri().path().to_string();
1838 if path == "/v1/ready" {
1839 let ready = g.ctx.is_ready();
1840 let mut body = health_fields(&g.ctx);
1841 body["ready"] = json!(ready);
1842 let status = if ready {
1843 StatusCode::OK
1844 } else {
1845 StatusCode::SERVICE_UNAVAILABLE
1846 };
1847 return (status, Json(body)).into_response();
1848 }
1849 if path == "/v1/health" {
1850 let resp = next.run(req).await;
1851 return crate::cluster::merge_json(resp, health_fields(&g.ctx)).await;
1852 }
1853 if g.ctx.defs_dirty.swap(false, Ordering::SeqCst) {
1854 let st = g.state.clone();
1855 let _ = tokio::task::spawn_blocking(move || engine(&st).refresh_definitions()).await;
1856 }
1857 let post = req.method() == Method::POST;
1858 let forwarding = post
1859 && path.starts_with("/v1/executions/")
1860 && (path.ends_with("/cancel") || path.contains("/signals/"));
1861 if forwarding {
1862 let slot = Arc::new(Mutex::new(None));
1863 let resp = FORWARDED.scope(slot.clone(), next.run(req)).await;
1864 let seq = *lock(&slot);
1865 return match seq {
1866 Some(seq) if resp.status() == StatusCode::OK => {
1867 finish_forwarded(&g, &path, seq, resp).await
1868 }
1869 _ => resp,
1870 };
1871 }
1872 let registers = post && path == "/v1/workflows";
1873 let resp = next.run(req).await;
1874 if registers && resp.status() == StatusCode::CREATED {
1875 let ctx = g.ctx.clone();
1876 let _ = tokio::task::spawn_blocking(move || {
1877 ctx.ops().run(|c| {
1878 c.execute("SELECT pg_notify('orbit_defs', '')", &[])?;
1879 Ok(())
1880 })
1881 })
1882 .await;
1883 }
1884 resp
1885}
1886
1887async fn finish_forwarded(g: &Gate, path: &str, seq: i64, resp: Response) -> Response {
1890 let ctx = g.ctx.clone();
1891 let reply = tokio::task::spawn_blocking(move || ctx.wait_reply(seq, REPLY_TIMEOUT))
1892 .await
1893 .ok()
1894 .flatten();
1895 if let Some(err) = reply.as_ref().and_then(|r| r.get("error")) {
1896 let status = err["status"]
1897 .as_u64()
1898 .and_then(|s| StatusCode::from_u16(s as u16).ok())
1899 .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1900 let (parts, _) = resp.into_parts();
1901 let mut out = (
1902 status,
1903 Json(json!({ "error": { "code": err["code"], "message": err["message"] } })),
1904 )
1905 .into_response();
1906 for (k, v) in parts.headers.iter() {
1907 if k != "content-length" && k != "content-type" {
1908 out.headers_mut().insert(k, v.clone());
1909 }
1910 }
1911 return out;
1912 }
1913 let id = path
1914 .trim_start_matches("/v1/executions/")
1915 .split('/')
1916 .next()
1917 .unwrap_or("")
1918 .to_string();
1919 if path.ends_with("/cancel") {
1920 if reply.is_none() {
1921 return resp;
1922 }
1923 if let Some(state) = reply.as_ref().and_then(|r| r.get("state")).cloned() {
1924 let (parts, _) = resp.into_parts();
1925 let body = axum::body::Body::from(serde_json::to_vec(&state).unwrap_or_default());
1926 let mut out = Response::from_parts(parts, body);
1927 out.headers_mut().remove("content-length");
1928 return out;
1929 }
1930 let st = g.state.clone();
1931 return match tokio::task::spawn_blocking(move || engine(&st).execution(&id)).await {
1932 Ok(Ok(s)) => {
1933 let (parts, _) = resp.into_parts();
1934 let body = axum::body::Body::from(serde_json::to_vec(&s).unwrap_or_default());
1935 let mut out = Response::from_parts(parts, body);
1936 out.headers_mut().remove("content-length");
1937 out
1938 }
1939 _ => resp,
1940 };
1941 }
1942 let seq_value = reply
1943 .and_then(|r| r.get("seq").cloned())
1944 .unwrap_or(Value::Null);
1945 crate::cluster::merge_json(resp, json!({ "seq": seq_value })).await
1946}