1use 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 pub session_timeout_ms: u64,
32 pub heartbeat_interval_ms: u64,
33 pub http_adapter: bool,
35 pub limits: Limits,
37}
38
39#[derive(Debug, Clone, Default, PartialEq)]
41pub struct Limits {
42 pub max_active_executions: Option<usize>,
44 pub max_fan_out: Option<usize>,
47 pub max_history_events: Option<u64>,
50 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 persisted_seen_ms: u64,
130 inflight: HashSet<String>,
131}
132
133#[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 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
185pub fn child_execution_id(parent: &str, unit: &str) -> String {
188 let unit = unit
189 .replace("compensate:", "compensate.")
190 .replace('[', ".")
191 .replace(']', "");
192 format!("{parent}.{unit}")
193}
194
195pub 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 ready: BTreeSet<(u64, String, String)>,
218 ready_by_execution: HashMap<String, Vec<(u64, String, String)>>,
219 tokens: HashMap<String, (String, String)>,
220 token_workers: HashMap<String, String>,
222 sessions: HashMap<String, Session>,
223 builtin_jobs: Vec<TaskAssignment>,
225 pub metrics: Metrics,
226 pub changed: Arc<tokio::sync::Notify>,
227 telemetry: Option<crate::telemetry::Exporter>,
228 pub(crate) shared: Option<Arc<crate::shared::Ctx>>,
231 pub(crate) schedule_poll_at: u64,
233 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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, ¤t[..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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn complete(&mut self, token: &str, mut output: Value) -> Result<ResultAck> {
1586 if self.forwards_result(token) {
1587 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 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 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 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 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 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 parent_span_id: Some(unit_span_id(&parent.execution_id, &parent.step, 1)),
1770 }
1771 }
1772
1773 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 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 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
1802fn 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
1850pub 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
1871fn 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 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
1908impl Engine {
1916 pub fn shared_context(&self) -> Option<Arc<crate::shared::Ctx>> {
1918 self.shared.clone()
1919 }
1920
1921 pub(crate) fn owns(&self, id: &str) -> bool {
1923 self.active.contains_key(id)
1924 }
1925
1926 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 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 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 #[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 #[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 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 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 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 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 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 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 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 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 pub(crate) fn forget_sessions(&mut self, ids: &[String]) {
2275 for id in ids {
2276 self.sessions.remove(id);
2277 }
2278 }
2279
2280 pub(crate) fn owned_ids(&self) -> Vec<String> {
2282 self.active.keys().cloned().collect()
2283 }
2284
2285 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}