1use crate::decide::{decide, invocation};
8use crate::metrics::Metrics;
9use crate::model::*;
10use crate::store::{ExecutionSummary, PutDefinition, Store, WorkerRecord};
11use orbit_ir::{WorkflowIr, WORKFLOW_CONTRACT_PREFIX};
12use serde::{Deserialize, Serialize};
13use serde_json::{json, Value};
14use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
15use std::sync::Arc;
16
17pub type Clock = Box<dyn Fn() -> u64 + Send>;
18
19pub fn system_clock() -> Clock {
20 Box::new(|| {
21 std::time::SystemTime::now()
22 .duration_since(std::time::UNIX_EPOCH)
23 .map(|d| d.as_millis() as u64)
24 .unwrap_or(0)
25 })
26}
27
28#[derive(Debug, Clone)]
29pub struct EngineConfig {
30 pub session_timeout_ms: u64,
32 pub heartbeat_interval_ms: u64,
33 pub http_adapter: bool,
35 pub limits: Limits,
37}
38
39#[derive(Debug, Clone, Default, PartialEq)]
41pub struct Limits {
42 pub max_active_executions: Option<usize>,
44 pub max_fan_out: Option<usize>,
47 pub max_history_events: Option<u64>,
50 pub max_payload_bytes: Option<usize>,
53}
54
55impl Default for EngineConfig {
56 fn default() -> Self {
57 EngineConfig {
58 session_timeout_ms: 30_000,
59 heartbeat_interval_ms: 5_000,
60 http_adapter: false,
61 limits: Limits::default(),
62 }
63 }
64}
65
66#[derive(Debug, Clone, PartialEq, Serialize)]
67pub struct EngineError {
68 pub code: &'static str,
69 pub message: String,
70 #[serde(skip)]
71 pub status: u16,
72}
73
74impl EngineError {
75 pub fn new(status: u16, code: &'static str, message: impl Into<String>) -> Self {
76 EngineError {
77 code,
78 message: message.into(),
79 status,
80 }
81 }
82 fn not_found(what: &str) -> Self {
83 Self::new(404, "NOT_FOUND", format!("{what} not found"))
84 }
85 fn storage(e: crate::store::StoreError) -> Self {
86 Self::new(500, "STORAGE_ERROR", e.to_string())
87 }
88}
89
90impl std::fmt::Display for EngineError {
91 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92 write!(f, "{}: {}", self.code, self.message)
93 }
94}
95
96pub type Result<T> = std::result::Result<T, EngineError>;
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct ContractAnnouncement {
100 pub task_name: String,
101 pub contract_version: String,
102}
103
104#[derive(Debug, Clone, Deserialize)]
105pub struct RegisterWorker {
106 pub worker_id: String,
107 pub runtime: String,
108 #[serde(default)]
109 pub sdk: Option<String>,
110 pub contracts: Vec<ContractAnnouncement>,
111}
112
113#[derive(Debug, Clone, Serialize)]
114pub struct Registration {
115 pub accepted: bool,
116 pub worker_session_id: String,
117 pub heartbeat_interval_ms: u64,
118 pub session_timeout_ms: u64,
119 pub warnings: Vec<String>,
120}
121
122#[derive(Debug, Clone)]
123struct Session {
124 worker_id: String,
125 runtime: String,
126 contracts: HashSet<(String, String)>,
127 last_seen_ms: u64,
128 persisted_seen_ms: u64,
130 inflight: HashSet<String>,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
135pub struct TaskAssignment {
136 pub token: String,
137 pub execution_id: String,
138 pub workflow: String,
139 pub workflow_version: String,
140 pub step_id: String,
141 pub task_name: String,
142 pub contract_version: String,
143 pub input: Value,
144 pub idempotency_key: String,
145 pub attempt: u32,
146 pub redelivery: bool,
147 #[serde(skip_serializing_if = "Option::is_none")]
148 pub deadline_ms: Option<u64>,
149 #[serde(skip_serializing_if = "Option::is_none")]
150 pub timeout_ms: Option<u64>,
151}
152
153#[derive(Debug, Clone, Serialize)]
154pub struct ResultAck {
155 pub accepted: bool,
156 #[serde(skip_serializing_if = "Option::is_none")]
157 pub reason: Option<String>,
158}
159
160#[derive(Debug, Clone, Serialize)]
161pub struct HeartbeatAck {
162 pub accepted: bool,
163 pub cancel: Vec<String>,
165}
166
167#[derive(Debug, Clone, Serialize)]
168pub struct WorkerInfo {
169 #[serde(flatten)]
170 pub record: WorkerRecord,
171 pub online: bool,
172 pub sessions: usize,
173 pub inflight: usize,
174}
175
176#[derive(Debug, Clone, Serialize)]
177pub struct StartResult {
178 pub execution_id: String,
179 pub created: bool,
180}
181
182const BUILTIN_SESSION_PREFIX: &str = "builtin:";
183const CHILD_SESSION_PREFIX: &str = "child:";
184
185pub fn child_execution_id(parent: &str, unit: &str) -> String {
188 let unit = unit
189 .replace("compensate:", "compensate.")
190 .replace('[', ".")
191 .replace(']', "");
192 format!("{parent}.{unit}")
193}
194
195pub struct Engine {
196 store: Store,
197 clock: Clock,
198 pub config: EngineConfig,
199 instance: String,
200 started_at_ms: u64,
201 by_version: BTreeMap<(String, String), Arc<WorkflowIr>>,
202 by_hash: HashMap<String, Arc<WorkflowIr>>,
203 active: BTreeMap<String, ExecutionState>,
204 ready: BTreeSet<(u64, String, String)>,
208 ready_by_execution: HashMap<String, Vec<(u64, String, String)>>,
209 tokens: HashMap<String, (String, String)>,
210 token_workers: HashMap<String, String>,
212 sessions: HashMap<String, Session>,
213 builtin_jobs: Vec<TaskAssignment>,
215 pub metrics: Metrics,
216 pub changed: Arc<tokio::sync::Notify>,
217 telemetry: Option<crate::telemetry::Exporter>,
218 pub(crate) shared: Option<Arc<crate::shared::Ctx>>,
221}
222
223fn semver_key(v: &str) -> (Vec<u64>, String) {
224 let core = v.split(['-', '+']).next().unwrap_or("");
225 (
226 core.split('.').map(|p| p.parse().unwrap_or(0)).collect(),
227 v.to_string(),
228 )
229}
230
231impl Engine {
232 pub fn open(store: Store, config: EngineConfig, clock: Clock) -> Result<Engine> {
234 let now = clock();
235 let mut engine = Engine {
236 store,
237 clock,
238 config,
239 instance: uuid::Uuid::new_v4().to_string(),
240 started_at_ms: now,
241 by_version: BTreeMap::new(),
242 by_hash: HashMap::new(),
243 active: BTreeMap::new(),
244 ready: BTreeSet::new(),
245 ready_by_execution: HashMap::new(),
246 tokens: HashMap::new(),
247 token_workers: HashMap::new(),
248 sessions: HashMap::new(),
249 builtin_jobs: vec![],
250 metrics: Metrics::default(),
251 changed: Arc::new(tokio::sync::Notify::new()),
252 telemetry: None,
253 shared: None,
254 };
255 for ir in engine.store.definitions().map_err(EngineError::storage)? {
256 engine.cache_definition(ir);
257 }
258 engine.recover()?;
259 Ok(engine)
260 }
261
262 pub fn now(&self) -> u64 {
263 (self.clock)()
264 }
265
266 fn cache_definition(&mut self, ir: WorkflowIr) -> Arc<WorkflowIr> {
267 let ir = Arc::new(ir);
268 self.by_version
269 .insert((ir.name.clone(), ir.version.clone()), ir.clone());
270 self.by_hash.insert(ir.definition_hash.clone(), ir.clone());
271 ir
272 }
273
274 fn recover(&mut self) -> Result<()> {
280 let ids = self
281 .store
282 .active_execution_ids()
283 .map_err(EngineError::storage)?;
284 let mut in_flight = 0;
285 for id in &ids {
286 let history = self.store.history(id).map_err(EngineError::storage)?;
287 for h in &history {
288 if let Event::StepDispatched {
289 token, worker_id, ..
290 } = &h.event
291 {
292 self.token_workers.insert(token.clone(), worker_id.clone());
293 }
294 }
295 let state = ExecutionState::replay(id, &history);
296 for (key, u) in &state.units {
297 for (t, _) in &u.tokens {
298 self.tokens.insert(t.clone(), (id.clone(), key.clone()));
299 }
300 if u.status() == UnitStatus::Dispatched {
301 in_flight += 1;
302 }
303 }
304 self.active.insert(id.clone(), state);
305 self.reindex(id);
306 }
307 if !ids.is_empty() {
308 tracing::info!(
309 executions = ids.len(),
310 in_flight,
311 "recovered active executions from history"
312 );
313 }
314 for id in ids {
315 self.advance(&id)?;
316 }
317 Ok(())
318 }
319
320 pub fn register_definition(&mut self, ir: WorkflowIr) -> Result<bool> {
323 ir.validate()
324 .map_err(|e| EngineError::new(400, "INVALID_IR", e.0))?;
325 if ir.definition_hash.is_empty() {
326 return Err(EngineError::new(
327 400,
328 "INVALID_IR",
329 "definition_hash is required",
330 ));
331 }
332 for c in ir.contracts.values() {
334 if let Some(hash) = &c.workflow_hash {
335 if !self.by_hash.contains_key(hash) {
336 return Err(EngineError::new(
337 400,
338 "CHILD_NOT_REGISTERED",
339 format!(
340 "{}@{} starts child workflow {}@{} ({hash}), which is not registered; register the child first (`orbit deploy` does this automatically)",
341 ir.name,
342 ir.version,
343 c.name.trim_start_matches(WORKFLOW_CONTRACT_PREFIX),
344 c.version
345 ),
346 ));
347 }
348 }
349 }
350 let now = self.now();
351 match self.store.put_definition(&ir, now).map_err(EngineError::storage)? {
352 PutDefinition::Created => {
353 tracing::info!(workflow = %ir.name, version = %ir.version, hash = %ir.definition_hash, "registered workflow definition");
354 self.cache_definition(ir);
355 Ok(true)
356 }
357 PutDefinition::Unchanged => Ok(false),
358 PutDefinition::Conflict { existing_hash } => Err(EngineError::new(
359 409,
360 "DEFINITION_CONFLICT",
361 format!(
362 "{}@{} is already registered with a different definition ({existing_hash}); workflow definitions are immutable, bump the version",
363 ir.name, ir.version
364 ),
365 )),
366 }
367 }
368
369 pub fn definitions(&self) -> Vec<Arc<WorkflowIr>> {
370 self.by_version.values().cloned().collect()
371 }
372
373 pub fn definition(&self, name: &str, version: Option<&str>) -> Option<Arc<WorkflowIr>> {
374 match version {
375 Some(v) => self
376 .by_version
377 .get(&(name.to_string(), v.to_string()))
378 .cloned(),
379 None => self
380 .by_version
381 .iter()
382 .filter(|((n, _), _)| n == name)
383 .max_by_key(|((_, v), _)| semver_key(v))
384 .map(|(_, d)| d.clone()),
385 }
386 }
387
388 pub fn start(
391 &mut self,
392 workflow: &str,
393 version: Option<&str>,
394 input: Value,
395 execution_id: Option<String>,
396 ) -> Result<StartResult> {
397 if self.shared.is_some() && self.definition(workflow, version).is_none() {
398 self.refresh_definitions()?;
400 }
401 let def = self.definition(workflow, version).ok_or_else(|| {
402 EngineError::new(
403 404,
404 "WORKFLOW_NOT_FOUND",
405 match version {
406 Some(v) => format!("workflow {workflow}@{v} is not registered"),
407 None => format!("workflow {workflow} is not registered"),
408 },
409 )
410 })?;
411 self.start_with(def, input, execution_id, None)
412 }
413
414 fn start_with(
418 &mut self,
419 def: Arc<WorkflowIr>,
420 mut input: Value,
421 execution_id: Option<String>,
422 parent: Option<ParentRef>,
423 ) -> Result<StartResult> {
424 if input.is_null() {
425 input = json!({});
426 }
427 def.input.apply_defaults(&mut input);
428 self.check_payload(Some(&def), "execution input", &input)?;
429 def.input.validate(&input, parent.is_none()).map_err(|e| {
430 EngineError::new(
431 400,
432 "INVALID_INPUT",
433 format!("workflow input is invalid at {e}"),
434 )
435 })?;
436 let id = match execution_id {
437 Some(id) => {
438 if id.is_empty()
439 || id.len() > 200
440 || !id
441 .chars()
442 .all(|c| c.is_ascii_alphanumeric() || "-_.:".contains(c))
443 {
444 return Err(EngineError::new(
445 400,
446 "INVALID_EXECUTION_ID",
447 "execution ids may contain letters, digits and - _ . :",
448 ));
449 }
450 if self.active.contains_key(&id)
452 || self
453 .store
454 .execution_exists(&id)
455 .map_err(EngineError::storage)?
456 {
457 let existing = self.execution(&id)?;
458 if existing.definition_hash != def.definition_hash {
459 return Err(EngineError::new(
460 409,
461 "EXECUTION_EXISTS",
462 format!("execution `{id}` already exists for a different workflow"),
463 ));
464 }
465 return Ok(StartResult {
466 execution_id: id,
467 created: false,
468 });
469 }
470 id
471 }
472 None => uuid::Uuid::new_v4().to_string(),
473 };
474 if parent.is_none() {
475 if let Some(max) = self.config.limits.max_active_executions {
476 let running = self.active.values().filter(|s| s.parent.is_none()).count();
479 if running >= max {
480 return Err(EngineError::new(
481 429,
482 "RESOURCE_EXHAUSTED",
483 format!("{max} executions are already running (--max-active-executions)"),
484 ));
485 }
486 }
487 }
488 self.active.insert(id.clone(), ExecutionState::new(&id));
489 let created = Event::WorkflowCreated {
490 workflow: def.name.clone(),
491 version: def.version.clone(),
492 definition_hash: def.definition_hash.clone(),
493 input,
494 parent,
495 };
496 if let Err(e) = self.commit(&id, vec![created, Event::WorkflowStarted {}]) {
497 self.active.remove(&id);
498 return Err(e);
499 }
500 tracing::info!(execution_id = %id, workflow = %def.name, version = %def.version, "execution started");
501 self.advance(&id)?;
502 Ok(StartResult {
503 execution_id: id,
504 created: true,
505 })
506 }
507
508 pub fn cancel(&mut self, id: &str, reason: &str) -> Result<ExecutionState> {
509 let state = self.execution(id)?;
510 if state.status.is_terminal() {
511 return Err(EngineError::new(
512 409,
513 "EXECUTION_FINISHED",
514 format!("execution is already {}", state.status.as_str()),
515 ));
516 }
517 if self.forwards(id) {
518 self.forward_command(id, "cancel", json!({ "reason": reason }))?;
519 return Ok(state);
520 }
521 if state.abort.is_none() {
522 self.commit(
523 id,
524 vec![Event::CancelRequested {
525 reason: reason.to_string(),
526 }],
527 )?;
528 self.advance(id)?;
529 }
530 self.execution(id)
531 }
532
533 pub fn signal(&mut self, id: &str, name: &str, mut payload: Value) -> Result<u64> {
538 let state = self.execution(id)?;
539 if state.status.is_terminal() {
540 return Err(EngineError::new(
541 409,
542 "EXECUTION_FINISHED",
543 format!("execution is already {}", state.status.as_str()),
544 ));
545 }
546 let def = self
547 .by_hash
548 .get(&state.definition_hash)
549 .cloned()
550 .ok_or_else(|| EngineError::not_found("workflow definition"))?;
551 let Some(ty) = def.signals.get(name) else {
552 let declared: Vec<&str> = def.signals.keys().map(String::as_str).collect();
553 return Err(EngineError::new(
554 404,
555 "UNKNOWN_SIGNAL",
556 if declared.is_empty() {
557 format!("{}@{} declares no signals", def.name, def.version)
558 } else {
559 format!(
560 "{}@{} has no signal `{name}`; declared: {}",
561 def.name,
562 def.version,
563 declared.join(", ")
564 )
565 },
566 ));
567 };
568 self.check_payload(Some(&def), "signal payload", &payload)?;
569 ty.apply_defaults(&mut payload);
570 if let Err(e) = ty.validate(&payload, false) {
571 return Err(EngineError::new(
572 422,
573 "SIGNAL_PAYLOAD_INVALID",
574 format!("payload for signal `{name}` is invalid at {e}"),
575 ));
576 }
577 if self.forwards(id) {
578 self.forward_command(id, "signal", json!({ "name": name, "payload": payload }))?;
579 return Ok(0);
580 }
581 let seq = state.last_seq + 1;
582 self.commit(
583 id,
584 vec![Event::SignalReceived {
585 name: name.to_string(),
586 payload,
587 }],
588 )?;
589 tracing::info!(execution_id = %id, signal = %name, "signal received");
590 self.advance(id)?;
591 Ok(seq)
592 }
593
594 pub fn query(&self, id: &str, name: &str) -> Result<Value> {
597 let state = self.execution(id)?;
598 let def = self
599 .by_hash
600 .get(&state.definition_hash)
601 .cloned()
602 .ok_or_else(|| EngineError::not_found("workflow definition"))?;
603 let Some(q) = def.queries.get(name) else {
604 let declared: Vec<&str> = def.queries.keys().map(String::as_str).collect();
605 return Err(EngineError::new(
606 404,
607 "UNKNOWN_QUERY",
608 if declared.is_empty() {
609 format!("{}@{} declares no queries", def.name, def.version)
610 } else {
611 format!(
612 "{}@{} has no query `{name}`; declared: {}",
613 def.name,
614 def.version,
615 declared.join(", ")
616 )
617 },
618 ));
619 };
620 struct QueryScope<'a>(crate::decide::StateScope<'a>, Value);
621 impl orbit_ir::Scope for QueryScope<'_> {
622 fn input(&self) -> &Value {
623 self.0.input()
624 }
625 fn step_output(&self, step: &str) -> Option<&Value> {
626 self.0.step_output(step).or(Some(&self.1))
627 }
628 fn execution_field(&self, field: &str) -> Option<Value> {
629 self.0.execution_field(field)
630 }
631 }
632 let scope = QueryScope(crate::decide::StateScope::new(&state), Value::Null);
633 orbit_ir::eval(&q.expr, &scope).map_err(|e| {
634 EngineError::new(
635 422,
636 "QUERY_FAILED",
637 format!("evaluating query `{name}`: {e}"),
638 )
639 })
640 }
641
642 fn reindex(&mut self, id: &str) {
645 if let Some(old) = self.ready_by_execution.remove(id) {
646 for e in &old {
647 self.ready.remove(e);
648 }
649 }
650 let Some(state) = self.active.get(id) else {
651 return;
652 };
653 let entries: Vec<(u64, String, String)> = state
654 .units
655 .iter()
656 .filter(|(_, u)| u.status() == UnitStatus::Scheduled)
657 .map(|(k, u)| (u.scheduled_at_ms.unwrap_or(0), id.to_string(), k.clone()))
658 .collect();
659 if !entries.is_empty() {
660 self.ready.extend(entries.iter().cloned());
661 self.ready_by_execution.insert(id.to_string(), entries);
662 }
663 }
664
665 fn check_payload(&self, def: Option<&WorkflowIr>, what: &str, value: &Value) -> Result<()> {
668 let limits = match def {
669 Some(d) => effective_limits(&self.config.limits, d),
670 None => self.config.limits.clone(),
671 };
672 if let Some(max) = limits.max_payload_bytes {
673 let size = serde_json::to_vec(value).map(|v| v.len()).unwrap_or(0);
674 if size > max {
675 return Err(EngineError::new(
676 413,
677 "PAYLOAD_TOO_LARGE",
678 format!("{what} is {size} bytes; the limit is {max} (--max-payload-bytes or the workflow's `limits`)"),
679 ));
680 }
681 }
682 Ok(())
683 }
684
685 pub fn execution(&self, id: &str) -> Result<ExecutionState> {
686 if let Some(s) = self.active.get(id) {
687 return Ok(s.clone());
688 }
689 let history = self.store.history(id).map_err(EngineError::storage)?;
690 if history.is_empty() {
691 return Err(EngineError::not_found("execution"));
692 }
693 Ok(ExecutionState::replay(id, &history))
694 }
695
696 pub fn history(&self, id: &str) -> Result<Vec<HistoryEvent>> {
697 let h = self.store.history(id).map_err(EngineError::storage)?;
698 if h.is_empty() {
699 return Err(EngineError::not_found("execution"));
700 }
701 Ok(h)
702 }
703
704 pub fn list_executions(
705 &self,
706 status: Option<&str>,
707 workflow: Option<&str>,
708 limit: u32,
709 ) -> Result<Vec<ExecutionSummary>> {
710 self.store
711 .list_executions(status, workflow, limit.clamp(1, 1000))
712 .map_err(EngineError::storage)
713 }
714
715 pub fn active_count(&self) -> usize {
716 self.active.len()
717 }
718
719 fn commit(&mut self, id: &str, events: Vec<Event>) -> Result<()> {
723 if events.is_empty() {
724 return Ok(());
725 }
726 let now = self.now();
727 let mut state = self
728 .active
729 .get(id)
730 .cloned()
731 .ok_or_else(|| EngineError::not_found("active execution"))?;
732 let mut history = Vec::with_capacity(events.len());
733 for event in events {
734 let h = HistoryEvent {
735 seq: state.last_seq + 1,
736 timestamp_ms: now,
737 event,
738 };
739 apply(&mut state, &h);
740 history.push(h);
741 }
742 match &self.shared {
743 Some(ctx) => {
744 let def = self.by_hash.get(&state.definition_hash).cloned();
745 ctx.append(&mut self.store, &state, &history, def.as_deref())
746 }
747 None => self.store.append(&state, &history),
748 }
749 .map_err(EngineError::storage)?;
750 if let Some(t) = &self.telemetry {
752 let ctx = self.trace_context(&state);
753 t.export(crate::telemetry::spans_for(&state, &ctx, &history));
754 }
755 for h in &history {
756 self.metrics.observe(&h.event);
757 if let Event::StepDispatched {
758 token,
759 step,
760 worker_id,
761 ..
762 } = &h.event
763 {
764 self.tokens
765 .insert(token.clone(), (id.to_string(), step.clone()));
766 self.token_workers.insert(token.clone(), worker_id.clone());
767 }
768 tracing::debug!(execution_id = %id, seq = h.seq, event = h.event.name(), step = h.event.step().unwrap_or(""), "event");
769 match &h.event {
770 Event::WorkflowCompleted { .. } => {
771 tracing::info!(execution_id = %id, "execution completed")
772 }
773 Event::WorkflowFailed {
774 error,
775 compensation,
776 } => {
777 tracing::warn!(execution_id = %id, code = %error.code, error = %error.message, compensation = ?compensation, "execution failed")
778 }
779 Event::WorkflowCancelled { .. } => {
780 tracing::info!(execution_id = %id, "execution cancelled")
781 }
782 Event::WorkflowTimedOut { .. } => {
783 tracing::warn!(execution_id = %id, "execution timed out")
784 }
785 Event::StepFailed { step, error } => {
786 tracing::warn!(execution_id = %id, step = %step, code = %error.code, error = %error.message, "step failed")
787 }
788 Event::RetryScheduled {
789 step,
790 next_attempt,
791 delay_ms,
792 ..
793 } => {
794 tracing::info!(execution_id = %id, step = %step, attempt = next_attempt, delay_ms, "retry scheduled")
795 }
796 _ => {}
797 }
798 }
799 if state.status.is_terminal() {
800 for u in state.units.values() {
801 for (t, _) in &u.tokens {
802 self.tokens.remove(t);
803 self.token_workers.remove(t);
804 }
805 }
806 self.active.remove(id);
807 } else {
808 self.active.insert(id.to_string(), state);
809 }
810 self.reindex(id);
811 self.changed.notify_waiters();
812 Ok(())
813 }
814
815 fn advance(&mut self, id: &str) -> Result<()> {
817 let Some(state) = self.active.get(id) else {
818 return Ok(());
819 };
820 let Some(def) = self.by_hash.get(&state.definition_hash).cloned() else {
821 tracing::error!(execution_id = %id, hash = %state.definition_hash, "definition missing for execution");
822 return Ok(());
823 };
824 let now = self.now();
825 let mut scratch = state.clone();
826 let mut batch = vec![];
827 let limits = effective_limits(&self.config.limits, &def);
828 for _ in 0..10_000 {
829 let mut events = decide(&scratch, &def, now);
830 if events.is_empty() {
831 break;
832 }
833 enforce_limits(&limits, &scratch, &mut events);
834 for e in events {
835 let seq = scratch.last_seq + 1;
836 apply(
837 &mut scratch,
838 &HistoryEvent {
839 seq,
840 timestamp_ms: now,
841 event: e.clone(),
842 },
843 );
844 batch.push(e);
845 }
846 }
847 self.commit(id, batch)
848 }
849
850 pub fn tick(&mut self) -> Result<Vec<TaskAssignment>> {
852 self.check_liveness()?;
853 let ids: Vec<String> = self.active.keys().cloned().collect();
854 for id in &ids {
855 self.advance(id)?;
856 }
857 self.dispatch_builtins()?;
858 self.dispatch_children()?;
859 self.reconcile_children()?;
860 Ok(std::mem::take(&mut self.builtin_jobs))
861 }
862
863 fn check_liveness(&mut self) -> Result<()> {
866 let now = self.now();
867 let timeout = self.config.session_timeout_ms;
868 self.sessions
869 .retain(|_, s| now < s.last_seen_ms + timeout * 4);
870 let mut lost: Vec<(String, String, u32, String)> = vec![];
871 for (id, state) in &self.active {
872 for (key, u) in &state.units {
873 if u.status() != UnitStatus::Dispatched {
874 continue;
875 }
876 let session = u.worker_session.clone().unwrap_or_default();
877 let alive = if session.starts_with(CHILD_SESSION_PREFIX) {
878 true
880 } else if let Some(inst) = session.strip_prefix(BUILTIN_SESSION_PREFIX) {
881 inst == self.instance
882 } else {
883 let session_alive = self
884 .sessions
885 .get(&session)
886 .is_some_and(|s| now < s.last_seen_ms + timeout)
887 || self
888 .shared
889 .as_ref()
890 .is_some_and(|c| c.session_alive(&session, timeout));
891 session_alive
894 && !self
895 .shared
896 .as_ref()
897 .is_some_and(|c| c.claim_lost(u.token.as_deref()))
898 };
899 let since = u.dispatched_at_ms.unwrap_or(0).max(self.started_at_ms);
900 if !alive && now >= since + timeout {
901 let reason = if session.starts_with(BUILTIN_SESSION_PREFIX) {
902 "runtime restarted while the built-in task was running".to_string()
903 } else {
904 format!(
905 "worker `{}` stopped responding",
906 u.worker_id.clone().unwrap_or_default()
907 )
908 };
909 lost.push((id.clone(), key.clone(), u.attempt, reason));
910 }
911 }
912 }
913 for (id, key, attempt, reason) in lost {
914 tracing::warn!(execution_id = %id, step = %key, attempt, reason = %reason, "attempt outcome uncertain; redelivering");
915 self.commit(
916 &id,
917 vec![Event::StepAttemptUncertain {
918 step: key,
919 attempt,
920 reason,
921 }],
922 )?;
923 self.advance(&id)?;
924 }
925 Ok(())
926 }
927
928 pub fn register_worker(&mut self, req: RegisterWorker) -> Result<Registration> {
931 if req.worker_id.trim().is_empty() {
932 return Err(EngineError::new(
933 400,
934 "INVALID_REQUEST",
935 "worker_id is required",
936 ));
937 }
938 if req.contracts.is_empty() {
939 return Err(EngineError::new(
940 400,
941 "INVALID_REQUEST",
942 "a worker must announce at least one task contract",
943 ));
944 }
945 let now = self.now();
946 let mut warnings = vec![];
947 for c in &req.contracts {
948 if c.task_name.starts_with(WORKFLOW_CONTRACT_PREFIX) {
949 return Err(EngineError::new(
950 400,
951 "RESERVED_TASK",
952 format!(
953 "`{}` names a child workflow, not a worker task",
954 c.task_name
955 ),
956 ));
957 }
958 if c.task_name.starts_with("orbit.") {
959 return Err(EngineError::new(
960 400,
961 "RESERVED_TASK",
962 format!("`{}` is a built-in task", c.task_name),
963 ));
964 }
965 let mut versions: Vec<&str> = self
966 .by_version
967 .values()
968 .filter_map(|d| d.contracts.get(&c.task_name).map(|k| k.version.as_str()))
969 .collect();
970 versions.sort();
971 versions.dedup();
972 if !versions.is_empty() && !versions.contains(&c.contract_version.as_str()) {
973 warnings.push(format!(
974 "`{}` is announced at version {} but registered workflows use {}; this worker will not receive those tasks",
975 c.task_name,
976 c.contract_version,
977 versions.join(", ")
978 ));
979 }
980 }
981 let session_id = uuid::Uuid::new_v4().to_string();
982 self.sessions.insert(
983 session_id.clone(),
984 Session {
985 worker_id: req.worker_id.clone(),
986 runtime: req.runtime.to_lowercase(),
987 contracts: req
988 .contracts
989 .iter()
990 .map(|c| (c.task_name.clone(), c.contract_version.clone()))
991 .collect(),
992 last_seen_ms: now,
993 persisted_seen_ms: now,
994 inflight: HashSet::new(),
995 },
996 );
997 if let Some(ctx) = &self.shared {
998 ctx.save_session(&session_id, &req.worker_id, &req.runtime, &req.contracts)?;
999 }
1000 self.store
1001 .upsert_worker(&WorkerRecord {
1002 worker_id: req.worker_id.clone(),
1003 runtime: req.runtime.to_lowercase(),
1004 sdk: req.sdk.clone(),
1005 contracts: serde_json::to_value(&req.contracts).unwrap_or_default(),
1006 last_seen_ms: now,
1007 registered_at_ms: now,
1008 })
1009 .map_err(EngineError::storage)?;
1010 tracing::info!(worker_id = %req.worker_id, runtime = %req.runtime, tasks = req.contracts.len(), "worker registered");
1011 Ok(Registration {
1012 accepted: true,
1013 worker_session_id: session_id,
1014 heartbeat_interval_ms: self.config.heartbeat_interval_ms,
1015 session_timeout_ms: self.config.session_timeout_ms,
1016 warnings,
1017 })
1018 }
1019
1020 fn touch(&mut self, session_id: &str) -> Result<()> {
1021 if self.shared.is_some() && !self.sessions.contains_key(session_id) {
1022 self.load_shared_session(session_id)?;
1023 }
1024 if let Some(ctx) = &self.shared {
1025 ctx.touched(session_id);
1026 }
1027 let now = self.now();
1028 let s = self.sessions.get_mut(session_id).ok_or_else(|| {
1029 EngineError::new(
1030 404,
1031 "UNKNOWN_SESSION",
1032 "unknown or expired worker session; register again",
1033 )
1034 })?;
1035 s.last_seen_ms = now;
1036 if now >= s.persisted_seen_ms + 1_000 {
1039 s.persisted_seen_ms = now;
1040 let wid = s.worker_id.clone();
1041 let _ = self.store.touch_worker(&wid, now);
1042 }
1043 Ok(())
1044 }
1045
1046 pub fn heartbeat(&mut self, session_id: &str) -> Result<HeartbeatAck> {
1047 self.touch(session_id)?;
1048 let inflight: Vec<String> = self.sessions[session_id].inflight.iter().cloned().collect();
1049 let mut cancel = vec![];
1050 for token in inflight {
1051 let live = self.tokens.get(&token).and_then(|(id, key)| {
1052 let st = self.active.get(id)?;
1053 let u = st.units.get(key)?;
1054 Some(
1055 u.status() == UnitStatus::Dispatched
1056 && u.token.as_deref() == Some(token.as_str())
1057 && (st.abort.is_none() || crate::model::is_cleanup_key(key)),
1058 )
1059 });
1060 if live != Some(true) {
1061 cancel.push(token.clone());
1062 self.sessions
1063 .get_mut(session_id)
1064 .unwrap()
1065 .inflight
1066 .remove(&token);
1067 }
1068 }
1069 if let Some(ctx) = &self.shared {
1070 cancel.extend(ctx.cancelled_for(session_id)?);
1071 }
1072 Ok(HeartbeatAck {
1073 accepted: true,
1074 cancel,
1075 })
1076 }
1077
1078 pub fn unregister_worker(&mut self, session_id: &str) -> Result<()> {
1079 if self.shared.is_some() {
1080 if !self.sessions.contains_key(session_id) {
1081 self.load_shared_session(session_id)?;
1082 }
1083 self.release_shared_session(session_id)?;
1084 }
1085 let s = self
1086 .sessions
1087 .remove(session_id)
1088 .ok_or_else(|| EngineError::new(404, "UNKNOWN_SESSION", "unknown worker session"))?;
1089 tracing::info!(worker_id = %s.worker_id, "worker unregistered");
1090 let mut lost = vec![];
1092 for token in s.inflight {
1093 if let Some((id, key)) = self.tokens.get(&token) {
1094 if let Some(u) = self.active.get(id).and_then(|st| st.units.get(key)) {
1095 if u.status() == UnitStatus::Dispatched
1096 && u.token.as_deref() == Some(token.as_str())
1097 {
1098 lost.push((id.clone(), key.clone(), u.attempt));
1099 }
1100 }
1101 }
1102 }
1103 for (id, key, attempt) in lost {
1104 self.commit(
1105 &id,
1106 vec![Event::StepAttemptUncertain {
1107 step: key,
1108 attempt,
1109 reason: "worker unregistered".into(),
1110 }],
1111 )?;
1112 self.advance(&id)?;
1113 }
1114 Ok(())
1115 }
1116
1117 pub fn workers(&self) -> Result<Vec<WorkerInfo>> {
1118 let now = self.now();
1119 let records = self.store.workers().map_err(EngineError::storage)?;
1120 if let Some(ctx) = &self.shared {
1121 return ctx.workers(records, self.config.session_timeout_ms);
1122 }
1123 Ok(records
1124 .into_iter()
1125 .map(|record| {
1126 let live: Vec<&Session> = self
1127 .sessions
1128 .values()
1129 .filter(|s| {
1130 s.worker_id == record.worker_id
1131 && now < s.last_seen_ms + self.config.session_timeout_ms
1132 })
1133 .collect();
1134 WorkerInfo {
1135 online: !live.is_empty(),
1136 sessions: live.len(),
1137 inflight: live.iter().map(|s| s.inflight.len()).sum(),
1138 record,
1139 }
1140 })
1141 .collect())
1142 }
1143
1144 pub fn poll(&mut self, session_id: &str) -> Result<Option<TaskAssignment>> {
1146 self.touch(session_id)?;
1147 if self.shared.is_some() {
1148 return self.shared_poll(session_id);
1149 }
1150 let session = self.sessions[session_id].clone();
1151 let mut best: Option<(u64, String, String)> = None;
1153 for (at, id, key) in &self.ready {
1154 let Some(state) = self.active.get(id) else {
1155 continue;
1156 };
1157 let Some(def) = self.by_hash.get(&state.definition_hash) else {
1158 continue;
1159 };
1160 let Some(inv) = invocation(def, key) else {
1161 continue;
1162 };
1163 if !session
1164 .contracts
1165 .contains(&(inv.task.clone(), inv.contract_version.clone()))
1166 {
1167 continue;
1168 }
1169 if let Some(lang) = &inv.language {
1170 if !lang.eq_ignore_ascii_case(&session.runtime) && lang != "any" {
1171 continue;
1172 }
1173 }
1174 best = Some((*at, id.clone(), key.clone()));
1175 break;
1176 }
1177 let Some((_, id, key)) = best else {
1178 return Ok(None);
1179 };
1180 let assignment = self.dispatch(&id, &key, session_id, &session.worker_id)?;
1181 if let Some(s) = self.sessions.get_mut(session_id) {
1182 s.inflight.insert(assignment.token.clone());
1183 }
1184 Ok(Some(assignment))
1185 }
1186
1187 fn dispatch(
1188 &mut self,
1189 id: &str,
1190 key: &str,
1191 session_id: &str,
1192 worker_id: &str,
1193 ) -> Result<TaskAssignment> {
1194 let now = self.now();
1195 let state = &self.active[id];
1196 let def = self.by_hash[&state.definition_hash].clone();
1197 let inv = invocation(&def, key)
1198 .expect("unit has an invocation")
1199 .clone();
1200 let u = state.units[key].clone();
1201 let token = uuid::Uuid::new_v4().to_string();
1202 let deadline = inv.timeout_ms.map(|t| now + t);
1203 let assignment = TaskAssignment {
1204 token: token.clone(),
1205 execution_id: id.to_string(),
1206 workflow: def.name.clone(),
1207 workflow_version: def.version.clone(),
1208 step_id: key.to_string(),
1209 task_name: inv.task.clone(),
1210 contract_version: inv.contract_version.clone(),
1211 input: u.input.clone().unwrap_or(Value::Null),
1212 idempotency_key: u.idempotency_key.clone().unwrap_or_default(),
1213 attempt: u.attempt,
1214 redelivery: u.redelivery,
1215 deadline_ms: deadline,
1216 timeout_ms: inv.timeout_ms,
1217 };
1218 self.commit(
1219 id,
1220 vec![Event::StepDispatched {
1221 step: key.to_string(),
1222 attempt: u.attempt,
1223 token,
1224 worker_id: worker_id.to_string(),
1225 worker_session: session_id.to_string(),
1226 deadline_ms: deadline,
1227 }],
1228 )?;
1229 Ok(assignment)
1230 }
1231
1232 fn dispatch_children(&mut self) -> Result<()> {
1234 let mut todo = vec![];
1235 for (id, state) in &self.active {
1236 let Some(def) = self.by_hash.get(&state.definition_hash) else {
1237 continue;
1238 };
1239 for (key, u) in &state.units {
1240 if u.status() != UnitStatus::Scheduled {
1241 continue;
1242 }
1243 if let Some(inv) = invocation(def, key) {
1244 if let Some(hash) = def
1245 .contracts
1246 .get(&inv.task)
1247 .and_then(|c| c.workflow_hash.clone())
1248 {
1249 todo.push((
1250 id.clone(),
1251 key.clone(),
1252 u.attempt,
1253 hash,
1254 u.input.clone().unwrap_or(Value::Null),
1255 ));
1256 }
1257 }
1258 }
1259 }
1260 for (id, key, attempt, hash, input) in todo {
1261 let child_id = child_execution_id(&id, &key);
1262 let failure = |code: &'static str, message: String| Event::StepAttemptFailed {
1263 step: key.clone(),
1264 attempt,
1265 error: TaskError::new(code, message, false),
1266 };
1267 let Some(child_def) = self.by_hash.get(&hash).cloned() else {
1268 self.commit(
1269 &id,
1270 vec![failure(
1271 "CHILD_NOT_REGISTERED",
1272 format!("child definition {hash} is not registered"),
1273 )],
1274 )?;
1275 self.advance(&id)?;
1276 continue;
1277 };
1278 let parent = ParentRef {
1279 execution_id: id.clone(),
1280 step: key.clone(),
1281 };
1282 if let Err(e) = self.start_with(child_def, input, Some(child_id.clone()), Some(parent))
1283 {
1284 self.commit(&id, vec![failure("CHILD_START_FAILED", e.message)])?;
1285 self.advance(&id)?;
1286 continue;
1287 }
1288 if self
1289 .active
1290 .get(&id)
1291 .and_then(|s| s.units.get(&key))
1292 .map(|u| u.status())
1293 == Some(UnitStatus::Scheduled)
1294 {
1295 self.dispatch(
1296 &id,
1297 &key,
1298 &format!("{CHILD_SESSION_PREFIX}{child_id}"),
1299 "orbit-runtime",
1300 )?;
1301 }
1302 }
1303 Ok(())
1304 }
1305
1306 fn reconcile_children(&mut self) -> Result<()> {
1310 let mut finished = vec![];
1311 let mut cancel = vec![];
1312 for state in self.active.values() {
1313 for u in state.units.values() {
1314 let Some(child) = u
1315 .worker_session
1316 .as_deref()
1317 .and_then(|s| s.strip_prefix(CHILD_SESSION_PREFIX))
1318 else {
1319 continue;
1320 };
1321 match self.active.get(child) {
1322 Some(c) => {
1323 let parent_gone = u.status() != UnitStatus::Dispatched;
1324 if c.abort.is_none() && (state.abort.is_some() || parent_gone) {
1325 cancel.push(child.to_string());
1326 }
1327 }
1328 None if u.status() == UnitStatus::Dispatched => {
1329 finished.push((child.to_string(), u.token.clone().unwrap_or_default()));
1330 }
1331 None => {}
1332 }
1333 }
1334 }
1335 for child in cancel {
1336 tracing::info!(execution_id = %child, "cancelling child workflow because its parent no longer needs it");
1337 self.cancel(&child, "parent workflow aborted")?;
1338 }
1339 for (child, token) in finished {
1340 let state = match self.execution(&child) {
1341 Ok(s) => s,
1342 Err(_) => {
1343 self.fail(
1344 &token,
1345 TaskError::new(
1346 "CHILD_MISSING",
1347 format!("child execution `{child}` not found"),
1348 false,
1349 ),
1350 )?;
1351 continue;
1352 }
1353 };
1354 let name = state.workflow.clone();
1355 match state.status {
1356 WorkflowStatus::Completed => {
1357 self.complete(&token, state.output.unwrap_or(Value::Null))?;
1358 }
1359 WorkflowStatus::Failed => {
1360 let e = state
1361 .error
1362 .unwrap_or_else(|| TaskError::new("UNKNOWN", "failed", false));
1363 let mut err = TaskError::new(
1364 &e.code,
1365 format!("child workflow `{name}` failed: {}", e.message),
1366 false,
1367 );
1368 err.details = Some(json!({ "child_execution_id": child }));
1369 self.fail(&token, err)?;
1370 }
1371 WorkflowStatus::Cancelled => {
1372 self.fail(
1373 &token,
1374 TaskError::new(
1375 "CHILD_CANCELLED",
1376 format!("child workflow `{name}` was cancelled"),
1377 false,
1378 ),
1379 )?;
1380 }
1381 WorkflowStatus::TimedOut => {
1382 self.fail(
1383 &token,
1384 TaskError::new(
1385 "CHILD_TIMED_OUT",
1386 format!("child workflow `{name}` timed out"),
1387 false,
1388 ),
1389 )?;
1390 }
1391 _ => {}
1392 }
1393 }
1394 Ok(())
1395 }
1396
1397 fn dispatch_builtins(&mut self) -> Result<()> {
1398 let mut todo = vec![];
1399 for (id, state) in &self.active {
1400 let Some(def) = self.by_hash.get(&state.definition_hash) else {
1401 continue;
1402 };
1403 for (key, u) in &state.units {
1404 if u.status() != UnitStatus::Scheduled {
1405 continue;
1406 }
1407 if let Some(inv) = invocation(def, key) {
1408 if def.contracts.get(&inv.task).is_some_and(|c| c.builtin) {
1409 todo.push((id.clone(), key.clone(), u.attempt));
1410 }
1411 }
1412 }
1413 }
1414 for (id, key, attempt) in todo {
1415 if !self.config.http_adapter {
1416 self.commit(
1417 &id,
1418 vec![Event::StepAttemptFailed {
1419 step: key,
1420 attempt,
1421 error: TaskError::new(codes::ADAPTER_DISABLED, "the HTTP adapter is disabled; start the server with --enable-http-adapter", false),
1422 }],
1423 )?;
1424 self.advance(&id)?;
1425 continue;
1426 }
1427 let session = format!("{BUILTIN_SESSION_PREFIX}{}", self.instance);
1428 let job = self.dispatch(&id, &key, &session, "orbit-runtime")?;
1429 self.builtin_jobs.push(job);
1430 }
1431 Ok(())
1432 }
1433
1434 pub fn complete(&mut self, token: &str, mut output: Value) -> Result<ResultAck> {
1436 if self.forwards_result(token) {
1437 self.check_payload(None, "task result", &output)?;
1439 return self.forward_result(
1440 token,
1441 "complete",
1442 json!({ "token": token, "output": output }),
1443 );
1444 }
1445 let (id, key, unit, def) = match self.resolve_token(token)? {
1446 Ok(x) => x,
1447 Err(ack) => return Ok(ack),
1448 };
1449 let attempt = unit
1450 .tokens
1451 .iter()
1452 .find(|(t, _)| t == token)
1453 .map(|(_, a)| *a)
1454 .unwrap_or(unit.attempt);
1455 if output.is_null() {
1456 output = json!({});
1457 }
1458 self.check_payload(Some(&def), "task result", &output)?;
1459 let inv = invocation(&def, &key).cloned();
1460 let validation = inv
1461 .as_ref()
1462 .and_then(|i| def.contracts.get(&i.task))
1463 .map(|c| {
1464 c.output.apply_defaults(&mut output);
1465 c.output.validate(&output, false)
1466 })
1467 .unwrap_or(Ok(()));
1468 let event = match validation {
1469 Ok(()) => Event::StepSucceeded {
1470 step: key.clone(),
1471 attempt,
1472 output,
1473 },
1474 Err(e) => Event::StepAttemptFailed {
1475 step: key.clone(),
1476 attempt: unit.attempt,
1477 error: TaskError::new(
1478 codes::OUTPUT_CONTRACT_VIOLATION,
1479 format!(
1480 "worker output for `{}` does not match its contract at {e}",
1481 inv.map(|i| i.task).unwrap_or_default()
1482 ),
1483 false,
1484 ),
1485 },
1486 };
1487 let accepted = matches!(event, Event::StepSucceeded { .. });
1488 self.commit(&id, vec![event])?;
1489 self.advance(&id)?;
1490 Ok(ResultAck {
1491 accepted,
1492 reason: (!accepted).then(|| "output violates the task contract".into()),
1493 })
1494 }
1495
1496 pub fn fail(&mut self, token: &str, error: TaskError) -> Result<ResultAck> {
1498 if self.forwards_result(token) {
1499 return self.forward_result(token, "fail", json!({ "token": token, "error": error }));
1500 }
1501 let (id, key, unit, _) = match self.resolve_token(token)? {
1502 Ok(x) => x,
1503 Err(ack) => return Ok(ack),
1504 };
1505 if unit.token.as_deref() != Some(token) {
1507 self.commit(
1508 &id,
1509 vec![Event::StaleResultIgnored {
1510 step: key,
1511 token: token.into(),
1512 outcome: "failure".into(),
1513 }],
1514 )?;
1515 return Ok(ResultAck {
1516 accepted: false,
1517 reason: Some("stale attempt".into()),
1518 });
1519 }
1520 self.commit(
1521 &id,
1522 vec![Event::StepAttemptFailed {
1523 step: key,
1524 attempt: unit.attempt,
1525 error,
1526 }],
1527 )?;
1528 self.advance(&id)?;
1529 Ok(ResultAck {
1530 accepted: true,
1531 reason: None,
1532 })
1533 }
1534
1535 #[allow(clippy::type_complexity)]
1536 fn resolve_token(
1537 &mut self,
1538 token: &str,
1539 ) -> Result<std::result::Result<(String, String, UnitState, Arc<WorkflowIr>), ResultAck>> {
1540 let Some((id, key)) = self.tokens.get(token).cloned() else {
1541 return Ok(Err(ResultAck {
1542 accepted: false,
1543 reason: Some("unknown token (execution finished or token invalid)".into()),
1544 }));
1545 };
1546 if let Some(ctx) = &self.shared {
1547 ctx.stage_done(&id, token);
1548 }
1549 let state = &self.active[&id];
1550 let unit = state.units.get(&key).cloned().unwrap_or_default();
1551 let def = self.by_hash[&state.definition_hash].clone();
1552 if let Some(session) = &unit.worker_session {
1553 if let Some(s) = self.sessions.get_mut(session) {
1554 s.inflight.remove(token);
1555 s.last_seen_ms = (self.clock)();
1556 }
1557 }
1558 let unresolved = matches!(
1561 unit.status(),
1562 UnitStatus::Dispatched
1563 | UnitStatus::Scheduled
1564 | UnitStatus::RetryWait
1565 | UnitStatus::Uncertain
1566 | UnitStatus::AttemptFailed
1567 );
1568 if !unresolved {
1569 self.commit(
1570 &id,
1571 vec![Event::StaleResultIgnored {
1572 step: key,
1573 token: token.into(),
1574 outcome: "duplicate".into(),
1575 }],
1576 )?;
1577 return Ok(Err(ResultAck {
1578 accepted: false,
1579 reason: Some("duplicate result; step already resolved".into()),
1580 }));
1581 }
1582 Ok(Ok((id, key, unit, def)))
1583 }
1584
1585 pub fn store_stats(&self) -> Result<Vec<(String, u64)>> {
1586 self.store.count_by_status().map_err(EngineError::storage)
1587 }
1588
1589 pub fn enable_telemetry(&mut self, config: crate::telemetry::TelemetryConfig) {
1591 tracing::info!(endpoint = %config.endpoint, service = %config.service_name, "exporting OpenTelemetry traces");
1592 self.telemetry = Some(crate::telemetry::Exporter::start(config));
1593 }
1594
1595 fn trace_context(&self, state: &ExecutionState) -> crate::telemetry::TraceContext {
1598 use crate::telemetry::{trace_id, unit_span_id, TraceContext};
1599 let Some(parent) = &state.parent else {
1600 return TraceContext {
1601 trace_id: trace_id(&state.id),
1602 parent_span_id: None,
1603 };
1604 };
1605 let mut root = parent.execution_id.clone();
1606 for _ in 0..64 {
1607 let next = match self.active.get(&root) {
1608 Some(s) => s.parent.clone(),
1609 None => self.execution(&root).ok().and_then(|s| s.parent),
1610 };
1611 match next {
1612 Some(p) => root = p.execution_id,
1613 None => break,
1614 }
1615 }
1616 TraceContext {
1617 trace_id: trace_id(&root),
1618 parent_span_id: Some(unit_span_id(&parent.execution_id, &parent.step, 1)),
1620 }
1621 }
1622
1623 pub fn session_worker(&self, session_id: &str) -> Option<&str> {
1625 self.sessions.get(session_id).map(|s| s.worker_id.as_str())
1626 }
1627
1628 pub fn token_worker(&self, token: &str) -> Option<&str> {
1630 self.token_workers.get(token).map(String::as_str)
1631 }
1632
1633 pub fn session_count(&self) -> usize {
1634 self.sessions.len()
1635 }
1636
1637 pub fn refresh_definitions(&mut self) -> Result<usize> {
1641 let mut added = 0;
1642 for ir in self.store.definitions().map_err(EngineError::storage)? {
1643 if !self.by_hash.contains_key(&ir.definition_hash) {
1644 self.cache_definition(ir);
1645 added += 1;
1646 }
1647 }
1648 Ok(added)
1649 }
1650}
1651
1652pub fn effective_limits(server: &Limits, def: &WorkflowIr) -> Limits {
1654 fn min<T: Ord + Copy>(a: Option<T>, b: Option<T>) -> Option<T> {
1655 match (a, b) {
1656 (Some(x), Some(y)) => Some(x.min(y)),
1657 (x, None) => x,
1658 (None, y) => y,
1659 }
1660 }
1661 let w = def.limits.clone().unwrap_or_default();
1662 Limits {
1663 max_active_executions: server.max_active_executions,
1664 max_fan_out: min(server.max_fan_out, w.max_fan_out.map(|v| v as usize)),
1665 max_history_events: min(server.max_history_events, w.max_history_events),
1666 max_payload_bytes: min(
1667 server.max_payload_bytes,
1668 w.max_payload_bytes.map(|v| v as usize),
1669 ),
1670 }
1671}
1672
1673fn enforce_limits(limits: &Limits, state: &ExecutionState, events: &mut Vec<Event>) {
1676 if let Some(max) = limits.max_fan_out {
1677 for e in events.iter_mut() {
1678 if let Event::StepFanOut { step, items } = e {
1679 if items.len() > max {
1680 *e = Event::StepFailed {
1681 step: step.clone(),
1682 error: TaskError::new(
1683 "LIMIT_EXCEEDED",
1684 format!(
1685 "`for` over {} elements exceeds the fan-out limit of {max} (--max-fan-out or the workflow's `limits`)",
1686 items.len()
1687 ),
1688 false,
1689 ),
1690 };
1691 }
1692 }
1693 }
1694 }
1695 if let Some(max) = limits.max_history_events {
1696 let would_be = state.last_seq + events.len() as u64;
1697 if state.abort.is_none() && would_be > max && !state.status.is_terminal() {
1698 events.clear();
1700 events.push(Event::LimitExceeded {
1701 limit: "max_history_events".into(),
1702 message: format!(
1703 "the execution reached {max} history events (--max-history-events or the workflow's `limits`)"
1704 ),
1705 });
1706 }
1707 }
1708}
1709
1710impl Engine {
1718 pub fn shared_context(&self) -> Option<Arc<crate::shared::Ctx>> {
1720 self.shared.clone()
1721 }
1722
1723 pub(crate) fn owns(&self, id: &str) -> bool {
1725 self.active.contains_key(id)
1726 }
1727
1728 fn forwards(&self, id: &str) -> bool {
1730 self.shared.as_ref().is_some_and(|c| !c.applying_inbox()) && !self.active.contains_key(id)
1731 }
1732
1733 fn forwards_result(&self, token: &str) -> bool {
1736 self.shared.as_ref().is_some_and(|c| !c.applying_inbox())
1737 && !self.tokens.contains_key(token)
1738 }
1739
1740 fn forward_command(&mut self, id: &str, kind: &str, payload: Value) -> Result<()> {
1741 let ctx = self.shared.clone().expect("shared mode");
1742 match ctx.forward(id, kind, payload, None)? {
1743 Some(seq) => {
1744 crate::shared::note_forwarded(seq);
1745 Ok(())
1746 }
1747 None => Err(EngineError::new(
1748 409,
1749 "EXECUTION_FINISHED",
1750 "execution has already finished",
1751 )),
1752 }
1753 }
1754
1755 fn forward_result(&mut self, token: &str, kind: &str, payload: Value) -> Result<ResultAck> {
1756 let unknown = ResultAck {
1757 accepted: false,
1758 reason: Some("unknown token (execution finished or token invalid)".into()),
1759 };
1760 let Some(exec) = crate::shared::token_execution(token) else {
1761 return Ok(unknown);
1762 };
1763 let ctx = self.shared.clone().expect("shared mode");
1764 Ok(match ctx.forward(exec, kind, payload, Some(token))? {
1765 Some(_) => ResultAck {
1766 accepted: true,
1767 reason: None,
1768 },
1769 None => unknown,
1770 })
1771 }
1772
1773 fn shared_poll(&mut self, session_id: &str) -> Result<Option<TaskAssignment>> {
1775 let ctx = self.shared.clone().expect("shared mode");
1776 if !ctx.should_claim(session_id) {
1777 return Ok(None);
1778 }
1779 let s = self.sessions[session_id].clone();
1780 for _ in 0..16 {
1781 let now = self.now();
1782 match ctx.claim(session_id, &s.worker_id, &s.runtime, &s.contracts, now)? {
1783 crate::shared::Claimed::Nothing => return Ok(None),
1784 crate::shared::Claimed::Stale => continue,
1785 crate::shared::Claimed::Remote(a) => return Ok(Some(a)),
1786 crate::shared::Claimed::Local {
1787 row_id,
1788 offer,
1789 assignment: a,
1790 } => {
1791 match self.apply_dispatch(
1792 &a.execution_id,
1793 &a.step_id,
1794 a.attempt,
1795 offer,
1796 &a.token,
1797 &s.worker_id,
1798 session_id,
1799 a.deadline_ms,
1800 ) {
1801 Ok(true) => return Ok(Some(a)),
1802 Ok(false) => {
1803 ctx.retire(row_id, &a.token)?;
1804 continue;
1805 }
1806 Err(e) => {
1807 let _ = ctx.release(row_id, &a.token);
1808 return Err(e);
1809 }
1810 }
1811 }
1812 }
1813 }
1814 Ok(None)
1815 }
1816
1817 #[allow(clippy::type_complexity)]
1821 pub(crate) fn prepare_claim(
1822 &mut self,
1823 session_id: &str,
1824 ) -> Result<Option<(String, String, HashSet<(String, String)>)>> {
1825 self.touch(session_id)?;
1826 let ctx = self.shared.clone().expect("shared mode");
1827 if !ctx.should_claim(session_id) {
1828 return Ok(None);
1829 }
1830 let s = &self.sessions[session_id];
1831 Ok(Some((
1832 s.worker_id.clone(),
1833 s.runtime.clone(),
1834 s.contracts.clone(),
1835 )))
1836 }
1837
1838 #[allow(clippy::too_many_arguments)]
1841 pub(crate) fn apply_dispatch(
1842 &mut self,
1843 id: &str,
1844 key: &str,
1845 attempt: u32,
1846 offer: u32,
1847 token: &str,
1848 worker_id: &str,
1849 session: &str,
1850 deadline_ms: Option<u64>,
1851 ) -> Result<bool> {
1852 let current = self
1853 .active
1854 .get(id)
1855 .and_then(|s| s.units.get(key))
1856 .is_some_and(|u| {
1857 u.status() == UnitStatus::Scheduled
1858 && u.attempt == attempt
1859 && u.dispatch_count == offer
1860 });
1861 if !current {
1862 return Ok(false);
1863 }
1864 self.commit(
1865 id,
1866 vec![Event::StepDispatched {
1867 step: key.to_string(),
1868 attempt,
1869 token: token.to_string(),
1870 worker_id: worker_id.to_string(),
1871 worker_session: session.to_string(),
1872 deadline_ms,
1873 }],
1874 )?;
1875 Ok(true)
1876 }
1877
1878 pub(crate) fn mark_uncertain(&mut self, token: &str, reason: &str) -> Result<bool> {
1881 let Some((id, key)) = self.tokens.get(token).cloned() else {
1882 return Ok(false);
1883 };
1884 let Some(u) = self.active.get(&id).and_then(|s| s.units.get(&key)) else {
1885 return Ok(false);
1886 };
1887 if u.status() != UnitStatus::Dispatched || u.token.as_deref() != Some(token) {
1888 return Ok(false);
1889 }
1890 let attempt = u.attempt;
1891 tracing::warn!(execution_id = %id, step = %key, attempt, reason = %reason, "attempt outcome uncertain; redelivering");
1892 self.commit(
1893 &id,
1894 vec![Event::StepAttemptUncertain {
1895 step: key,
1896 attempt,
1897 reason: reason.to_string(),
1898 }],
1899 )?;
1900 self.advance(&id)?;
1901 Ok(true)
1902 }
1903
1904 fn load_shared_session(&mut self, session_id: &str) -> Result<()> {
1906 let ctx = self.shared.clone().expect("shared mode");
1907 if let Some((worker_id, runtime, contracts)) =
1908 ctx.load_session(session_id, self.config.session_timeout_ms)?
1909 {
1910 let now = self.now();
1911 self.sessions.insert(
1912 session_id.to_string(),
1913 Session {
1914 worker_id,
1915 runtime,
1916 contracts: contracts.into_iter().collect(),
1917 last_seen_ms: now,
1918 persisted_seen_ms: now,
1919 inflight: HashSet::new(),
1920 },
1921 );
1922 }
1923 Ok(())
1924 }
1925
1926 fn release_shared_session(&mut self, session_id: &str) -> Result<()> {
1929 let ctx = self.shared.clone().expect("shared mode");
1930 for (exec, token) in ctx.drop_session(session_id)? {
1931 if self.tokens.contains_key(&token) {
1932 self.mark_uncertain(&token, "worker unregistered")?;
1933 } else {
1934 ctx.forward(
1935 &exec,
1936 "uncertain",
1937 json!({ "token": token, "reason": "worker unregistered" }),
1938 None,
1939 )?;
1940 }
1941 }
1942 Ok(())
1943 }
1944
1945 pub fn session_worker_lookup(&mut self, session_id: &str) -> Option<String> {
1948 if self.shared.is_some() && !self.sessions.contains_key(session_id) {
1949 let _ = self.load_shared_session(session_id);
1950 }
1951 self.session_worker(session_id).map(str::to_string)
1952 }
1953
1954 pub fn token_worker_lookup(&self, token: &str) -> Option<String> {
1957 if let Some(w) = self.token_worker(token) {
1958 return Some(w.to_string());
1959 }
1960 self.shared.as_ref().and_then(|c| c.token_worker(token))
1961 }
1962
1963 pub(crate) fn adopt(&mut self, items: &[(String, i64)], from: &str) -> Result<usize> {
1969 let ctx = self.shared.clone().expect("shared mode");
1970 let mut loaded = vec![];
1971 let mut in_flight = 0;
1972 for (id, epoch) in items {
1973 let history = self.store.history(id).map_err(EngineError::storage)?;
1974 if history.is_empty() {
1975 continue;
1976 }
1977 let state = ExecutionState::replay(id, &history);
1978 if state.status.is_terminal() {
1979 continue;
1980 }
1981 if !self.by_hash.contains_key(&state.definition_hash) {
1982 self.refresh_definitions()?;
1983 }
1984 for h in &history {
1985 if let Event::StepDispatched {
1986 token, worker_id, ..
1987 } = &h.event
1988 {
1989 self.token_workers.insert(token.clone(), worker_id.clone());
1990 }
1991 }
1992 for (key, u) in &state.units {
1993 for (t, _) in &u.tokens {
1994 self.tokens.insert(t.clone(), (id.clone(), key.clone()));
1995 }
1996 if u.status() == UnitStatus::Dispatched {
1997 in_flight += 1;
1998 }
1999 }
2000 ctx.set_epoch(id, *epoch);
2001 self.active.insert(id.clone(), state);
2002 self.reindex(id);
2003 loaded.push(id.clone());
2004 }
2005 let mut offers = vec![];
2006 for id in &loaded {
2007 let state = &self.active[id];
2008 let Some(def) = self.by_hash.get(&state.definition_hash) else {
2009 continue;
2010 };
2011 for (key, u) in &state.units {
2012 if u.status() == UnitStatus::Scheduled {
2013 if let Some(o) = crate::shared::offer_for(state, key, def) {
2014 offers.push((id.clone(), o));
2015 }
2016 }
2017 }
2018 }
2019 ctx.republish(offers, from)?;
2020 if !loaded.is_empty() {
2021 tracing::info!(
2022 executions = loaded.len(),
2023 in_flight,
2024 from = %from,
2025 "shared: took ownership of executions"
2026 );
2027 }
2028 for id in &loaded {
2029 if let Err(e) = self.advance(id) {
2030 tracing::error!(execution_id = %id, error = %e, "shared: resuming an adopted execution failed");
2031 }
2032 }
2033 Ok(loaded.len())
2034 }
2035
2036 pub(crate) fn evict(&mut self, id: &str) {
2039 if let Some(state) = self.active.remove(id) {
2040 for u in state.units.values() {
2041 for (t, _) in &u.tokens {
2042 self.tokens.remove(t);
2043 self.token_workers.remove(t);
2044 }
2045 }
2046 self.reindex(id);
2047 tracing::warn!(execution_id = %id, "shared: another server owns this execution now; dropped it");
2048 }
2049 if let Some(ctx) = &self.shared {
2050 ctx.forget(id);
2051 }
2052 }
2053
2054 pub(crate) fn dispatched_attempts(&self) -> Vec<(String, String)> {
2057 let mut out = vec![];
2058 for state in self.active.values() {
2059 for u in state.units.values() {
2060 if u.status() != UnitStatus::Dispatched {
2061 continue;
2062 }
2063 if let (Some(s), Some(t)) = (&u.worker_session, &u.token) {
2064 if !s.starts_with(CHILD_SESSION_PREFIX)
2065 && !s.starts_with(BUILTIN_SESSION_PREFIX)
2066 {
2067 out.push((s.clone(), t.clone()));
2068 }
2069 }
2070 }
2071 }
2072 out
2073 }
2074
2075 pub(crate) fn forget_sessions(&mut self, ids: &[String]) {
2077 for id in ids {
2078 self.sessions.remove(id);
2079 }
2080 }
2081
2082 pub(crate) fn owned_ids(&self) -> Vec<String> {
2084 self.active.keys().cloned().collect()
2085 }
2086
2087 pub(crate) fn queued_units(&self) -> Vec<(String, String, i64, i64)> {
2089 let mut out = vec![];
2090 for (id, state) in &self.active {
2091 let Some(def) = self.by_hash.get(&state.definition_hash) else {
2092 continue;
2093 };
2094 for (key, u) in &state.units {
2095 if u.status() == UnitStatus::Scheduled
2096 && crate::shared::worker_invocation(def, key).is_some()
2097 {
2098 out.push((
2099 id.clone(),
2100 key.clone(),
2101 u.attempt as i64,
2102 u.dispatch_count as i64,
2103 ));
2104 }
2105 }
2106 }
2107 out
2108 }
2109}