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