Skip to main content

orbit_runtime/
engine.rs

1//! The execution engine: owns persistence, in-memory projections of active
2//! executions, the worker registry and task dispatch.
3//!
4//! Every state change goes through [`Engine::commit`], which assigns sequence
5//! numbers, persists the events transactionally and only then updates memory.
6
7use crate::decide::{decide, invocation};
8use crate::metrics::Metrics;
9use crate::model::*;
10use crate::store::{ExecutionSummary, PutDefinition, Store, WorkerRecord};
11use orbit_ir::{WorkflowIr, WORKFLOW_CONTRACT_PREFIX};
12use serde::{Deserialize, Serialize};
13use serde_json::{json, Value};
14use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
15use std::sync::Arc;
16
17pub type Clock = Box<dyn Fn() -> u64 + Send>;
18
19pub fn system_clock() -> Clock {
20    Box::new(|| {
21        std::time::SystemTime::now()
22            .duration_since(std::time::UNIX_EPOCH)
23            .map(|d| d.as_millis() as u64)
24            .unwrap_or(0)
25    })
26}
27
28#[derive(Debug, Clone)]
29pub struct EngineConfig {
30    /// A worker that has not polled or heartbeated for this long is considered lost.
31    pub session_timeout_ms: u64,
32    pub heartbeat_interval_ms: u64,
33    /// Execute `orbit.http.request` steps inside the runtime.
34    pub http_adapter: bool,
35    /// Resource limits (all optional; `None` means unlimited).
36    pub limits: Limits,
37}
38
39/// Server-wide resource limits.
40#[derive(Debug, Clone, Default, PartialEq)]
41pub struct Limits {
42    /// Running executions at once; further starts get 429 `RESOURCE_EXHAUSTED`.
43    pub max_active_executions: Option<usize>,
44    /// Elements one `for` step may fan out to; larger lists fail the step with
45    /// `LIMIT_EXCEEDED`.
46    pub max_fan_out: Option<usize>,
47    /// History events per execution; past it the execution is failed with
48    /// `LIMIT_EXCEEDED` (compensation and handlers still run).
49    pub max_history_events: Option<u64>,
50    /// Serialized size of an execution input, a task result or a signal
51    /// payload; larger ones get 413 `PAYLOAD_TOO_LARGE`.
52    pub max_payload_bytes: Option<usize>,
53}
54
55impl Default for EngineConfig {
56    fn default() -> Self {
57        EngineConfig {
58            session_timeout_ms: 30_000,
59            heartbeat_interval_ms: 5_000,
60            http_adapter: false,
61            limits: Limits::default(),
62        }
63    }
64}
65
66#[derive(Debug, Clone, PartialEq, Serialize)]
67pub struct EngineError {
68    pub code: &'static str,
69    pub message: String,
70    #[serde(skip)]
71    pub status: u16,
72}
73
74impl EngineError {
75    pub fn new(status: u16, code: &'static str, message: impl Into<String>) -> Self {
76        EngineError {
77            code,
78            message: message.into(),
79            status,
80        }
81    }
82    fn not_found(what: &str) -> Self {
83        Self::new(404, "NOT_FOUND", format!("{what} not found"))
84    }
85    fn storage(e: crate::store::StoreError) -> Self {
86        Self::new(500, "STORAGE_ERROR", e.to_string())
87    }
88}
89
90impl std::fmt::Display for EngineError {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        write!(f, "{}: {}", self.code, self.message)
93    }
94}
95
96pub type Result<T> = std::result::Result<T, EngineError>;
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct ContractAnnouncement {
100    pub task_name: String,
101    pub contract_version: String,
102}
103
104#[derive(Debug, Clone, Deserialize)]
105pub struct RegisterWorker {
106    pub worker_id: String,
107    pub runtime: String,
108    #[serde(default)]
109    pub sdk: Option<String>,
110    pub contracts: Vec<ContractAnnouncement>,
111}
112
113#[derive(Debug, Clone, Serialize)]
114pub struct Registration {
115    pub accepted: bool,
116    pub worker_session_id: String,
117    pub heartbeat_interval_ms: u64,
118    pub session_timeout_ms: u64,
119    pub warnings: Vec<String>,
120}
121
122#[derive(Debug, Clone)]
123struct Session {
124    worker_id: String,
125    runtime: String,
126    contracts: HashSet<(String, String)>,
127    last_seen_ms: u64,
128    /// When `last_seen_ms` was last written to the worker registry.
129    persisted_seen_ms: u64,
130    inflight: HashSet<String>,
131}
132
133/// A task handed to a worker (RFC-0001 §8.3 `ExecuteTaskRequest`).
134#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
135pub struct TaskAssignment {
136    pub token: String,
137    pub execution_id: String,
138    pub workflow: String,
139    pub workflow_version: String,
140    pub step_id: String,
141    pub task_name: String,
142    pub contract_version: String,
143    pub input: Value,
144    pub idempotency_key: String,
145    pub attempt: u32,
146    pub redelivery: bool,
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub deadline_ms: Option<u64>,
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub timeout_ms: Option<u64>,
151}
152
153#[derive(Debug, Clone, Serialize)]
154pub struct ResultAck {
155    pub accepted: bool,
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub reason: Option<String>,
158}
159
160#[derive(Debug, Clone, Serialize)]
161pub struct HeartbeatAck {
162    pub accepted: bool,
163    /// Tokens the worker should stop working on (cooperative cancellation).
164    pub cancel: Vec<String>,
165}
166
167#[derive(Debug, Clone, Serialize)]
168pub struct WorkerInfo {
169    #[serde(flatten)]
170    pub record: WorkerRecord,
171    pub online: bool,
172    pub sessions: usize,
173    pub inflight: usize,
174}
175
176#[derive(Debug, Clone, Serialize)]
177pub struct StartResult {
178    pub execution_id: String,
179    pub created: bool,
180}
181
182const BUILTIN_SESSION_PREFIX: &str = "builtin:";
183const CHILD_SESSION_PREFIX: &str = "child:";
184
185/// Deterministic id of the child execution started by `unit` of `parent`, so
186/// that starting it again after a crash is a no-op.
187pub fn child_execution_id(parent: &str, unit: &str) -> String {
188    let unit = unit
189        .replace("compensate:", "compensate.")
190        .replace('[', ".")
191        .replace(']', "");
192    format!("{parent}.{unit}")
193}
194
195pub struct Engine {
196    store: Store,
197    clock: Clock,
198    pub config: EngineConfig,
199    instance: String,
200    started_at_ms: u64,
201    by_version: BTreeMap<(String, String), Arc<WorkflowIr>>,
202    by_hash: HashMap<String, Arc<WorkflowIr>>,
203    active: BTreeMap<String, ExecutionState>,
204    /// Units waiting for a worker, oldest first: `(scheduled_at_ms, execution,
205    /// unit)`. Kept in step with `active` by `reindex`, so `poll` does not
206    /// scan every execution.
207    ready: BTreeSet<(u64, String, String)>,
208    ready_by_execution: HashMap<String, Vec<(u64, String, String)>>,
209    tokens: HashMap<String, (String, String)>,
210    /// Which worker each task token was issued to (for per-worker authorization).
211    token_workers: HashMap<String, String>,
212    sessions: HashMap<String, Session>,
213    /// Built-in tasks dispatched to the runtime itself, waiting to be run.
214    builtin_jobs: Vec<TaskAssignment>,
215    pub metrics: Metrics,
216    pub changed: Arc<tokio::sync::Notify>,
217    telemetry: Option<crate::telemetry::Exporter>,
218    /// Shared mode (`--cluster-mode shared`): ownership, task queue and inbox
219    /// shared with the other servers. `None` everywhere else.
220    pub(crate) shared: Option<Arc<crate::shared::Ctx>>,
221}
222
223fn semver_key(v: &str) -> (Vec<u64>, String) {
224    let core = v.split(['-', '+']).next().unwrap_or("");
225    (
226        core.split('.').map(|p| p.parse().unwrap_or(0)).collect(),
227        v.to_string(),
228    )
229}
230
231impl Engine {
232    /// Open the engine and recover every non-terminal execution from history.
233    pub fn open(store: Store, config: EngineConfig, clock: Clock) -> Result<Engine> {
234        let now = clock();
235        let mut engine = Engine {
236            store,
237            clock,
238            config,
239            instance: uuid::Uuid::new_v4().to_string(),
240            started_at_ms: now,
241            by_version: BTreeMap::new(),
242            by_hash: HashMap::new(),
243            active: BTreeMap::new(),
244            ready: BTreeSet::new(),
245            ready_by_execution: HashMap::new(),
246            tokens: HashMap::new(),
247            token_workers: HashMap::new(),
248            sessions: HashMap::new(),
249            builtin_jobs: vec![],
250            metrics: Metrics::default(),
251            changed: Arc::new(tokio::sync::Notify::new()),
252            telemetry: None,
253            shared: None,
254        };
255        for ir in engine.store.definitions().map_err(EngineError::storage)? {
256            engine.cache_definition(ir);
257        }
258        engine.recover()?;
259        Ok(engine)
260    }
261
262    pub fn now(&self) -> u64 {
263        (self.clock)()
264    }
265
266    fn cache_definition(&mut self, ir: WorkflowIr) -> Arc<WorkflowIr> {
267        let ir = Arc::new(ir);
268        self.by_version
269            .insert((ir.name.clone(), ir.version.clone()), ir.clone());
270        self.by_hash.insert(ir.definition_hash.clone(), ir.clone());
271        ir
272    }
273
274    /// RFC-0001 §9.9 crash recovery: load active executions, rebuild state
275    /// from history, re-index in-flight work and resume. Attempts that were
276    /// in flight are reconciled by `check_liveness` once their worker fails to
277    /// report back within the session timeout (outcome treated as unknown,
278    /// never as failed).
279    fn recover(&mut self) -> Result<()> {
280        let ids = self
281            .store
282            .active_execution_ids()
283            .map_err(EngineError::storage)?;
284        let mut in_flight = 0;
285        for id in &ids {
286            let history = self.store.history(id).map_err(EngineError::storage)?;
287            for h in &history {
288                if let Event::StepDispatched {
289                    token, worker_id, ..
290                } = &h.event
291                {
292                    self.token_workers.insert(token.clone(), worker_id.clone());
293                }
294            }
295            let state = ExecutionState::replay(id, &history);
296            for (key, u) in &state.units {
297                for (t, _) in &u.tokens {
298                    self.tokens.insert(t.clone(), (id.clone(), key.clone()));
299                }
300                if u.status() == UnitStatus::Dispatched {
301                    in_flight += 1;
302                }
303            }
304            self.active.insert(id.clone(), state);
305            self.reindex(id);
306        }
307        if !ids.is_empty() {
308            tracing::info!(
309                executions = ids.len(),
310                in_flight,
311                "recovered active executions from history"
312            );
313        }
314        for id in ids {
315            self.advance(&id)?;
316        }
317        Ok(())
318    }
319
320    // ------------------------------------------------------------ definitions
321
322    pub fn register_definition(&mut self, ir: WorkflowIr) -> Result<bool> {
323        ir.validate()
324            .map_err(|e| EngineError::new(400, "INVALID_IR", e.0))?;
325        if ir.definition_hash.is_empty() {
326            return Err(EngineError::new(
327                400,
328                "INVALID_IR",
329                "definition_hash is required",
330            ));
331        }
332        // A parent pins the exact child definition it was compiled against.
333        for c in ir.contracts.values() {
334            if let Some(hash) = &c.workflow_hash {
335                if !self.by_hash.contains_key(hash) {
336                    return Err(EngineError::new(
337                        400,
338                        "CHILD_NOT_REGISTERED",
339                        format!(
340                            "{}@{} starts child workflow {}@{} ({hash}), which is not registered; register the child first (`orbit deploy` does this automatically)",
341                            ir.name,
342                            ir.version,
343                            c.name.trim_start_matches(WORKFLOW_CONTRACT_PREFIX),
344                            c.version
345                        ),
346                    ));
347                }
348            }
349        }
350        let now = self.now();
351        match self.store.put_definition(&ir, now).map_err(EngineError::storage)? {
352            PutDefinition::Created => {
353                tracing::info!(workflow = %ir.name, version = %ir.version, hash = %ir.definition_hash, "registered workflow definition");
354                self.cache_definition(ir);
355                Ok(true)
356            }
357            PutDefinition::Unchanged => Ok(false),
358            PutDefinition::Conflict { existing_hash } => Err(EngineError::new(
359                409,
360                "DEFINITION_CONFLICT",
361                format!(
362                    "{}@{} is already registered with a different definition ({existing_hash}); workflow definitions are immutable, bump the version",
363                    ir.name, ir.version
364                ),
365            )),
366        }
367    }
368
369    pub fn definitions(&self) -> Vec<Arc<WorkflowIr>> {
370        self.by_version.values().cloned().collect()
371    }
372
373    pub fn definition(&self, name: &str, version: Option<&str>) -> Option<Arc<WorkflowIr>> {
374        match version {
375            Some(v) => self
376                .by_version
377                .get(&(name.to_string(), v.to_string()))
378                .cloned(),
379            None => self
380                .by_version
381                .iter()
382                .filter(|((n, _), _)| n == name)
383                .max_by_key(|((_, v), _)| semver_key(v))
384                .map(|(_, d)| d.clone()),
385        }
386    }
387
388    // ------------------------------------------------------------- executions
389
390    pub fn start(
391        &mut self,
392        workflow: &str,
393        version: Option<&str>,
394        input: Value,
395        execution_id: Option<String>,
396    ) -> Result<StartResult> {
397        if self.shared.is_some() && self.definition(workflow, version).is_none() {
398            // Registered on another server since this one loaded definitions.
399            self.refresh_definitions()?;
400        }
401        let def = self.definition(workflow, version).ok_or_else(|| {
402            EngineError::new(
403                404,
404                "WORKFLOW_NOT_FOUND",
405                match version {
406                    Some(v) => format!("workflow {workflow}@{v} is not registered"),
407                    None => format!("workflow {workflow} is not registered"),
408                },
409            )
410        })?;
411        self.start_with(def, input, execution_id, None)
412    }
413
414    /// Start an execution of a specific definition. Child workflows pass their
415    /// parent link; their input was already validated against the child's
416    /// contract by the parent, so it is checked leniently.
417    fn start_with(
418        &mut self,
419        def: Arc<WorkflowIr>,
420        mut input: Value,
421        execution_id: Option<String>,
422        parent: Option<ParentRef>,
423    ) -> Result<StartResult> {
424        if input.is_null() {
425            input = json!({});
426        }
427        def.input.apply_defaults(&mut input);
428        self.check_payload(Some(&def), "execution input", &input)?;
429        def.input.validate(&input, parent.is_none()).map_err(|e| {
430            EngineError::new(
431                400,
432                "INVALID_INPUT",
433                format!("workflow input is invalid at {e}"),
434            )
435        })?;
436        let id = match execution_id {
437            Some(id) => {
438                if id.is_empty()
439                    || id.len() > 200
440                    || !id
441                        .chars()
442                        .all(|c| c.is_ascii_alphanumeric() || "-_.:".contains(c))
443                {
444                    return Err(EngineError::new(
445                        400,
446                        "INVALID_EXECUTION_ID",
447                        "execution ids may contain letters, digits and - _ . :",
448                    ));
449                }
450                // Idempotent start: the same id never starts a second execution.
451                if self.active.contains_key(&id)
452                    || self
453                        .store
454                        .execution_exists(&id)
455                        .map_err(EngineError::storage)?
456                {
457                    let existing = self.execution(&id)?;
458                    if existing.definition_hash != def.definition_hash {
459                        return Err(EngineError::new(
460                            409,
461                            "EXECUTION_EXISTS",
462                            format!("execution `{id}` already exists for a different workflow"),
463                        ));
464                    }
465                    return Ok(StartResult {
466                        execution_id: id,
467                        created: false,
468                    });
469                }
470                id
471            }
472            None => uuid::Uuid::new_v4().to_string(),
473        };
474        if parent.is_none() {
475            if let Some(max) = self.config.limits.max_active_executions {
476                // Child workflows are part of their parent's work: only
477                // executions started by callers count.
478                let running = self.active.values().filter(|s| s.parent.is_none()).count();
479                if running >= max {
480                    return Err(EngineError::new(
481                        429,
482                        "RESOURCE_EXHAUSTED",
483                        format!("{max} executions are already running (--max-active-executions)"),
484                    ));
485                }
486            }
487        }
488        self.active.insert(id.clone(), ExecutionState::new(&id));
489        let created = Event::WorkflowCreated {
490            workflow: def.name.clone(),
491            version: def.version.clone(),
492            definition_hash: def.definition_hash.clone(),
493            input,
494            parent,
495        };
496        if let Err(e) = self.commit(&id, vec![created, Event::WorkflowStarted {}]) {
497            self.active.remove(&id);
498            return Err(e);
499        }
500        tracing::info!(execution_id = %id, workflow = %def.name, version = %def.version, "execution started");
501        self.advance(&id)?;
502        Ok(StartResult {
503            execution_id: id,
504            created: true,
505        })
506    }
507
508    pub fn cancel(&mut self, id: &str, reason: &str) -> Result<ExecutionState> {
509        let state = self.execution(id)?;
510        if state.status.is_terminal() {
511            return Err(EngineError::new(
512                409,
513                "EXECUTION_FINISHED",
514                format!("execution is already {}", state.status.as_str()),
515            ));
516        }
517        if self.forwards(id) {
518            self.forward_command(id, "cancel", json!({ "reason": reason }))?;
519            return Ok(state);
520        }
521        if state.abort.is_none() {
522            self.commit(
523                id,
524                vec![Event::CancelRequested {
525                    reason: reason.to_string(),
526                }],
527            )?;
528            self.advance(id)?;
529        }
530        self.execution(id)
531    }
532
533    /// Send a typed signal to a running execution. The payload is validated
534    /// against the workflow's `signal` declaration (defaults applied) and
535    /// buffered until an `await` step takes it. Returns the history sequence
536    /// number of the recorded signal.
537    pub fn signal(&mut self, id: &str, name: &str, mut payload: Value) -> Result<u64> {
538        let state = self.execution(id)?;
539        if state.status.is_terminal() {
540            return Err(EngineError::new(
541                409,
542                "EXECUTION_FINISHED",
543                format!("execution is already {}", state.status.as_str()),
544            ));
545        }
546        let def = self
547            .by_hash
548            .get(&state.definition_hash)
549            .cloned()
550            .ok_or_else(|| EngineError::not_found("workflow definition"))?;
551        let Some(ty) = def.signals.get(name) else {
552            let declared: Vec<&str> = def.signals.keys().map(String::as_str).collect();
553            return Err(EngineError::new(
554                404,
555                "UNKNOWN_SIGNAL",
556                if declared.is_empty() {
557                    format!("{}@{} declares no signals", def.name, def.version)
558                } else {
559                    format!(
560                        "{}@{} has no signal `{name}`; declared: {}",
561                        def.name,
562                        def.version,
563                        declared.join(", ")
564                    )
565                },
566            ));
567        };
568        self.check_payload(Some(&def), "signal payload", &payload)?;
569        ty.apply_defaults(&mut payload);
570        if let Err(e) = ty.validate(&payload, false) {
571            return Err(EngineError::new(
572                422,
573                "SIGNAL_PAYLOAD_INVALID",
574                format!("payload for signal `{name}` is invalid at {e}"),
575            ));
576        }
577        if self.forwards(id) {
578            self.forward_command(id, "signal", json!({ "name": name, "payload": payload }))?;
579            return Ok(0);
580        }
581        let seq = state.last_seq + 1;
582        self.commit(
583            id,
584            vec![Event::SignalReceived {
585                name: name.to_string(),
586                payload,
587            }],
588        )?;
589        tracing::info!(execution_id = %id, signal = %name, "signal received");
590        self.advance(id)?;
591        Ok(seq)
592    }
593
594    /// Evaluate a declared query against the current state of an execution.
595    /// Steps that have not completed read as `null`.
596    pub fn query(&self, id: &str, name: &str) -> Result<Value> {
597        let state = self.execution(id)?;
598        let def = self
599            .by_hash
600            .get(&state.definition_hash)
601            .cloned()
602            .ok_or_else(|| EngineError::not_found("workflow definition"))?;
603        let Some(q) = def.queries.get(name) else {
604            let declared: Vec<&str> = def.queries.keys().map(String::as_str).collect();
605            return Err(EngineError::new(
606                404,
607                "UNKNOWN_QUERY",
608                if declared.is_empty() {
609                    format!("{}@{} declares no queries", def.name, def.version)
610                } else {
611                    format!(
612                        "{}@{} has no query `{name}`; declared: {}",
613                        def.name,
614                        def.version,
615                        declared.join(", ")
616                    )
617                },
618            ));
619        };
620        struct QueryScope<'a>(crate::decide::StateScope<'a>, Value);
621        impl orbit_ir::Scope for QueryScope<'_> {
622            fn input(&self) -> &Value {
623                self.0.input()
624            }
625            fn step_output(&self, step: &str) -> Option<&Value> {
626                self.0.step_output(step).or(Some(&self.1))
627            }
628            fn execution_field(&self, field: &str) -> Option<Value> {
629                self.0.execution_field(field)
630            }
631        }
632        let scope = QueryScope(crate::decide::StateScope::new(&state), Value::Null);
633        orbit_ir::eval(&q.expr, &scope).map_err(|e| {
634            EngineError::new(
635                422,
636                "QUERY_FAILED",
637                format!("evaluating query `{name}`: {e}"),
638            )
639        })
640    }
641
642    /// Refresh the `ready` index for one execution after its state changed
643    /// (or it left `active`).
644    fn reindex(&mut self, id: &str) {
645        if let Some(old) = self.ready_by_execution.remove(id) {
646            for e in &old {
647                self.ready.remove(e);
648            }
649        }
650        let Some(state) = self.active.get(id) else {
651            return;
652        };
653        let entries: Vec<(u64, String, String)> = state
654            .units
655            .iter()
656            .filter(|(_, u)| u.status() == UnitStatus::Scheduled)
657            .map(|(k, u)| (u.scheduled_at_ms.unwrap_or(0), id.to_string(), k.clone()))
658            .collect();
659        if !entries.is_empty() {
660            self.ready.extend(entries.iter().cloned());
661            self.ready_by_execution.insert(id.to_string(), entries);
662        }
663    }
664
665    /// Reject a payload larger than `--max-payload-bytes` (or the workflow's
666    /// own `max_payload_bytes`, whichever is smaller).
667    fn check_payload(&self, def: Option<&WorkflowIr>, what: &str, value: &Value) -> Result<()> {
668        let limits = match def {
669            Some(d) => effective_limits(&self.config.limits, d),
670            None => self.config.limits.clone(),
671        };
672        if let Some(max) = limits.max_payload_bytes {
673            let size = serde_json::to_vec(value).map(|v| v.len()).unwrap_or(0);
674            if size > max {
675                return Err(EngineError::new(
676                    413,
677                    "PAYLOAD_TOO_LARGE",
678                    format!("{what} is {size} bytes; the limit is {max} (--max-payload-bytes or the workflow's `limits`)"),
679                ));
680            }
681        }
682        Ok(())
683    }
684
685    pub fn execution(&self, id: &str) -> Result<ExecutionState> {
686        if let Some(s) = self.active.get(id) {
687            return Ok(s.clone());
688        }
689        let history = self.store.history(id).map_err(EngineError::storage)?;
690        if history.is_empty() {
691            return Err(EngineError::not_found("execution"));
692        }
693        Ok(ExecutionState::replay(id, &history))
694    }
695
696    pub fn history(&self, id: &str) -> Result<Vec<HistoryEvent>> {
697        let h = self.store.history(id).map_err(EngineError::storage)?;
698        if h.is_empty() {
699            return Err(EngineError::not_found("execution"));
700        }
701        Ok(h)
702    }
703
704    pub fn list_executions(
705        &self,
706        status: Option<&str>,
707        workflow: Option<&str>,
708        limit: u32,
709    ) -> Result<Vec<ExecutionSummary>> {
710        self.store
711            .list_executions(status, workflow, limit.clamp(1, 1000))
712            .map_err(EngineError::storage)
713    }
714
715    pub fn active_count(&self) -> usize {
716        self.active.len()
717    }
718
719    // ----------------------------------------------------------------- commit
720
721    /// Persist events for one execution, then update the in-memory state.
722    fn commit(&mut self, id: &str, events: Vec<Event>) -> Result<()> {
723        if events.is_empty() {
724            return Ok(());
725        }
726        let now = self.now();
727        let mut state = self
728            .active
729            .get(id)
730            .cloned()
731            .ok_or_else(|| EngineError::not_found("active execution"))?;
732        let mut history = Vec::with_capacity(events.len());
733        for event in events {
734            let h = HistoryEvent {
735                seq: state.last_seq + 1,
736                timestamp_ms: now,
737                event,
738            };
739            apply(&mut state, &h);
740            history.push(h);
741        }
742        match &self.shared {
743            Some(ctx) => {
744                let def = self.by_hash.get(&state.definition_hash).cloned();
745                ctx.append(&mut self.store, &state, &history, def.as_deref())
746            }
747            None => self.store.append(&state, &history),
748        }
749        .map_err(EngineError::storage)?;
750        // Export spans only once the events are durable.
751        if let Some(t) = &self.telemetry {
752            let ctx = self.trace_context(&state);
753            t.export(crate::telemetry::spans_for(&state, &ctx, &history));
754        }
755        for h in &history {
756            self.metrics.observe(&h.event);
757            if let Event::StepDispatched {
758                token,
759                step,
760                worker_id,
761                ..
762            } = &h.event
763            {
764                self.tokens
765                    .insert(token.clone(), (id.to_string(), step.clone()));
766                self.token_workers.insert(token.clone(), worker_id.clone());
767            }
768            tracing::debug!(execution_id = %id, seq = h.seq, event = h.event.name(), step = h.event.step().unwrap_or(""), "event");
769            match &h.event {
770                Event::WorkflowCompleted { .. } => {
771                    tracing::info!(execution_id = %id, "execution completed")
772                }
773                Event::WorkflowFailed {
774                    error,
775                    compensation,
776                } => {
777                    tracing::warn!(execution_id = %id, code = %error.code, error = %error.message, compensation = ?compensation, "execution failed")
778                }
779                Event::WorkflowCancelled { .. } => {
780                    tracing::info!(execution_id = %id, "execution cancelled")
781                }
782                Event::WorkflowTimedOut { .. } => {
783                    tracing::warn!(execution_id = %id, "execution timed out")
784                }
785                Event::StepFailed { step, error } => {
786                    tracing::warn!(execution_id = %id, step = %step, code = %error.code, error = %error.message, "step failed")
787                }
788                Event::RetryScheduled {
789                    step,
790                    next_attempt,
791                    delay_ms,
792                    ..
793                } => {
794                    tracing::info!(execution_id = %id, step = %step, attempt = next_attempt, delay_ms, "retry scheduled")
795                }
796                _ => {}
797            }
798        }
799        if state.status.is_terminal() {
800            for u in state.units.values() {
801                for (t, _) in &u.tokens {
802                    self.tokens.remove(t);
803                    self.token_workers.remove(t);
804                }
805            }
806            self.active.remove(id);
807        } else {
808            self.active.insert(id.to_string(), state);
809        }
810        self.reindex(id);
811        self.changed.notify_waiters();
812        Ok(())
813    }
814
815    /// Run the decider to a fixpoint and commit the resulting events as one batch.
816    fn advance(&mut self, id: &str) -> Result<()> {
817        let Some(state) = self.active.get(id) else {
818            return Ok(());
819        };
820        let Some(def) = self.by_hash.get(&state.definition_hash).cloned() else {
821            tracing::error!(execution_id = %id, hash = %state.definition_hash, "definition missing for execution");
822            return Ok(());
823        };
824        let now = self.now();
825        let mut scratch = state.clone();
826        let mut batch = vec![];
827        let limits = effective_limits(&self.config.limits, &def);
828        for _ in 0..10_000 {
829            let mut events = decide(&scratch, &def, now);
830            if events.is_empty() {
831                break;
832            }
833            enforce_limits(&limits, &scratch, &mut events);
834            for e in events {
835                let seq = scratch.last_seq + 1;
836                apply(
837                    &mut scratch,
838                    &HistoryEvent {
839                        seq,
840                        timestamp_ms: now,
841                        event: e.clone(),
842                    },
843                );
844                batch.push(e);
845            }
846        }
847        self.commit(id, batch)
848    }
849
850    /// Periodic work: timers, retries, deadlines, worker liveness, built-ins.
851    pub fn tick(&mut self) -> Result<Vec<TaskAssignment>> {
852        self.check_liveness()?;
853        let ids: Vec<String> = self.active.keys().cloned().collect();
854        for id in &ids {
855            self.advance(id)?;
856        }
857        self.dispatch_builtins()?;
858        self.dispatch_children()?;
859        self.reconcile_children()?;
860        Ok(std::mem::take(&mut self.builtin_jobs))
861    }
862
863    /// Dispatched attempts whose worker session is gone become uncertain and
864    /// are redelivered with the same idempotency key (at-least-once delivery).
865    fn check_liveness(&mut self) -> Result<()> {
866        let now = self.now();
867        let timeout = self.config.session_timeout_ms;
868        self.sessions
869            .retain(|_, s| now < s.last_seen_ms + timeout * 4);
870        let mut lost: Vec<(String, String, u32, String)> = vec![];
871        for (id, state) in &self.active {
872            for (key, u) in &state.units {
873                if u.status() != UnitStatus::Dispatched {
874                    continue;
875                }
876                let session = u.worker_session.clone().unwrap_or_default();
877                let alive = if session.starts_with(CHILD_SESSION_PREFIX) {
878                    // Child workflows are durable executions, not processes.
879                    true
880                } else if let Some(inst) = session.strip_prefix(BUILTIN_SESSION_PREFIX) {
881                    inst == self.instance
882                } else {
883                    let session_alive = self
884                        .sessions
885                        .get(&session)
886                        .is_some_and(|s| now < s.last_seen_ms + timeout)
887                        || self
888                            .shared
889                            .as_ref()
890                            .is_some_and(|c| c.session_alive(&session, timeout));
891                    // Shared mode: the server that handed the task out died
892                    // and its result has not arrived.
893                    session_alive
894                        && !self
895                            .shared
896                            .as_ref()
897                            .is_some_and(|c| c.claim_lost(u.token.as_deref()))
898                };
899                let since = u.dispatched_at_ms.unwrap_or(0).max(self.started_at_ms);
900                if !alive && now >= since + timeout {
901                    let reason = if session.starts_with(BUILTIN_SESSION_PREFIX) {
902                        "runtime restarted while the built-in task was running".to_string()
903                    } else {
904                        format!(
905                            "worker `{}` stopped responding",
906                            u.worker_id.clone().unwrap_or_default()
907                        )
908                    };
909                    lost.push((id.clone(), key.clone(), u.attempt, reason));
910                }
911            }
912        }
913        for (id, key, attempt, reason) in lost {
914            tracing::warn!(execution_id = %id, step = %key, attempt, reason = %reason, "attempt outcome uncertain; redelivering");
915            self.commit(
916                &id,
917                vec![Event::StepAttemptUncertain {
918                    step: key,
919                    attempt,
920                    reason,
921                }],
922            )?;
923            self.advance(&id)?;
924        }
925        Ok(())
926    }
927
928    // ---------------------------------------------------------------- workers
929
930    pub fn register_worker(&mut self, req: RegisterWorker) -> Result<Registration> {
931        if req.worker_id.trim().is_empty() {
932            return Err(EngineError::new(
933                400,
934                "INVALID_REQUEST",
935                "worker_id is required",
936            ));
937        }
938        if req.contracts.is_empty() {
939            return Err(EngineError::new(
940                400,
941                "INVALID_REQUEST",
942                "a worker must announce at least one task contract",
943            ));
944        }
945        let now = self.now();
946        let mut warnings = vec![];
947        for c in &req.contracts {
948            if c.task_name.starts_with(WORKFLOW_CONTRACT_PREFIX) {
949                return Err(EngineError::new(
950                    400,
951                    "RESERVED_TASK",
952                    format!(
953                        "`{}` names a child workflow, not a worker task",
954                        c.task_name
955                    ),
956                ));
957            }
958            if c.task_name.starts_with("orbit.") {
959                return Err(EngineError::new(
960                    400,
961                    "RESERVED_TASK",
962                    format!("`{}` is a built-in task", c.task_name),
963                ));
964            }
965            let mut versions: Vec<&str> = self
966                .by_version
967                .values()
968                .filter_map(|d| d.contracts.get(&c.task_name).map(|k| k.version.as_str()))
969                .collect();
970            versions.sort();
971            versions.dedup();
972            if !versions.is_empty() && !versions.contains(&c.contract_version.as_str()) {
973                warnings.push(format!(
974                    "`{}` is announced at version {} but registered workflows use {}; this worker will not receive those tasks",
975                    c.task_name,
976                    c.contract_version,
977                    versions.join(", ")
978                ));
979            }
980        }
981        let session_id = uuid::Uuid::new_v4().to_string();
982        self.sessions.insert(
983            session_id.clone(),
984            Session {
985                worker_id: req.worker_id.clone(),
986                runtime: req.runtime.to_lowercase(),
987                contracts: req
988                    .contracts
989                    .iter()
990                    .map(|c| (c.task_name.clone(), c.contract_version.clone()))
991                    .collect(),
992                last_seen_ms: now,
993                persisted_seen_ms: now,
994                inflight: HashSet::new(),
995            },
996        );
997        if let Some(ctx) = &self.shared {
998            ctx.save_session(&session_id, &req.worker_id, &req.runtime, &req.contracts)?;
999        }
1000        self.store
1001            .upsert_worker(&WorkerRecord {
1002                worker_id: req.worker_id.clone(),
1003                runtime: req.runtime.to_lowercase(),
1004                sdk: req.sdk.clone(),
1005                contracts: serde_json::to_value(&req.contracts).unwrap_or_default(),
1006                last_seen_ms: now,
1007                registered_at_ms: now,
1008            })
1009            .map_err(EngineError::storage)?;
1010        tracing::info!(worker_id = %req.worker_id, runtime = %req.runtime, tasks = req.contracts.len(), "worker registered");
1011        Ok(Registration {
1012            accepted: true,
1013            worker_session_id: session_id,
1014            heartbeat_interval_ms: self.config.heartbeat_interval_ms,
1015            session_timeout_ms: self.config.session_timeout_ms,
1016            warnings,
1017        })
1018    }
1019
1020    fn touch(&mut self, session_id: &str) -> Result<()> {
1021        if self.shared.is_some() && !self.sessions.contains_key(session_id) {
1022            self.load_shared_session(session_id)?;
1023        }
1024        if let Some(ctx) = &self.shared {
1025            ctx.touched(session_id);
1026        }
1027        let now = self.now();
1028        let s = self.sessions.get_mut(session_id).ok_or_else(|| {
1029            EngineError::new(
1030                404,
1031                "UNKNOWN_SESSION",
1032                "unknown or expired worker session; register again",
1033            )
1034        })?;
1035        s.last_seen_ms = now;
1036        // The registry's `last_seen` is informational (`GET /v1/workers`);
1037        // writing it on every poll would cost a durable write per request.
1038        if now >= s.persisted_seen_ms + 1_000 {
1039            s.persisted_seen_ms = now;
1040            let wid = s.worker_id.clone();
1041            let _ = self.store.touch_worker(&wid, now);
1042        }
1043        Ok(())
1044    }
1045
1046    pub fn heartbeat(&mut self, session_id: &str) -> Result<HeartbeatAck> {
1047        self.touch(session_id)?;
1048        let inflight: Vec<String> = self.sessions[session_id].inflight.iter().cloned().collect();
1049        let mut cancel = vec![];
1050        for token in inflight {
1051            let live = self.tokens.get(&token).and_then(|(id, key)| {
1052                let st = self.active.get(id)?;
1053                let u = st.units.get(key)?;
1054                Some(
1055                    u.status() == UnitStatus::Dispatched
1056                        && u.token.as_deref() == Some(token.as_str())
1057                        && (st.abort.is_none() || crate::model::is_cleanup_key(key)),
1058                )
1059            });
1060            if live != Some(true) {
1061                cancel.push(token.clone());
1062                self.sessions
1063                    .get_mut(session_id)
1064                    .unwrap()
1065                    .inflight
1066                    .remove(&token);
1067            }
1068        }
1069        if let Some(ctx) = &self.shared {
1070            cancel.extend(ctx.cancelled_for(session_id)?);
1071        }
1072        Ok(HeartbeatAck {
1073            accepted: true,
1074            cancel,
1075        })
1076    }
1077
1078    pub fn unregister_worker(&mut self, session_id: &str) -> Result<()> {
1079        if self.shared.is_some() {
1080            if !self.sessions.contains_key(session_id) {
1081                self.load_shared_session(session_id)?;
1082            }
1083            self.release_shared_session(session_id)?;
1084        }
1085        let s = self
1086            .sessions
1087            .remove(session_id)
1088            .ok_or_else(|| EngineError::new(404, "UNKNOWN_SESSION", "unknown worker session"))?;
1089        tracing::info!(worker_id = %s.worker_id, "worker unregistered");
1090        // In-flight work of a departing worker is redelivered immediately.
1091        let mut lost = vec![];
1092        for token in s.inflight {
1093            if let Some((id, key)) = self.tokens.get(&token) {
1094                if let Some(u) = self.active.get(id).and_then(|st| st.units.get(key)) {
1095                    if u.status() == UnitStatus::Dispatched
1096                        && u.token.as_deref() == Some(token.as_str())
1097                    {
1098                        lost.push((id.clone(), key.clone(), u.attempt));
1099                    }
1100                }
1101            }
1102        }
1103        for (id, key, attempt) in lost {
1104            self.commit(
1105                &id,
1106                vec![Event::StepAttemptUncertain {
1107                    step: key,
1108                    attempt,
1109                    reason: "worker unregistered".into(),
1110                }],
1111            )?;
1112            self.advance(&id)?;
1113        }
1114        Ok(())
1115    }
1116
1117    pub fn workers(&self) -> Result<Vec<WorkerInfo>> {
1118        let now = self.now();
1119        let records = self.store.workers().map_err(EngineError::storage)?;
1120        if let Some(ctx) = &self.shared {
1121            return ctx.workers(records, self.config.session_timeout_ms);
1122        }
1123        Ok(records
1124            .into_iter()
1125            .map(|record| {
1126                let live: Vec<&Session> = self
1127                    .sessions
1128                    .values()
1129                    .filter(|s| {
1130                        s.worker_id == record.worker_id
1131                            && now < s.last_seen_ms + self.config.session_timeout_ms
1132                    })
1133                    .collect();
1134                WorkerInfo {
1135                    online: !live.is_empty(),
1136                    sessions: live.len(),
1137                    inflight: live.iter().map(|s| s.inflight.len()).sum(),
1138                    record,
1139                }
1140            })
1141            .collect())
1142    }
1143
1144    /// Hand the oldest matching queued task to a worker, if any.
1145    pub fn poll(&mut self, session_id: &str) -> Result<Option<TaskAssignment>> {
1146        self.touch(session_id)?;
1147        if self.shared.is_some() {
1148            return self.shared_poll(session_id);
1149        }
1150        let session = self.sessions[session_id].clone();
1151        // The oldest scheduled unit this session can serve.
1152        let mut best: Option<(u64, String, String)> = None;
1153        for (at, id, key) in &self.ready {
1154            let Some(state) = self.active.get(id) else {
1155                continue;
1156            };
1157            let Some(def) = self.by_hash.get(&state.definition_hash) else {
1158                continue;
1159            };
1160            let Some(inv) = invocation(def, key) else {
1161                continue;
1162            };
1163            if !session
1164                .contracts
1165                .contains(&(inv.task.clone(), inv.contract_version.clone()))
1166            {
1167                continue;
1168            }
1169            if let Some(lang) = &inv.language {
1170                if !lang.eq_ignore_ascii_case(&session.runtime) && lang != "any" {
1171                    continue;
1172                }
1173            }
1174            best = Some((*at, id.clone(), key.clone()));
1175            break;
1176        }
1177        let Some((_, id, key)) = best else {
1178            return Ok(None);
1179        };
1180        let assignment = self.dispatch(&id, &key, session_id, &session.worker_id)?;
1181        if let Some(s) = self.sessions.get_mut(session_id) {
1182            s.inflight.insert(assignment.token.clone());
1183        }
1184        Ok(Some(assignment))
1185    }
1186
1187    fn dispatch(
1188        &mut self,
1189        id: &str,
1190        key: &str,
1191        session_id: &str,
1192        worker_id: &str,
1193    ) -> Result<TaskAssignment> {
1194        let now = self.now();
1195        let state = &self.active[id];
1196        let def = self.by_hash[&state.definition_hash].clone();
1197        let inv = invocation(&def, key)
1198            .expect("unit has an invocation")
1199            .clone();
1200        let u = state.units[key].clone();
1201        let token = uuid::Uuid::new_v4().to_string();
1202        let deadline = inv.timeout_ms.map(|t| now + t);
1203        let assignment = TaskAssignment {
1204            token: token.clone(),
1205            execution_id: id.to_string(),
1206            workflow: def.name.clone(),
1207            workflow_version: def.version.clone(),
1208            step_id: key.to_string(),
1209            task_name: inv.task.clone(),
1210            contract_version: inv.contract_version.clone(),
1211            input: u.input.clone().unwrap_or(Value::Null),
1212            idempotency_key: u.idempotency_key.clone().unwrap_or_default(),
1213            attempt: u.attempt,
1214            redelivery: u.redelivery,
1215            deadline_ms: deadline,
1216            timeout_ms: inv.timeout_ms,
1217        };
1218        self.commit(
1219            id,
1220            vec![Event::StepDispatched {
1221                step: key.to_string(),
1222                attempt: u.attempt,
1223                token,
1224                worker_id: worker_id.to_string(),
1225                worker_session: session_id.to_string(),
1226                deadline_ms: deadline,
1227            }],
1228        )?;
1229        Ok(assignment)
1230    }
1231
1232    /// Start child executions for scheduled `workflow` steps.
1233    fn dispatch_children(&mut self) -> Result<()> {
1234        let mut todo = vec![];
1235        for (id, state) in &self.active {
1236            let Some(def) = self.by_hash.get(&state.definition_hash) else {
1237                continue;
1238            };
1239            for (key, u) in &state.units {
1240                if u.status() != UnitStatus::Scheduled {
1241                    continue;
1242                }
1243                if let Some(inv) = invocation(def, key) {
1244                    if let Some(hash) = def
1245                        .contracts
1246                        .get(&inv.task)
1247                        .and_then(|c| c.workflow_hash.clone())
1248                    {
1249                        todo.push((
1250                            id.clone(),
1251                            key.clone(),
1252                            u.attempt,
1253                            hash,
1254                            u.input.clone().unwrap_or(Value::Null),
1255                        ));
1256                    }
1257                }
1258            }
1259        }
1260        for (id, key, attempt, hash, input) in todo {
1261            let child_id = child_execution_id(&id, &key);
1262            let failure = |code: &'static str, message: String| Event::StepAttemptFailed {
1263                step: key.clone(),
1264                attempt,
1265                error: TaskError::new(code, message, false),
1266            };
1267            let Some(child_def) = self.by_hash.get(&hash).cloned() else {
1268                self.commit(
1269                    &id,
1270                    vec![failure(
1271                        "CHILD_NOT_REGISTERED",
1272                        format!("child definition {hash} is not registered"),
1273                    )],
1274                )?;
1275                self.advance(&id)?;
1276                continue;
1277            };
1278            let parent = ParentRef {
1279                execution_id: id.clone(),
1280                step: key.clone(),
1281            };
1282            if let Err(e) = self.start_with(child_def, input, Some(child_id.clone()), Some(parent))
1283            {
1284                self.commit(&id, vec![failure("CHILD_START_FAILED", e.message)])?;
1285                self.advance(&id)?;
1286                continue;
1287            }
1288            if self
1289                .active
1290                .get(&id)
1291                .and_then(|s| s.units.get(&key))
1292                .map(|u| u.status())
1293                == Some(UnitStatus::Scheduled)
1294            {
1295                self.dispatch(
1296                    &id,
1297                    &key,
1298                    &format!("{CHILD_SESSION_PREFIX}{child_id}"),
1299                    "orbit-runtime",
1300                )?;
1301            }
1302        }
1303        Ok(())
1304    }
1305
1306    /// Deliver finished children's results to their parents, and cancel
1307    /// children whose parent is aborting or no longer waits for them. Runs
1308    /// every tick, so it also repairs anything a crash interrupted.
1309    fn reconcile_children(&mut self) -> Result<()> {
1310        let mut finished = vec![];
1311        let mut cancel = vec![];
1312        for state in self.active.values() {
1313            for u in state.units.values() {
1314                let Some(child) = u
1315                    .worker_session
1316                    .as_deref()
1317                    .and_then(|s| s.strip_prefix(CHILD_SESSION_PREFIX))
1318                else {
1319                    continue;
1320                };
1321                match self.active.get(child) {
1322                    Some(c) => {
1323                        let parent_gone = u.status() != UnitStatus::Dispatched;
1324                        if c.abort.is_none() && (state.abort.is_some() || parent_gone) {
1325                            cancel.push(child.to_string());
1326                        }
1327                    }
1328                    None if u.status() == UnitStatus::Dispatched => {
1329                        finished.push((child.to_string(), u.token.clone().unwrap_or_default()));
1330                    }
1331                    None => {}
1332                }
1333            }
1334        }
1335        for child in cancel {
1336            tracing::info!(execution_id = %child, "cancelling child workflow because its parent no longer needs it");
1337            self.cancel(&child, "parent workflow aborted")?;
1338        }
1339        for (child, token) in finished {
1340            let state = match self.execution(&child) {
1341                Ok(s) => s,
1342                Err(_) => {
1343                    self.fail(
1344                        &token,
1345                        TaskError::new(
1346                            "CHILD_MISSING",
1347                            format!("child execution `{child}` not found"),
1348                            false,
1349                        ),
1350                    )?;
1351                    continue;
1352                }
1353            };
1354            let name = state.workflow.clone();
1355            match state.status {
1356                WorkflowStatus::Completed => {
1357                    self.complete(&token, state.output.unwrap_or(Value::Null))?;
1358                }
1359                WorkflowStatus::Failed => {
1360                    let e = state
1361                        .error
1362                        .unwrap_or_else(|| TaskError::new("UNKNOWN", "failed", false));
1363                    let mut err = TaskError::new(
1364                        &e.code,
1365                        format!("child workflow `{name}` failed: {}", e.message),
1366                        false,
1367                    );
1368                    err.details = Some(json!({ "child_execution_id": child }));
1369                    self.fail(&token, err)?;
1370                }
1371                WorkflowStatus::Cancelled => {
1372                    self.fail(
1373                        &token,
1374                        TaskError::new(
1375                            "CHILD_CANCELLED",
1376                            format!("child workflow `{name}` was cancelled"),
1377                            false,
1378                        ),
1379                    )?;
1380                }
1381                WorkflowStatus::TimedOut => {
1382                    self.fail(
1383                        &token,
1384                        TaskError::new(
1385                            "CHILD_TIMED_OUT",
1386                            format!("child workflow `{name}` timed out"),
1387                            false,
1388                        ),
1389                    )?;
1390                }
1391                _ => {}
1392            }
1393        }
1394        Ok(())
1395    }
1396
1397    fn dispatch_builtins(&mut self) -> Result<()> {
1398        let mut todo = vec![];
1399        for (id, state) in &self.active {
1400            let Some(def) = self.by_hash.get(&state.definition_hash) else {
1401                continue;
1402            };
1403            for (key, u) in &state.units {
1404                if u.status() != UnitStatus::Scheduled {
1405                    continue;
1406                }
1407                if let Some(inv) = invocation(def, key) {
1408                    if def.contracts.get(&inv.task).is_some_and(|c| c.builtin) {
1409                        todo.push((id.clone(), key.clone(), u.attempt));
1410                    }
1411                }
1412            }
1413        }
1414        for (id, key, attempt) in todo {
1415            if !self.config.http_adapter {
1416                self.commit(
1417                    &id,
1418                    vec![Event::StepAttemptFailed {
1419                        step: key,
1420                        attempt,
1421                        error: TaskError::new(codes::ADAPTER_DISABLED, "the HTTP adapter is disabled; start the server with --enable-http-adapter", false),
1422                    }],
1423                )?;
1424                self.advance(&id)?;
1425                continue;
1426            }
1427            let session = format!("{BUILTIN_SESSION_PREFIX}{}", self.instance);
1428            let job = self.dispatch(&id, &key, &session, "orbit-runtime")?;
1429            self.builtin_jobs.push(job);
1430        }
1431        Ok(())
1432    }
1433
1434    /// Record a successful task result.
1435    pub fn complete(&mut self, token: &str, mut output: Value) -> Result<ResultAck> {
1436        if self.forwards_result(token) {
1437            // The owner holds the definition; the server-wide limit applies here.
1438            self.check_payload(None, "task result", &output)?;
1439            return self.forward_result(
1440                token,
1441                "complete",
1442                json!({ "token": token, "output": output }),
1443            );
1444        }
1445        let (id, key, unit, def) = match self.resolve_token(token)? {
1446            Ok(x) => x,
1447            Err(ack) => return Ok(ack),
1448        };
1449        let attempt = unit
1450            .tokens
1451            .iter()
1452            .find(|(t, _)| t == token)
1453            .map(|(_, a)| *a)
1454            .unwrap_or(unit.attempt);
1455        if output.is_null() {
1456            output = json!({});
1457        }
1458        self.check_payload(Some(&def), "task result", &output)?;
1459        let inv = invocation(&def, &key).cloned();
1460        let validation = inv
1461            .as_ref()
1462            .and_then(|i| def.contracts.get(&i.task))
1463            .map(|c| {
1464                c.output.apply_defaults(&mut output);
1465                c.output.validate(&output, false)
1466            })
1467            .unwrap_or(Ok(()));
1468        let event = match validation {
1469            Ok(()) => Event::StepSucceeded {
1470                step: key.clone(),
1471                attempt,
1472                output,
1473            },
1474            Err(e) => Event::StepAttemptFailed {
1475                step: key.clone(),
1476                attempt: unit.attempt,
1477                error: TaskError::new(
1478                    codes::OUTPUT_CONTRACT_VIOLATION,
1479                    format!(
1480                        "worker output for `{}` does not match its contract at {e}",
1481                        inv.map(|i| i.task).unwrap_or_default()
1482                    ),
1483                    false,
1484                ),
1485            },
1486        };
1487        let accepted = matches!(event, Event::StepSucceeded { .. });
1488        self.commit(&id, vec![event])?;
1489        self.advance(&id)?;
1490        Ok(ResultAck {
1491            accepted,
1492            reason: (!accepted).then(|| "output violates the task contract".into()),
1493        })
1494    }
1495
1496    /// Record a failed attempt.
1497    pub fn fail(&mut self, token: &str, error: TaskError) -> Result<ResultAck> {
1498        if self.forwards_result(token) {
1499            return self.forward_result(token, "fail", json!({ "token": token, "error": error }));
1500        }
1501        let (id, key, unit, _) = match self.resolve_token(token)? {
1502            Ok(x) => x,
1503            Err(ack) => return Ok(ack),
1504        };
1505        // Failures only count for the attempt currently in flight.
1506        if unit.token.as_deref() != Some(token) {
1507            self.commit(
1508                &id,
1509                vec![Event::StaleResultIgnored {
1510                    step: key,
1511                    token: token.into(),
1512                    outcome: "failure".into(),
1513                }],
1514            )?;
1515            return Ok(ResultAck {
1516                accepted: false,
1517                reason: Some("stale attempt".into()),
1518            });
1519        }
1520        self.commit(
1521            &id,
1522            vec![Event::StepAttemptFailed {
1523                step: key,
1524                attempt: unit.attempt,
1525                error,
1526            }],
1527        )?;
1528        self.advance(&id)?;
1529        Ok(ResultAck {
1530            accepted: true,
1531            reason: None,
1532        })
1533    }
1534
1535    #[allow(clippy::type_complexity)]
1536    fn resolve_token(
1537        &mut self,
1538        token: &str,
1539    ) -> Result<std::result::Result<(String, String, UnitState, Arc<WorkflowIr>), ResultAck>> {
1540        let Some((id, key)) = self.tokens.get(token).cloned() else {
1541            return Ok(Err(ResultAck {
1542                accepted: false,
1543                reason: Some("unknown token (execution finished or token invalid)".into()),
1544            }));
1545        };
1546        if let Some(ctx) = &self.shared {
1547            ctx.stage_done(&id, token);
1548        }
1549        let state = &self.active[&id];
1550        let unit = state.units.get(&key).cloned().unwrap_or_default();
1551        let def = self.by_hash[&state.definition_hash].clone();
1552        if let Some(session) = &unit.worker_session {
1553            if let Some(s) = self.sessions.get_mut(session) {
1554                s.inflight.remove(token);
1555                s.last_seen_ms = (self.clock)();
1556            }
1557        }
1558        // A result for any attempt of an unresolved unit is accepted: the work
1559        // really happened (reconciliation of uncertain/timed-out attempts).
1560        let unresolved = matches!(
1561            unit.status(),
1562            UnitStatus::Dispatched
1563                | UnitStatus::Scheduled
1564                | UnitStatus::RetryWait
1565                | UnitStatus::Uncertain
1566                | UnitStatus::AttemptFailed
1567        );
1568        if !unresolved {
1569            self.commit(
1570                &id,
1571                vec![Event::StaleResultIgnored {
1572                    step: key,
1573                    token: token.into(),
1574                    outcome: "duplicate".into(),
1575                }],
1576            )?;
1577            return Ok(Err(ResultAck {
1578                accepted: false,
1579                reason: Some("duplicate result; step already resolved".into()),
1580            }));
1581        }
1582        Ok(Ok((id, key, unit, def)))
1583    }
1584
1585    pub fn store_stats(&self) -> Result<Vec<(String, u64)>> {
1586        self.store.count_by_status().map_err(EngineError::storage)
1587    }
1588
1589    /// Export OpenTelemetry traces of executions (see [`crate::telemetry`]).
1590    pub fn enable_telemetry(&mut self, config: crate::telemetry::TelemetryConfig) {
1591        tracing::info!(endpoint = %config.endpoint, service = %config.service_name, "exporting OpenTelemetry traces");
1592        self.telemetry = Some(crate::telemetry::Exporter::start(config));
1593    }
1594
1595    /// Trace of an execution: its own, or (for a child workflow) the trace of
1596    /// its top-level ancestor, nested under the parent step's span.
1597    fn trace_context(&self, state: &ExecutionState) -> crate::telemetry::TraceContext {
1598        use crate::telemetry::{trace_id, unit_span_id, TraceContext};
1599        let Some(parent) = &state.parent else {
1600            return TraceContext {
1601                trace_id: trace_id(&state.id),
1602                parent_span_id: None,
1603            };
1604        };
1605        let mut root = parent.execution_id.clone();
1606        for _ in 0..64 {
1607            let next = match self.active.get(&root) {
1608                Some(s) => s.parent.clone(),
1609                None => self.execution(&root).ok().and_then(|s| s.parent),
1610            };
1611            match next {
1612                Some(p) => root = p.execution_id,
1613                None => break,
1614            }
1615        }
1616        TraceContext {
1617            trace_id: trace_id(&root),
1618            // Child workflow steps are dispatched exactly once.
1619            parent_span_id: Some(unit_span_id(&parent.execution_id, &parent.step, 1)),
1620        }
1621    }
1622
1623    /// The worker id that owns a session, if the session exists.
1624    pub fn session_worker(&self, session_id: &str) -> Option<&str> {
1625        self.sessions.get(session_id).map(|s| s.worker_id.as_str())
1626    }
1627
1628    /// The worker id a task token was issued to, if the token is known.
1629    pub fn token_worker(&self, token: &str) -> Option<&str> {
1630        self.token_workers.get(token).map(String::as_str)
1631    }
1632
1633    pub fn session_count(&self) -> usize {
1634        self.sessions.len()
1635    }
1636
1637    /// Load definitions registered in the store since this engine opened
1638    /// (another server registered them). Used by cluster standbys, whose
1639    /// engine only reads. Returns how many were new.
1640    pub fn refresh_definitions(&mut self) -> Result<usize> {
1641        let mut added = 0;
1642        for ir in self.store.definitions().map_err(EngineError::storage)? {
1643            if !self.by_hash.contains_key(&ir.definition_hash) {
1644                self.cache_definition(ir);
1645                added += 1;
1646            }
1647        }
1648        Ok(added)
1649    }
1650}
1651
1652/// The stricter of the server's limits and the workflow's `limits { ... }`.
1653pub fn effective_limits(server: &Limits, def: &WorkflowIr) -> Limits {
1654    fn min<T: Ord + Copy>(a: Option<T>, b: Option<T>) -> Option<T> {
1655        match (a, b) {
1656            (Some(x), Some(y)) => Some(x.min(y)),
1657            (x, None) => x,
1658            (None, y) => y,
1659        }
1660    }
1661    let w = def.limits.clone().unwrap_or_default();
1662    Limits {
1663        max_active_executions: server.max_active_executions,
1664        max_fan_out: min(server.max_fan_out, w.max_fan_out.map(|v| v as usize)),
1665        max_history_events: min(server.max_history_events, w.max_history_events),
1666        max_payload_bytes: min(
1667            server.max_payload_bytes,
1668            w.max_payload_bytes.map(|v| v as usize),
1669        ),
1670    }
1671}
1672
1673/// Apply fan-out and history limits to the decider's next events (the decider
1674/// itself stays a pure function of the history and the definition).
1675fn enforce_limits(limits: &Limits, state: &ExecutionState, events: &mut Vec<Event>) {
1676    if let Some(max) = limits.max_fan_out {
1677        for e in events.iter_mut() {
1678            if let Event::StepFanOut { step, items } = e {
1679                if items.len() > max {
1680                    *e = Event::StepFailed {
1681                        step: step.clone(),
1682                        error: TaskError::new(
1683                            "LIMIT_EXCEEDED",
1684                            format!(
1685                                "`for` over {} elements exceeds the fan-out limit of {max} (--max-fan-out or the workflow's `limits`)",
1686                                items.len()
1687                            ),
1688                            false,
1689                        ),
1690                    };
1691                }
1692            }
1693        }
1694    }
1695    if let Some(max) = limits.max_history_events {
1696        let would_be = state.last_seq + events.len() as u64;
1697        if state.abort.is_none() && would_be > max && !state.status.is_terminal() {
1698            // Stop scheduling new work; compensation and handlers still run.
1699            events.clear();
1700            events.push(Event::LimitExceeded {
1701                limit: "max_history_events".into(),
1702                message: format!(
1703                    "the execution reached {max} history events (--max-history-events or the workflow's `limits`)"
1704                ),
1705            });
1706        }
1707    }
1708}
1709
1710// ------------------------------------------------------------ shared mode
1711//
1712// Engine operations used by `crate::shared` (`--cluster-mode shared`). In
1713// that mode this engine holds only the executions this server owns; the
1714// methods below load and drop them, record dispatches made by other servers,
1715// and route commands for executions owned elsewhere to their owner's inbox.
1716
1717impl Engine {
1718    /// The shared-mode context, when this engine runs in shared mode.
1719    pub fn shared_context(&self) -> Option<Arc<crate::shared::Ctx>> {
1720        self.shared.clone()
1721    }
1722
1723    /// True when this engine holds (owns) the execution.
1724    pub(crate) fn owns(&self, id: &str) -> bool {
1725        self.active.contains_key(id)
1726    }
1727
1728    /// A command for `id` must be sent to the server that owns it.
1729    fn forwards(&self, id: &str) -> bool {
1730        self.shared.as_ref().is_some_and(|c| !c.applying_inbox()) && !self.active.contains_key(id)
1731    }
1732
1733    /// A result for `token` must be sent to the owner of its execution (the
1734    /// token was issued by another server, or its dispatch is not recorded yet).
1735    fn forwards_result(&self, token: &str) -> bool {
1736        self.shared.as_ref().is_some_and(|c| !c.applying_inbox())
1737            && !self.tokens.contains_key(token)
1738    }
1739
1740    fn forward_command(&mut self, id: &str, kind: &str, payload: Value) -> Result<()> {
1741        let ctx = self.shared.clone().expect("shared mode");
1742        match ctx.forward(id, kind, payload, None)? {
1743            Some(seq) => {
1744                crate::shared::note_forwarded(seq);
1745                Ok(())
1746            }
1747            None => Err(EngineError::new(
1748                409,
1749                "EXECUTION_FINISHED",
1750                "execution has already finished",
1751            )),
1752        }
1753    }
1754
1755    fn forward_result(&mut self, token: &str, kind: &str, payload: Value) -> Result<ResultAck> {
1756        let unknown = ResultAck {
1757            accepted: false,
1758            reason: Some("unknown token (execution finished or token invalid)".into()),
1759        };
1760        let Some(exec) = crate::shared::token_execution(token) else {
1761            return Ok(unknown);
1762        };
1763        let ctx = self.shared.clone().expect("shared mode");
1764        Ok(match ctx.forward(exec, kind, payload, Some(token))? {
1765            Some(_) => ResultAck {
1766                accepted: true,
1767                reason: None,
1768            },
1769            None => unknown,
1770        })
1771    }
1772
1773    /// Claim a task from the shared queue for a worker session.
1774    fn shared_poll(&mut self, session_id: &str) -> Result<Option<TaskAssignment>> {
1775        let ctx = self.shared.clone().expect("shared mode");
1776        if !ctx.should_claim(session_id) {
1777            return Ok(None);
1778        }
1779        let s = self.sessions[session_id].clone();
1780        for _ in 0..16 {
1781            let now = self.now();
1782            match ctx.claim(session_id, &s.worker_id, &s.runtime, &s.contracts, now)? {
1783                crate::shared::Claimed::Nothing => return Ok(None),
1784                crate::shared::Claimed::Stale => continue,
1785                crate::shared::Claimed::Remote(a) => return Ok(Some(a)),
1786                crate::shared::Claimed::Local {
1787                    row_id,
1788                    offer,
1789                    assignment: a,
1790                } => {
1791                    match self.apply_dispatch(
1792                        &a.execution_id,
1793                        &a.step_id,
1794                        a.attempt,
1795                        offer,
1796                        &a.token,
1797                        &s.worker_id,
1798                        session_id,
1799                        a.deadline_ms,
1800                    ) {
1801                        Ok(true) => return Ok(Some(a)),
1802                        Ok(false) => {
1803                            ctx.retire(row_id, &a.token)?;
1804                            continue;
1805                        }
1806                        Err(e) => {
1807                            let _ = ctx.release(row_id, &a.token);
1808                            return Err(e);
1809                        }
1810                    }
1811                }
1812            }
1813        }
1814        Ok(None)
1815    }
1816
1817    /// Shared-mode poll, first half: touch the session and return what a
1818    /// claim needs, or `None` when the queue has not changed since this
1819    /// session last found it empty.
1820    #[allow(clippy::type_complexity)]
1821    pub(crate) fn prepare_claim(
1822        &mut self,
1823        session_id: &str,
1824    ) -> Result<Option<(String, String, HashSet<(String, String)>)>> {
1825        self.touch(session_id)?;
1826        let ctx = self.shared.clone().expect("shared mode");
1827        if !ctx.should_claim(session_id) {
1828            return Ok(None);
1829        }
1830        let s = &self.sessions[session_id];
1831        Ok(Some((
1832            s.worker_id.clone(),
1833            s.runtime.clone(),
1834            s.contracts.clone(),
1835        )))
1836    }
1837
1838    /// Record a dispatch made from the shared queue, if the unit is still
1839    /// waiting for exactly that offer. Returns false for a stale claim.
1840    #[allow(clippy::too_many_arguments)]
1841    pub(crate) fn apply_dispatch(
1842        &mut self,
1843        id: &str,
1844        key: &str,
1845        attempt: u32,
1846        offer: u32,
1847        token: &str,
1848        worker_id: &str,
1849        session: &str,
1850        deadline_ms: Option<u64>,
1851    ) -> Result<bool> {
1852        let current = self
1853            .active
1854            .get(id)
1855            .and_then(|s| s.units.get(key))
1856            .is_some_and(|u| {
1857                u.status() == UnitStatus::Scheduled
1858                    && u.attempt == attempt
1859                    && u.dispatch_count == offer
1860            });
1861        if !current {
1862            return Ok(false);
1863        }
1864        self.commit(
1865            id,
1866            vec![Event::StepDispatched {
1867                step: key.to_string(),
1868                attempt,
1869                token: token.to_string(),
1870                worker_id: worker_id.to_string(),
1871                worker_session: session.to_string(),
1872                deadline_ms,
1873            }],
1874        )?;
1875        Ok(true)
1876    }
1877
1878    /// The attempt holding `token` lost its worker (it unregistered on
1879    /// another server): its outcome is uncertain and it is redelivered.
1880    pub(crate) fn mark_uncertain(&mut self, token: &str, reason: &str) -> Result<bool> {
1881        let Some((id, key)) = self.tokens.get(token).cloned() else {
1882            return Ok(false);
1883        };
1884        let Some(u) = self.active.get(&id).and_then(|s| s.units.get(&key)) else {
1885            return Ok(false);
1886        };
1887        if u.status() != UnitStatus::Dispatched || u.token.as_deref() != Some(token) {
1888            return Ok(false);
1889        }
1890        let attempt = u.attempt;
1891        tracing::warn!(execution_id = %id, step = %key, attempt, reason = %reason, "attempt outcome uncertain; redelivering");
1892        self.commit(
1893            &id,
1894            vec![Event::StepAttemptUncertain {
1895                step: key,
1896                attempt,
1897                reason: reason.to_string(),
1898            }],
1899        )?;
1900        self.advance(&id)?;
1901        Ok(true)
1902    }
1903
1904    /// Load a worker session registered on another server.
1905    fn load_shared_session(&mut self, session_id: &str) -> Result<()> {
1906        let ctx = self.shared.clone().expect("shared mode");
1907        if let Some((worker_id, runtime, contracts)) =
1908            ctx.load_session(session_id, self.config.session_timeout_ms)?
1909        {
1910            let now = self.now();
1911            self.sessions.insert(
1912                session_id.to_string(),
1913                Session {
1914                    worker_id,
1915                    runtime,
1916                    contracts: contracts.into_iter().collect(),
1917                    last_seen_ms: now,
1918                    persisted_seen_ms: now,
1919                    inflight: HashSet::new(),
1920                },
1921            );
1922        }
1923        Ok(())
1924    }
1925
1926    /// Unregistration in shared mode: delete the session everywhere and
1927    /// redeliver what it held, wherever those executions are owned.
1928    fn release_shared_session(&mut self, session_id: &str) -> Result<()> {
1929        let ctx = self.shared.clone().expect("shared mode");
1930        for (exec, token) in ctx.drop_session(session_id)? {
1931            if self.tokens.contains_key(&token) {
1932                self.mark_uncertain(&token, "worker unregistered")?;
1933            } else {
1934                ctx.forward(
1935                    &exec,
1936                    "uncertain",
1937                    json!({ "token": token, "reason": "worker unregistered" }),
1938                    None,
1939                )?;
1940            }
1941        }
1942        Ok(())
1943    }
1944
1945    /// [`Engine::session_worker`] that also finds sessions opened on other
1946    /// servers (shared mode).
1947    pub fn session_worker_lookup(&mut self, session_id: &str) -> Option<String> {
1948        if self.shared.is_some() && !self.sessions.contains_key(session_id) {
1949            let _ = self.load_shared_session(session_id);
1950        }
1951        self.session_worker(session_id).map(str::to_string)
1952    }
1953
1954    /// [`Engine::token_worker`] that also finds tokens issued by other
1955    /// servers (shared mode).
1956    pub fn token_worker_lookup(&self, token: &str) -> Option<String> {
1957        if let Some(w) = self.token_worker(token) {
1958            return Some(w.to_string());
1959        }
1960        self.shared.as_ref().and_then(|c| c.token_worker(token))
1961    }
1962
1963    /// Take ownership of executions (startup or takeover): rebuild them from
1964    /// history like [`Engine::open`] does, put their queued tasks back on the
1965    /// shared queue, and resume them. Attempts in flight stay in flight while
1966    /// their worker sessions live; the others become uncertain and are
1967    /// redelivered by the liveness check.
1968    pub(crate) fn adopt(&mut self, items: &[(String, i64)], from: &str) -> Result<usize> {
1969        let ctx = self.shared.clone().expect("shared mode");
1970        let mut loaded = vec![];
1971        let mut in_flight = 0;
1972        for (id, epoch) in items {
1973            let history = self.store.history(id).map_err(EngineError::storage)?;
1974            if history.is_empty() {
1975                continue;
1976            }
1977            let state = ExecutionState::replay(id, &history);
1978            if state.status.is_terminal() {
1979                continue;
1980            }
1981            if !self.by_hash.contains_key(&state.definition_hash) {
1982                self.refresh_definitions()?;
1983            }
1984            for h in &history {
1985                if let Event::StepDispatched {
1986                    token, worker_id, ..
1987                } = &h.event
1988                {
1989                    self.token_workers.insert(token.clone(), worker_id.clone());
1990                }
1991            }
1992            for (key, u) in &state.units {
1993                for (t, _) in &u.tokens {
1994                    self.tokens.insert(t.clone(), (id.clone(), key.clone()));
1995                }
1996                if u.status() == UnitStatus::Dispatched {
1997                    in_flight += 1;
1998                }
1999            }
2000            ctx.set_epoch(id, *epoch);
2001            self.active.insert(id.clone(), state);
2002            self.reindex(id);
2003            loaded.push(id.clone());
2004        }
2005        let mut offers = vec![];
2006        for id in &loaded {
2007            let state = &self.active[id];
2008            let Some(def) = self.by_hash.get(&state.definition_hash) else {
2009                continue;
2010            };
2011            for (key, u) in &state.units {
2012                if u.status() == UnitStatus::Scheduled {
2013                    if let Some(o) = crate::shared::offer_for(state, key, def) {
2014                        offers.push((id.clone(), o));
2015                    }
2016                }
2017            }
2018        }
2019        ctx.republish(offers, from)?;
2020        if !loaded.is_empty() {
2021            tracing::info!(
2022                executions = loaded.len(),
2023                in_flight,
2024                from = %from,
2025                "shared: took ownership of executions"
2026            );
2027        }
2028        for id in &loaded {
2029            if let Err(e) = self.advance(id) {
2030                tracing::error!(execution_id = %id, error = %e, "shared: resuming an adopted execution failed");
2031            }
2032        }
2033        Ok(loaded.len())
2034    }
2035
2036    /// Drop an execution this server no longer owns (another server took it
2037    /// over): it stops ticking it and forgets its tokens.
2038    pub(crate) fn evict(&mut self, id: &str) {
2039        if let Some(state) = self.active.remove(id) {
2040            for u in state.units.values() {
2041                for (t, _) in &u.tokens {
2042                    self.tokens.remove(t);
2043                    self.token_workers.remove(t);
2044                }
2045            }
2046            self.reindex(id);
2047            tracing::warn!(execution_id = %id, "shared: another server owns this execution now; dropped it");
2048        }
2049        if let Some(ctx) = &self.shared {
2050            ctx.forget(id);
2051        }
2052    }
2053
2054    /// (session, token) of the worker attempts in flight in executions this
2055    /// engine owns.
2056    pub(crate) fn dispatched_attempts(&self) -> Vec<(String, String)> {
2057        let mut out = vec![];
2058        for state in self.active.values() {
2059            for u in state.units.values() {
2060                if u.status() != UnitStatus::Dispatched {
2061                    continue;
2062                }
2063                if let (Some(s), Some(t)) = (&u.worker_session, &u.token) {
2064                    if !s.starts_with(CHILD_SESSION_PREFIX)
2065                        && !s.starts_with(BUILTIN_SESSION_PREFIX)
2066                    {
2067                        out.push((s.clone(), t.clone()));
2068                    }
2069                }
2070            }
2071        }
2072        out
2073    }
2074
2075    /// Forget sessions that no longer exist (unregistered or expired elsewhere).
2076    pub(crate) fn forget_sessions(&mut self, ids: &[String]) {
2077        for id in ids {
2078            self.sessions.remove(id);
2079        }
2080    }
2081
2082    /// Ids of the executions this engine owns.
2083    pub(crate) fn owned_ids(&self) -> Vec<String> {
2084        self.active.keys().cloned().collect()
2085    }
2086
2087    /// Queued worker units (execution, unit, attempt, offer) of owned executions.
2088    pub(crate) fn queued_units(&self) -> Vec<(String, String, i64, i64)> {
2089        let mut out = vec![];
2090        for (id, state) in &self.active {
2091            let Some(def) = self.by_hash.get(&state.definition_hash) else {
2092                continue;
2093            };
2094            for (key, u) in &state.units {
2095                if u.status() == UnitStatus::Scheduled
2096                    && crate::shared::worker_invocation(def, key).is_some()
2097                {
2098                    out.push((
2099                        id.clone(),
2100                        key.clone(),
2101                        u.attempt as i64,
2102                        u.dispatch_count as i64,
2103                    ));
2104                }
2105            }
2106        }
2107        out
2108    }
2109}