Skip to main content

orbit_runtime/
shared.rs

1//! Shared mode: several active servers on one PostgreSQL database
2//! (`orbit server --cluster --cluster-mode shared`). Every node serves every
3//! route; executions, worker tasks and worker sessions are spread over them.
4//!
5//! * **Membership.** Every node keeps a row in `orbit_nodes` and refreshes
6//!   its heartbeat (database clock) every second. A node whose heartbeat is
7//!   older than the node timeout is dead.
8//! * **Ownership.** Each running execution has one owner node and an epoch in
9//!   `orbit_owners`. The node that starts an execution owns it (epoch 1);
10//!   child workflows share their root's ownership. Only the owner appends to
11//!   an execution's history, and every append checks the epoch with
12//!   `SELECT ... FOR SHARE` in its own transaction, as the active/standby
13//!   cluster checks its term. Live nodes take over a dead node's executions
14//!   with `UPDATE ... SET epoch = epoch + 1 WHERE node_id = <dead>` (one winner
15//!   per row), spread by rendezvous hashing, and rebuild them from history.
16//! * **Task queue.** The owner writes a row to `orbit_tasks` for every worker
17//!   task it schedules, in the same transaction as the `StepScheduled` event.
18//!   Any node claims rows for the workers polling it (`FOR UPDATE SKIP
19//!   LOCKED`), returns the assignment at once, and tells the owner through
20//!   the owner's inbox, so the owner remains the only writer of the history.
21//! * **Inbox.** Commands for an execution owned elsewhere (dispatches made by
22//!   other nodes, results, signals, cancellations) go to `orbit_inbox`. The
23//!   owner applies them in order through the ordinary engine methods and
24//!   deletes each entry in the same transaction as the events it caused.
25//! * **Worker sessions** are stored in `orbit_sessions`, so a worker can poll
26//!   and report through any node, and keep working when a node dies.
27//!
28//! See `docs/src/runtime/cluster.md` ("Shared mode") for the full picture.
29
30use 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
50/// Advisory lock key serialising creation of the shared-mode tables.
51const SCHEMA_LOCK_KEY: i64 = 0x6f72_6269_7473_6873;
52/// How much later than on one server a worker session that went quiet is
53/// declared lost: its last contact reaches the owner through the database.
54const SESSION_LAG_MS: u64 = 1_000;
55/// Connections for request-path statements per server.
56const OPS_CONNECTIONS: usize = 4;
57/// How long a signal or cancel sent through a non-owner waits for the owner.
58const REPLY_TIMEOUT: Duration = Duration::from_secs(10);
59/// A session that made no claim since the queue last changed re-queries
60/// the database at most this often.
61const REQUERY: Duration = Duration::from_secs(1);
62
63/// Builds a node's engine on its store (as for [`crate::cluster`]).
64pub type EngineFactory = crate::cluster::EngineFactory;
65
66#[derive(Debug, Clone)]
67pub struct SharedConfig {
68    /// PostgreSQL URL shared by every node.
69    pub url: String,
70    /// This node's name in `orbit_nodes`, health output and logs. Must be
71    /// unique among running servers.
72    pub node_id: String,
73    /// URL clients and workers can use to reach this node directly.
74    pub advertise_addr: Option<String>,
75    /// How often the node refreshes its heartbeat and looks for dead nodes.
76    pub heartbeat_interval: Duration,
77    /// A node whose heartbeat is older than this is dead; its executions are
78    /// taken over.
79    pub node_timeout: Duration,
80    /// Executions trees taken over per round and node.
81    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
97/// Outcome of a claim on the shared queue.
98pub(crate) enum Claimed {
99    /// Nothing for this worker.
100    Nothing,
101    /// A row whose execution finished meanwhile; claim again.
102    Stale,
103    /// A task of an execution another node owns (the owner was told).
104    Remote(TaskAssignment),
105    /// A task of an execution this node owns: record the dispatch locally.
106    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
120/// Shared-mode state of one node, held by its engine and its threads.
121pub struct Ctx {
122    config: SharedConfig,
123    incarnation: String,
124    session_timeout_ms: u64,
125    /// Request-path statements (claims, forwarding, sessions), spread over
126    /// a few connections so that concurrent polls do not queue on one.
127    pool: Vec<PgStore>,
128    next: std::sync::atomic::AtomicUsize,
129    /// Ownership epochs of the executions this node holds.
130    epochs: Arc<Mutex<HashMap<String, i64>>>,
131    /// Inbox entries being applied: consumed by the next append for their
132    /// execution, in the same transaction.
133    staged: Mutex<Vec<Staged>>,
134    /// (execution, token) whose result arrived: their queue rows become done.
135    done: Mutex<Vec<(String, String)>>,
136    /// Executions whose append was fenced: another node owns them now.
137    fenced: Mutex<HashSet<String>>,
138    /// Sessions touched since the last flush to `orbit_sessions`.
139    touched: Mutex<HashMap<String, Instant>>,
140    /// Last contact of sessions holding attempts this node owns: age when
141    /// read and when it was read; `None` when the session no longer exists.
142    seen: Mutex<HashMap<String, Option<(u64, Instant)>>>,
143    /// Tokens of attempts handed out by a server that died (or restarted)
144    /// more than a session timeout ago without their result arriving.
145    lost: Mutex<HashSet<String>>,
146    /// Bumped whenever the queue may have new rows.
147    queue_gen: AtomicU64,
148    /// Per session: the queue generation and time of its last empty claim.
149    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    /// Set by [`start`]; the control thread then sets `exited` when it leaves.
160    spawned: AtomicBool,
161    /// Set by the control thread when it has left (released this node).
162    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
188/// Remember, for the HTTP request being served, that its command was sent to
189/// the owner's inbox as entry `seq` (the gate then waits for the owner).
190pub(crate) fn note_forwarded(seq: i64) {
191    let _ = FORWARDED.try_with(|s| *lock(s) = Some(seq));
192}
193
194/// The execution a shared-mode task token belongs to (`<execution>/<uuid>`).
195pub(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
202/// The invocation of a unit that runs on a worker (not a built-in task, not a
203/// child workflow): these are the units that go on the shared queue.
204pub(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
212/// The queue row for a scheduled worker unit.
213pub(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
243/// Queue changes caused by a batch of events: new offers for units that were
244/// scheduled, and withdrawals for units that moved on.
245fn 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    // unit -> (scheduled in this batch, needs a withdrawal check)
262    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    /// Number of executions this node owns.
319    pub fn owned_executions(&self) -> usize {
320        lock(&self.epochs).len()
321    }
322
323    /// Tasks claimed from the shared queue by this node since it started.
324    pub fn tasks_claimed(&self) -> u64 {
325        self.tasks_claimed.load(Ordering::Relaxed)
326    }
327
328    /// Leave the cluster on shutdown: other nodes take over at once. Waits
329    /// (up to five seconds) until the node's release is written.
330    pub fn stop(&self) {
331        self.stop.store(true, Ordering::SeqCst);
332        if !self.spawned.load(Ordering::SeqCst) {
333            // `start` never ran (an engine that was not served).
334            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    /// Freeze this node as if its process hung: no heartbeats, no takeovers,
343    /// no inbox processing, and no release on shutdown (tests).
344    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    /// Serving: started, heartbeat confirmed within the node timeout.
357    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    /// Remove a staged entry; true when an append consumed it.
390    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    /// History append for an owned (or new) execution, with the queue and
398    /// inbox changes it implies, in one transaction.
399    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    /// Whether a poll from `session` should query the queue now.
493    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    /// Claim the oldest queued task matching a worker's contracts and runtime.
501    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    /// Withdraw a claimed row whose unit no longer waits for it.
624    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    /// Put a claimed row back on the queue (the local dispatch failed).
638    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    /// Send a command to the owner of `execution_id` through its inbox.
655    /// Returns the inbox sequence number, or `None` when the execution has no
656    /// owner any more (it finished). A result (`token` set) also marks its
657    /// queue row done.
658    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    /// Wait for the owner to apply inbox entry `seq`; its reply, or `None`
704    /// on timeout.
705    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    /// Record a new worker session for every node.
734    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    /// A session opened on another node, if it still exists.
763    #[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    /// Delete a session; returns (execution, token) of the tasks it held.
797    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    /// Tokens held by `session` that their owners no longer need (each is
822    /// reported once, like the single-server heartbeat).
823    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    /// Whether a worker session is alive, as last read from the database.
844    /// Sessions not read yet count as alive.
845    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    /// Whether the attempt holding `token` was handed out by a server that
856    /// is gone: the worker may never have received it.
857    pub(crate) fn claim_lost(&self, token: Option<&str>) -> bool {
858        token.is_some_and(|t| lock(&self.lost).contains(t))
859    }
860
861    /// The worker a token issued by another node was given to.
862    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    /// The worker list with sessions and in-flight tasks across all nodes.
877    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    /// After a takeover: put the adopted executions' queued tasks on the
922    /// queue (again), and requeue tasks the previous owner had claimed for
923    /// itself without recording the dispatch before it died.
924    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
961/// Put claimed rows of still-queued units back on the queue when the claim
962/// never reached the owner (claimed by `by` for itself, or older than
963/// `older_than`), unless a dispatch for them waits in the inbox.
964fn 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
990// ------------------------------------------------------------------ setup
991
992fn 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
1014/// Register this node in `orbit_nodes`. A previous process with the same
1015/// node id that is still heartbeating is waited for (a quick restart) for up
1016/// to three node timeouts; after that, starting is refused.
1017fn 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
1063/// Open a shared-mode engine: builds it (through `factory`) on a new
1064/// shared-mode store and [`attach`]es it.
1065pub 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
1073/// Turn an engine opened on a [`PgMode::Shared`] store into a shared-mode
1074/// node: creates the shared tables and registers the node. Serve it with
1075/// [`crate::server::serve_node`], which starts the node's threads; the node
1076/// then resumes or takes over executions.
1077pub 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
1118/// Start the node's control and notification threads. Called by
1119/// [`crate::server::serve_node`] for a shared-mode engine.
1120pub 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
1139// --------------------------------------------------------------- listener
1140
1141/// LISTEN for queue, inbox, completion and definition notifications.
1142fn 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        // Anything may have happened while not listening.
1160        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
1199// ---------------------------------------------------------------- control
1200
1201struct 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                // Tasks this server handed out were delivered: they live on
1261                // with their worker sessions, not with this server.
1262                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            // Executions this node id owned before a restart: fence the
1279            // previous process (epoch + 1) and resume them.
1280            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            // Fallback for missed notifications: waits and polls look again.
1325            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            // Other nodes may have taken over while this one was not heartbeating.
1377            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    /// Take over the executions of dead nodes: this node's share, chosen by
1409    /// rendezvous hashing over the live nodes, one execution tree at a time.
1410    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    /// Compare what this node holds with `orbit_owners`: drop executions
1469    /// another node took over, load executions recorded as this node's.
1470    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    /// Requeue claims older than 30 s that never reached this owner (a
1494    /// safety net; claims normally reach the owner atomically).
1495    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    /// Apply inbox entries for executions this node owns, in order.
1509    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                    // Fenced or the database failed: leave it for the owner.
1544                    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    /// Write the last contact of sessions this node served to the database.
1564    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    /// Read the last contact of the sessions holding this node's attempts,
1595    /// and which of those attempts were handed out by a server that is gone.
1596    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
1677/// Take over one execution tree from `old`: every execution of `root` that
1678/// `old` owns moves to `me` with its epoch incremented. Concurrent callers
1679/// each win a disjoint set of rows (the update re-checks `node_id = old`
1680/// after waiting for a competing update). Returns (execution, new epoch).
1681pub 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
1699/// `POST /v1/workers/poll` (HTTP and gRPC): [`Engine::poll`], except that in
1700/// shared mode the claim on the queue runs without holding the engine, so
1701/// that polls on one server do not wait for each other's database round trips.
1702pub(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
1748/// Apply one inbox entry through the engine.
1749fn 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// --------------------------------------------------------------- HTTP gate
1788
1789#[derive(Clone)]
1790struct Gate {
1791    ctx: Arc<Ctx>,
1792    state: Shared,
1793}
1794
1795/// Wrap the API router for shared mode: `/v1/ready`, the shared fields of
1796/// `/v1/health`, fresh definitions, and replies for signals and cancels that
1797/// were forwarded to another node.
1798pub 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
1865/// The owner applied (or not yet) a forwarded signal or cancel: answer as a
1866/// single server would.
1867async 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}