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 ) -> crate::store::Result<()> {
406 let Store::Postgres(pg) = store else {
407 return Err(StoreError("shared mode needs a PostgreSQL store".into()));
408 };
409 let id = state.id.clone();
410 let created = history.first().is_some_and(|h| h.seq == 1);
411 let epoch = if created {
412 None
413 } else {
414 match lock(&self.epochs).get(&id).copied() {
415 Some(e) => Some(e),
416 None => {
417 return Err(StoreError(format!(
418 "fenced: this server does not own execution `{id}`"
419 )))
420 }
421 }
422 };
423 let (offers, syncs) = match def {
424 Some(d) => queue_changes(state, history, d),
425 None => (vec![], vec![]),
426 };
427 let mut consumed = vec![];
428 {
429 let signal_seq = history
430 .iter()
431 .find_map(|h| matches!(h.event, Event::SignalReceived { .. }).then_some(h.seq));
432 for e in lock(&self.staged).iter_mut() {
433 if e.execution_id == id && !e.consumed {
434 e.consumed = true;
435 let reply = match e.kind.as_str() {
436 "signal" => Some(json!({ "seq": signal_seq }).to_string()),
437 "cancel" => Some(json!({ "ok": true }).to_string()),
438 _ => None,
439 };
440 consumed.push((e.seq, reply));
441 }
442 }
443 }
444 let done_tokens: Vec<String> = {
445 let mut d = lock(&self.done);
446 let (mine, rest): (Vec<_>, Vec<_>) = d.drain(..).partition(|(e, _)| *e == id);
447 *d = rest;
448 mine.into_iter().map(|(_, t)| t).collect()
449 };
450 let terminal = state.status.is_terminal();
451 let ext = SharedAppend {
452 node_id: self.config.node_id.clone(),
453 epoch,
454 parent: state.parent.as_ref().map(|p| p.execution_id.clone()),
455 offers,
456 syncs,
457 done_tokens,
458 consumed: consumed.clone(),
459 finished_reply: terminal.then(|| {
460 error_json(
461 409,
462 "EXECUTION_FINISHED",
463 &format!("execution is already {}", state.status.as_str()),
464 )
465 }),
466 };
467 let result = pg.append_shared(state, history, ext);
468 match &result {
469 Ok(()) => {
470 let mut epochs = lock(&self.epochs);
471 if terminal {
472 epochs.remove(&id);
473 } else if created {
474 epochs.insert(id, 1);
475 }
476 }
477 Err(e) => {
478 let seqs: HashSet<i64> = consumed.iter().map(|(s, _)| *s).collect();
479 for e in lock(&self.staged).iter_mut() {
480 if seqs.contains(&e.seq) {
481 e.consumed = false;
482 }
483 }
484 if e.0.starts_with("fenced") && !created {
485 lock(&self.fenced).insert(id);
486 }
487 }
488 }
489 result
490 }
491
492 pub(crate) fn should_claim(&self, session: &str) -> bool {
494 let gen = self.queue_gen.load(Ordering::SeqCst);
495 lock(&self.empty)
496 .get(session)
497 .is_none_or(|(g, at)| *g != gen || at.elapsed() >= REQUERY)
498 }
499
500 pub(crate) fn claim(
502 &self,
503 session: &str,
504 worker_id: &str,
505 runtime: &str,
506 contracts: &HashSet<(String, String)>,
507 now: u64,
508 ) -> Result<Claimed, EngineError> {
509 let gen = self.queue_gen.load(Ordering::SeqCst);
510 let (names, versions): (Vec<String>, Vec<String>) = contracts.iter().cloned().unzip();
511 let suffix = uuid::Uuid::new_v4().to_string();
512 let me = self.config.node_id.clone();
513 let epochs = self.epochs.clone();
514 let incarnation = self.incarnation.clone();
515 let (session_id, worker, runtime) = (
516 session.to_string(),
517 worker_id.to_string(),
518 runtime.to_lowercase(),
519 );
520 let now = now as i64;
521 let claimed = self
522 .ops()
523 .run(move |c| {
524 let mut tx = c.transaction()?;
525 let Some(row) = tx.query_opt(
526 "WITH c AS (
527 SELECT id FROM orbit_tasks
528 WHERE state = 'queued'
529 AND (task_name, contract_version) IN (SELECT n, v FROM unnest($1::text[], $2::text[]) AS u(n, v))
530 AND (language IS NULL OR language = 'any' OR language = $3)
531 ORDER BY enqueued_ms, id
532 LIMIT 1
533 FOR UPDATE SKIP LOCKED)
534 UPDATE orbit_tasks t
535 SET state = 'claimed', token = t.execution_id || '/' || $4, claimed_by = $5, claimed_inc = $9,
536 claimed_session = $6, claimed_worker = $7, claimed_ms = $8, updated_ms = $8
537 FROM c WHERE t.id = c.id
538 RETURNING t.id, t.execution_id, t.unit_key, t.attempt, t.offer, t.payload, t.token",
539 &[&names, &versions, &runtime, &suffix, &me, &session_id, &worker, &now, &incarnation],
540 )?
541 else {
542 return Ok(None);
543 };
544 let (row_id, exec, unit, attempt, offer, payload, token): (
545 i64,
546 String,
547 String,
548 i64,
549 i64,
550 String,
551 String,
552 ) = (
553 row.get(0),
554 row.get(1),
555 row.get(2),
556 row.get(3),
557 row.get(4),
558 row.get(5),
559 row.get(6),
560 );
561 let mut a: TaskAssignment = serde_json::from_str(&payload)
562 .map_err(|e| StoreError(format!("unreadable queued task: {e}")))?;
563 a.token = token.clone();
564 a.deadline_ms = a.timeout_ms.map(|t| now as u64 + t);
565 let owner: Option<(String, i64)> = tx
566 .query_opt(
567 "SELECT node_id, epoch FROM orbit_owners WHERE execution_id = $1 FOR SHARE",
568 &[&exec],
569 )?
570 .map(|r| (r.get(0), r.get(1)));
571 let mine = lock(&epochs).get(&exec).copied();
572 let local = matches!((&owner, mine), (Some((n, e)), Some(m)) if *n == me && *e == m);
573 let outcome = match &owner {
574 _ if local => Claimed::Local {
575 row_id,
576 offer: offer as u32,
577 assignment: a,
578 },
579 None => {
580 tx.execute(
581 "UPDATE orbit_tasks SET state = 'cancelled', updated_ms = $2 WHERE id = $1",
582 &[&row_id, &now],
583 )?;
584 Claimed::Stale
585 }
586 Some((owner_node, _)) => {
587 let msg = json!({
588 "unit": unit,
589 "attempt": attempt,
590 "offer": offer,
591 "token": token,
592 "worker_id": worker,
593 "session": session_id,
594 "deadline_ms": a.deadline_ms,
595 });
596 tx.execute(
597 "INSERT INTO orbit_inbox (execution_id, kind, token, payload, created_ms) VALUES ($1, 'dispatched', $2, $3, $4)",
598 &[&exec, &token, &msg.to_string(), &now],
599 )?;
600 tx.execute("SELECT pg_notify('orbit_inbox', $1)", &[owner_node])?;
601 Claimed::Remote(a)
602 }
603 };
604 tx.commit()?;
605 Ok(Some(outcome))
606 })
607 .map_err(storage)?;
608 match claimed {
609 None => {
610 lock(&self.empty).insert(session.to_string(), (gen, Instant::now()));
611 Ok(Claimed::Nothing)
612 }
613 Some(c) => {
614 lock(&self.empty).remove(session);
615 if !matches!(c, Claimed::Stale) {
616 self.tasks_claimed.fetch_add(1, Ordering::Relaxed);
617 }
618 Ok(c)
619 }
620 }
621 }
622
623 pub(crate) fn retire(&self, row_id: i64, token: &str) -> Result<(), EngineError> {
625 let token = token.to_string();
626 self.ops()
627 .run(move |c| {
628 c.execute(
629 "UPDATE orbit_tasks SET state = 'cancelled', updated_ms = $3 WHERE id = $1 AND token = $2",
630 &[&row_id, &token, &now_ms()],
631 )?;
632 Ok(())
633 })
634 .map_err(storage)
635 }
636
637 pub(crate) fn release(&self, row_id: i64, token: &str) -> Result<(), EngineError> {
639 let token = token.to_string();
640 self.ops()
641 .run(move |c| {
642 c.execute(
643 "UPDATE orbit_tasks SET state = 'queued', token = NULL, claimed_by = NULL, claimed_inc = NULL,
644 claimed_session = NULL, claimed_worker = NULL, claimed_ms = NULL, updated_ms = $3
645 WHERE id = $1 AND token = $2 AND state = 'claimed'",
646 &[&row_id, &token, &now_ms()],
647 )?;
648 c.execute("SELECT pg_notify('orbit_tasks', '')", &[])?;
649 Ok(())
650 })
651 .map_err(storage)
652 }
653
654 pub(crate) fn forward(
659 &self,
660 execution_id: &str,
661 kind: &str,
662 payload: Value,
663 token: Option<&str>,
664 ) -> Result<Option<i64>, EngineError> {
665 let (exec, kind, token) = (
666 execution_id.to_string(),
667 kind.to_string(),
668 token.map(str::to_string),
669 );
670 let payload = payload.to_string();
671 self.ops()
672 .run(move |c| {
673 let mut tx = c.transaction()?;
674 let Some(owner) = tx
675 .query_opt(
676 "SELECT node_id FROM orbit_owners WHERE execution_id = $1",
677 &[&exec],
678 )?
679 .map(|r| r.get::<_, String>(0))
680 else {
681 return Ok(None);
682 };
683 let now = now_ms();
684 if let Some(t) = &token {
685 tx.execute(
686 "UPDATE orbit_tasks SET state = 'done', updated_ms = $2 WHERE token = $1 AND state IN ('claimed', 'cancelled')",
687 &[t, &now],
688 )?;
689 }
690 let seq: i64 = tx
691 .query_one(
692 "INSERT INTO orbit_inbox (execution_id, kind, token, payload, created_ms) VALUES ($1, $2, $3, $4, $5) RETURNING seq",
693 &[&exec, &kind, &token, &payload, &now],
694 )?
695 .get(0);
696 tx.execute("SELECT pg_notify('orbit_inbox', $1)", &[&owner])?;
697 tx.commit()?;
698 Ok(Some(seq))
699 })
700 .map_err(storage)
701 }
702
703 fn wait_reply(&self, seq: i64, timeout: Duration) -> Option<Value> {
706 let deadline = Instant::now() + timeout;
707 loop {
708 let r = self.ops().run(move |c| {
709 Ok(c.query_opt(
710 "SELECT done_ms IS NOT NULL, result FROM orbit_inbox WHERE seq = $1",
711 &[&seq],
712 )?
713 .map(|r| (r.get::<_, bool>(0), r.get::<_, Option<String>>(1))))
714 });
715 match r {
716 Ok(Some((true, result))) => {
717 return Some(
718 result
719 .and_then(|r| serde_json::from_str(&r).ok())
720 .unwrap_or(Value::Null),
721 )
722 }
723 Ok(None) => return Some(Value::Null),
724 _ => {}
725 }
726 if Instant::now() >= deadline {
727 return None;
728 }
729 std::thread::sleep(Duration::from_millis(20));
730 }
731 }
732
733 pub(crate) fn save_session(
735 &self,
736 session_id: &str,
737 worker_id: &str,
738 runtime: &str,
739 contracts: &[ContractAnnouncement],
740 ) -> Result<(), EngineError> {
741 let (sid, wid, rt, node) = (
742 session_id.to_string(),
743 worker_id.to_string(),
744 runtime.to_lowercase(),
745 self.config.node_id.clone(),
746 );
747 let contracts = serde_json::to_string(contracts).expect("contracts serialise");
748 self.ops()
749 .run(move |c| {
750 c.execute(
751 &format!(
752 "INSERT INTO orbit_sessions (session_id, worker_id, runtime, contracts, node_id, registered_ms, last_seen_ms)
753 VALUES ($1, $2, $3, $4, $5, {NOW_MS}, {NOW_MS}) ON CONFLICT DO NOTHING"
754 ),
755 &[&sid, &wid, &rt, &contracts, &node],
756 )?;
757 Ok(())
758 })
759 .map_err(storage)
760 }
761
762 #[allow(clippy::type_complexity)]
764 pub(crate) fn load_session(
765 &self,
766 session_id: &str,
767 session_timeout_ms: u64,
768 ) -> Result<Option<(String, String, Vec<(String, String)>)>, EngineError> {
769 let sid = session_id.to_string();
770 let retain = (session_timeout_ms * 4) as i64;
771 let row = self
772 .ops()
773 .run(move |c| {
774 Ok(c.query_opt(
775 &format!(
776 "SELECT worker_id, runtime, contracts FROM orbit_sessions WHERE session_id = $1 AND {NOW_MS} - last_seen_ms < $2"
777 ),
778 &[&sid, &retain],
779 )?
780 .map(|r| (r.get::<_, String>(0), r.get::<_, String>(1), r.get::<_, String>(2))))
781 })
782 .map_err(storage)?;
783 Ok(row.map(|(w, rt, contracts)| {
784 let list: Vec<ContractAnnouncement> =
785 serde_json::from_str(&contracts).unwrap_or_default();
786 (
787 w,
788 rt,
789 list.into_iter()
790 .map(|c| (c.task_name, c.contract_version))
791 .collect(),
792 )
793 }))
794 }
795
796 pub(crate) fn drop_session(
798 &self,
799 session_id: &str,
800 ) -> Result<Vec<(String, String)>, EngineError> {
801 let sid = session_id.to_string();
802 lock(&self.touched).remove(session_id);
803 self.ops()
804 .run(move |c| {
805 let mut tx = c.transaction()?;
806 tx.execute("DELETE FROM orbit_sessions WHERE session_id = $1", &[&sid])?;
807 let held = tx
808 .query(
809 "SELECT execution_id, token FROM orbit_tasks WHERE claimed_session = $1 AND state = 'claimed'",
810 &[&sid],
811 )?
812 .into_iter()
813 .map(|r| (r.get(0), r.get(1)))
814 .collect();
815 tx.commit()?;
816 Ok(held)
817 })
818 .map_err(storage)
819 }
820
821 pub(crate) fn cancelled_for(&self, session: &str) -> Result<Vec<String>, EngineError> {
824 let sid = session.to_string();
825 self.ops()
826 .run(move |c| {
827 Ok(c.query(
828 "UPDATE orbit_tasks SET state = 'cancel_sent', updated_ms = $2
829 WHERE claimed_session = $1 AND state = 'cancelled' RETURNING token",
830 &[&sid, &now_ms()],
831 )?
832 .into_iter()
833 .filter_map(|r| r.get::<_, Option<String>>(0))
834 .collect())
835 })
836 .map_err(storage)
837 }
838
839 pub(crate) fn touched(&self, session: &str) {
840 lock(&self.touched).insert(session.to_string(), Instant::now());
841 }
842
843 pub(crate) fn session_alive(&self, session: &str, timeout_ms: u64) -> bool {
846 match lock(&self.seen).get(session) {
847 None => true,
848 Some(None) => false,
849 Some(Some((age, at))) => {
850 age + (at.elapsed().as_millis() as u64) < timeout_ms + SESSION_LAG_MS
851 }
852 }
853 }
854
855 pub(crate) fn claim_lost(&self, token: Option<&str>) -> bool {
858 token.is_some_and(|t| lock(&self.lost).contains(t))
859 }
860
861 pub(crate) fn token_worker(&self, token: &str) -> Option<String> {
863 let t = token.to_string();
864 self.ops()
865 .run(move |c| {
866 Ok(c.query_opt(
867 "SELECT claimed_worker FROM orbit_tasks WHERE token = $1",
868 &[&t],
869 )?
870 .and_then(|r| r.get::<_, Option<String>>(0)))
871 })
872 .ok()
873 .flatten()
874 }
875
876 pub(crate) fn workers(
878 &self,
879 records: Vec<WorkerRecord>,
880 session_timeout_ms: u64,
881 ) -> Result<Vec<WorkerInfo>, EngineError> {
882 let timeout = session_timeout_ms as i64;
883 let (sessions, inflight): (HashMap<String, i64>, HashMap<String, i64>) = self
884 .ops()
885 .run(move |c| {
886 let s = c
887 .query(
888 &format!(
889 "SELECT worker_id, count(*) FROM orbit_sessions WHERE {NOW_MS} - last_seen_ms < $1 GROUP BY worker_id"
890 ),
891 &[&timeout],
892 )?
893 .into_iter()
894 .map(|r| (r.get(0), r.get(1)))
895 .collect();
896 let i = c
897 .query(
898 "SELECT claimed_worker, count(*) FROM orbit_tasks WHERE state = 'claimed' AND claimed_worker IS NOT NULL GROUP BY claimed_worker",
899 &[],
900 )?
901 .into_iter()
902 .map(|r| (r.get(0), r.get(1)))
903 .collect();
904 Ok((s, i))
905 })
906 .map_err(storage)?;
907 Ok(records
908 .into_iter()
909 .map(|record| {
910 let n = sessions.get(&record.worker_id).copied().unwrap_or(0) as usize;
911 WorkerInfo {
912 online: n > 0,
913 sessions: n,
914 inflight: inflight.get(&record.worker_id).copied().unwrap_or(0) as usize,
915 record,
916 }
917 })
918 .collect())
919 }
920
921 pub(crate) fn republish(
925 &self,
926 offers: Vec<(String, TaskOffer)>,
927 previous_owner: &str,
928 ) -> Result<(), EngineError> {
929 if offers.is_empty() {
930 return Ok(());
931 }
932 let previous = previous_owner.to_string();
933 self.ops()
934 .run(move |c| {
935 let mut tx = c.transaction()?;
936 let now = now_ms();
937 let ins = tx.prepare(
938 "INSERT INTO orbit_tasks (execution_id, unit_key, attempt, offer, task_name, contract_version, language, payload, enqueued_ms, state, updated_ms)
939 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'queued', $10)
940 ON CONFLICT (execution_id, unit_key, attempt, offer) DO NOTHING",
941 )?;
942 for (id, o) in &offers {
943 tx.execute(
944 &ins,
945 &[id, &o.unit_key, &o.attempt, &o.offer, &o.task_name, &o.contract_version, &o.language, &o.payload, &o.enqueued_ms, &now],
946 )?;
947 }
948 let units: Vec<(String, String, i64, i64)> = offers
949 .iter()
950 .map(|(id, o)| (id.clone(), o.unit_key.clone(), o.attempt, o.offer))
951 .collect();
952 requeue(&mut tx, &units, Some(&previous), None)?;
953 tx.execute("SELECT pg_notify('orbit_tasks', '')", &[])?;
954 tx.commit()?;
955 Ok(())
956 })
957 .map_err(storage)
958 }
959}
960
961fn requeue(
965 c: &mut impl postgres::GenericClient,
966 units: &[(String, String, i64, i64)],
967 by: Option<&str>,
968 older_than: Option<i64>,
969) -> Result<u64, postgres::Error> {
970 if units.is_empty() {
971 return Ok(0);
972 }
973 let ids: Vec<String> = units.iter().map(|u| u.0.clone()).collect();
974 let keys: Vec<String> = units.iter().map(|u| u.1.clone()).collect();
975 let attempts: Vec<i64> = units.iter().map(|u| u.2).collect();
976 let offers: Vec<i64> = units.iter().map(|u| u.3).collect();
977 c.execute(
978 "UPDATE orbit_tasks t SET state = 'queued', token = NULL, claimed_by = NULL, claimed_inc = NULL,
979 claimed_session = NULL, claimed_worker = NULL, claimed_ms = NULL, updated_ms = $7
980 WHERE t.state = 'claimed'
981 AND ($5::text IS NULL OR t.claimed_by = $5)
982 AND ($6::bigint IS NULL OR t.claimed_ms < $6)
983 AND (t.execution_id, t.unit_key, t.attempt, t.offer) IN
984 (SELECT * FROM unnest($1::text[], $2::text[], $3::bigint[], $4::bigint[]))
985 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)",
986 &[&ids, &keys, &attempts, &offers, &by, &older_than, &now_ms()],
987 )
988}
989
990fn connect(config: &SharedConfig, purpose: &str) -> Result<postgres::Client, String> {
993 let mut c = session_client(
994 &config.url,
995 &format!("orbit-shared-{purpose}:{}", config.node_id),
996 )
997 .map_err(|e| e.to_string())?;
998 c.batch_execute("SET statement_timeout = '30s'")
999 .map_err(|e| e.to_string())?;
1000 let mut tx = c.transaction().map_err(|e| e.to_string())?;
1001 tx.execute("SELECT pg_advisory_xact_lock($1)", &[&SCHEMA_LOCK_KEY])
1002 .map_err(|e| e.to_string())?;
1003 let exists: bool = tx
1004 .query_one("SELECT to_regclass('orbit_sessions') IS NOT NULL", &[])
1005 .map_err(|e| e.to_string())?
1006 .get(0);
1007 if !exists {
1008 tx.batch_execute(SHARED_SCHEMA).map_err(|e| e.to_string())?;
1009 }
1010 tx.commit().map_err(|e| e.to_string())?;
1011 Ok(c)
1012}
1013
1014fn register_node(
1018 c: &mut postgres::Client,
1019 config: &SharedConfig,
1020 incarnation: &str,
1021) -> Result<(), String> {
1022 let timeout = config.node_timeout.as_millis() as i64;
1023 let deadline = Instant::now() + config.node_timeout * 3;
1024 let mut warned = false;
1025 loop {
1026 let row = c
1027 .query_opt(
1028 &format!(
1029 "SELECT incarnation, {NOW_MS} - heartbeat_ms FROM orbit_nodes WHERE node_id = $1"
1030 ),
1031 &[&config.node_id],
1032 )
1033 .map_err(|e| e.to_string())?;
1034 if let Some((inc, age)) = row.map(|r| (r.get::<_, String>(0), r.get::<_, i64>(1))) {
1035 if inc != incarnation && age < timeout {
1036 if Instant::now() >= deadline {
1037 return Err(format!(
1038 "another server with node id `{}` is running (it heartbeated {age} ms ago); give every server its own --node-id",
1039 config.node_id
1040 ));
1041 }
1042 if !warned {
1043 tracing::warn!(node_id = %config.node_id, "shared: a server with this node id heartbeated recently; waiting for it to expire");
1044 warned = true;
1045 }
1046 std::thread::sleep(Duration::from_millis(250));
1047 continue;
1048 }
1049 }
1050 c.execute(
1051 &format!(
1052 "INSERT INTO orbit_nodes (node_id, incarnation, addr, heartbeat_ms, started_ms) VALUES ($1, $2, $3, {NOW_MS}, {NOW_MS})
1053 ON CONFLICT (node_id) DO UPDATE SET incarnation = EXCLUDED.incarnation, addr = EXCLUDED.addr,
1054 heartbeat_ms = EXCLUDED.heartbeat_ms, started_ms = EXCLUDED.started_ms"
1055 ),
1056 &[&config.node_id, &incarnation, &config.advertise_addr],
1057 )
1058 .map_err(|e| e.to_string())?;
1059 return Ok(());
1060 }
1061}
1062
1063pub fn open(
1066 config: SharedConfig,
1067 factory: impl Fn(Store) -> Result<Engine, EngineError>,
1068) -> Result<Engine, EngineError> {
1069 let store = PgStore::connect_with(&config.url, PgMode::Shared).map_err(storage)?;
1070 attach(factory(Store::Postgres(store))?, config)
1071}
1072
1073pub fn attach(mut engine: Engine, config: SharedConfig) -> Result<Engine, EngineError> {
1078 let unavailable = |e: String| EngineError::new(500, "STORAGE_ERROR", e);
1079 let incarnation = uuid::Uuid::new_v4().to_string();
1080 let mut c = connect(&config, "control").map_err(unavailable)?;
1081 register_node(&mut c, &config, &incarnation).map_err(unavailable)?;
1082 drop(c);
1083 let pool = (0..OPS_CONNECTIONS)
1084 .map(|_| PgStore::connect_with(&config.url, PgMode::Shared))
1085 .collect::<Result<Vec<_>, _>>()
1086 .map_err(storage)?;
1087 let ctx = Arc::new(Ctx {
1088 session_timeout_ms: engine.config.session_timeout_ms,
1089 config,
1090 incarnation,
1091 pool,
1092 next: std::sync::atomic::AtomicUsize::new(0),
1093 epochs: Arc::new(Mutex::new(HashMap::new())),
1094 staged: Mutex::new(vec![]),
1095 done: Mutex::new(vec![]),
1096 fenced: Mutex::new(HashSet::new()),
1097 touched: Mutex::new(HashMap::new()),
1098 seen: Mutex::new(HashMap::new()),
1099 lost: Mutex::new(HashSet::new()),
1100 queue_gen: AtomicU64::new(0),
1101 empty: Mutex::new(HashMap::new()),
1102 defs_dirty: AtomicBool::new(false),
1103 applying: AtomicBool::new(false),
1104 started: AtomicBool::new(false),
1105 duplicate: AtomicBool::new(false),
1106 last_beat: Mutex::new(Some(Instant::now())),
1107 nodes: Mutex::new(json!([])),
1108 tasks_claimed: AtomicU64::new(0),
1109 stop: AtomicBool::new(false),
1110 halt: AtomicBool::new(false),
1111 spawned: AtomicBool::new(false),
1112 exited: AtomicBool::new(false),
1113 });
1114 engine.shared = Some(ctx);
1115 Ok(engine)
1116}
1117
1118pub fn start(ctx: Arc<Ctx>, state: Shared) -> std::io::Result<()> {
1121 tracing::info!(
1122 node_id = %ctx.config.node_id,
1123 advertise_addr = ctx.config.advertise_addr.as_deref().unwrap_or(""),
1124 node_timeout_ms = ctx.config.node_timeout.as_millis() as u64,
1125 "cluster mode: shared (every server active)"
1126 );
1127 let (wake_tx, wake_rx) = mpsc::channel();
1128 let (c, s) = (ctx.clone(), state.clone());
1129 std::thread::Builder::new()
1130 .name("orbit-shared".into())
1131 .spawn(move || Control::new(c, s).run(wake_rx))?;
1132 ctx.spawned.store(true, Ordering::SeqCst);
1133 std::thread::Builder::new()
1134 .name("orbit-shared-listen".into())
1135 .spawn(move || listen_loop(&ctx, &state, wake_tx))?;
1136 Ok(())
1137}
1138
1139fn listen_loop(ctx: &Ctx, state: &Shared, wake: mpsc::Sender<()>) {
1143 while !ctx.stopped() {
1144 let mut c = match connect(&ctx.config, "listen") {
1145 Ok(c) => c,
1146 Err(e) => {
1147 tracing::warn!(error = %e, "shared: cannot open the notification session; retrying");
1148 std::thread::sleep(Duration::from_secs(1));
1149 continue;
1150 }
1151 };
1152 if let Err(e) = c.batch_execute(
1153 "LISTEN orbit_inbox; LISTEN orbit_tasks; LISTEN orbit_done; LISTEN orbit_defs",
1154 ) {
1155 tracing::warn!(error = %e, "shared: LISTEN failed; retrying");
1156 std::thread::sleep(Duration::from_secs(1));
1157 continue;
1158 }
1159 ctx.queue_gen.fetch_add(1, Ordering::SeqCst);
1161 ctx.defs_dirty.store(true, Ordering::SeqCst);
1162 let _ = wake.send(());
1163 state.changed.notify_waiters();
1164 'session: while !ctx.stopped() {
1165 {
1166 let mut n = c.notifications();
1167 let mut it = n.timeout_iter(Duration::from_millis(500));
1168 loop {
1169 match it.next() {
1170 Ok(None) => break,
1171 Ok(Some(note)) => match note.channel() {
1172 "orbit_inbox" => {
1173 if note.payload() == ctx.config.node_id {
1174 let _ = wake.send(());
1175 }
1176 }
1177 "orbit_tasks" => {
1178 ctx.queue_gen.fetch_add(1, Ordering::SeqCst);
1179 state.changed.notify_waiters();
1180 }
1181 "orbit_done" => state.changed.notify_waiters(),
1182 "orbit_defs" => ctx.defs_dirty.store(true, Ordering::SeqCst),
1183 _ => {}
1184 },
1185 Err(e) => {
1186 tracing::warn!(error = %e, "shared: notification session failed; reconnecting");
1187 break 'session;
1188 }
1189 }
1190 }
1191 }
1192 if c.is_closed() {
1193 break;
1194 }
1195 }
1196 }
1197}
1198
1199struct Control {
1202 ctx: Arc<Ctx>,
1203 state: Shared,
1204 resumed_own: bool,
1205 beat: Instant,
1206 flush: Instant,
1207 seen: Instant,
1208 notify: Instant,
1209 reconcile: Instant,
1210 cleanup: Instant,
1211}
1212
1213impl Control {
1214 fn new(ctx: Arc<Ctx>, state: Shared) -> Control {
1215 let past = Instant::now() - Duration::from_secs(3600);
1216 Control {
1217 ctx,
1218 state,
1219 resumed_own: false,
1220 beat: past,
1221 flush: past,
1222 seen: past,
1223 notify: past,
1224 reconcile: Instant::now(),
1225 cleanup: Instant::now(),
1226 }
1227 }
1228
1229 fn me(&self) -> &str {
1230 &self.ctx.config.node_id
1231 }
1232
1233 fn run(mut self, wake: mpsc::Receiver<()>) {
1234 let mut client: Option<postgres::Client> = None;
1235 while !self.ctx.stopped() {
1236 if self.ctx.halted() {
1237 std::thread::sleep(Duration::from_millis(100));
1238 continue;
1239 }
1240 if client.as_ref().is_none_or(|c| c.is_closed()) {
1241 match connect(&self.ctx.config, "control") {
1242 Ok(c) => client = Some(c),
1243 Err(e) => {
1244 tracing::warn!(error = %e, "shared: cannot reach PostgreSQL; retrying");
1245 std::thread::sleep(Duration::from_secs(1));
1246 continue;
1247 }
1248 }
1249 }
1250 let c = client.as_mut().expect("connected");
1251 if let Err(e) = self.cycle(c) {
1252 tracing::warn!(error = %e, "shared: control cycle failed");
1253 std::thread::sleep(Duration::from_millis(200));
1254 }
1255 let _ = wake.recv_timeout(Duration::from_millis(100));
1256 while wake.try_recv().is_ok() {}
1257 }
1258 if !self.ctx.halted() {
1259 if let Some(c) = client.as_mut() {
1260 let _ = c.execute(
1263 "UPDATE orbit_tasks SET claimed_inc = NULL WHERE claimed_inc = $1",
1264 &[&self.ctx.incarnation],
1265 );
1266 let _ = c.execute(
1267 "UPDATE orbit_nodes SET heartbeat_ms = 0 WHERE node_id = $1 AND incarnation = $2",
1268 &[&self.ctx.config.node_id, &self.ctx.incarnation],
1269 );
1270 tracing::info!(node_id = %self.ctx.config.node_id, "shared: left the cluster; other servers take over this server's executions");
1271 }
1272 }
1273 self.ctx.exited.store(true, Ordering::SeqCst);
1274 }
1275
1276 fn cycle(&mut self, c: &mut postgres::Client) -> Result<(), String> {
1277 if !self.resumed_own {
1278 let rows = c
1281 .query(
1282 &format!(
1283 "UPDATE orbit_owners SET epoch = epoch + 1, since_ms = {NOW_MS} WHERE node_id = $1 RETURNING execution_id, epoch"
1284 ),
1285 &[&self.ctx.config.node_id],
1286 )
1287 .map_err(|e| e.to_string())?;
1288 let items: Vec<(String, i64)> = rows.iter().map(|r| (r.get(0), r.get(1))).collect();
1289 if !items.is_empty() {
1290 tracing::info!(
1291 executions = items.len(),
1292 "shared: resuming the executions this node owned before it restarted"
1293 );
1294 let me = self.me().to_string();
1295 engine(&self.state)
1296 .adopt(&items, &me)
1297 .map_err(|e| e.to_string())?;
1298 }
1299 self.resumed_own = true;
1300 *lock(&self.ctx.last_beat) = Some(Instant::now());
1301 self.ctx.started.store(true, Ordering::SeqCst);
1302 tracing::info!(node_id = %self.me(), resumed = items.len(), "shared: node is serving");
1303 }
1304 if self.beat.elapsed() >= self.ctx.config.heartbeat_interval {
1305 self.beat = Instant::now();
1306 self.heartbeat(c)?;
1307 self.take_over(c)?;
1308 self.evict_fenced();
1309 }
1310 self.consume_inbox(c)?;
1311 if self.flush.elapsed() >= Duration::from_millis(250) {
1312 self.flush = Instant::now();
1313 self.flush_touches(c)?;
1314 }
1315 if self.seen.elapsed() >= Duration::from_millis(500) {
1316 self.seen = Instant::now();
1317 self.refresh_seen(c)?;
1318 }
1319 if self.notify.elapsed() >= Duration::from_secs(1) {
1320 self.notify = Instant::now();
1321 if self.ctx.defs_dirty.swap(false, Ordering::SeqCst) {
1322 let _ = engine(&self.state).refresh_definitions();
1323 }
1324 self.state.changed.notify_waiters();
1326 }
1327 if self.reconcile.elapsed() >= Duration::from_secs(10) {
1328 self.reconcile = Instant::now();
1329 self.reconcile_ownership(c)?;
1330 self.sweep_claims(c)?;
1331 }
1332 if self.cleanup.elapsed() >= Duration::from_secs(30) {
1333 self.cleanup = Instant::now();
1334 self.cleanup(c)?;
1335 }
1336 Ok(())
1337 }
1338
1339 fn heartbeat(&mut self, c: &mut postgres::Client) -> Result<(), String> {
1340 let ctx = self.ctx.clone();
1341 let cfg = &ctx.config;
1342 let n = c
1343 .execute(
1344 &format!(
1345 "UPDATE orbit_nodes SET heartbeat_ms = {NOW_MS}, addr = $3 WHERE node_id = $1 AND incarnation = $2"
1346 ),
1347 &[&cfg.node_id, &ctx.incarnation, &cfg.advertise_addr],
1348 )
1349 .map_err(|e| e.to_string())?;
1350 let previous = *lock(&ctx.last_beat);
1351 if n == 0 {
1352 let row = c
1353 .query_opt(
1354 &format!("SELECT incarnation, {NOW_MS} - heartbeat_ms FROM orbit_nodes WHERE node_id = $1"),
1355 &[&cfg.node_id],
1356 )
1357 .map_err(|e| e.to_string())?
1358 .map(|r| (r.get::<_, String>(0), r.get::<_, i64>(1)));
1359 if let Some((_, age)) = row.filter(|(inc, age)| {
1360 *inc != ctx.incarnation && *age < cfg.node_timeout.as_millis() as i64
1361 }) {
1362 if !ctx.duplicate.swap(true, Ordering::SeqCst) {
1363 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");
1364 }
1365 let mut e = engine(&self.state);
1366 for id in e.owned_ids() {
1367 e.evict(&id);
1368 }
1369 return Ok(());
1370 }
1371 register_node(c, cfg, &ctx.incarnation)?;
1372 }
1373 ctx.duplicate.store(false, Ordering::SeqCst);
1374 *lock(&ctx.last_beat) = Some(Instant::now());
1375 if n == 0 || previous.is_none_or(|p| p.elapsed() >= cfg.node_timeout) {
1376 self.reconcile_ownership(c)?;
1378 }
1379 let rows = c
1380 .query(
1381 &format!(
1382 "SELECT n.node_id, n.addr, {NOW_MS} - n.heartbeat_ms,
1383 (SELECT count(*) FROM orbit_owners o WHERE o.node_id = n.node_id)
1384 FROM orbit_nodes n ORDER BY n.node_id"
1385 ),
1386 &[],
1387 )
1388 .map_err(|e| e.to_string())?;
1389 let timeout = cfg.node_timeout.as_millis() as i64;
1390 let nodes: Vec<Value> = rows
1391 .iter()
1392 .map(|r| {
1393 let age: i64 = r.get(2);
1394 json!({
1395 "node_id": r.get::<_, String>(0),
1396 "addr": r.get::<_, Option<String>>(1),
1397 "live": age < timeout,
1398 "heartbeat_age_ms": age.max(0),
1399 "owned_executions": r.get::<_, i64>(3),
1400 })
1401 })
1402 .filter(|n| n["live"] == json!(true) || n["owned_executions"] != json!(0))
1403 .collect();
1404 *lock(&ctx.nodes) = Value::Array(nodes);
1405 Ok(())
1406 }
1407
1408 fn take_over(&mut self, c: &mut postgres::Client) -> Result<(), String> {
1411 let timeout = self.ctx.config.node_timeout.as_millis() as i64;
1412 let batch = self.ctx.config.takeover_batch.max(1);
1413 let me = self.me().to_string();
1414 loop {
1415 let picks = c
1416 .query(
1417 &format!(
1418 "WITH live AS (SELECT node_id FROM orbit_nodes WHERE {NOW_MS} - heartbeat_ms < $1),
1419 orphan AS (
1420 SELECT DISTINCT ON (o.root_id) o.root_id, o.node_id AS old
1421 FROM orbit_owners o
1422 WHERE NOT EXISTS (SELECT 1 FROM live l WHERE l.node_id = o.node_id)
1423 ORDER BY o.root_id),
1424 pick AS (
1425 SELECT DISTINCT ON (orphan.root_id) orphan.root_id, orphan.old, l.node_id AS taker
1426 FROM orphan CROSS JOIN live l
1427 ORDER BY orphan.root_id, md5(orphan.root_id || '/' || l.node_id) DESC)
1428 SELECT root_id, old FROM pick WHERE taker = $2 LIMIT $3"
1429 ),
1430 &[&timeout, &me, &batch],
1431 )
1432 .map_err(|e| e.to_string())?;
1433 if picks.is_empty() {
1434 return Ok(());
1435 }
1436 let mut by_old: BTreeMap<String, Vec<(String, i64)>> = BTreeMap::new();
1437 for p in &picks {
1438 let (root, old): (String, String) = (p.get(0), p.get(1));
1439 let won = take_over_root(c, &me, &root, &old).map_err(|e| e.to_string())?;
1440 by_old.entry(old).or_default().extend(won);
1441 }
1442 for (old, items) in by_old {
1443 if items.is_empty() {
1444 continue;
1445 }
1446 tracing::warn!(from = %old, executions = items.len(), "shared: taking over executions of a node that stopped heartbeating");
1447 engine(&self.state)
1448 .adopt(&items, &old)
1449 .map_err(|e| e.to_string())?;
1450 }
1451 if (picks.len() as i64) < batch {
1452 return Ok(());
1453 }
1454 }
1455 }
1456
1457 fn evict_fenced(&mut self) {
1458 let fenced: Vec<String> = lock(&self.ctx.fenced).drain().collect();
1459 if fenced.is_empty() {
1460 return;
1461 }
1462 let mut e = engine(&self.state);
1463 for id in fenced {
1464 e.evict(&id);
1465 }
1466 }
1467
1468 fn reconcile_ownership(&mut self, c: &mut postgres::Client) -> Result<(), String> {
1471 let mut e = engine(&self.state);
1472 let rows = c
1473 .query(
1474 "SELECT execution_id, epoch FROM orbit_owners WHERE node_id = $1",
1475 &[&self.ctx.config.node_id],
1476 )
1477 .map_err(|e| e.to_string())?;
1478 let db: HashMap<String, i64> = rows.iter().map(|r| (r.get(0), r.get(1))).collect();
1479 let held = lock(&self.ctx.epochs).clone();
1480 for id in e.owned_ids() {
1481 if db.get(&id) != held.get(&id) {
1482 e.evict(&id);
1483 }
1484 }
1485 let missing: Vec<(String, i64)> = db.into_iter().filter(|(id, _)| !e.owns(id)).collect();
1486 if !missing.is_empty() {
1487 let me = self.me().to_string();
1488 e.adopt(&missing, &me).map_err(|e| e.to_string())?;
1489 }
1490 Ok(())
1491 }
1492
1493 fn sweep_claims(&mut self, c: &mut postgres::Client) -> Result<(), String> {
1496 let units = engine(&self.state).queued_units();
1497 let n = requeue(c, &units, None, Some(now_ms() - 30_000)).map_err(|e| e.to_string())?;
1498 if n > 0 {
1499 tracing::warn!(
1500 tasks = n,
1501 "shared: requeued tasks whose claim never reached this owner"
1502 );
1503 let _ = c.execute("SELECT pg_notify('orbit_tasks', '')", &[]);
1504 }
1505 Ok(())
1506 }
1507
1508 fn consume_inbox(&mut self, c: &mut postgres::Client) -> Result<(), String> {
1510 let rows = c
1511 .query(
1512 "SELECT i.seq, i.execution_id, i.kind, i.payload FROM orbit_inbox i
1513 JOIN orbit_owners o ON o.execution_id = i.execution_id
1514 WHERE o.node_id = $1 AND i.done_ms IS NULL
1515 ORDER BY i.seq LIMIT 500",
1516 &[&self.ctx.config.node_id],
1517 )
1518 .map_err(|e| e.to_string())?;
1519 let mut blocked: HashSet<String> = HashSet::new();
1520 for row in rows {
1521 let (seq, exec, kind, payload): (i64, String, String, String) =
1522 (row.get(0), row.get(1), row.get(2), row.get(3));
1523 if blocked.contains(&exec) {
1524 continue;
1525 }
1526 let p: Value = serde_json::from_str(&payload).unwrap_or(Value::Null);
1527 let mut e = engine(&self.state);
1528 if !e.owns(&exec) {
1529 blocked.insert(exec);
1530 continue;
1531 }
1532 self.ctx.stage(seq, &exec, &kind);
1533 self.ctx.applying.store(true, Ordering::SeqCst);
1534 let r = apply_entry(&mut e, &exec, &kind, &p);
1535 self.ctx.applying.store(false, Ordering::SeqCst);
1536 drop(e);
1537 if self.ctx.unstage(seq) {
1538 continue;
1539 }
1540 let reply = |v: String| matches!(kind.as_str(), "signal" | "cancel").then_some(v);
1541 let finish = match r {
1542 Err(err) if err.code == "STORAGE_ERROR" => {
1543 tracing::warn!(execution_id = %exec, kind = %kind, error = %err, "shared: applying an inbox entry failed");
1545 blocked.insert(exec);
1546 continue;
1547 }
1548 Err(err) => reply(error_json(err.status, err.code, &err.message)),
1549 Ok(()) => reply(json!({ "ok": true }).to_string()),
1550 };
1551 match finish {
1552 None => c.execute("DELETE FROM orbit_inbox WHERE seq = $1", &[&seq]),
1553 Some(r) => c.execute(
1554 "UPDATE orbit_inbox SET done_ms = $2, result = $3 WHERE seq = $1",
1555 &[&seq, &now_ms(), &r],
1556 ),
1557 }
1558 .map_err(|e| e.to_string())?;
1559 }
1560 Ok(())
1561 }
1562
1563 fn flush_touches(&mut self, c: &mut postgres::Client) -> Result<(), String> {
1565 let touched: Vec<(String, Instant)> = lock(&self.ctx.touched).drain().collect();
1566 if touched.is_empty() {
1567 return Ok(());
1568 }
1569 let ids: Vec<String> = touched.iter().map(|t| t.0.clone()).collect();
1570 let ago: Vec<i64> = touched
1571 .iter()
1572 .map(|t| t.1.elapsed().as_millis() as i64)
1573 .collect();
1574 let found: HashSet<String> = c
1575 .query(
1576 &format!(
1577 "UPDATE orbit_sessions s SET last_seen_ms = GREATEST(s.last_seen_ms, {NOW_MS} - v.ago)
1578 FROM unnest($1::text[], $2::bigint[]) AS v(id, ago)
1579 WHERE s.session_id = v.id RETURNING s.session_id"
1580 ),
1581 &[&ids, &ago],
1582 )
1583 .map_err(|e| e.to_string())?
1584 .into_iter()
1585 .map(|r| r.get(0))
1586 .collect();
1587 let gone: Vec<String> = ids.into_iter().filter(|i| !found.contains(i)).collect();
1588 if !gone.is_empty() {
1589 engine(&self.state).forget_sessions(&gone);
1590 }
1591 Ok(())
1592 }
1593
1594 fn refresh_seen(&mut self, c: &mut postgres::Client) -> Result<(), String> {
1597 let attempts = engine(&self.state).dispatched_attempts();
1598 let mut sessions: Vec<String> = attempts.iter().map(|a| a.0.clone()).collect();
1599 sessions.sort();
1600 sessions.dedup();
1601 let tokens: Vec<String> = attempts.into_iter().map(|a| a.1).collect();
1602 let mut lost = HashSet::new();
1603 if !tokens.is_empty() {
1604 let session_timeout = self.ctx.session_timeout_ms as i64;
1605 let grace = self.ctx.config.node_timeout.as_millis() as i64 + session_timeout;
1606 for r in c
1607 .query(
1608 &format!(
1609 "SELECT t.token FROM orbit_tasks t LEFT JOIN orbit_nodes n ON n.node_id = t.claimed_by
1610 WHERE t.token = ANY($1) AND t.state <> 'done' AND t.claimed_inc IS NOT NULL
1611 AND (n.node_id IS NULL
1612 OR {NOW_MS} - n.heartbeat_ms > $2
1613 OR (n.incarnation <> t.claimed_inc AND {NOW_MS} - n.started_ms > $3))"
1614 ),
1615 &[&tokens, &grace, &session_timeout],
1616 )
1617 .map_err(|e| e.to_string())?
1618 {
1619 lost.insert(r.get::<_, String>(0));
1620 }
1621 }
1622 *lock(&self.ctx.lost) = lost;
1623 let mut seen: HashMap<String, Option<(u64, Instant)>> =
1624 sessions.iter().map(|s| (s.clone(), None)).collect();
1625 if !sessions.is_empty() {
1626 let at = Instant::now();
1627 for r in c
1628 .query(
1629 &format!(
1630 "SELECT session_id, {NOW_MS} - last_seen_ms FROM orbit_sessions WHERE session_id = ANY($1)"
1631 ),
1632 &[&sessions],
1633 )
1634 .map_err(|e| e.to_string())?
1635 {
1636 let age: i64 = r.get(1);
1637 seen.insert(r.get(0), Some((age.max(0) as u64, at)));
1638 }
1639 }
1640 *lock(&self.ctx.seen) = seen;
1641 Ok(())
1642 }
1643
1644 fn cleanup(&mut self, c: &mut postgres::Client) -> Result<(), String> {
1645 let retain = (self.ctx.session_timeout_ms * 4).max(60_000) as i64;
1646 let cutoff = now_ms() - retain;
1647 let e = |e: postgres::Error| e.to_string();
1648 c.execute(
1649 &format!("DELETE FROM orbit_sessions WHERE {NOW_MS} - last_seen_ms > $1"),
1650 &[&((self.ctx.session_timeout_ms * 4) as i64)],
1651 )
1652 .map_err(e)?;
1653 c.execute(
1654 "DELETE FROM orbit_tasks t WHERE t.state NOT IN ('queued', 'claimed') AND t.updated_ms < $1
1655 AND NOT EXISTS (SELECT 1 FROM orbit_owners o WHERE o.execution_id = t.execution_id)",
1656 &[&cutoff],
1657 )
1658 .map_err(e)?;
1659 c.execute(
1660 "DELETE FROM orbit_inbox i WHERE i.created_ms < $1
1661 AND (i.done_ms IS NOT NULL OR NOT EXISTS (SELECT 1 FROM orbit_owners o WHERE o.execution_id = i.execution_id))",
1662 &[&(now_ms() - 60_000)],
1663 )
1664 .map_err(e)?;
1665 c.execute(
1666 &format!(
1667 "DELETE FROM orbit_nodes n WHERE {NOW_MS} - n.heartbeat_ms > 3600000
1668 AND NOT EXISTS (SELECT 1 FROM orbit_owners o WHERE o.node_id = n.node_id)"
1669 ),
1670 &[],
1671 )
1672 .map_err(e)?;
1673 Ok(())
1674 }
1675}
1676
1677pub fn take_over_root(
1682 c: &mut postgres::Client,
1683 me: &str,
1684 root: &str,
1685 old: &str,
1686) -> Result<Vec<(String, i64)>, postgres::Error> {
1687 Ok(c.query(
1688 &format!(
1689 "UPDATE orbit_owners SET node_id = $1, epoch = epoch + 1, since_ms = {NOW_MS}
1690 WHERE root_id = $2 AND node_id = $3 RETURNING execution_id, epoch"
1691 ),
1692 &[&me, &root, &old],
1693 )?
1694 .iter()
1695 .map(|r| (r.get(0), r.get(1)))
1696 .collect())
1697}
1698
1699pub(crate) fn poll(state: &Shared, session: &str) -> Result<Option<TaskAssignment>, EngineError> {
1703 let (ctx, worker, runtime, contracts) = {
1704 let mut e = engine(state);
1705 let Some(ctx) = e.shared_context() else {
1706 return e.poll(session);
1707 };
1708 match e.prepare_claim(session)? {
1709 Some((w, r, c)) => (ctx, w, r, c),
1710 None => return Ok(None),
1711 }
1712 };
1713 for _ in 0..16 {
1714 let now = engine(state).now();
1715 match ctx.claim(session, &worker, &runtime, &contracts, now)? {
1716 Claimed::Nothing => return Ok(None),
1717 Claimed::Stale => continue,
1718 Claimed::Remote(a) => return Ok(Some(a)),
1719 Claimed::Local {
1720 row_id,
1721 offer,
1722 assignment: a,
1723 } => {
1724 let applied = engine(state).apply_dispatch(
1725 &a.execution_id,
1726 &a.step_id,
1727 a.attempt,
1728 offer,
1729 &a.token,
1730 &worker,
1731 session,
1732 a.deadline_ms,
1733 );
1734 match applied {
1735 Ok(true) => return Ok(Some(a)),
1736 Ok(false) => ctx.retire(row_id, &a.token)?,
1737 Err(e) => {
1738 let _ = ctx.release(row_id, &a.token);
1739 return Err(e);
1740 }
1741 }
1742 }
1743 }
1744 }
1745 Ok(None)
1746}
1747
1748fn apply_entry(e: &mut Engine, exec: &str, kind: &str, p: &Value) -> Result<(), EngineError> {
1750 let s = |k: &str| p[k].as_str().unwrap_or("").to_string();
1751 match kind {
1752 "dispatched" => {
1753 e.apply_dispatch(
1754 exec,
1755 &s("unit"),
1756 p["attempt"].as_u64().unwrap_or(0) as u32,
1757 p["offer"].as_u64().unwrap_or(0) as u32,
1758 &s("token"),
1759 &s("worker_id"),
1760 &s("session"),
1761 p["deadline_ms"].as_u64(),
1762 )?;
1763 }
1764 "complete" => {
1765 e.complete(&s("token"), p["output"].clone())?;
1766 }
1767 "fail" => {
1768 let error = serde_json::from_value(p["error"].clone()).unwrap_or_else(|_| {
1769 crate::model::TaskError::new("UNKNOWN", "unreadable failure", true)
1770 });
1771 e.fail(&s("token"), error)?;
1772 }
1773 "uncertain" => {
1774 e.mark_uncertain(&s("token"), &s("reason"))?;
1775 }
1776 "signal" => {
1777 e.signal(exec, &s("name"), p["payload"].clone())?;
1778 }
1779 "cancel" => {
1780 e.cancel(exec, &s("reason"))?;
1781 }
1782 other => tracing::warn!(kind = other, "shared: unknown inbox entry"),
1783 }
1784 Ok(())
1785}
1786
1787#[derive(Clone)]
1790struct Gate {
1791 ctx: Arc<Ctx>,
1792 state: Shared,
1793}
1794
1795pub fn layer(app: Router, ctx: Arc<Ctx>, state: Shared) -> Router {
1799 app.layer(middleware::from_fn_with_state(Gate { ctx, state }, gate))
1800}
1801
1802fn health_fields(ctx: &Ctx) -> Value {
1803 json!({
1804 "role": "shared",
1805 "node_id": ctx.config.node_id,
1806 "term": null,
1807 "leader": null,
1808 "owned_executions": ctx.owned_executions(),
1809 "tasks_claimed": ctx.tasks_claimed(),
1810 "nodes": lock(&ctx.nodes).clone(),
1811 })
1812}
1813
1814async fn gate(State(g): State<Gate>, req: Request, next: Next) -> Response {
1815 let path = req.uri().path().to_string();
1816 if path == "/v1/ready" {
1817 let ready = g.ctx.is_ready();
1818 let mut body = health_fields(&g.ctx);
1819 body["ready"] = json!(ready);
1820 let status = if ready {
1821 StatusCode::OK
1822 } else {
1823 StatusCode::SERVICE_UNAVAILABLE
1824 };
1825 return (status, Json(body)).into_response();
1826 }
1827 if path == "/v1/health" {
1828 let resp = next.run(req).await;
1829 return crate::cluster::merge_json(resp, health_fields(&g.ctx)).await;
1830 }
1831 if g.ctx.defs_dirty.swap(false, Ordering::SeqCst) {
1832 let st = g.state.clone();
1833 let _ = tokio::task::spawn_blocking(move || engine(&st).refresh_definitions()).await;
1834 }
1835 let post = req.method() == Method::POST;
1836 let forwarding = post
1837 && path.starts_with("/v1/executions/")
1838 && (path.ends_with("/cancel") || path.contains("/signals/"));
1839 if forwarding {
1840 let slot = Arc::new(Mutex::new(None));
1841 let resp = FORWARDED.scope(slot.clone(), next.run(req)).await;
1842 let seq = *lock(&slot);
1843 return match seq {
1844 Some(seq) if resp.status() == StatusCode::OK => {
1845 finish_forwarded(&g, &path, seq, resp).await
1846 }
1847 _ => resp,
1848 };
1849 }
1850 let registers = post && path == "/v1/workflows";
1851 let resp = next.run(req).await;
1852 if registers && resp.status() == StatusCode::CREATED {
1853 let ctx = g.ctx.clone();
1854 let _ = tokio::task::spawn_blocking(move || {
1855 ctx.ops().run(|c| {
1856 c.execute("SELECT pg_notify('orbit_defs', '')", &[])?;
1857 Ok(())
1858 })
1859 })
1860 .await;
1861 }
1862 resp
1863}
1864
1865async fn finish_forwarded(g: &Gate, path: &str, seq: i64, resp: Response) -> Response {
1868 let ctx = g.ctx.clone();
1869 let reply = tokio::task::spawn_blocking(move || ctx.wait_reply(seq, REPLY_TIMEOUT))
1870 .await
1871 .ok()
1872 .flatten();
1873 if let Some(err) = reply.as_ref().and_then(|r| r.get("error")) {
1874 let status = err["status"]
1875 .as_u64()
1876 .and_then(|s| StatusCode::from_u16(s as u16).ok())
1877 .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1878 let (parts, _) = resp.into_parts();
1879 let mut out = (
1880 status,
1881 Json(json!({ "error": { "code": err["code"], "message": err["message"] } })),
1882 )
1883 .into_response();
1884 for (k, v) in parts.headers.iter() {
1885 if k != "content-length" && k != "content-type" {
1886 out.headers_mut().insert(k, v.clone());
1887 }
1888 }
1889 return out;
1890 }
1891 let id = path
1892 .trim_start_matches("/v1/executions/")
1893 .split('/')
1894 .next()
1895 .unwrap_or("")
1896 .to_string();
1897 if path.ends_with("/cancel") {
1898 if reply.is_none() {
1899 return resp;
1900 }
1901 let st = g.state.clone();
1902 return match tokio::task::spawn_blocking(move || engine(&st).execution(&id)).await {
1903 Ok(Ok(s)) => {
1904 let (parts, _) = resp.into_parts();
1905 let body = axum::body::Body::from(serde_json::to_vec(&s).unwrap_or_default());
1906 let mut out = Response::from_parts(parts, body);
1907 out.headers_mut().remove("content-length");
1908 out
1909 }
1910 _ => resp,
1911 };
1912 }
1913 let seq_value = reply
1914 .and_then(|r| r.get("seq").cloned())
1915 .unwrap_or(Value::Null);
1916 crate::cluster::merge_json(resp, json!({ "seq": seq_value })).await
1917}