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