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 signal(&mut self, id: &str, name: &str, mut payload: Value) -> Result<u64> {
482 let state = self.execution(id)?;
483 if state.status.is_terminal() {
484 return Err(EngineError::new(
485 409,
486 "EXECUTION_FINISHED",
487 format!("execution is already {}", state.status.as_str()),
488 ));
489 }
490 let def = self
491 .by_hash
492 .get(&state.definition_hash)
493 .cloned()
494 .ok_or_else(|| EngineError::not_found("workflow definition"))?;
495 let Some(ty) = def.signals.get(name) else {
496 let declared: Vec<&str> = def.signals.keys().map(String::as_str).collect();
497 return Err(EngineError::new(
498 404,
499 "UNKNOWN_SIGNAL",
500 if declared.is_empty() {
501 format!("{}@{} declares no signals", def.name, def.version)
502 } else {
503 format!(
504 "{}@{} has no signal `{name}`; declared: {}",
505 def.name,
506 def.version,
507 declared.join(", ")
508 )
509 },
510 ));
511 };
512 ty.apply_defaults(&mut payload);
513 if let Err(e) = ty.validate(&payload, false) {
514 return Err(EngineError::new(
515 422,
516 "SIGNAL_PAYLOAD_INVALID",
517 format!("payload for signal `{name}` is invalid at {e}"),
518 ));
519 }
520 let seq = state.last_seq + 1;
521 self.commit(
522 id,
523 vec![Event::SignalReceived {
524 name: name.to_string(),
525 payload,
526 }],
527 )?;
528 tracing::info!(execution_id = %id, signal = %name, "signal received");
529 self.advance(id)?;
530 Ok(seq)
531 }
532
533 pub fn query(&self, id: &str, name: &str) -> Result<Value> {
536 let state = self.execution(id)?;
537 let def = self
538 .by_hash
539 .get(&state.definition_hash)
540 .cloned()
541 .ok_or_else(|| EngineError::not_found("workflow definition"))?;
542 let Some(q) = def.queries.get(name) else {
543 let declared: Vec<&str> = def.queries.keys().map(String::as_str).collect();
544 return Err(EngineError::new(
545 404,
546 "UNKNOWN_QUERY",
547 if declared.is_empty() {
548 format!("{}@{} declares no queries", def.name, def.version)
549 } else {
550 format!(
551 "{}@{} has no query `{name}`; declared: {}",
552 def.name,
553 def.version,
554 declared.join(", ")
555 )
556 },
557 ));
558 };
559 struct QueryScope<'a>(crate::decide::StateScope<'a>, Value);
560 impl orbit_ir::Scope for QueryScope<'_> {
561 fn input(&self) -> &Value {
562 self.0.input()
563 }
564 fn step_output(&self, step: &str) -> Option<&Value> {
565 self.0.step_output(step).or(Some(&self.1))
566 }
567 fn execution_field(&self, field: &str) -> Option<Value> {
568 self.0.execution_field(field)
569 }
570 }
571 let scope = QueryScope(crate::decide::StateScope::new(&state), Value::Null);
572 orbit_ir::eval(&q.expr, &scope).map_err(|e| {
573 EngineError::new(
574 422,
575 "QUERY_FAILED",
576 format!("evaluating query `{name}`: {e}"),
577 )
578 })
579 }
580
581 pub fn execution(&self, id: &str) -> Result<ExecutionState> {
582 if let Some(s) = self.active.get(id) {
583 return Ok(s.clone());
584 }
585 let history = self.store.history(id).map_err(EngineError::storage)?;
586 if history.is_empty() {
587 return Err(EngineError::not_found("execution"));
588 }
589 Ok(ExecutionState::replay(id, &history))
590 }
591
592 pub fn history(&self, id: &str) -> Result<Vec<HistoryEvent>> {
593 let h = self.store.history(id).map_err(EngineError::storage)?;
594 if h.is_empty() {
595 return Err(EngineError::not_found("execution"));
596 }
597 Ok(h)
598 }
599
600 pub fn list_executions(
601 &self,
602 status: Option<&str>,
603 workflow: Option<&str>,
604 limit: u32,
605 ) -> Result<Vec<ExecutionSummary>> {
606 self.store
607 .list_executions(status, workflow, limit.clamp(1, 1000))
608 .map_err(EngineError::storage)
609 }
610
611 pub fn active_count(&self) -> usize {
612 self.active.len()
613 }
614
615 fn commit(&mut self, id: &str, events: Vec<Event>) -> Result<()> {
619 if events.is_empty() {
620 return Ok(());
621 }
622 let now = self.now();
623 let mut state = self
624 .active
625 .get(id)
626 .cloned()
627 .ok_or_else(|| EngineError::not_found("active execution"))?;
628 let mut history = Vec::with_capacity(events.len());
629 for event in events {
630 let h = HistoryEvent {
631 seq: state.last_seq + 1,
632 timestamp_ms: now,
633 event,
634 };
635 apply(&mut state, &h);
636 history.push(h);
637 }
638 self.store
639 .append(&state, &history)
640 .map_err(EngineError::storage)?;
641 if let Some(t) = &self.telemetry {
643 let ctx = self.trace_context(&state);
644 t.export(crate::telemetry::spans_for(&state, &ctx, &history));
645 }
646 for h in &history {
647 self.metrics.observe(&h.event);
648 if let Event::StepDispatched {
649 token,
650 step,
651 worker_id,
652 ..
653 } = &h.event
654 {
655 self.tokens
656 .insert(token.clone(), (id.to_string(), step.clone()));
657 self.token_workers.insert(token.clone(), worker_id.clone());
658 }
659 tracing::debug!(execution_id = %id, seq = h.seq, event = h.event.name(), step = h.event.step().unwrap_or(""), "event");
660 match &h.event {
661 Event::WorkflowCompleted { .. } => {
662 tracing::info!(execution_id = %id, "execution completed")
663 }
664 Event::WorkflowFailed {
665 error,
666 compensation,
667 } => {
668 tracing::warn!(execution_id = %id, code = %error.code, error = %error.message, compensation = ?compensation, "execution failed")
669 }
670 Event::WorkflowCancelled { .. } => {
671 tracing::info!(execution_id = %id, "execution cancelled")
672 }
673 Event::WorkflowTimedOut { .. } => {
674 tracing::warn!(execution_id = %id, "execution timed out")
675 }
676 Event::StepFailed { step, error } => {
677 tracing::warn!(execution_id = %id, step = %step, code = %error.code, error = %error.message, "step failed")
678 }
679 Event::RetryScheduled {
680 step,
681 next_attempt,
682 delay_ms,
683 ..
684 } => {
685 tracing::info!(execution_id = %id, step = %step, attempt = next_attempt, delay_ms, "retry scheduled")
686 }
687 _ => {}
688 }
689 }
690 if state.status.is_terminal() {
691 for u in state.units.values() {
692 for (t, _) in &u.tokens {
693 self.tokens.remove(t);
694 self.token_workers.remove(t);
695 }
696 }
697 self.active.remove(id);
698 } else {
699 self.active.insert(id.to_string(), state);
700 }
701 self.changed.notify_waiters();
702 Ok(())
703 }
704
705 fn advance(&mut self, id: &str) -> Result<()> {
707 let Some(state) = self.active.get(id) else {
708 return Ok(());
709 };
710 let Some(def) = self.by_hash.get(&state.definition_hash).cloned() else {
711 tracing::error!(execution_id = %id, hash = %state.definition_hash, "definition missing for execution");
712 return Ok(());
713 };
714 let now = self.now();
715 let mut scratch = state.clone();
716 let mut batch = vec![];
717 for _ in 0..10_000 {
718 let events = decide(&scratch, &def, now);
719 if events.is_empty() {
720 break;
721 }
722 for e in events {
723 let seq = scratch.last_seq + 1;
724 apply(
725 &mut scratch,
726 &HistoryEvent {
727 seq,
728 timestamp_ms: now,
729 event: e.clone(),
730 },
731 );
732 batch.push(e);
733 }
734 }
735 self.commit(id, batch)
736 }
737
738 pub fn tick(&mut self) -> Result<Vec<TaskAssignment>> {
740 self.check_liveness()?;
741 let ids: Vec<String> = self.active.keys().cloned().collect();
742 for id in &ids {
743 self.advance(id)?;
744 }
745 self.dispatch_builtins()?;
746 self.dispatch_children()?;
747 self.reconcile_children()?;
748 Ok(std::mem::take(&mut self.builtin_jobs))
749 }
750
751 fn check_liveness(&mut self) -> Result<()> {
754 let now = self.now();
755 let timeout = self.config.session_timeout_ms;
756 self.sessions
757 .retain(|_, s| now < s.last_seen_ms + timeout * 4);
758 let mut lost: Vec<(String, String, u32, String)> = vec![];
759 for (id, state) in &self.active {
760 for (key, u) in &state.units {
761 if u.status() != UnitStatus::Dispatched {
762 continue;
763 }
764 let session = u.worker_session.clone().unwrap_or_default();
765 let alive = if session.starts_with(CHILD_SESSION_PREFIX) {
766 true
768 } else if let Some(inst) = session.strip_prefix(BUILTIN_SESSION_PREFIX) {
769 inst == self.instance
770 } else {
771 self.sessions
772 .get(&session)
773 .is_some_and(|s| now < s.last_seen_ms + timeout)
774 };
775 let since = u.dispatched_at_ms.unwrap_or(0).max(self.started_at_ms);
776 if !alive && now >= since + timeout {
777 let reason = if session.starts_with(BUILTIN_SESSION_PREFIX) {
778 "runtime restarted while the built-in task was running".to_string()
779 } else {
780 format!(
781 "worker `{}` stopped responding",
782 u.worker_id.clone().unwrap_or_default()
783 )
784 };
785 lost.push((id.clone(), key.clone(), u.attempt, reason));
786 }
787 }
788 }
789 for (id, key, attempt, reason) in lost {
790 tracing::warn!(execution_id = %id, step = %key, attempt, reason = %reason, "attempt outcome uncertain; redelivering");
791 self.commit(
792 &id,
793 vec![Event::StepAttemptUncertain {
794 step: key,
795 attempt,
796 reason,
797 }],
798 )?;
799 self.advance(&id)?;
800 }
801 Ok(())
802 }
803
804 pub fn register_worker(&mut self, req: RegisterWorker) -> Result<Registration> {
807 if req.worker_id.trim().is_empty() {
808 return Err(EngineError::new(
809 400,
810 "INVALID_REQUEST",
811 "worker_id is required",
812 ));
813 }
814 if req.contracts.is_empty() {
815 return Err(EngineError::new(
816 400,
817 "INVALID_REQUEST",
818 "a worker must announce at least one task contract",
819 ));
820 }
821 let now = self.now();
822 let mut warnings = vec![];
823 for c in &req.contracts {
824 if c.task_name.starts_with(WORKFLOW_CONTRACT_PREFIX) {
825 return Err(EngineError::new(
826 400,
827 "RESERVED_TASK",
828 format!(
829 "`{}` names a child workflow, not a worker task",
830 c.task_name
831 ),
832 ));
833 }
834 if c.task_name.starts_with("orbit.") {
835 return Err(EngineError::new(
836 400,
837 "RESERVED_TASK",
838 format!("`{}` is a built-in task", c.task_name),
839 ));
840 }
841 let mut versions: Vec<&str> = self
842 .by_version
843 .values()
844 .filter_map(|d| d.contracts.get(&c.task_name).map(|k| k.version.as_str()))
845 .collect();
846 versions.sort();
847 versions.dedup();
848 if !versions.is_empty() && !versions.contains(&c.contract_version.as_str()) {
849 warnings.push(format!(
850 "`{}` is announced at version {} but registered workflows use {}; this worker will not receive those tasks",
851 c.task_name,
852 c.contract_version,
853 versions.join(", ")
854 ));
855 }
856 }
857 let session_id = uuid::Uuid::new_v4().to_string();
858 self.sessions.insert(
859 session_id.clone(),
860 Session {
861 worker_id: req.worker_id.clone(),
862 runtime: req.runtime.to_lowercase(),
863 contracts: req
864 .contracts
865 .iter()
866 .map(|c| (c.task_name.clone(), c.contract_version.clone()))
867 .collect(),
868 last_seen_ms: now,
869 inflight: HashSet::new(),
870 },
871 );
872 self.store
873 .upsert_worker(&WorkerRecord {
874 worker_id: req.worker_id.clone(),
875 runtime: req.runtime.to_lowercase(),
876 sdk: req.sdk.clone(),
877 contracts: serde_json::to_value(&req.contracts).unwrap_or_default(),
878 last_seen_ms: now,
879 registered_at_ms: now,
880 })
881 .map_err(EngineError::storage)?;
882 tracing::info!(worker_id = %req.worker_id, runtime = %req.runtime, tasks = req.contracts.len(), "worker registered");
883 Ok(Registration {
884 accepted: true,
885 worker_session_id: session_id,
886 heartbeat_interval_ms: self.config.heartbeat_interval_ms,
887 session_timeout_ms: self.config.session_timeout_ms,
888 warnings,
889 })
890 }
891
892 fn touch(&mut self, session_id: &str) -> Result<()> {
893 let now = self.now();
894 let s = self.sessions.get_mut(session_id).ok_or_else(|| {
895 EngineError::new(
896 404,
897 "UNKNOWN_SESSION",
898 "unknown or expired worker session; register again",
899 )
900 })?;
901 s.last_seen_ms = now;
902 let wid = s.worker_id.clone();
903 let _ = self.store.touch_worker(&wid, now);
904 Ok(())
905 }
906
907 pub fn heartbeat(&mut self, session_id: &str) -> Result<HeartbeatAck> {
908 self.touch(session_id)?;
909 let inflight: Vec<String> = self.sessions[session_id].inflight.iter().cloned().collect();
910 let mut cancel = vec![];
911 for token in inflight {
912 let live = self.tokens.get(&token).and_then(|(id, key)| {
913 let st = self.active.get(id)?;
914 let u = st.units.get(key)?;
915 Some(
916 u.status() == UnitStatus::Dispatched
917 && u.token.as_deref() == Some(token.as_str())
918 && (st.abort.is_none() || key.starts_with("compensate:")),
919 )
920 });
921 if live != Some(true) {
922 cancel.push(token.clone());
923 self.sessions
924 .get_mut(session_id)
925 .unwrap()
926 .inflight
927 .remove(&token);
928 }
929 }
930 Ok(HeartbeatAck {
931 accepted: true,
932 cancel,
933 })
934 }
935
936 pub fn unregister_worker(&mut self, session_id: &str) -> Result<()> {
937 let s = self
938 .sessions
939 .remove(session_id)
940 .ok_or_else(|| EngineError::new(404, "UNKNOWN_SESSION", "unknown worker session"))?;
941 tracing::info!(worker_id = %s.worker_id, "worker unregistered");
942 let mut lost = vec![];
944 for token in s.inflight {
945 if let Some((id, key)) = self.tokens.get(&token) {
946 if let Some(u) = self.active.get(id).and_then(|st| st.units.get(key)) {
947 if u.status() == UnitStatus::Dispatched
948 && u.token.as_deref() == Some(token.as_str())
949 {
950 lost.push((id.clone(), key.clone(), u.attempt));
951 }
952 }
953 }
954 }
955 for (id, key, attempt) in lost {
956 self.commit(
957 &id,
958 vec![Event::StepAttemptUncertain {
959 step: key,
960 attempt,
961 reason: "worker unregistered".into(),
962 }],
963 )?;
964 self.advance(&id)?;
965 }
966 Ok(())
967 }
968
969 pub fn workers(&self) -> Result<Vec<WorkerInfo>> {
970 let now = self.now();
971 let records = self.store.workers().map_err(EngineError::storage)?;
972 Ok(records
973 .into_iter()
974 .map(|record| {
975 let live: Vec<&Session> = self
976 .sessions
977 .values()
978 .filter(|s| {
979 s.worker_id == record.worker_id
980 && now < s.last_seen_ms + self.config.session_timeout_ms
981 })
982 .collect();
983 WorkerInfo {
984 online: !live.is_empty(),
985 sessions: live.len(),
986 inflight: live.iter().map(|s| s.inflight.len()).sum(),
987 record,
988 }
989 })
990 .collect())
991 }
992
993 pub fn poll(&mut self, session_id: &str) -> Result<Option<TaskAssignment>> {
995 self.touch(session_id)?;
996 let session = self.sessions[session_id].clone();
997 let mut best: Option<(u64, String, String)> = None;
998 for (id, state) in &self.active {
999 let Some(def) = self.by_hash.get(&state.definition_hash) else {
1000 continue;
1001 };
1002 for (key, u) in &state.units {
1003 if u.status() != UnitStatus::Scheduled {
1004 continue;
1005 }
1006 let Some(inv) = invocation(def, key) else {
1007 continue;
1008 };
1009 if !session
1010 .contracts
1011 .contains(&(inv.task.clone(), inv.contract_version.clone()))
1012 {
1013 continue;
1014 }
1015 if let Some(lang) = &inv.language {
1016 if !lang.eq_ignore_ascii_case(&session.runtime) && lang != "any" {
1017 continue;
1018 }
1019 }
1020 let at = u.scheduled_at_ms.unwrap_or(0);
1021 if best.as_ref().is_none_or(|(b, _, _)| at < *b) {
1022 best = Some((at, id.clone(), key.clone()));
1023 }
1024 }
1025 }
1026 let Some((_, id, key)) = best else {
1027 return Ok(None);
1028 };
1029 let assignment = self.dispatch(&id, &key, session_id, &session.worker_id)?;
1030 if let Some(s) = self.sessions.get_mut(session_id) {
1031 s.inflight.insert(assignment.token.clone());
1032 }
1033 Ok(Some(assignment))
1034 }
1035
1036 fn dispatch(
1037 &mut self,
1038 id: &str,
1039 key: &str,
1040 session_id: &str,
1041 worker_id: &str,
1042 ) -> Result<TaskAssignment> {
1043 let now = self.now();
1044 let state = &self.active[id];
1045 let def = self.by_hash[&state.definition_hash].clone();
1046 let inv = invocation(&def, key)
1047 .expect("unit has an invocation")
1048 .clone();
1049 let u = state.units[key].clone();
1050 let token = uuid::Uuid::new_v4().to_string();
1051 let deadline = inv.timeout_ms.map(|t| now + t);
1052 let assignment = TaskAssignment {
1053 token: token.clone(),
1054 execution_id: id.to_string(),
1055 workflow: def.name.clone(),
1056 workflow_version: def.version.clone(),
1057 step_id: key.to_string(),
1058 task_name: inv.task.clone(),
1059 contract_version: inv.contract_version.clone(),
1060 input: u.input.clone().unwrap_or(Value::Null),
1061 idempotency_key: u.idempotency_key.clone().unwrap_or_default(),
1062 attempt: u.attempt,
1063 redelivery: u.redelivery,
1064 deadline_ms: deadline,
1065 timeout_ms: inv.timeout_ms,
1066 };
1067 self.commit(
1068 id,
1069 vec![Event::StepDispatched {
1070 step: key.to_string(),
1071 attempt: u.attempt,
1072 token,
1073 worker_id: worker_id.to_string(),
1074 worker_session: session_id.to_string(),
1075 deadline_ms: deadline,
1076 }],
1077 )?;
1078 Ok(assignment)
1079 }
1080
1081 fn dispatch_children(&mut self) -> Result<()> {
1083 let mut todo = vec![];
1084 for (id, state) in &self.active {
1085 let Some(def) = self.by_hash.get(&state.definition_hash) else {
1086 continue;
1087 };
1088 for (key, u) in &state.units {
1089 if u.status() != UnitStatus::Scheduled {
1090 continue;
1091 }
1092 if let Some(inv) = invocation(def, key) {
1093 if let Some(hash) = def
1094 .contracts
1095 .get(&inv.task)
1096 .and_then(|c| c.workflow_hash.clone())
1097 {
1098 todo.push((
1099 id.clone(),
1100 key.clone(),
1101 u.attempt,
1102 hash,
1103 u.input.clone().unwrap_or(Value::Null),
1104 ));
1105 }
1106 }
1107 }
1108 }
1109 for (id, key, attempt, hash, input) in todo {
1110 let child_id = child_execution_id(&id, &key);
1111 let failure = |code: &'static str, message: String| Event::StepAttemptFailed {
1112 step: key.clone(),
1113 attempt,
1114 error: TaskError::new(code, message, false),
1115 };
1116 let Some(child_def) = self.by_hash.get(&hash).cloned() else {
1117 self.commit(
1118 &id,
1119 vec![failure(
1120 "CHILD_NOT_REGISTERED",
1121 format!("child definition {hash} is not registered"),
1122 )],
1123 )?;
1124 self.advance(&id)?;
1125 continue;
1126 };
1127 let parent = ParentRef {
1128 execution_id: id.clone(),
1129 step: key.clone(),
1130 };
1131 if let Err(e) = self.start_with(child_def, input, Some(child_id.clone()), Some(parent))
1132 {
1133 self.commit(&id, vec![failure("CHILD_START_FAILED", e.message)])?;
1134 self.advance(&id)?;
1135 continue;
1136 }
1137 if self
1138 .active
1139 .get(&id)
1140 .and_then(|s| s.units.get(&key))
1141 .map(|u| u.status())
1142 == Some(UnitStatus::Scheduled)
1143 {
1144 self.dispatch(
1145 &id,
1146 &key,
1147 &format!("{CHILD_SESSION_PREFIX}{child_id}"),
1148 "orbit-runtime",
1149 )?;
1150 }
1151 }
1152 Ok(())
1153 }
1154
1155 fn reconcile_children(&mut self) -> Result<()> {
1159 let mut finished = vec![];
1160 let mut cancel = vec![];
1161 for state in self.active.values() {
1162 for u in state.units.values() {
1163 let Some(child) = u
1164 .worker_session
1165 .as_deref()
1166 .and_then(|s| s.strip_prefix(CHILD_SESSION_PREFIX))
1167 else {
1168 continue;
1169 };
1170 match self.active.get(child) {
1171 Some(c) => {
1172 let parent_gone = u.status() != UnitStatus::Dispatched;
1173 if c.abort.is_none() && (state.abort.is_some() || parent_gone) {
1174 cancel.push(child.to_string());
1175 }
1176 }
1177 None if u.status() == UnitStatus::Dispatched => {
1178 finished.push((child.to_string(), u.token.clone().unwrap_or_default()));
1179 }
1180 None => {}
1181 }
1182 }
1183 }
1184 for child in cancel {
1185 tracing::info!(execution_id = %child, "cancelling child workflow because its parent no longer needs it");
1186 self.cancel(&child, "parent workflow aborted")?;
1187 }
1188 for (child, token) in finished {
1189 let state = match self.execution(&child) {
1190 Ok(s) => s,
1191 Err(_) => {
1192 self.fail(
1193 &token,
1194 TaskError::new(
1195 "CHILD_MISSING",
1196 format!("child execution `{child}` not found"),
1197 false,
1198 ),
1199 )?;
1200 continue;
1201 }
1202 };
1203 let name = state.workflow.clone();
1204 match state.status {
1205 WorkflowStatus::Completed => {
1206 self.complete(&token, state.output.unwrap_or(Value::Null))?;
1207 }
1208 WorkflowStatus::Failed => {
1209 let e = state
1210 .error
1211 .unwrap_or_else(|| TaskError::new("UNKNOWN", "failed", false));
1212 let mut err = TaskError::new(
1213 &e.code,
1214 format!("child workflow `{name}` failed: {}", e.message),
1215 false,
1216 );
1217 err.details = Some(json!({ "child_execution_id": child }));
1218 self.fail(&token, err)?;
1219 }
1220 WorkflowStatus::Cancelled => {
1221 self.fail(
1222 &token,
1223 TaskError::new(
1224 "CHILD_CANCELLED",
1225 format!("child workflow `{name}` was cancelled"),
1226 false,
1227 ),
1228 )?;
1229 }
1230 WorkflowStatus::TimedOut => {
1231 self.fail(
1232 &token,
1233 TaskError::new(
1234 "CHILD_TIMED_OUT",
1235 format!("child workflow `{name}` timed out"),
1236 false,
1237 ),
1238 )?;
1239 }
1240 _ => {}
1241 }
1242 }
1243 Ok(())
1244 }
1245
1246 fn dispatch_builtins(&mut self) -> Result<()> {
1247 let mut todo = vec![];
1248 for (id, state) in &self.active {
1249 let Some(def) = self.by_hash.get(&state.definition_hash) else {
1250 continue;
1251 };
1252 for (key, u) in &state.units {
1253 if u.status() != UnitStatus::Scheduled {
1254 continue;
1255 }
1256 if let Some(inv) = invocation(def, key) {
1257 if def.contracts.get(&inv.task).is_some_and(|c| c.builtin) {
1258 todo.push((id.clone(), key.clone(), u.attempt));
1259 }
1260 }
1261 }
1262 }
1263 for (id, key, attempt) in todo {
1264 if !self.config.http_adapter {
1265 self.commit(
1266 &id,
1267 vec![Event::StepAttemptFailed {
1268 step: key,
1269 attempt,
1270 error: TaskError::new(codes::ADAPTER_DISABLED, "the HTTP adapter is disabled; start the server with --enable-http-adapter", false),
1271 }],
1272 )?;
1273 self.advance(&id)?;
1274 continue;
1275 }
1276 let session = format!("{BUILTIN_SESSION_PREFIX}{}", self.instance);
1277 let job = self.dispatch(&id, &key, &session, "orbit-runtime")?;
1278 self.builtin_jobs.push(job);
1279 }
1280 Ok(())
1281 }
1282
1283 pub fn complete(&mut self, token: &str, mut output: Value) -> Result<ResultAck> {
1285 let (id, key, unit, def) = match self.resolve_token(token)? {
1286 Ok(x) => x,
1287 Err(ack) => return Ok(ack),
1288 };
1289 let attempt = unit
1290 .tokens
1291 .iter()
1292 .find(|(t, _)| t == token)
1293 .map(|(_, a)| *a)
1294 .unwrap_or(unit.attempt);
1295 if output.is_null() {
1296 output = json!({});
1297 }
1298 let inv = invocation(&def, &key).cloned();
1299 let validation = inv
1300 .as_ref()
1301 .and_then(|i| def.contracts.get(&i.task))
1302 .map(|c| {
1303 c.output.apply_defaults(&mut output);
1304 c.output.validate(&output, false)
1305 })
1306 .unwrap_or(Ok(()));
1307 let event = match validation {
1308 Ok(()) => Event::StepSucceeded {
1309 step: key.clone(),
1310 attempt,
1311 output,
1312 },
1313 Err(e) => Event::StepAttemptFailed {
1314 step: key.clone(),
1315 attempt: unit.attempt,
1316 error: TaskError::new(
1317 codes::OUTPUT_CONTRACT_VIOLATION,
1318 format!(
1319 "worker output for `{}` does not match its contract at {e}",
1320 inv.map(|i| i.task).unwrap_or_default()
1321 ),
1322 false,
1323 ),
1324 },
1325 };
1326 let accepted = matches!(event, Event::StepSucceeded { .. });
1327 self.commit(&id, vec![event])?;
1328 self.advance(&id)?;
1329 Ok(ResultAck {
1330 accepted,
1331 reason: (!accepted).then(|| "output violates the task contract".into()),
1332 })
1333 }
1334
1335 pub fn fail(&mut self, token: &str, error: TaskError) -> Result<ResultAck> {
1337 let (id, key, unit, _) = match self.resolve_token(token)? {
1338 Ok(x) => x,
1339 Err(ack) => return Ok(ack),
1340 };
1341 if unit.token.as_deref() != Some(token) {
1343 self.commit(
1344 &id,
1345 vec![Event::StaleResultIgnored {
1346 step: key,
1347 token: token.into(),
1348 outcome: "failure".into(),
1349 }],
1350 )?;
1351 return Ok(ResultAck {
1352 accepted: false,
1353 reason: Some("stale attempt".into()),
1354 });
1355 }
1356 self.commit(
1357 &id,
1358 vec![Event::StepAttemptFailed {
1359 step: key,
1360 attempt: unit.attempt,
1361 error,
1362 }],
1363 )?;
1364 self.advance(&id)?;
1365 Ok(ResultAck {
1366 accepted: true,
1367 reason: None,
1368 })
1369 }
1370
1371 #[allow(clippy::type_complexity)]
1372 fn resolve_token(
1373 &mut self,
1374 token: &str,
1375 ) -> Result<std::result::Result<(String, String, UnitState, Arc<WorkflowIr>), ResultAck>> {
1376 let Some((id, key)) = self.tokens.get(token).cloned() else {
1377 return Ok(Err(ResultAck {
1378 accepted: false,
1379 reason: Some("unknown token (execution finished or token invalid)".into()),
1380 }));
1381 };
1382 let state = &self.active[&id];
1383 let unit = state.units.get(&key).cloned().unwrap_or_default();
1384 let def = self.by_hash[&state.definition_hash].clone();
1385 if let Some(session) = &unit.worker_session {
1386 if let Some(s) = self.sessions.get_mut(session) {
1387 s.inflight.remove(token);
1388 s.last_seen_ms = (self.clock)();
1389 }
1390 }
1391 let unresolved = matches!(
1394 unit.status(),
1395 UnitStatus::Dispatched
1396 | UnitStatus::Scheduled
1397 | UnitStatus::RetryWait
1398 | UnitStatus::Uncertain
1399 | UnitStatus::AttemptFailed
1400 );
1401 if !unresolved {
1402 self.commit(
1403 &id,
1404 vec![Event::StaleResultIgnored {
1405 step: key,
1406 token: token.into(),
1407 outcome: "duplicate".into(),
1408 }],
1409 )?;
1410 return Ok(Err(ResultAck {
1411 accepted: false,
1412 reason: Some("duplicate result; step already resolved".into()),
1413 }));
1414 }
1415 Ok(Ok((id, key, unit, def)))
1416 }
1417
1418 pub fn store_stats(&self) -> Result<Vec<(String, u64)>> {
1419 self.store.count_by_status().map_err(EngineError::storage)
1420 }
1421
1422 pub fn enable_telemetry(&mut self, config: crate::telemetry::TelemetryConfig) {
1424 tracing::info!(endpoint = %config.endpoint, service = %config.service_name, "exporting OpenTelemetry traces");
1425 self.telemetry = Some(crate::telemetry::Exporter::start(config));
1426 }
1427
1428 fn trace_context(&self, state: &ExecutionState) -> crate::telemetry::TraceContext {
1431 use crate::telemetry::{trace_id, unit_span_id, TraceContext};
1432 let Some(parent) = &state.parent else {
1433 return TraceContext {
1434 trace_id: trace_id(&state.id),
1435 parent_span_id: None,
1436 };
1437 };
1438 let mut root = parent.execution_id.clone();
1439 for _ in 0..64 {
1440 let next = match self.active.get(&root) {
1441 Some(s) => s.parent.clone(),
1442 None => self.execution(&root).ok().and_then(|s| s.parent),
1443 };
1444 match next {
1445 Some(p) => root = p.execution_id,
1446 None => break,
1447 }
1448 }
1449 TraceContext {
1450 trace_id: trace_id(&root),
1451 parent_span_id: Some(unit_span_id(&parent.execution_id, &parent.step, 1)),
1453 }
1454 }
1455
1456 pub fn session_worker(&self, session_id: &str) -> Option<&str> {
1458 self.sessions.get(session_id).map(|s| s.worker_id.as_str())
1459 }
1460
1461 pub fn token_worker(&self, token: &str) -> Option<&str> {
1463 self.token_workers.get(token).map(String::as_str)
1464 }
1465
1466 pub fn session_count(&self) -> usize {
1467 self.sessions.len()
1468 }
1469}