aion/engine/api_workloop.rs
1//! Workloop verbs on the engine API (workloop brief Leg 2): register, close
2//! iteration, retire, hatch.
3
4use std::collections::HashMap;
5use std::future::Future;
6use std::sync::Arc;
7
8use aion_core::{
9 Event, Payload, RunId, SearchAttributeValue, WORKFLOW_KIND_ATTRIBUTE, WORKLOOP_KIND,
10 WorkflowId, WorkflowStatus, WorkloopSpec, status_from_events,
11};
12use aion_store::StoreError;
13use aion_store::workloop::WorkloopStore;
14use chrono::Utc;
15use tokio::task::JoinHandle;
16
17use super::api::Engine;
18use crate::durability::Recorder;
19use crate::error::EngineError;
20use crate::workloop::hatch::{HatchOutcome, derive_identity};
21use crate::workloop::iteration::WorkloopIterationClose;
22use crate::workloop::service::WorkloopService;
23
24/// The engine's running workloop machinery: the service, its store, and the
25/// sweep task with its shutdown line. Present only when the builder was given
26/// a workloop store and a sweep interval.
27pub(crate) struct WorkloopEngineRuntime {
28 pub(crate) service: Arc<WorkloopService>,
29 pub(crate) store: Arc<dyn WorkloopStore>,
30 pub(crate) shutdown: tokio::sync::watch::Sender<bool>,
31 pub(crate) task: JoinHandle<()>,
32 /// The NIF state holding this engine's `close_iteration/3` bridge, so
33 /// shutdown can EMPTY that slot. See [`WorkloopEngineRuntime::stop`].
34 pub(crate) nif_state: Arc<crate::runtime::EngineNifState>,
35}
36
37impl WorkloopEngineRuntime {
38 /// Signal and abort the sweep task, then EMPTY the NIF bridge slot.
39 ///
40 /// Called from both `Engine::shutdown` and `Drop for Engine`, because a
41 /// task spawned on the HOST runtime is not reached by the engine-task
42 /// epoch gate.
43 ///
44 /// # 🔴 THE BRIDGE MUST BE RELEASED, OR THE STORE IS NEVER RELEASED
45 ///
46 /// The bridge holds an `IterationCloseContext` — the workloop store, the
47 /// event store, the visibility store and the registry — and it lives in
48 /// the NIF state, which outlives this engine. So an engine that installed
49 /// a bridge and shut down left its EVENT STORE alive forever, and the next
50 /// process to want that store waited on its file lock: a server that had
51 /// ever built a workloop-capable engine could not hand its data directory
52 /// to a successor. Measured as an indefinite hang in
53 /// `recovery_declared_body_e2e` the moment the server wired the service
54 /// unconditionally — a restart is exactly what that test performs.
55 ///
56 /// Emptying the slot is also the CORRECT runtime behaviour and not merely
57 /// a leak fix: after shutdown there is no engine to close an iteration
58 /// against, and an empty slot is precisely how the NIF says so
59 /// (`no workloop service is configured on this engine`) instead of driving
60 /// a close through a half-torn-down engine.
61 pub(crate) fn stop(&self) {
62 // A closed receiver means the task already exited; nothing to signal.
63 let _ = self.shutdown.send(true);
64 self.task.abort();
65 crate::runtime::nif_workloop::release_workloop_nif_bridge(&self.nif_state);
66 }
67}
68
69impl Engine {
70 pub(crate) fn workloop_runtime(&self) -> Result<&WorkloopEngineRuntime, EngineError> {
71 self.workloop.as_ref().ok_or_else(|| EngineError::Runtime {
72 reason: "workloop service is not configured on this engine \
73 (EngineBuilder::with_workloop_service)"
74 .to_owned(),
75 })
76 }
77
78 /// The cadence service, when configured — the operational surface for
79 /// sweeps and registration state.
80 #[must_use]
81 pub fn workloop_service(&self) -> Option<Arc<WorkloopService>> {
82 self.workloop
83 .as_ref()
84 .map(|runtime| Arc::clone(&runtime.service))
85 }
86
87 /// The workloop spec the currently ROUTED deployment of `workflow_type`
88 /// declares, or `None` when that deployment is an ordinary workflow.
89 ///
90 /// # 🔴 THE START PATH ASKS THIS, AND IT IS WHY A `.awl` WORKLOOP RUNS
91 ///
92 /// Every caller-facing start surface — HTTP, gRPC, the CLI — funnels into
93 /// one start verb, and that verb cannot tell a workloop from a workflow by
94 /// looking at the request: nothing in a start request says "this is a
95 /// loop". The DEPLOYED PACKAGE says so, in the contract the compiler bound
96 /// into its identity, and this is where that is read. Without it a
97 /// workloop deploys, starts, runs its first iteration and is REFUSED at
98 /// `close_iteration` for not being a registered workloop — which is
99 /// exactly the shape this whole integration exists to close.
100 ///
101 /// Answering from the ROUTED version (not an exact pin) is deliberate: a
102 /// start goes to whatever version routing would run, so the declaration a
103 /// start is registered under must come from the same place.
104 ///
105 /// # Errors
106 ///
107 /// Propagates catalog failures, and refuses a declaration the engine
108 /// cannot act on — an unarmed loop, an invariant with no tolerance or no
109 /// confirming route, a retention window of zero. Those are refused HERE,
110 /// before anything is registered or started, so the loop that cannot be
111 /// armed never exists rather than existing and never firing.
112 pub fn declared_workloop_spec(
113 &self,
114 workflow_type: &str,
115 ) -> Result<Option<WorkloopSpec>, EngineError> {
116 let Some(loaded) = self.catalog.routed(workflow_type)? else {
117 return Ok(None);
118 };
119 // A package with no contract at all is a pre-`.v4` identity that
120 // commits to none; it cannot be a workloop, because the header a loop
121 // needs did not exist under those identities.
122 let Ok(contract) = loaded.contract() else {
123 return Ok(None);
124 };
125 let Some(workloop) = contract.workloop.as_ref() else {
126 return Ok(None);
127 };
128 crate::workloop::spec_from_contract(workflow_type, workloop)
129 .map(Some)
130 .map_err(EngineError::from)
131 }
132
133 /// Registers a STARTED workflow as a workloop: stamps the `aion.kind`
134 /// listing attribute durably in its history and arms the declared cadence
135 /// and tolerance deadlines on the sweep set. Every declared value —
136 /// arming, tolerance, retention — arrives validated inside `spec`; there
137 /// are no defaults to assume.
138 ///
139 /// # Errors
140 ///
141 /// Refuses an unknown or non-Running workflow, a duplicate registration,
142 /// and propagates store/append failures.
143 pub async fn register_workloop(
144 &self,
145 loop_id: &WorkflowId,
146 namespace: String,
147 spec: WorkloopSpec,
148 ) -> Result<(), EngineError> {
149 let runtime = self.workloop_runtime()?;
150 let history = self.store.read_history(loop_id).await?;
151 if history.is_empty() {
152 return Err(EngineError::InvalidState {
153 reason: format!(
154 "workloop registration requires a started workflow; {loop_id} has no history"
155 ),
156 });
157 }
158 let status = status_from_events(&history);
159 if status != WorkflowStatus::Running {
160 return Err(EngineError::InvalidState {
161 reason: format!(
162 "workloop registration requires a Running workflow; {loop_id} is {status:?}"
163 ),
164 });
165 }
166 // The duplicate check runs BEFORE the kind stamp so a refused
167 // registration leaves history byte-identical (the service re-checks
168 // under its own put, so a racing duplicate still cannot register
169 // twice — this ordering only keeps the refusal append-free).
170 if runtime
171 .store
172 .get_workloop(loop_id)
173 .await
174 .map_err(EngineError::from)?
175 .is_some()
176 {
177 return Err(EngineError::InvalidState {
178 reason: format!("workloop {loop_id} is already registered"),
179 });
180 }
181
182 // Stamp the kind attribute through the loop's one Recorder so every
183 // listing surface projects it (additive; no status change).
184 let attributes = HashMap::from([(
185 String::from(WORKFLOW_KIND_ATTRIBUTE),
186 SearchAttributeValue::String(String::from(WORKLOOP_KIND)),
187 )]);
188 self.with_loop_recorder(loop_id, |recorder, _history| {
189 let schema = Arc::clone(&self.search_attribute_schema);
190 Box::pin(async move {
191 recorder
192 .record_search_attributes_updated(Utc::now(), attributes, &schema)
193 .await
194 })
195 })
196 .await?;
197
198 runtime
199 .service
200 .register(loop_id.clone(), namespace, spec)
201 .await
202 .map_err(EngineError::from)?;
203 Ok(())
204 }
205
206 /// Starts a workflow AS A WORKLOOP: seeds generation 1's carry, registers
207 /// the loop, and only then lets the body run.
208 ///
209 /// # 🔴 THIS EXISTS BECAUSE START-THEN-REGISTER IS A RACE
210 ///
211 /// `register_workloop` requires an already-RUNNING workflow, so the only
212 /// way to stand a loop up was to start it and then register it — and the
213 /// started body begins executing immediately. Generation 1 could reach
214 /// `close_iteration` BEFORE its registration landed, and the close would
215 /// then refuse with "not a registered workloop" and FAIL the run. A loop's
216 /// very first iteration could lose a race with its own registration.
217 ///
218 /// The registration row is therefore written FIRST, before the workflow
219 /// exists, so the close always finds it. A failed start removes the row
220 /// again rather than leaving a registration for a loop that never ran.
221 ///
222 /// # 🔴 AND BECAUSE GENERATION 1 HAS NO PREVIOUS ITERATION TO CARRY FROM
223 ///
224 /// Every later generation gets its carry from the previous iteration's
225 /// `route start` payload. Generation 1 has none, and the compiled input
226 /// codec requires the carry fields regardless — so a start payload that
227 /// passes schema admission would then be UNDECODABLE by the workflow it
228 /// was admitted for. The declared carry defaults are merged into the
229 /// generation-1 input here, filling only ABSENT fields so a caller-supplied
230 /// value is never clobbered by a default.
231 ///
232 /// # 🔴 THE KIND STAMP IS PART OF THE START, NOT A THIRD STEP
233 ///
234 /// The `aion.kind` attribute travels in the START's own attribute map, so
235 /// `WorkflowStarted` and `SearchAttributesUpdated` land in ONE append
236 /// (`Recorder::record_workflow_started_with_attributes`). It used to be a
237 /// separate durable step after the start, and the gap was not cosmetic:
238 /// boot recovery reads the kind FROM HISTORY to decide whether a Running
239 /// workflow with no resident process is a crashed workflow to resurrect or
240 /// a parked LOOP to leave alone. A crash between the start and the stamp
241 /// left a registered loop that boot recovery did not recognise as one — it
242 /// resurrected the generation resident while the sweep set still carried
243 /// the row and would wake it, which is two paths driving one loop, the
244 /// exact thing the skip exists to prevent.
245 ///
246 /// # 🔴 WHAT IS STILL NOT ATOMIC, SAID PLAINLY
247 ///
248 /// Two durable systems are involved — the workloop registration (a KV row)
249 /// and the workflow's event history — and nothing spans them. The
250 /// registration is written FIRST because the alternative loses the race
251 /// above, so a crash between it and the start leaves a sweep-set row for a
252 /// workflow with no history. That row is not left to alarm forever: the
253 /// sweep refuses to fire at a workflow with no recorded start and reports
254 /// the fault, and engine boot reconciliation
255 /// ([`WorkloopService::withdraw_unstarted_registrations`]) withdraws every
256 /// such row. Boot is the point at which the answer is unambiguous — no
257 /// start can be in flight across a process boundary — which is why the
258 /// reconciliation lives there rather than in the sweep, where it would
259 /// race the birth window it is supposed to tolerate.
260 ///
261 /// [`WorkloopService::withdraw_unstarted_registrations`]:
262 /// crate::workloop::service::WorkloopService::withdraw_unstarted_registrations
263 ///
264 /// # Errors
265 ///
266 /// Refuses an unconfigured workloop service, a start payload that is not a
267 /// JSON object when carry is declared, a duplicate registration, a caller
268 /// attempting to set `aion.kind` itself, and propagates start failures.
269 pub async fn start_workloop(
270 &self,
271 workflow_type: &str,
272 input: Payload,
273 search_attributes: HashMap<String, SearchAttributeValue>,
274 namespace: String,
275 spec: WorkloopSpec,
276 ) -> Result<crate::registry::WorkflowHandle, EngineError> {
277 let runtime = self.workloop_runtime()?;
278 let seeded = spec
279 .carry()
280 .seed(&input)
281 .map_err(|error| EngineError::InvalidState {
282 reason: format!("seeding generation 1's carry refused: {error}"),
283 })?;
284 let attributes = with_workloop_kind(search_attributes)?;
285
286 // The identity is minted here so the registration can precede the
287 // start. Nothing observes it until the start records `WorkflowStarted`
288 // under exactly this id.
289 let loop_id = WorkflowId::new_v4();
290 runtime
291 .service
292 .register(loop_id.clone(), namespace.clone(), spec)
293 .await
294 .map_err(EngineError::from)?;
295
296 let started = self
297 .start_workflow_with_id(
298 workflow_type,
299 seeded,
300 attributes,
301 namespace,
302 Some(loop_id.clone()),
303 None,
304 )
305 .await;
306 match started {
307 Ok(handle) => Ok(handle),
308 Err(error) => {
309 // No loop ran, so no registration should survive. A failure to
310 // withdraw it is reported rather than swallowed: the sweep set
311 // would otherwise carry a row for a workflow that does not
312 // exist until the next boot reconciles it away.
313 if let Err(cleanup) = runtime.service.deregister(&loop_id).await {
314 tracing::error!(
315 %loop_id,
316 start_error = %error,
317 cleanup_error = %cleanup,
318 "workloop start failed AND its registration could not be withdrawn; the \
319 sweep set carries a row for a workflow that was never started until \
320 boot reconciliation withdraws it"
321 );
322 }
323 Err(error)
324 }
325 }
326 }
327
328 /// Closes the current iteration at the continue-as-new boundary (R3.1):
329 /// derives one health sample per invariant from the taken routes (R3.3),
330 /// records `IterationClosed` + `WorkflowContinuedAsNew` + the successor
331 /// generation's `WorkflowStarted` in ONE atomic batch through the loop's
332 /// Recorder — spawning NO successor process (R13.3) — then installs the
333 /// produced invariant current-state records with retention pruning (R7/
334 /// R8) and feeds the samples into tolerance accounting. Returns the
335 /// successor generation's run id.
336 ///
337 /// # Errors
338 ///
339 /// Refuses an unregistered loop, a terminal run, pending work, and
340 /// undeclared invariants; propagates store/append failures.
341 pub async fn close_workloop_iteration(
342 &self,
343 loop_id: &WorkflowId,
344 close: WorkloopIterationClose,
345 ) -> Result<RunId, EngineError> {
346 let context = self.iteration_close_context()?;
347 crate::workloop::close::close_iteration(&context, loop_id, close).await
348 }
349
350 /// The component set one iteration close needs, assembled from this
351 /// engine. Shared with the `close_iteration/3` NIF bridge so a close
352 /// reached from compiled workflow code and a close reached from this API
353 /// verb are the SAME close.
354 ///
355 /// # Errors
356 ///
357 /// Refuses when no workloop service is configured on this engine.
358 pub(crate) fn iteration_close_context(
359 &self,
360 ) -> Result<crate::workloop::close::IterationCloseContext, EngineError> {
361 let runtime = self.workloop_runtime()?;
362 Ok(crate::workloop::close::IterationCloseContext {
363 workloop_store: Arc::clone(&runtime.store),
364 service: Arc::clone(&runtime.service),
365 store: self.store(),
366 visibility_store: self.visibility_store(),
367 registry: Arc::clone(&self.registry),
368 })
369 }
370
371 /// Retires a workloop (R2.5): records `LoopRetired { reason }` and its
372 /// `WorkflowCompleted` terminal in ONE atomic batch — the declared,
373 /// recorded way to stop that is not failure — and removes the loop from
374 /// the sweep set. Invariant current-state records survive indefinitely
375 /// (R8.1).
376 ///
377 /// # Errors
378 ///
379 /// Refuses an unconfigured service and a terminal run; propagates
380 /// store/append failures.
381 pub async fn retire_workloop(
382 &self,
383 loop_id: &WorkflowId,
384 reason: String,
385 result: Payload,
386 ) -> Result<(), EngineError> {
387 self.retire_workloop_inner(loop_id, reason, result, RetireBody::Invoke)
388 .await
389 }
390
391 /// [`Engine::retire_workloop`] for a loop that declares NO retire body.
392 ///
393 /// Kept as a separate verb rather than a flag on the main one because the
394 /// difference is a DECLARATION, not a caller preference: a loop whose
395 /// document declares `retire` must run it, and a caller must never be able
396 /// to skip a declared cleanup by passing an argument. The AWL-driven path
397 /// selects between them from the compiled contract; this exists so an
398 /// operator retiring a bodyless loop is not forced through an entry probe
399 /// that would refuse a module which correctly exports nothing.
400 ///
401 /// # Errors
402 ///
403 /// As [`Engine::retire_workloop`], minus the retire-body refusals.
404 pub async fn retire_workloop_without_body(
405 &self,
406 loop_id: &WorkflowId,
407 reason: String,
408 result: Payload,
409 ) -> Result<(), EngineError> {
410 self.retire_workloop_inner(loop_id, reason, result, RetireBody::None)
411 .await
412 }
413
414 /// Retires a workloop, taking the retire-body decision FROM ITS DEPLOYED
415 /// DECLARATION rather than from the caller.
416 ///
417 /// # 🔴 THE OPERATOR SURFACE, AND WHY IT HAS NO "SKIP CLEANUP" FLAG
418 ///
419 /// [`Engine::retire_workloop`] and [`Engine::retire_workloop_without_body`]
420 /// are two verbs precisely so a caller cannot choose: a document that
421 /// declares `retire` must run it, and letting an argument skip a declared
422 /// cleanup is how a lease is released twice or a queue is stranded. This
423 /// is the verb every operator-facing surface calls, and it reads the
424 /// answer out of the package the loop's CURRENT generation is pinned to —
425 /// the same package whose module the body would be spawned from.
426 ///
427 /// A loop whose deployment declares no workloop surface at all (registered
428 /// through the Rust API rather than deployed from a `.awl` document) is
429 /// retired without a body: the engine has no declaration saying there is
430 /// one, and inventing a `retire/1` probe for it would refuse every such
431 /// loop for lacking an entry it was never meant to export.
432 ///
433 /// # Errors
434 ///
435 /// As [`Engine::retire_workloop`], plus catalog and history failures while
436 /// resolving the loop's deployed declaration.
437 pub async fn retire_declared_workloop(
438 &self,
439 loop_id: &WorkflowId,
440 reason: String,
441 result: Payload,
442 ) -> Result<(), EngineError> {
443 let history = self.store.read_history(loop_id).await?;
444 let workflow_type = history
445 .iter()
446 .rev()
447 .find_map(|event| match event {
448 Event::WorkflowStarted { workflow_type, .. } => Some(workflow_type.clone()),
449 _ => None,
450 })
451 .ok_or_else(|| EngineError::InvalidState {
452 reason: format!("workloop {loop_id} has no recorded generation to retire"),
453 })?;
454 let declares_body = self
455 .catalog
456 .routed(&workflow_type)?
457 .and_then(|loaded| loaded.contract().ok().cloned())
458 .and_then(|contract| contract.workloop)
459 .is_some_and(|workloop| workloop.has_retire_body);
460 if declares_body {
461 self.retire_workloop(loop_id, reason, result).await
462 } else {
463 self.retire_workloop_without_body(loop_id, reason, result)
464 .await
465 }
466 }
467
468 async fn retire_workloop_inner(
469 &self,
470 loop_id: &WorkflowId,
471 reason: String,
472 result: Payload,
473 body: RetireBody,
474 ) -> Result<(), EngineError> {
475 let runtime = self.workloop_runtime()?;
476
477 // 🔴 TERMINALITY IS CHECKED BEFORE THE BODY RUNS, NOT AFTER IT.
478 //
479 // The check used to live only inside the terminal append below, so
480 // retiring an ALREADY-RETIRED loop ran the declared cleanup a second
481 // time — releasing a released lease, re-draining a drained queue,
482 // appending after `WorkflowCompleted` — and only then reported the
483 // refusal it had already earned. A refusal that arrives after the
484 // effects is not a refusal.
485 //
486 // The check inside `with_loop_recorder` stays: it is the one taken
487 // under the same acquisition as the append, and it is what makes the
488 // decision atomic rather than merely early. This one is what makes it
489 // EFFECT-FREE.
490 let history = self.store.read_history(loop_id).await?;
491 crate::workloop::retire::refuse_if_terminal(loop_id, &history)
492 .map_err(EngineError::from)?;
493
494 // 🔴 THE RETIRE BODY RUNS FIRST, AND A MISSING ENTRY REFUSES LOUDLY.
495 //
496 // Before any terminal is recorded: a run that already holds its
497 // terminal cannot append, so a body invoked afterwards could record
498 // nothing it did. And a declared body whose entry is absent from the
499 // deployed module is REFUSED — the engine cannot tell "compiled
500 // before the retire entry existed" from "declared no cleanup", and
501 // silently skipping the first is how a lease is lost.
502 if body == RetireBody::Invoke {
503 crate::workloop::retire::run_retire_body(&crate::workloop::retire::RetireInvocation {
504 loop_id,
505 runtime: &self.runtime,
506 catalog: self.catalog.as_ref(),
507 registry: &self.registry,
508 store: &self.store,
509 visibility_store: &self.visibility_store(),
510 })
511 .await
512 .map_err(EngineError::from)?;
513 }
514 let retire_reason = reason;
515 self.with_loop_recorder(loop_id, move |recorder, history| {
516 let reason = retire_reason.clone();
517 let result = result.clone();
518 let terminal = aion_core::current_lease_terminal(history).is_some();
519 Box::pin(async move {
520 if terminal {
521 return Err(crate::durability::DurabilityError::HistoryShape {
522 reason: "cannot retire a workloop whose run is already terminal".to_owned(),
523 });
524 }
525 recorder
526 .record_loop_retired(Utc::now(), reason, result)
527 .await
528 })
529 })
530 .await?;
531
532 // 🔴 DEREGISTER BEFORE THE HANDLE GOES, AND ACCOUNT FOR THE ANSWER.
533 //
534 // Between the terminal above and the deregistration below the loop is
535 // a REGISTERED row whose run holds a terminal — precisely the shape
536 // the sweep reads as loop death (`sink.rs` refuses the cadence fire on
537 // a terminal run, and `declare_loop_dead` then fans an `AlarmCause::
538 // LoopDead` at every declared invariant). A cleanly retired loop would
539 // be declared dead, durably, in its own history. The sweep now checks
540 // for `LoopRetired` before declaring death, and this ordering narrows
541 // the window it has to check in.
542 //
543 // The `bool` is not discarded: `deregister` answering `false` means
544 // the row was already gone, which on this path means a sweep removed
545 // it — the operator's retirement succeeded but the loop had already
546 // been swept, and that is worth a line rather than a shrug.
547 let was_registered = runtime
548 .service
549 .deregister(loop_id)
550 .await
551 .map_err(EngineError::from)?;
552 if !was_registered {
553 tracing::warn!(
554 %loop_id,
555 "workloop retirement recorded its terminal but the loop was no longer on the \
556 sweep set; a sweep deregistered it first, so its invariants may already carry \
557 loop-dead alarms for a loop that was being retired on purpose"
558 );
559 }
560 if let Some(handle) = self.registry_handle(loop_id)? {
561 let run = handle.run_id().clone();
562 self.registry.remove(loop_id, &run)?;
563 }
564 Ok(())
565 }
566
567 /// Starts a DETACHED top-level workflow under the mandatory dedupe
568 /// identity (R13.1): `WorkflowId = hatch_workflow_id(namespace, type,
569 /// key)`. Not a child — no lifecycle tie, no supervision edge. A
570 /// duplicate hatch is a recorded no-op returning the existing workflow id;
571 /// two racing first-hatches are settled by the store's optimistic append,
572 /// the loser resolving to the winner's workflow.
573 ///
574 /// # Errors
575 ///
576 /// Refuses empty/NUL identity parts and propagates start failures other
577 /// than the dedupe race.
578 pub async fn hatch_workflow(
579 &self,
580 namespace: &str,
581 workflow_type: &str,
582 key: &str,
583 input: Payload,
584 search_attributes: HashMap<String, SearchAttributeValue>,
585 ) -> Result<HatchOutcome, EngineError> {
586 let hatch_id = derive_identity(namespace, workflow_type, key).map_err(|error| {
587 EngineError::InvalidState {
588 reason: format!("hatch identity refused: {error}"),
589 }
590 })?;
591 if !self.store.read_history(&hatch_id).await?.is_empty() {
592 return Ok(HatchOutcome::Existing(hatch_id));
593 }
594 match self
595 .start_workflow_with_id(
596 workflow_type,
597 input,
598 search_attributes,
599 namespace.to_owned(),
600 Some(hatch_id.clone()),
601 None,
602 )
603 .await
604 {
605 Ok(_handle) => Ok(HatchOutcome::Hatched(hatch_id)),
606 // The dedupe race: a concurrent hatch of the same identity won the
607 // first append. The store's optimistic concurrency IS the index —
608 // the loser resolves to the existing workflow, never a second one.
609 Err(EngineError::Store(StoreError::SequenceConflict { .. })) => {
610 Ok(HatchOutcome::Existing(hatch_id))
611 }
612 Err(error) => Err(error),
613 }
614 }
615
616 fn registry_handle(
617 &self,
618 loop_id: &WorkflowId,
619 ) -> Result<Option<crate::registry::WorkflowHandle>, EngineError> {
620 // aion#213: `with_loop_recorder` APPENDS DURABLY through whatever this
621 // returns — see the twin in `workloop/close.rs`.
622 self.registry.sole_handle(loop_id)
623 }
624
625 /// Append through the loop's ONE Recorder: the live handle's recorder
626 /// when registered, a one-shot `Recorder::resume_at` when suspended (the
627 /// sanctioned non-resident pattern). The closure receives the history
628 /// read under the same acquisition, so check-then-append is not
629 /// interleaved.
630 async fn with_loop_recorder<T>(
631 &self,
632 loop_id: &WorkflowId,
633 record: impl for<'a> FnOnce(
634 &'a mut Recorder,
635 &'a [Event],
636 ) -> std::pin::Pin<
637 Box<dyn Future<Output = Result<T, crate::durability::DurabilityError>> + Send + 'a>,
638 >,
639 ) -> Result<T, EngineError> {
640 if let Some(handle) = self.registry_handle(loop_id)? {
641 let recorder = handle.recorder();
642 let mut recorder = recorder.lock().await;
643 let history = self.store.read_history(loop_id).await?;
644 let value = record(&mut recorder, &history).await?;
645 return Ok(value);
646 }
647 let history = self.store.read_history(loop_id).await?;
648 let head = history.iter().map(Event::seq).max().unwrap_or_default();
649 let mut recorder = Recorder::resume_at(loop_id.clone(), self.store(), head);
650 if let Some(run_id) = active_run_id(&history) {
651 recorder = recorder.with_visibility(run_id, self.visibility_store());
652 }
653 let value = record(&mut recorder, &history).await?;
654 Ok(value)
655 }
656}
657
658/// Whether a retirement invokes the loop's declared retire body.
659///
660/// Not a boolean: `retire(loop, reason, result, true)` at a call site says
661/// nothing about what `true` means, and this decision is load-bearing enough
662/// that a reader must not have to look it up.
663#[derive(Clone, Copy, Debug, PartialEq, Eq)]
664enum RetireBody {
665 /// The document declares a `retire` block; run it, and refuse if the
666 /// deployed module exports no entry for it.
667 Invoke,
668 /// The document declares no `retire` block.
669 None,
670}
671
672/// The caller's attributes plus the workloop kind stamp, refusing a caller
673/// that tried to set the stamp itself.
674///
675/// The refusal is not fussiness. `aion.kind` is what boot recovery reads to
676/// decide whether a Running workflow with no process is a crash to resurrect
677/// or a park to leave alone, so a caller that could write it could make an
678/// ordinary workflow claim to be a loop — or, worse, silently disagree with
679/// the registration this very call is about to write.
680fn with_workloop_kind(
681 mut attributes: HashMap<String, SearchAttributeValue>,
682) -> Result<HashMap<String, SearchAttributeValue>, EngineError> {
683 if let Some(existing) = attributes.get(WORKFLOW_KIND_ATTRIBUTE) {
684 return Err(EngineError::InvalidState {
685 reason: format!(
686 "`{WORKFLOW_KIND_ATTRIBUTE}` is stamped by the engine and cannot be supplied by \
687 a caller; it was given as {existing:?}. It is the attribute boot recovery reads \
688 to tell a parked workloop from a crashed workflow, and a caller-set value could \
689 disagree with the registration this start writes"
690 ),
691 });
692 }
693 attributes.insert(
694 String::from(WORKFLOW_KIND_ATTRIBUTE),
695 SearchAttributeValue::String(String::from(WORKLOOP_KIND)),
696 );
697 Ok(attributes)
698}
699
700fn active_run_id(history: &[Event]) -> Option<RunId> {
701 history.iter().rev().find_map(|event| match event {
702 Event::WorkflowStarted { run_id, .. } => Some(run_id.clone()),
703 _ => None,
704 })
705}