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, 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}
36
37impl Default for EngineConfig {
38    fn default() -> Self {
39        EngineConfig {
40            session_timeout_ms: 30_000,
41            heartbeat_interval_ms: 5_000,
42            http_adapter: false,
43        }
44    }
45}
46
47#[derive(Debug, Clone, PartialEq, Serialize)]
48pub struct EngineError {
49    pub code: &'static str,
50    pub message: String,
51    #[serde(skip)]
52    pub status: u16,
53}
54
55impl EngineError {
56    pub fn new(status: u16, code: &'static str, message: impl Into<String>) -> Self {
57        EngineError {
58            code,
59            message: message.into(),
60            status,
61        }
62    }
63    fn not_found(what: &str) -> Self {
64        Self::new(404, "NOT_FOUND", format!("{what} not found"))
65    }
66    fn storage(e: crate::store::StoreError) -> Self {
67        Self::new(500, "STORAGE_ERROR", e.to_string())
68    }
69}
70
71impl std::fmt::Display for EngineError {
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        write!(f, "{}: {}", self.code, self.message)
74    }
75}
76
77pub type Result<T> = std::result::Result<T, EngineError>;
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct ContractAnnouncement {
81    pub task_name: String,
82    pub contract_version: String,
83}
84
85#[derive(Debug, Clone, Deserialize)]
86pub struct RegisterWorker {
87    pub worker_id: String,
88    pub runtime: String,
89    #[serde(default)]
90    pub sdk: Option<String>,
91    pub contracts: Vec<ContractAnnouncement>,
92}
93
94#[derive(Debug, Clone, Serialize)]
95pub struct Registration {
96    pub accepted: bool,
97    pub worker_session_id: String,
98    pub heartbeat_interval_ms: u64,
99    pub session_timeout_ms: u64,
100    pub warnings: Vec<String>,
101}
102
103#[derive(Debug, Clone)]
104struct Session {
105    worker_id: String,
106    runtime: String,
107    contracts: HashSet<(String, String)>,
108    last_seen_ms: u64,
109    inflight: HashSet<String>,
110}
111
112/// A task handed to a worker (RFC-0001 §8.3 `ExecuteTaskRequest`).
113#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
114pub struct TaskAssignment {
115    pub token: String,
116    pub execution_id: String,
117    pub workflow: String,
118    pub workflow_version: String,
119    pub step_id: String,
120    pub task_name: String,
121    pub contract_version: String,
122    pub input: Value,
123    pub idempotency_key: String,
124    pub attempt: u32,
125    pub redelivery: bool,
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub deadline_ms: Option<u64>,
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub timeout_ms: Option<u64>,
130}
131
132#[derive(Debug, Clone, Serialize)]
133pub struct ResultAck {
134    pub accepted: bool,
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub reason: Option<String>,
137}
138
139#[derive(Debug, Clone, Serialize)]
140pub struct HeartbeatAck {
141    pub accepted: bool,
142    /// Tokens the worker should stop working on (cooperative cancellation).
143    pub cancel: Vec<String>,
144}
145
146#[derive(Debug, Clone, Serialize)]
147pub struct WorkerInfo {
148    #[serde(flatten)]
149    pub record: WorkerRecord,
150    pub online: bool,
151    pub sessions: usize,
152    pub inflight: usize,
153}
154
155#[derive(Debug, Clone, Serialize)]
156pub struct StartResult {
157    pub execution_id: String,
158    pub created: bool,
159}
160
161const BUILTIN_SESSION_PREFIX: &str = "builtin:";
162const CHILD_SESSION_PREFIX: &str = "child:";
163
164/// Deterministic id of the child execution started by `unit` of `parent`, so
165/// that starting it again after a crash is a no-op.
166pub fn child_execution_id(parent: &str, unit: &str) -> String {
167    let unit = unit
168        .replace("compensate:", "compensate.")
169        .replace('[', ".")
170        .replace(']', "");
171    format!("{parent}.{unit}")
172}
173
174pub struct Engine {
175    store: Store,
176    clock: Clock,
177    pub config: EngineConfig,
178    instance: String,
179    started_at_ms: u64,
180    by_version: BTreeMap<(String, String), Arc<WorkflowIr>>,
181    by_hash: HashMap<String, Arc<WorkflowIr>>,
182    active: BTreeMap<String, ExecutionState>,
183    tokens: HashMap<String, (String, String)>,
184    /// Which worker each task token was issued to (for per-worker authorization).
185    token_workers: HashMap<String, String>,
186    sessions: HashMap<String, Session>,
187    /// Built-in tasks dispatched to the runtime itself, waiting to be run.
188    builtin_jobs: Vec<TaskAssignment>,
189    pub metrics: Metrics,
190    pub changed: Arc<tokio::sync::Notify>,
191    telemetry: Option<crate::telemetry::Exporter>,
192}
193
194fn semver_key(v: &str) -> (Vec<u64>, String) {
195    let core = v.split(['-', '+']).next().unwrap_or("");
196    (
197        core.split('.').map(|p| p.parse().unwrap_or(0)).collect(),
198        v.to_string(),
199    )
200}
201
202impl Engine {
203    /// Open the engine and recover every non-terminal execution from history.
204    pub fn open(store: Store, config: EngineConfig, clock: Clock) -> Result<Engine> {
205        let now = clock();
206        let mut engine = Engine {
207            store,
208            clock,
209            config,
210            instance: uuid::Uuid::new_v4().to_string(),
211            started_at_ms: now,
212            by_version: BTreeMap::new(),
213            by_hash: HashMap::new(),
214            active: BTreeMap::new(),
215            tokens: HashMap::new(),
216            token_workers: HashMap::new(),
217            sessions: HashMap::new(),
218            builtin_jobs: vec![],
219            metrics: Metrics::default(),
220            changed: Arc::new(tokio::sync::Notify::new()),
221            telemetry: None,
222        };
223        for ir in engine.store.definitions().map_err(EngineError::storage)? {
224            engine.cache_definition(ir);
225        }
226        engine.recover()?;
227        Ok(engine)
228    }
229
230    pub fn now(&self) -> u64 {
231        (self.clock)()
232    }
233
234    fn cache_definition(&mut self, ir: WorkflowIr) -> Arc<WorkflowIr> {
235        let ir = Arc::new(ir);
236        self.by_version
237            .insert((ir.name.clone(), ir.version.clone()), ir.clone());
238        self.by_hash.insert(ir.definition_hash.clone(), ir.clone());
239        ir
240    }
241
242    /// RFC-0001 §9.9 crash recovery: load active executions, rebuild state
243    /// from history, re-index in-flight work and resume. Attempts that were
244    /// in flight are reconciled by `check_liveness` once their worker fails to
245    /// report back within the session timeout (outcome treated as unknown,
246    /// never as failed).
247    fn recover(&mut self) -> Result<()> {
248        let ids = self
249            .store
250            .active_execution_ids()
251            .map_err(EngineError::storage)?;
252        let mut in_flight = 0;
253        for id in &ids {
254            let history = self.store.history(id).map_err(EngineError::storage)?;
255            for h in &history {
256                if let Event::StepDispatched {
257                    token, worker_id, ..
258                } = &h.event
259                {
260                    self.token_workers.insert(token.clone(), worker_id.clone());
261                }
262            }
263            let state = ExecutionState::replay(id, &history);
264            for (key, u) in &state.units {
265                for (t, _) in &u.tokens {
266                    self.tokens.insert(t.clone(), (id.clone(), key.clone()));
267                }
268                if u.status() == UnitStatus::Dispatched {
269                    in_flight += 1;
270                }
271            }
272            self.active.insert(id.clone(), state);
273        }
274        if !ids.is_empty() {
275            tracing::info!(
276                executions = ids.len(),
277                in_flight,
278                "recovered active executions from history"
279            );
280        }
281        for id in ids {
282            self.advance(&id)?;
283        }
284        Ok(())
285    }
286
287    // ------------------------------------------------------------ definitions
288
289    pub fn register_definition(&mut self, ir: WorkflowIr) -> Result<bool> {
290        ir.validate()
291            .map_err(|e| EngineError::new(400, "INVALID_IR", e.0))?;
292        if ir.definition_hash.is_empty() {
293            return Err(EngineError::new(
294                400,
295                "INVALID_IR",
296                "definition_hash is required",
297            ));
298        }
299        // A parent pins the exact child definition it was compiled against.
300        for c in ir.contracts.values() {
301            if let Some(hash) = &c.workflow_hash {
302                if !self.by_hash.contains_key(hash) {
303                    return Err(EngineError::new(
304                        400,
305                        "CHILD_NOT_REGISTERED",
306                        format!(
307                            "{}@{} starts child workflow {}@{} ({hash}), which is not registered; register the child first (`orbit deploy` does this automatically)",
308                            ir.name,
309                            ir.version,
310                            c.name.trim_start_matches(WORKFLOW_CONTRACT_PREFIX),
311                            c.version
312                        ),
313                    ));
314                }
315            }
316        }
317        let now = self.now();
318        match self.store.put_definition(&ir, now).map_err(EngineError::storage)? {
319            PutDefinition::Created => {
320                tracing::info!(workflow = %ir.name, version = %ir.version, hash = %ir.definition_hash, "registered workflow definition");
321                self.cache_definition(ir);
322                Ok(true)
323            }
324            PutDefinition::Unchanged => Ok(false),
325            PutDefinition::Conflict { existing_hash } => Err(EngineError::new(
326                409,
327                "DEFINITION_CONFLICT",
328                format!(
329                    "{}@{} is already registered with a different definition ({existing_hash}); workflow definitions are immutable, bump the version",
330                    ir.name, ir.version
331                ),
332            )),
333        }
334    }
335
336    pub fn definitions(&self) -> Vec<Arc<WorkflowIr>> {
337        self.by_version.values().cloned().collect()
338    }
339
340    pub fn definition(&self, name: &str, version: Option<&str>) -> Option<Arc<WorkflowIr>> {
341        match version {
342            Some(v) => self
343                .by_version
344                .get(&(name.to_string(), v.to_string()))
345                .cloned(),
346            None => self
347                .by_version
348                .iter()
349                .filter(|((n, _), _)| n == name)
350                .max_by_key(|((_, v), _)| semver_key(v))
351                .map(|(_, d)| d.clone()),
352        }
353    }
354
355    // ------------------------------------------------------------- executions
356
357    pub fn start(
358        &mut self,
359        workflow: &str,
360        version: Option<&str>,
361        input: Value,
362        execution_id: Option<String>,
363    ) -> Result<StartResult> {
364        let def = self.definition(workflow, version).ok_or_else(|| {
365            EngineError::new(
366                404,
367                "WORKFLOW_NOT_FOUND",
368                match version {
369                    Some(v) => format!("workflow {workflow}@{v} is not registered"),
370                    None => format!("workflow {workflow} is not registered"),
371                },
372            )
373        })?;
374        self.start_with(def, input, execution_id, None)
375    }
376
377    /// Start an execution of a specific definition. Child workflows pass their
378    /// parent link; their input was already validated against the child's
379    /// contract by the parent, so it is checked leniently.
380    fn start_with(
381        &mut self,
382        def: Arc<WorkflowIr>,
383        mut input: Value,
384        execution_id: Option<String>,
385        parent: Option<ParentRef>,
386    ) -> Result<StartResult> {
387        if input.is_null() {
388            input = json!({});
389        }
390        def.input.apply_defaults(&mut input);
391        def.input.validate(&input, parent.is_none()).map_err(|e| {
392            EngineError::new(
393                400,
394                "INVALID_INPUT",
395                format!("workflow input is invalid at {e}"),
396            )
397        })?;
398        let id = match execution_id {
399            Some(id) => {
400                if id.is_empty()
401                    || id.len() > 200
402                    || !id
403                        .chars()
404                        .all(|c| c.is_ascii_alphanumeric() || "-_.:".contains(c))
405                {
406                    return Err(EngineError::new(
407                        400,
408                        "INVALID_EXECUTION_ID",
409                        "execution ids may contain letters, digits and - _ . :",
410                    ));
411                }
412                // Idempotent start: the same id never starts a second execution.
413                if self.active.contains_key(&id)
414                    || self
415                        .store
416                        .execution_exists(&id)
417                        .map_err(EngineError::storage)?
418                {
419                    let existing = self.execution(&id)?;
420                    if existing.definition_hash != def.definition_hash {
421                        return Err(EngineError::new(
422                            409,
423                            "EXECUTION_EXISTS",
424                            format!("execution `{id}` already exists for a different workflow"),
425                        ));
426                    }
427                    return Ok(StartResult {
428                        execution_id: id,
429                        created: false,
430                    });
431                }
432                id
433            }
434            None => uuid::Uuid::new_v4().to_string(),
435        };
436        self.active.insert(id.clone(), ExecutionState::new(&id));
437        let created = Event::WorkflowCreated {
438            workflow: def.name.clone(),
439            version: def.version.clone(),
440            definition_hash: def.definition_hash.clone(),
441            input,
442            parent,
443        };
444        if let Err(e) = self.commit(&id, vec![created, Event::WorkflowStarted {}]) {
445            self.active.remove(&id);
446            return Err(e);
447        }
448        tracing::info!(execution_id = %id, workflow = %def.name, version = %def.version, "execution started");
449        self.advance(&id)?;
450        Ok(StartResult {
451            execution_id: id,
452            created: true,
453        })
454    }
455
456    pub fn cancel(&mut self, id: &str, reason: &str) -> Result<ExecutionState> {
457        let state = self.execution(id)?;
458        if state.status.is_terminal() {
459            return Err(EngineError::new(
460                409,
461                "EXECUTION_FINISHED",
462                format!("execution is already {}", state.status.as_str()),
463            ));
464        }
465        if state.abort.is_none() {
466            self.commit(
467                id,
468                vec![Event::CancelRequested {
469                    reason: reason.to_string(),
470                }],
471            )?;
472            self.advance(id)?;
473        }
474        self.execution(id)
475    }
476
477    /// Send a typed signal to a running execution. The payload is validated
478    /// against the workflow's `signal` declaration (defaults applied) and
479    /// buffered until an `await` step takes it. Returns the history sequence
480    /// number of the recorded signal.
481    pub fn signal(&mut self, id: &str, name: &str, mut payload: Value) -> Result<u64> {
482        let state = self.execution(id)?;
483        if state.status.is_terminal() {
484            return Err(EngineError::new(
485                409,
486                "EXECUTION_FINISHED",
487                format!("execution is already {}", state.status.as_str()),
488            ));
489        }
490        let def = self
491            .by_hash
492            .get(&state.definition_hash)
493            .cloned()
494            .ok_or_else(|| EngineError::not_found("workflow definition"))?;
495        let Some(ty) = def.signals.get(name) else {
496            let declared: Vec<&str> = def.signals.keys().map(String::as_str).collect();
497            return Err(EngineError::new(
498                404,
499                "UNKNOWN_SIGNAL",
500                if declared.is_empty() {
501                    format!("{}@{} declares no signals", def.name, def.version)
502                } else {
503                    format!(
504                        "{}@{} has no signal `{name}`; declared: {}",
505                        def.name,
506                        def.version,
507                        declared.join(", ")
508                    )
509                },
510            ));
511        };
512        ty.apply_defaults(&mut payload);
513        if let Err(e) = ty.validate(&payload, false) {
514            return Err(EngineError::new(
515                422,
516                "SIGNAL_PAYLOAD_INVALID",
517                format!("payload for signal `{name}` is invalid at {e}"),
518            ));
519        }
520        let seq = state.last_seq + 1;
521        self.commit(
522            id,
523            vec![Event::SignalReceived {
524                name: name.to_string(),
525                payload,
526            }],
527        )?;
528        tracing::info!(execution_id = %id, signal = %name, "signal received");
529        self.advance(id)?;
530        Ok(seq)
531    }
532
533    /// Evaluate a declared query against the current state of an execution.
534    /// Steps that have not completed read as `null`.
535    pub fn query(&self, id: &str, name: &str) -> Result<Value> {
536        let state = self.execution(id)?;
537        let def = self
538            .by_hash
539            .get(&state.definition_hash)
540            .cloned()
541            .ok_or_else(|| EngineError::not_found("workflow definition"))?;
542        let Some(q) = def.queries.get(name) else {
543            let declared: Vec<&str> = def.queries.keys().map(String::as_str).collect();
544            return Err(EngineError::new(
545                404,
546                "UNKNOWN_QUERY",
547                if declared.is_empty() {
548                    format!("{}@{} declares no queries", def.name, def.version)
549                } else {
550                    format!(
551                        "{}@{} has no query `{name}`; declared: {}",
552                        def.name,
553                        def.version,
554                        declared.join(", ")
555                    )
556                },
557            ));
558        };
559        struct QueryScope<'a>(crate::decide::StateScope<'a>, Value);
560        impl orbit_ir::Scope for QueryScope<'_> {
561            fn input(&self) -> &Value {
562                self.0.input()
563            }
564            fn step_output(&self, step: &str) -> Option<&Value> {
565                self.0.step_output(step).or(Some(&self.1))
566            }
567            fn execution_field(&self, field: &str) -> Option<Value> {
568                self.0.execution_field(field)
569            }
570        }
571        let scope = QueryScope(crate::decide::StateScope::new(&state), Value::Null);
572        orbit_ir::eval(&q.expr, &scope).map_err(|e| {
573            EngineError::new(
574                422,
575                "QUERY_FAILED",
576                format!("evaluating query `{name}`: {e}"),
577            )
578        })
579    }
580
581    pub fn execution(&self, id: &str) -> Result<ExecutionState> {
582        if let Some(s) = self.active.get(id) {
583            return Ok(s.clone());
584        }
585        let history = self.store.history(id).map_err(EngineError::storage)?;
586        if history.is_empty() {
587            return Err(EngineError::not_found("execution"));
588        }
589        Ok(ExecutionState::replay(id, &history))
590    }
591
592    pub fn history(&self, id: &str) -> Result<Vec<HistoryEvent>> {
593        let h = self.store.history(id).map_err(EngineError::storage)?;
594        if h.is_empty() {
595            return Err(EngineError::not_found("execution"));
596        }
597        Ok(h)
598    }
599
600    pub fn list_executions(
601        &self,
602        status: Option<&str>,
603        workflow: Option<&str>,
604        limit: u32,
605    ) -> Result<Vec<ExecutionSummary>> {
606        self.store
607            .list_executions(status, workflow, limit.clamp(1, 1000))
608            .map_err(EngineError::storage)
609    }
610
611    pub fn active_count(&self) -> usize {
612        self.active.len()
613    }
614
615    // ----------------------------------------------------------------- commit
616
617    /// Persist events for one execution, then update the in-memory state.
618    fn commit(&mut self, id: &str, events: Vec<Event>) -> Result<()> {
619        if events.is_empty() {
620            return Ok(());
621        }
622        let now = self.now();
623        let mut state = self
624            .active
625            .get(id)
626            .cloned()
627            .ok_or_else(|| EngineError::not_found("active execution"))?;
628        let mut history = Vec::with_capacity(events.len());
629        for event in events {
630            let h = HistoryEvent {
631                seq: state.last_seq + 1,
632                timestamp_ms: now,
633                event,
634            };
635            apply(&mut state, &h);
636            history.push(h);
637        }
638        self.store
639            .append(&state, &history)
640            .map_err(EngineError::storage)?;
641        // Export spans only once the events are durable.
642        if let Some(t) = &self.telemetry {
643            let ctx = self.trace_context(&state);
644            t.export(crate::telemetry::spans_for(&state, &ctx, &history));
645        }
646        for h in &history {
647            self.metrics.observe(&h.event);
648            if let Event::StepDispatched {
649                token,
650                step,
651                worker_id,
652                ..
653            } = &h.event
654            {
655                self.tokens
656                    .insert(token.clone(), (id.to_string(), step.clone()));
657                self.token_workers.insert(token.clone(), worker_id.clone());
658            }
659            tracing::debug!(execution_id = %id, seq = h.seq, event = h.event.name(), step = h.event.step().unwrap_or(""), "event");
660            match &h.event {
661                Event::WorkflowCompleted { .. } => {
662                    tracing::info!(execution_id = %id, "execution completed")
663                }
664                Event::WorkflowFailed {
665                    error,
666                    compensation,
667                } => {
668                    tracing::warn!(execution_id = %id, code = %error.code, error = %error.message, compensation = ?compensation, "execution failed")
669                }
670                Event::WorkflowCancelled { .. } => {
671                    tracing::info!(execution_id = %id, "execution cancelled")
672                }
673                Event::WorkflowTimedOut { .. } => {
674                    tracing::warn!(execution_id = %id, "execution timed out")
675                }
676                Event::StepFailed { step, error } => {
677                    tracing::warn!(execution_id = %id, step = %step, code = %error.code, error = %error.message, "step failed")
678                }
679                Event::RetryScheduled {
680                    step,
681                    next_attempt,
682                    delay_ms,
683                    ..
684                } => {
685                    tracing::info!(execution_id = %id, step = %step, attempt = next_attempt, delay_ms, "retry scheduled")
686                }
687                _ => {}
688            }
689        }
690        if state.status.is_terminal() {
691            for u in state.units.values() {
692                for (t, _) in &u.tokens {
693                    self.tokens.remove(t);
694                    self.token_workers.remove(t);
695                }
696            }
697            self.active.remove(id);
698        } else {
699            self.active.insert(id.to_string(), state);
700        }
701        self.changed.notify_waiters();
702        Ok(())
703    }
704
705    /// Run the decider to a fixpoint and commit the resulting events as one batch.
706    fn advance(&mut self, id: &str) -> Result<()> {
707        let Some(state) = self.active.get(id) else {
708            return Ok(());
709        };
710        let Some(def) = self.by_hash.get(&state.definition_hash).cloned() else {
711            tracing::error!(execution_id = %id, hash = %state.definition_hash, "definition missing for execution");
712            return Ok(());
713        };
714        let now = self.now();
715        let mut scratch = state.clone();
716        let mut batch = vec![];
717        for _ in 0..10_000 {
718            let events = decide(&scratch, &def, now);
719            if events.is_empty() {
720                break;
721            }
722            for e in events {
723                let seq = scratch.last_seq + 1;
724                apply(
725                    &mut scratch,
726                    &HistoryEvent {
727                        seq,
728                        timestamp_ms: now,
729                        event: e.clone(),
730                    },
731                );
732                batch.push(e);
733            }
734        }
735        self.commit(id, batch)
736    }
737
738    /// Periodic work: timers, retries, deadlines, worker liveness, built-ins.
739    pub fn tick(&mut self) -> Result<Vec<TaskAssignment>> {
740        self.check_liveness()?;
741        let ids: Vec<String> = self.active.keys().cloned().collect();
742        for id in &ids {
743            self.advance(id)?;
744        }
745        self.dispatch_builtins()?;
746        self.dispatch_children()?;
747        self.reconcile_children()?;
748        Ok(std::mem::take(&mut self.builtin_jobs))
749    }
750
751    /// Dispatched attempts whose worker session is gone become uncertain and
752    /// are redelivered with the same idempotency key (at-least-once delivery).
753    fn check_liveness(&mut self) -> Result<()> {
754        let now = self.now();
755        let timeout = self.config.session_timeout_ms;
756        self.sessions
757            .retain(|_, s| now < s.last_seen_ms + timeout * 4);
758        let mut lost: Vec<(String, String, u32, String)> = vec![];
759        for (id, state) in &self.active {
760            for (key, u) in &state.units {
761                if u.status() != UnitStatus::Dispatched {
762                    continue;
763                }
764                let session = u.worker_session.clone().unwrap_or_default();
765                let alive = if session.starts_with(CHILD_SESSION_PREFIX) {
766                    // Child workflows are durable executions, not processes.
767                    true
768                } else if let Some(inst) = session.strip_prefix(BUILTIN_SESSION_PREFIX) {
769                    inst == self.instance
770                } else {
771                    self.sessions
772                        .get(&session)
773                        .is_some_and(|s| now < s.last_seen_ms + timeout)
774                };
775                let since = u.dispatched_at_ms.unwrap_or(0).max(self.started_at_ms);
776                if !alive && now >= since + timeout {
777                    let reason = if session.starts_with(BUILTIN_SESSION_PREFIX) {
778                        "runtime restarted while the built-in task was running".to_string()
779                    } else {
780                        format!(
781                            "worker `{}` stopped responding",
782                            u.worker_id.clone().unwrap_or_default()
783                        )
784                    };
785                    lost.push((id.clone(), key.clone(), u.attempt, reason));
786                }
787            }
788        }
789        for (id, key, attempt, reason) in lost {
790            tracing::warn!(execution_id = %id, step = %key, attempt, reason = %reason, "attempt outcome uncertain; redelivering");
791            self.commit(
792                &id,
793                vec![Event::StepAttemptUncertain {
794                    step: key,
795                    attempt,
796                    reason,
797                }],
798            )?;
799            self.advance(&id)?;
800        }
801        Ok(())
802    }
803
804    // ---------------------------------------------------------------- workers
805
806    pub fn register_worker(&mut self, req: RegisterWorker) -> Result<Registration> {
807        if req.worker_id.trim().is_empty() {
808            return Err(EngineError::new(
809                400,
810                "INVALID_REQUEST",
811                "worker_id is required",
812            ));
813        }
814        if req.contracts.is_empty() {
815            return Err(EngineError::new(
816                400,
817                "INVALID_REQUEST",
818                "a worker must announce at least one task contract",
819            ));
820        }
821        let now = self.now();
822        let mut warnings = vec![];
823        for c in &req.contracts {
824            if c.task_name.starts_with(WORKFLOW_CONTRACT_PREFIX) {
825                return Err(EngineError::new(
826                    400,
827                    "RESERVED_TASK",
828                    format!(
829                        "`{}` names a child workflow, not a worker task",
830                        c.task_name
831                    ),
832                ));
833            }
834            if c.task_name.starts_with("orbit.") {
835                return Err(EngineError::new(
836                    400,
837                    "RESERVED_TASK",
838                    format!("`{}` is a built-in task", c.task_name),
839                ));
840            }
841            let mut versions: Vec<&str> = self
842                .by_version
843                .values()
844                .filter_map(|d| d.contracts.get(&c.task_name).map(|k| k.version.as_str()))
845                .collect();
846            versions.sort();
847            versions.dedup();
848            if !versions.is_empty() && !versions.contains(&c.contract_version.as_str()) {
849                warnings.push(format!(
850                    "`{}` is announced at version {} but registered workflows use {}; this worker will not receive those tasks",
851                    c.task_name,
852                    c.contract_version,
853                    versions.join(", ")
854                ));
855            }
856        }
857        let session_id = uuid::Uuid::new_v4().to_string();
858        self.sessions.insert(
859            session_id.clone(),
860            Session {
861                worker_id: req.worker_id.clone(),
862                runtime: req.runtime.to_lowercase(),
863                contracts: req
864                    .contracts
865                    .iter()
866                    .map(|c| (c.task_name.clone(), c.contract_version.clone()))
867                    .collect(),
868                last_seen_ms: now,
869                inflight: HashSet::new(),
870            },
871        );
872        self.store
873            .upsert_worker(&WorkerRecord {
874                worker_id: req.worker_id.clone(),
875                runtime: req.runtime.to_lowercase(),
876                sdk: req.sdk.clone(),
877                contracts: serde_json::to_value(&req.contracts).unwrap_or_default(),
878                last_seen_ms: now,
879                registered_at_ms: now,
880            })
881            .map_err(EngineError::storage)?;
882        tracing::info!(worker_id = %req.worker_id, runtime = %req.runtime, tasks = req.contracts.len(), "worker registered");
883        Ok(Registration {
884            accepted: true,
885            worker_session_id: session_id,
886            heartbeat_interval_ms: self.config.heartbeat_interval_ms,
887            session_timeout_ms: self.config.session_timeout_ms,
888            warnings,
889        })
890    }
891
892    fn touch(&mut self, session_id: &str) -> Result<()> {
893        let now = self.now();
894        let s = self.sessions.get_mut(session_id).ok_or_else(|| {
895            EngineError::new(
896                404,
897                "UNKNOWN_SESSION",
898                "unknown or expired worker session; register again",
899            )
900        })?;
901        s.last_seen_ms = now;
902        let wid = s.worker_id.clone();
903        let _ = self.store.touch_worker(&wid, now);
904        Ok(())
905    }
906
907    pub fn heartbeat(&mut self, session_id: &str) -> Result<HeartbeatAck> {
908        self.touch(session_id)?;
909        let inflight: Vec<String> = self.sessions[session_id].inflight.iter().cloned().collect();
910        let mut cancel = vec![];
911        for token in inflight {
912            let live = self.tokens.get(&token).and_then(|(id, key)| {
913                let st = self.active.get(id)?;
914                let u = st.units.get(key)?;
915                Some(
916                    u.status() == UnitStatus::Dispatched
917                        && u.token.as_deref() == Some(token.as_str())
918                        && (st.abort.is_none() || key.starts_with("compensate:")),
919                )
920            });
921            if live != Some(true) {
922                cancel.push(token.clone());
923                self.sessions
924                    .get_mut(session_id)
925                    .unwrap()
926                    .inflight
927                    .remove(&token);
928            }
929        }
930        Ok(HeartbeatAck {
931            accepted: true,
932            cancel,
933        })
934    }
935
936    pub fn unregister_worker(&mut self, session_id: &str) -> Result<()> {
937        let s = self
938            .sessions
939            .remove(session_id)
940            .ok_or_else(|| EngineError::new(404, "UNKNOWN_SESSION", "unknown worker session"))?;
941        tracing::info!(worker_id = %s.worker_id, "worker unregistered");
942        // In-flight work of a departing worker is redelivered immediately.
943        let mut lost = vec![];
944        for token in s.inflight {
945            if let Some((id, key)) = self.tokens.get(&token) {
946                if let Some(u) = self.active.get(id).and_then(|st| st.units.get(key)) {
947                    if u.status() == UnitStatus::Dispatched
948                        && u.token.as_deref() == Some(token.as_str())
949                    {
950                        lost.push((id.clone(), key.clone(), u.attempt));
951                    }
952                }
953            }
954        }
955        for (id, key, attempt) in lost {
956            self.commit(
957                &id,
958                vec![Event::StepAttemptUncertain {
959                    step: key,
960                    attempt,
961                    reason: "worker unregistered".into(),
962                }],
963            )?;
964            self.advance(&id)?;
965        }
966        Ok(())
967    }
968
969    pub fn workers(&self) -> Result<Vec<WorkerInfo>> {
970        let now = self.now();
971        let records = self.store.workers().map_err(EngineError::storage)?;
972        Ok(records
973            .into_iter()
974            .map(|record| {
975                let live: Vec<&Session> = self
976                    .sessions
977                    .values()
978                    .filter(|s| {
979                        s.worker_id == record.worker_id
980                            && now < s.last_seen_ms + self.config.session_timeout_ms
981                    })
982                    .collect();
983                WorkerInfo {
984                    online: !live.is_empty(),
985                    sessions: live.len(),
986                    inflight: live.iter().map(|s| s.inflight.len()).sum(),
987                    record,
988                }
989            })
990            .collect())
991    }
992
993    /// Hand the oldest matching queued task to a worker, if any.
994    pub fn poll(&mut self, session_id: &str) -> Result<Option<TaskAssignment>> {
995        self.touch(session_id)?;
996        let session = self.sessions[session_id].clone();
997        let mut best: Option<(u64, String, String)> = None;
998        for (id, state) in &self.active {
999            let Some(def) = self.by_hash.get(&state.definition_hash) else {
1000                continue;
1001            };
1002            for (key, u) in &state.units {
1003                if u.status() != UnitStatus::Scheduled {
1004                    continue;
1005                }
1006                let Some(inv) = invocation(def, key) else {
1007                    continue;
1008                };
1009                if !session
1010                    .contracts
1011                    .contains(&(inv.task.clone(), inv.contract_version.clone()))
1012                {
1013                    continue;
1014                }
1015                if let Some(lang) = &inv.language {
1016                    if !lang.eq_ignore_ascii_case(&session.runtime) && lang != "any" {
1017                        continue;
1018                    }
1019                }
1020                let at = u.scheduled_at_ms.unwrap_or(0);
1021                if best.as_ref().is_none_or(|(b, _, _)| at < *b) {
1022                    best = Some((at, id.clone(), key.clone()));
1023                }
1024            }
1025        }
1026        let Some((_, id, key)) = best else {
1027            return Ok(None);
1028        };
1029        let assignment = self.dispatch(&id, &key, session_id, &session.worker_id)?;
1030        if let Some(s) = self.sessions.get_mut(session_id) {
1031            s.inflight.insert(assignment.token.clone());
1032        }
1033        Ok(Some(assignment))
1034    }
1035
1036    fn dispatch(
1037        &mut self,
1038        id: &str,
1039        key: &str,
1040        session_id: &str,
1041        worker_id: &str,
1042    ) -> Result<TaskAssignment> {
1043        let now = self.now();
1044        let state = &self.active[id];
1045        let def = self.by_hash[&state.definition_hash].clone();
1046        let inv = invocation(&def, key)
1047            .expect("unit has an invocation")
1048            .clone();
1049        let u = state.units[key].clone();
1050        let token = uuid::Uuid::new_v4().to_string();
1051        let deadline = inv.timeout_ms.map(|t| now + t);
1052        let assignment = TaskAssignment {
1053            token: token.clone(),
1054            execution_id: id.to_string(),
1055            workflow: def.name.clone(),
1056            workflow_version: def.version.clone(),
1057            step_id: key.to_string(),
1058            task_name: inv.task.clone(),
1059            contract_version: inv.contract_version.clone(),
1060            input: u.input.clone().unwrap_or(Value::Null),
1061            idempotency_key: u.idempotency_key.clone().unwrap_or_default(),
1062            attempt: u.attempt,
1063            redelivery: u.redelivery,
1064            deadline_ms: deadline,
1065            timeout_ms: inv.timeout_ms,
1066        };
1067        self.commit(
1068            id,
1069            vec![Event::StepDispatched {
1070                step: key.to_string(),
1071                attempt: u.attempt,
1072                token,
1073                worker_id: worker_id.to_string(),
1074                worker_session: session_id.to_string(),
1075                deadline_ms: deadline,
1076            }],
1077        )?;
1078        Ok(assignment)
1079    }
1080
1081    /// Start child executions for scheduled `workflow` steps.
1082    fn dispatch_children(&mut self) -> Result<()> {
1083        let mut todo = vec![];
1084        for (id, state) in &self.active {
1085            let Some(def) = self.by_hash.get(&state.definition_hash) else {
1086                continue;
1087            };
1088            for (key, u) in &state.units {
1089                if u.status() != UnitStatus::Scheduled {
1090                    continue;
1091                }
1092                if let Some(inv) = invocation(def, key) {
1093                    if let Some(hash) = def
1094                        .contracts
1095                        .get(&inv.task)
1096                        .and_then(|c| c.workflow_hash.clone())
1097                    {
1098                        todo.push((
1099                            id.clone(),
1100                            key.clone(),
1101                            u.attempt,
1102                            hash,
1103                            u.input.clone().unwrap_or(Value::Null),
1104                        ));
1105                    }
1106                }
1107            }
1108        }
1109        for (id, key, attempt, hash, input) in todo {
1110            let child_id = child_execution_id(&id, &key);
1111            let failure = |code: &'static str, message: String| Event::StepAttemptFailed {
1112                step: key.clone(),
1113                attempt,
1114                error: TaskError::new(code, message, false),
1115            };
1116            let Some(child_def) = self.by_hash.get(&hash).cloned() else {
1117                self.commit(
1118                    &id,
1119                    vec![failure(
1120                        "CHILD_NOT_REGISTERED",
1121                        format!("child definition {hash} is not registered"),
1122                    )],
1123                )?;
1124                self.advance(&id)?;
1125                continue;
1126            };
1127            let parent = ParentRef {
1128                execution_id: id.clone(),
1129                step: key.clone(),
1130            };
1131            if let Err(e) = self.start_with(child_def, input, Some(child_id.clone()), Some(parent))
1132            {
1133                self.commit(&id, vec![failure("CHILD_START_FAILED", e.message)])?;
1134                self.advance(&id)?;
1135                continue;
1136            }
1137            if self
1138                .active
1139                .get(&id)
1140                .and_then(|s| s.units.get(&key))
1141                .map(|u| u.status())
1142                == Some(UnitStatus::Scheduled)
1143            {
1144                self.dispatch(
1145                    &id,
1146                    &key,
1147                    &format!("{CHILD_SESSION_PREFIX}{child_id}"),
1148                    "orbit-runtime",
1149                )?;
1150            }
1151        }
1152        Ok(())
1153    }
1154
1155    /// Deliver finished children's results to their parents, and cancel
1156    /// children whose parent is aborting or no longer waits for them. Runs
1157    /// every tick, so it also repairs anything a crash interrupted.
1158    fn reconcile_children(&mut self) -> Result<()> {
1159        let mut finished = vec![];
1160        let mut cancel = vec![];
1161        for state in self.active.values() {
1162            for u in state.units.values() {
1163                let Some(child) = u
1164                    .worker_session
1165                    .as_deref()
1166                    .and_then(|s| s.strip_prefix(CHILD_SESSION_PREFIX))
1167                else {
1168                    continue;
1169                };
1170                match self.active.get(child) {
1171                    Some(c) => {
1172                        let parent_gone = u.status() != UnitStatus::Dispatched;
1173                        if c.abort.is_none() && (state.abort.is_some() || parent_gone) {
1174                            cancel.push(child.to_string());
1175                        }
1176                    }
1177                    None if u.status() == UnitStatus::Dispatched => {
1178                        finished.push((child.to_string(), u.token.clone().unwrap_or_default()));
1179                    }
1180                    None => {}
1181                }
1182            }
1183        }
1184        for child in cancel {
1185            tracing::info!(execution_id = %child, "cancelling child workflow because its parent no longer needs it");
1186            self.cancel(&child, "parent workflow aborted")?;
1187        }
1188        for (child, token) in finished {
1189            let state = match self.execution(&child) {
1190                Ok(s) => s,
1191                Err(_) => {
1192                    self.fail(
1193                        &token,
1194                        TaskError::new(
1195                            "CHILD_MISSING",
1196                            format!("child execution `{child}` not found"),
1197                            false,
1198                        ),
1199                    )?;
1200                    continue;
1201                }
1202            };
1203            let name = state.workflow.clone();
1204            match state.status {
1205                WorkflowStatus::Completed => {
1206                    self.complete(&token, state.output.unwrap_or(Value::Null))?;
1207                }
1208                WorkflowStatus::Failed => {
1209                    let e = state
1210                        .error
1211                        .unwrap_or_else(|| TaskError::new("UNKNOWN", "failed", false));
1212                    let mut err = TaskError::new(
1213                        &e.code,
1214                        format!("child workflow `{name}` failed: {}", e.message),
1215                        false,
1216                    );
1217                    err.details = Some(json!({ "child_execution_id": child }));
1218                    self.fail(&token, err)?;
1219                }
1220                WorkflowStatus::Cancelled => {
1221                    self.fail(
1222                        &token,
1223                        TaskError::new(
1224                            "CHILD_CANCELLED",
1225                            format!("child workflow `{name}` was cancelled"),
1226                            false,
1227                        ),
1228                    )?;
1229                }
1230                WorkflowStatus::TimedOut => {
1231                    self.fail(
1232                        &token,
1233                        TaskError::new(
1234                            "CHILD_TIMED_OUT",
1235                            format!("child workflow `{name}` timed out"),
1236                            false,
1237                        ),
1238                    )?;
1239                }
1240                _ => {}
1241            }
1242        }
1243        Ok(())
1244    }
1245
1246    fn dispatch_builtins(&mut self) -> Result<()> {
1247        let mut todo = vec![];
1248        for (id, state) in &self.active {
1249            let Some(def) = self.by_hash.get(&state.definition_hash) else {
1250                continue;
1251            };
1252            for (key, u) in &state.units {
1253                if u.status() != UnitStatus::Scheduled {
1254                    continue;
1255                }
1256                if let Some(inv) = invocation(def, key) {
1257                    if def.contracts.get(&inv.task).is_some_and(|c| c.builtin) {
1258                        todo.push((id.clone(), key.clone(), u.attempt));
1259                    }
1260                }
1261            }
1262        }
1263        for (id, key, attempt) in todo {
1264            if !self.config.http_adapter {
1265                self.commit(
1266                    &id,
1267                    vec![Event::StepAttemptFailed {
1268                        step: key,
1269                        attempt,
1270                        error: TaskError::new(codes::ADAPTER_DISABLED, "the HTTP adapter is disabled; start the server with --enable-http-adapter", false),
1271                    }],
1272                )?;
1273                self.advance(&id)?;
1274                continue;
1275            }
1276            let session = format!("{BUILTIN_SESSION_PREFIX}{}", self.instance);
1277            let job = self.dispatch(&id, &key, &session, "orbit-runtime")?;
1278            self.builtin_jobs.push(job);
1279        }
1280        Ok(())
1281    }
1282
1283    /// Record a successful task result.
1284    pub fn complete(&mut self, token: &str, mut output: Value) -> Result<ResultAck> {
1285        let (id, key, unit, def) = match self.resolve_token(token)? {
1286            Ok(x) => x,
1287            Err(ack) => return Ok(ack),
1288        };
1289        let attempt = unit
1290            .tokens
1291            .iter()
1292            .find(|(t, _)| t == token)
1293            .map(|(_, a)| *a)
1294            .unwrap_or(unit.attempt);
1295        if output.is_null() {
1296            output = json!({});
1297        }
1298        let inv = invocation(&def, &key).cloned();
1299        let validation = inv
1300            .as_ref()
1301            .and_then(|i| def.contracts.get(&i.task))
1302            .map(|c| {
1303                c.output.apply_defaults(&mut output);
1304                c.output.validate(&output, false)
1305            })
1306            .unwrap_or(Ok(()));
1307        let event = match validation {
1308            Ok(()) => Event::StepSucceeded {
1309                step: key.clone(),
1310                attempt,
1311                output,
1312            },
1313            Err(e) => Event::StepAttemptFailed {
1314                step: key.clone(),
1315                attempt: unit.attempt,
1316                error: TaskError::new(
1317                    codes::OUTPUT_CONTRACT_VIOLATION,
1318                    format!(
1319                        "worker output for `{}` does not match its contract at {e}",
1320                        inv.map(|i| i.task).unwrap_or_default()
1321                    ),
1322                    false,
1323                ),
1324            },
1325        };
1326        let accepted = matches!(event, Event::StepSucceeded { .. });
1327        self.commit(&id, vec![event])?;
1328        self.advance(&id)?;
1329        Ok(ResultAck {
1330            accepted,
1331            reason: (!accepted).then(|| "output violates the task contract".into()),
1332        })
1333    }
1334
1335    /// Record a failed attempt.
1336    pub fn fail(&mut self, token: &str, error: TaskError) -> Result<ResultAck> {
1337        let (id, key, unit, _) = match self.resolve_token(token)? {
1338            Ok(x) => x,
1339            Err(ack) => return Ok(ack),
1340        };
1341        // Failures only count for the attempt currently in flight.
1342        if unit.token.as_deref() != Some(token) {
1343            self.commit(
1344                &id,
1345                vec![Event::StaleResultIgnored {
1346                    step: key,
1347                    token: token.into(),
1348                    outcome: "failure".into(),
1349                }],
1350            )?;
1351            return Ok(ResultAck {
1352                accepted: false,
1353                reason: Some("stale attempt".into()),
1354            });
1355        }
1356        self.commit(
1357            &id,
1358            vec![Event::StepAttemptFailed {
1359                step: key,
1360                attempt: unit.attempt,
1361                error,
1362            }],
1363        )?;
1364        self.advance(&id)?;
1365        Ok(ResultAck {
1366            accepted: true,
1367            reason: None,
1368        })
1369    }
1370
1371    #[allow(clippy::type_complexity)]
1372    fn resolve_token(
1373        &mut self,
1374        token: &str,
1375    ) -> Result<std::result::Result<(String, String, UnitState, Arc<WorkflowIr>), ResultAck>> {
1376        let Some((id, key)) = self.tokens.get(token).cloned() else {
1377            return Ok(Err(ResultAck {
1378                accepted: false,
1379                reason: Some("unknown token (execution finished or token invalid)".into()),
1380            }));
1381        };
1382        let state = &self.active[&id];
1383        let unit = state.units.get(&key).cloned().unwrap_or_default();
1384        let def = self.by_hash[&state.definition_hash].clone();
1385        if let Some(session) = &unit.worker_session {
1386            if let Some(s) = self.sessions.get_mut(session) {
1387                s.inflight.remove(token);
1388                s.last_seen_ms = (self.clock)();
1389            }
1390        }
1391        // A result for any attempt of an unresolved unit is accepted: the work
1392        // really happened (reconciliation of uncertain/timed-out attempts).
1393        let unresolved = matches!(
1394            unit.status(),
1395            UnitStatus::Dispatched
1396                | UnitStatus::Scheduled
1397                | UnitStatus::RetryWait
1398                | UnitStatus::Uncertain
1399                | UnitStatus::AttemptFailed
1400        );
1401        if !unresolved {
1402            self.commit(
1403                &id,
1404                vec![Event::StaleResultIgnored {
1405                    step: key,
1406                    token: token.into(),
1407                    outcome: "duplicate".into(),
1408                }],
1409            )?;
1410            return Ok(Err(ResultAck {
1411                accepted: false,
1412                reason: Some("duplicate result; step already resolved".into()),
1413            }));
1414        }
1415        Ok(Ok((id, key, unit, def)))
1416    }
1417
1418    pub fn store_stats(&self) -> Result<Vec<(String, u64)>> {
1419        self.store.count_by_status().map_err(EngineError::storage)
1420    }
1421
1422    /// Export OpenTelemetry traces of executions (see [`crate::telemetry`]).
1423    pub fn enable_telemetry(&mut self, config: crate::telemetry::TelemetryConfig) {
1424        tracing::info!(endpoint = %config.endpoint, service = %config.service_name, "exporting OpenTelemetry traces");
1425        self.telemetry = Some(crate::telemetry::Exporter::start(config));
1426    }
1427
1428    /// Trace of an execution: its own, or (for a child workflow) the trace of
1429    /// its top-level ancestor, nested under the parent step's span.
1430    fn trace_context(&self, state: &ExecutionState) -> crate::telemetry::TraceContext {
1431        use crate::telemetry::{trace_id, unit_span_id, TraceContext};
1432        let Some(parent) = &state.parent else {
1433            return TraceContext {
1434                trace_id: trace_id(&state.id),
1435                parent_span_id: None,
1436            };
1437        };
1438        let mut root = parent.execution_id.clone();
1439        for _ in 0..64 {
1440            let next = match self.active.get(&root) {
1441                Some(s) => s.parent.clone(),
1442                None => self.execution(&root).ok().and_then(|s| s.parent),
1443            };
1444            match next {
1445                Some(p) => root = p.execution_id,
1446                None => break,
1447            }
1448        }
1449        TraceContext {
1450            trace_id: trace_id(&root),
1451            // Child workflow steps are dispatched exactly once.
1452            parent_span_id: Some(unit_span_id(&parent.execution_id, &parent.step, 1)),
1453        }
1454    }
1455
1456    /// The worker id that owns a session, if the session exists.
1457    pub fn session_worker(&self, session_id: &str) -> Option<&str> {
1458        self.sessions.get(session_id).map(|s| s.worker_id.as_str())
1459    }
1460
1461    /// The worker id a task token was issued to, if the token is known.
1462    pub fn token_worker(&self, token: &str) -> Option<&str> {
1463        self.token_workers.get(token).map(String::as_str)
1464    }
1465
1466    pub fn session_count(&self) -> usize {
1467        self.sessions.len()
1468    }
1469}