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