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