mako_engine/process.rs
1//! [`Process`] — ergonomic typed handle for a single MaKo process instance.
2//!
3//! Instead of threading `stream_id`, `workflow_id`, `tenant_id`, and a store
4//! reference through every call to the write path, bind them once into a
5//! `Process<W, S>` and call [`execute`] / [`state`] directly.
6//!
7//! # Starting a new process
8//!
9//! ```rust,ignore
10//! use mako_engine::{
11//! event_store::InMemoryEventStore,
12//! ids::TenantId,
13//! process::Process,
14//! version::WorkflowId,
15//! };
16//!
17//! let store = InMemoryEventStore::new();
18//! let process = Process::<MyWorkflow, _>::new(
19//! store,
20//! TenantId::new(),
21//! WorkflowId::new("my-workflow", "FV2024-10-01"),
22//! );
23//!
24//! let envelopes = process.execute(my_command).await?;
25//! let current = process.state().await?;
26//! ```
27//!
28//! # Resuming an existing process
29//!
30//! ```rust,ignore
31//! let process = Process::<MyWorkflow, _>::from_stream(
32//! store, stream_id, process_id, tenant_id, workflow_id,
33//! );
34//! ```
35//!
36//! [`execute`]: Process::execute
37//! [`state`]: Process::state
38
39use std::marker::PhantomData;
40
41use crate::{
42 envelope::EventEnvelope,
43 error::EngineError,
44 event_store::EventStore,
45 ids::{ProcessId, ProcessIdentity, StreamId, TenantId},
46 snapshot::{Snapshot, SnapshotStore},
47 version::WorkflowId,
48 workflow::{
49 CommandContext, Workflow, execute_command, execute_command_and_collect,
50 execute_command_with_snapshot,
51 },
52};
53
54// ── Process ───────────────────────────────────────────────────────────────────
55
56/// An ergonomic typed handle for a single MaKo process instance.
57///
58/// `Process` bundles the [`StreamId`], [`ProcessId`], [`TenantId`],
59/// [`WorkflowId`], and event store into a single owned value so callers do not
60/// need to pass them on every command dispatch.
61///
62/// ## Generic parameters
63///
64/// - `W` — the [`Workflow`] implementation. In practice this is a zero-size
65/// marker struct; the type parameter carries the domain logic as associated
66/// types.
67/// - `S` — the [`EventStore`] backend. `InMemoryEventStore` (requires `testing` feature) is the default
68/// for tests; production deployments wrap a persistent backend in
69/// [`Arc`][std::sync::Arc] and use `Process<W, Arc<MyStore>>`.
70///
71/// ## Clone semantics
72///
73/// If `S: Clone` (e.g. `InMemoryEventStore` (requires `testing` feature) or `Arc<…>`), `Process` is also
74/// `Clone` and all clones share the same underlying storage.
75#[expect(clippy::struct_field_names)] // `process_id` and `stream_id` are intentional: they
76// describe engine-layer concepts, not redundant prefixes.
77pub struct Process<W: Workflow, S: EventStore> {
78 stream_id: StreamId,
79 process_id: ProcessId,
80 tenant_id: TenantId,
81 workflow_id: WorkflowId,
82 store: S,
83 _phantom: PhantomData<fn() -> W>,
84}
85
86impl<W: Workflow, S: EventStore> Process<W, S> {
87 /// Create a fresh process instance.
88 ///
89 /// Generates a new [`ProcessId`] and derives the [`StreamId`] from
90 /// `tenant_id` and `process_id` (`process/{tenant_id}/{process_id}`).
91 /// Use this when starting a new MaKo process
92 /// (e.g. on receipt of the first inbound UTILMD Lieferbeginn).
93 #[must_use]
94 pub fn new(store: S, tenant_id: TenantId, workflow_id: WorkflowId) -> Self {
95 let process_id = ProcessId::new();
96 let stream_id = StreamId::for_process(tenant_id, &process_id);
97 Self {
98 stream_id,
99 process_id,
100 tenant_id,
101 workflow_id,
102 store,
103 _phantom: PhantomData,
104 }
105 }
106
107 /// Attach to an existing process stream.
108 ///
109 /// Use this on service restart or when routing an inbound message to an
110 /// already-running process whose identifiers were previously persisted.
111 #[must_use]
112 pub fn from_stream(
113 store: S,
114 stream_id: StreamId,
115 process_id: ProcessId,
116 tenant_id: TenantId,
117 workflow_id: WorkflowId,
118 ) -> Self {
119 Self {
120 stream_id,
121 process_id,
122 tenant_id,
123 workflow_id,
124 store,
125 _phantom: PhantomData,
126 }
127 }
128
129 /// The event stream identifier for this process.
130 #[must_use]
131 pub fn stream_id(&self) -> &StreamId {
132 &self.stream_id
133 }
134
135 /// The stable process identifier.
136 #[must_use]
137 pub fn process_id(&self) -> ProcessId {
138 self.process_id
139 }
140
141 /// The tenant that owns this process.
142 #[must_use]
143 pub fn tenant_id(&self) -> TenantId {
144 self.tenant_id
145 }
146
147 /// The workflow version under which this process was created.
148 #[must_use]
149 pub fn workflow_id(&self) -> &WorkflowId {
150 &self.workflow_id
151 }
152
153 /// Return a serializable value bundle of all four process identifiers.
154 ///
155 /// Persist this to a routing table (e.g. keyed by `conversation_id` or
156 /// `correlation_id`) so inbound messages can be routed to the correct
157 /// running process without the caller needing to manage four separate
158 /// fields.
159 ///
160 /// Use [`Process::from_identity`] to re-attach to the same process stream
161 /// on a subsequent request.
162 ///
163 /// ```rust,ignore
164 /// let id = process.identity();
165 /// routing_table.insert(conv_id, id.clone());
166 ///
167 /// // Later, on a subsequent inbound message:
168 /// let id = routing_table.get(&conv_id)?;
169 /// let process = Process::<MyWorkflow, _>::from_identity(store, id);
170 /// ```
171 #[must_use]
172 pub fn identity(&self) -> ProcessIdentity {
173 ProcessIdentity::new(self.process_id, self.tenant_id, self.workflow_id.clone())
174 }
175
176 /// Build a [`CommandContext`] for an inbound EDIFACT message dispatch.
177 ///
178 /// Derives a **deterministic** [`CorrelationId`] from `interchange_ref`
179 /// (UUID v5) so repeated dispatches of the same EDIFACT message — e.g.
180 /// AS4 retransmissions or idempotent REST replays — produce the same
181 /// correlation root. This makes EDIFACT-level idempotency observable in
182 /// distributed traces without any extra dedup logic at the engine level.
183 ///
184 /// Use this instead of [`Process::execute`] when you need to propagate
185 /// EDIFACT correlation metadata into the event stream. The returned
186 /// context is passed to [`Process::execute_with`].
187 ///
188 /// # Example
189 ///
190 /// ```rust,ignore
191 /// let process = ctx.resume::<GpkeSupplierChangeWorkflow>(identity);
192 /// let cmd_ctx = process.context_for_inbound(&utilmd_interchange_ref);
193 /// process.execute_with(command, cmd_ctx).await?;
194 /// ```
195 ///
196 /// [`CorrelationId`]: crate::ids::CorrelationId
197 #[must_use]
198 pub fn context_for_inbound(&self, interchange_ref: &str) -> CommandContext {
199 CommandContext::new(self.tenant_id, self.process_id, self.workflow_id.clone())
200 .with_correlation(crate::ids::CorrelationId::from_interchange_ref(
201 interchange_ref,
202 ))
203 }
204
205 /// Attach to an existing process stream from a previously persisted
206 /// [`ProcessIdentity`].
207 ///
208 /// This is the companion to [`Process::identity`]: look up the identity
209 /// from your routing table and call `from_identity` to get a live
210 /// `Process` handle bound to `store`.
211 #[must_use]
212 pub fn from_identity(store: S, identity: ProcessIdentity) -> Self {
213 Self {
214 stream_id: identity.stream_id().clone(),
215 process_id: identity.process_id,
216 tenant_id: identity.tenant_id,
217 workflow_id: identity.workflow_id,
218 store,
219 _phantom: PhantomData,
220 }
221 }
222
223 /// Return the number of events currently in the stream.
224 ///
225 /// Uses [`EventStore::stream_version`] for an efficient O(1) metadata
226 /// query on backends that override it. Falls back to loading all events
227 /// on stores that use the default implementation.
228 ///
229 /// Use this to decide whether to take a snapshot — e.g. with
230 /// [`Snapshot::should_take`]:
231 ///
232 /// ```rust,ignore
233 /// if Snapshot::should_take(process.event_count().await?, 100) {
234 /// process.take_snapshot(&snap_store, 100).await?;
235 /// }
236 /// ```
237 ///
238 /// # Errors
239 ///
240 /// Returns [`EngineError::Store`] on storage failures.
241 ///
242 /// [`Snapshot::should_take`]: crate::snapshot::Snapshot::should_take
243 pub async fn event_count(&self) -> Result<u64, EngineError> {
244 self.store.stream_version(&self.stream_id).await
245 }
246
247 /// Dispatch `command` using a freshly generated [`CommandContext`].
248 ///
249 /// A new [`CorrelationId`] and [`ConversationId`] are auto-generated for
250 /// each call. To propagate tracing IDs from an inbound EDIFACT message
251 /// across a multi-step command chain, use [`execute_with`].
252 ///
253 /// # Errors
254 ///
255 /// - [`EngineError::VersionConflict`] when a concurrent writer raced ahead;
256 /// retry by calling `execute` again.
257 /// - [`EngineError::Workflow`] when the workflow rejects the command.
258 /// - [`EngineError::Deserialization`] when a stored event cannot be decoded.
259 ///
260 /// [`CorrelationId`]: crate::ids::CorrelationId
261 /// [`ConversationId`]: crate::ids::ConversationId
262 /// [`execute_with`]: Process::execute_with
263 #[cfg_attr(
264 feature = "tracing",
265 tracing::instrument(skip(self, command), fields(
266 workflow = %self.workflow_id,
267 process_id = %self.process_id,
268 stream_id = %self.stream_id,
269 ))
270 )]
271 pub async fn execute(&self, command: W::Command) -> Result<Vec<EventEnvelope>, EngineError> {
272 let ctx = CommandContext::new(self.tenant_id, self.process_id, self.workflow_id.clone());
273 execute_command::<W, S>(&self.store, &self.stream_id, command, &ctx).await
274 }
275
276 /// Like [`execute`] but also returns the outbox messages produced by
277 /// [`Workflow::handle`], fully stamped with the real IDs from the persisted
278 /// event.
279 ///
280 /// The returned [`OutboxMessage`] entries have their `causation_event_id`
281 /// set to the `event_id` of the first persisted event — identical to what
282 /// `execute_and_enqueue` writes into the [`OutboxStore`] atomically. This
283 /// makes the messages ready to pass directly to the EDIFACT renderer
284 /// without any manual ID stitching.
285 ///
286 /// Use this in E2E and integration tests that need to inspect or render
287 /// outbox messages after a command is persisted, without the awkward
288 /// `handle()` + `execute()` double invocation.
289 ///
290 /// [`execute`]: Process::execute
291 /// [`OutboxMessage`]: crate::outbox::OutboxMessage
292 /// [`OutboxStore`]: crate::outbox::OutboxStore
293 ///
294 /// # Errors
295 ///
296 /// Returns [`EngineError`] on storage or command handling failure.
297 pub async fn execute_and_collect(
298 &self,
299 command: W::Command,
300 ) -> Result<(Vec<EventEnvelope>, Vec<crate::outbox::OutboxMessage>), EngineError> {
301 let ctx = CommandContext::new(self.tenant_id, self.process_id, self.workflow_id.clone());
302 let (events, pending) =
303 execute_command_and_collect::<W, S>(&self.store, &self.stream_id, command, &ctx)
304 .await?;
305
306 // Stamp each PendingOutbox with the real IDs from the persisted event.
307 // Using the first event's event_id as causation_event_id mirrors what
308 // execute_and_enqueue writes into the OutboxStore atomically.
309 let causation_event_id = events
310 .first()
311 .map_or_else(crate::ids::EventId::new, |e| e.event_id);
312
313 let outbox = pending
314 .into_iter()
315 .map(|p| {
316 crate::outbox::OutboxMessage::new(
317 self.stream_id.clone(),
318 self.process_id,
319 self.tenant_id,
320 ctx.correlation_id,
321 ctx.conversation_id,
322 causation_event_id,
323 p.message_type,
324 p.recipient,
325 p.payload,
326 )
327 })
328 .collect();
329
330 Ok((events, outbox))
331 }
332
333 /// Dispatch `command` with a caller-supplied [`CommandContext`].
334 ///
335 /// Use this when you need to thread a specific `correlation_id`,
336 /// `conversation_id`, or `causation_id` through the command. For example,
337 /// when dispatching an APERAK in response to a UTILMD, pass the
338 /// `conversation_id` from the UTILMD envelope so both exchanges are
339 /// traceable as a single business conversation.
340 ///
341 /// Build a context with:
342 ///
343 /// ```rust,ignore
344 /// let ctx = CommandContext::new(tenant_id, process_id, workflow_id)
345 /// .with_causation(utilmd_event_id.into()) // From<EventId> for CausationId
346 /// .with_conversation(utilmd_conversation_id);
347 /// process.execute_with(DispatchAperak { .. }, ctx).await?;
348 /// ```
349 ///
350 /// # Errors
351 ///
352 /// See [`Process::execute`] for the error contract.
353 ///
354 /// [`Process::execute`]: Process::execute
355 #[cfg_attr(
356 feature = "tracing",
357 tracing::instrument(skip(self, command, ctx), fields(
358 workflow = %self.workflow_id,
359 process_id = %self.process_id,
360 correlation_id = %ctx.correlation_id,
361 ))
362 )]
363 pub async fn execute_with(
364 &self,
365 command: W::Command,
366 ctx: CommandContext,
367 ) -> Result<Vec<EventEnvelope>, EngineError> {
368 execute_command::<W, S>(&self.store, &self.stream_id, command, &ctx).await
369 }
370
371 /// Dispatch `command` using a snapshot store to accelerate state reconstruction.
372 ///
373 /// Equivalent to [`Process::execute`] but starts replay from the most recent
374 /// snapshot rather than from sequence 0. For streams with thousands of events
375 /// and a snapshot within the last 100 events, this reduces replay cost from
376 /// O(n) to O(k) where k is the tail length since the last snapshot.
377 ///
378 /// When no snapshot exists or the schema version has changed, falls back to
379 /// full O(n) replay — identical in cost to [`Process::execute`].
380 ///
381 /// # Errors
382 ///
383 /// Same contract as [`Process::execute`].
384 pub async fn execute_snapshot<Snap>(
385 &self,
386 command: W::Command,
387 snap_store: &Snap,
388 ) -> Result<Vec<EventEnvelope>, EngineError>
389 where
390 W::State: serde::de::DeserializeOwned,
391 Snap: SnapshotStore,
392 {
393 let ctx = CommandContext::new(self.tenant_id, self.process_id, self.workflow_id.clone());
394 execute_command_with_snapshot::<W, S, Snap>(
395 &self.store,
396 snap_store,
397 &self.stream_id,
398 command,
399 &ctx,
400 )
401 .await
402 }
403
404 /// Reconstruct the current workflow state by replaying all persisted events.
405 ///
406 /// This is a **read-only** operation — it loads events but does not
407 /// acquire any write lock or check optimistic concurrency. Use it to:
408 ///
409 /// - Inspect process status in tests without dispatching a command.
410 /// - Build a diagnostic snapshot for observability or health checks.
411 /// - Implement query-side read models that need the full typed state.
412 ///
413 /// For production read models, prefer a [`Projection`] that is updated
414 /// incrementally rather than replaying the full stream on every query.
415 ///
416 /// To accelerate replay for long-lived streams, use
417 /// [`Process::state_with_snapshot`] instead.
418 ///
419 /// # Errors
420 ///
421 /// - [`EngineError::Store`] on storage failures.
422 /// - [`EngineError::Deserialization`] when a stored event cannot be decoded
423 /// into `W::Event` (schema migration required).
424 ///
425 /// [`Projection`]: crate::projection::Projection
426 #[cfg_attr(
427 feature = "tracing",
428 tracing::instrument(skip(self), fields(
429 workflow = %self.workflow_id,
430 stream_id = %self.stream_id,
431 ))
432 )]
433 pub async fn state(&self) -> Result<W::State, EngineError> {
434 self.store
435 .fold_stream(&self.stream_id, 0, W::State::default(), |acc, env| {
436 let payload = W::upcast(&env.event_type, env.schema_version, env.payload)?;
437 let event: W::Event = serde_json::from_value(payload)
438 .map_err(|e| EngineError::Deserialization(e.to_string()))?;
439 Ok(W::apply(acc, &event))
440 })
441 .await
442 }
443
444 // ── Snapshot-aware state reconstruction ──────────────────────────────────
445
446 /// Reconstruct current state using a snapshot as the starting point.
447 ///
448 /// Loads the most recent snapshot for this stream from `snap_store`. If
449 /// one exists, deserializes it into `W::State` and then replays only
450 /// events appended **after** the snapshot's `sequence_number`
451 /// (O(k) instead of O(n)). Falls back to full replay when no snapshot
452 /// exists.
453 ///
454 /// ## When to use
455 ///
456 /// Use this instead of [`Process::state`] for long-lived processes where
457 /// the event count grows large. Pair it with [`Process::take_snapshot`]
458 /// to keep the snapshot store current after each command.
459 ///
460 /// ## Schema version compatibility
461 ///
462 /// Snapshots whose `state` field cannot be deserialized into `W::State`
463 /// (e.g. after a breaking state schema change) will return
464 /// [`EngineError::Deserialization`]. In that case, fall back to
465 /// [`Process::state`] (full replay) and take a fresh snapshot.
466 ///
467 /// # Errors
468 ///
469 /// - [`EngineError::Store`] on snapshot or event storage failures.
470 /// - [`EngineError::Deserialization`] when the snapshot state or a tail
471 /// event cannot be decoded.
472 #[cfg_attr(
473 feature = "tracing",
474 tracing::instrument(skip(self, snap_store), fields(
475 workflow = %self.workflow_id,
476 stream_id = %self.stream_id,
477 ))
478 )]
479 pub async fn state_with_snapshot<Snap: SnapshotStore>(
480 &self,
481 snap_store: &Snap,
482 ) -> Result<W::State, EngineError>
483 where
484 W::State: serde::de::DeserializeOwned,
485 {
486 let maybe_snap = snap_store.load(&self.stream_id).await?;
487
488 let (initial_state, from_sequence) = match maybe_snap {
489 Some(snap) => {
490 if snap.state_schema_version == W::state_schema_version() {
491 let state = serde_json::from_value::<W::State>(snap.state)
492 .map_err(|e| EngineError::Deserialization(e.to_string()))?;
493 (state, snap.sequence_number)
494 } else {
495 // Schema version mismatch: discard the stale snapshot and
496 // fall back to full replay. The caller should take a fresh
497 // snapshot after this reconstruction completes.
498 tracing::warn!(
499 expected = W::state_schema_version(),
500 actual = snap.state_schema_version,
501 stream_id = %self.stream_id,
502 "snapshot schema version mismatch; falling back to full replay"
503 );
504 (W::State::default(), 0)
505 }
506 }
507 None => (W::State::default(), 0),
508 };
509
510 let tail = self
511 .store
512 .fold_stream(&self.stream_id, from_sequence, initial_state, |acc, env| {
513 let payload = W::upcast(&env.event_type, env.schema_version, env.payload)?;
514 let event: W::Event = serde_json::from_value(payload)
515 .map_err(|e| EngineError::Deserialization(e.to_string()))?;
516 Ok(W::apply(acc, &event))
517 })
518 .await?;
519 Ok(tail)
520 }
521
522 /// Reconstruct current state and save a snapshot if the event-count
523 /// threshold is reached.
524 ///
525 /// Checks [`Snapshot::should_take`] with `interval`. When at least
526 /// `interval` new events have accumulated since the last snapshot,
527 /// reconstructs state via full replay, serializes it, and calls
528 /// [`SnapshotStore::save`].
529 ///
530 /// Returns `true` when a snapshot was taken, `false` when the threshold
531 /// was not reached or `interval` is `0`.
532 ///
533 /// ## Integration pattern
534 ///
535 /// ```rust,ignore
536 /// // After every successful command:
537 /// process.execute(command).await?;
538 /// process.take_snapshot(&snap_store, 100).await?;
539 ///
540 /// // On the read path — O(k) instead of O(n):
541 /// let state = process.state_with_snapshot(&snap_store).await?;
542 /// ```
543 ///
544 /// # Errors
545 ///
546 /// - [`EngineError::Store`] on snapshot storage failures.
547 /// - [`EngineError::Serialization`] when the state cannot be JSON-encoded.
548 /// - [`EngineError::Deserialization`] when a stored event cannot be decoded.
549 ///
550 /// [`Snapshot::should_take`]: crate::snapshot::Snapshot::should_take
551 pub async fn take_snapshot<Snap: SnapshotStore>(
552 &self,
553 snap_store: &Snap,
554 interval: u64,
555 ) -> Result<bool, EngineError>
556 where
557 W::State: serde::Serialize,
558 {
559 let count = self.event_count().await?;
560 // Load the last snapshot (if any) to get its sequence number.
561 let last_snap_seq = snap_store
562 .load(&self.stream_id)
563 .await?
564 .map_or(0, |s| s.sequence_number);
565 if !Snapshot::should_take(count, last_snap_seq, interval) {
566 return Ok(false);
567 }
568 let state = self.state().await?;
569 let payload =
570 serde_json::to_value(&state).map_err(|e| EngineError::Serialization(e.to_string()))?;
571 let snap = Snapshot::new(
572 self.stream_id.clone(),
573 count,
574 W::state_schema_version(),
575 payload,
576 );
577 snap_store.save(&snap).await?;
578 Ok(true)
579 }
580
581 // ── Retry ─────────────────────────────────────────────────────────────────
582
583 /// Dispatch `command` with automatic retry on [`EngineError::VersionConflict`].
584 ///
585 /// A version conflict occurs when a concurrent writer appended events
586 /// between this process's read and its append attempt. On each conflict,
587 /// the engine **reloads the complete event stream from the store and
588 /// replays all events** to rebuild fresh state before re-handling the
589 /// command. Stale in-memory state from a previous attempt is never
590 /// carried forward — each retry always starts from a fully-rebuilt snapshot.
591 ///
592 /// Non-conflict errors (storage failures, workflow rejections) are
593 /// returned immediately without retrying.
594 ///
595 /// A freshly-generated [`CommandContext`] is pinned before the first
596 /// attempt and reused across all retries so all events share the same
597 /// correlation root regardless of retry count. Use
598 /// [`execute_with_retry_ctx`] to supply a specific context (e.g. one
599 /// derived from an inbound EDIFACT envelope).
600 ///
601 /// ## When to use
602 ///
603 /// Use for commands where two inbound EDIFACT messages for the same
604 /// process may arrive concurrently — e.g. a UTILMD and its APERAK
605 /// processed on separate async tasks.
606 ///
607 /// ## Command cloning
608 ///
609 /// `W::Command` must implement [`Clone`] so it can be resubmitted on
610 /// each retry without reconstructing it from scratch.
611 ///
612 /// # Errors
613 ///
614 /// - [`EngineError::VersionConflict`] when all `max_attempts` are
615 /// exhausted without a successful append.
616 /// - Any non-conflict [`EngineError`] returned by the workflow or storage.
617 /// - [`EngineError::Store`] when `max_attempts` is `0`.
618 ///
619 /// [`execute_with_retry_ctx`]: Process::execute_with_retry_ctx
620 pub async fn execute_with_retry(
621 &self,
622 command: W::Command,
623 max_attempts: u32,
624 ) -> Result<Vec<EventEnvelope>, EngineError>
625 where
626 W::Command: Clone,
627 {
628 if max_attempts == 0 {
629 return Err(EngineError::store("max_attempts must be >= 1"));
630 }
631 // Pin context before the loop — all retry attempts share the same
632 // correlation root for consistent distributed tracing.
633 let ctx = CommandContext::new(self.tenant_id, self.process_id, self.workflow_id.clone());
634 self.execute_with_retry_ctx(command, ctx, max_attempts)
635 .await
636 }
637
638 /// Dispatch `command` with a caller-supplied [`CommandContext`] and
639 /// automatic retry on [`EngineError::VersionConflict`].
640 ///
641 /// Identical to [`execute_with_retry`] but threads the provided `ctx`
642 /// (including its `correlation_id`, `conversation_id`, and `causation_id`)
643 /// through every retry attempt. Use this when you need to propagate
644 /// tracing IDs from an inbound EDIFACT envelope across a retried command.
645 ///
646 /// # Example
647 ///
648 /// ```rust,ignore
649 /// let ctx = CommandContext::from_envelope(&utilmd_envelope, workflow_id);
650 /// process.execute_with_retry_ctx(HandleAperak { .. }, ctx, 3).await?;
651 /// ```
652 ///
653 /// # Errors
654 ///
655 /// See [`execute_with_retry`] for the error contract.
656 ///
657 /// # Panics
658 ///
659 /// Panics if `max_attempts` is 0 and the guard at the top of the function
660 /// is somehow bypassed (unreachable in practice).
661 ///
662 /// [`execute_with_retry`]: Process::execute_with_retry
663 pub async fn execute_with_retry_ctx(
664 &self,
665 command: W::Command,
666 ctx: CommandContext,
667 max_attempts: u32,
668 ) -> Result<Vec<EventEnvelope>, EngineError>
669 where
670 W::Command: Clone,
671 {
672 if max_attempts == 0 {
673 return Err(EngineError::store("max_attempts must be >= 1"));
674 }
675 let mut conflict_err: Option<EngineError> = None;
676 for attempt in 0..max_attempts {
677 // Each call to `execute_with` internally calls `fold_stream` from
678 // sequence 0 (or from the most recent snapshot if one is available).
679 // State is always freshly reconstructed from the event log on every
680 // attempt — there is no stale state carried forward between retries.
681 // Do NOT "optimise" this by caching state across attempts; doing so
682 // would allow a winning concurrent writer's events to be invisible
683 // to the retry, producing incorrect decisions and duplicate events.
684 match self.execute_with(command.clone(), ctx.clone()).await {
685 Ok(envs) => return Ok(envs),
686 Err(e) if e.is_version_conflict() => {
687 conflict_err = Some(e);
688 // Brief jittered sleep to reduce thundering-herd under
689 // concurrent ERP commands targeting the same stream.
690 // Delay = uniform random in [0, 10ms * attempt], capped at 80ms.
691 // Uses the OS CSPRNG via rand so every retry gets independent
692 // entropy regardless of stream-ID prefix.
693 if attempt + 1 < max_attempts {
694 let entropy: u64 = rand::random();
695 let window_ms: u64 = (10 * (u64::from(attempt) + 1)).min(80);
696 let jitter_ms = if window_ms == 0 {
697 0
698 } else {
699 entropy % window_ms
700 };
701 tokio::time::sleep(std::time::Duration::from_millis(jitter_ms)).await;
702 }
703 }
704 Err(e) => return Err(e), // non-retriable — propagate immediately
705 }
706 }
707 // At least one attempt ran (max_attempts >= 1), so conflict_err is Some.
708 Err(conflict_err.expect("loop ran at least once"))
709 }
710
711 /// Execute `command` and atomically co-persist any [`PendingOutbox`] messages
712 /// produced by [`Workflow::handle`].
713 ///
714 /// Like [`execute`], but requires `S: AtomicAppend`. When the workflow's
715 /// `handle` returns outbox messages alongside events, both are written to
716 /// storage in a single `WriteBatch`, eliminating the silent message-loss
717 /// window that would exist with separate writes.
718 ///
719 /// When the handle returns no outbox messages, this degenerates to a plain
720 /// `EventStore::append` (no performance cost).
721 ///
722 /// **Use this method instead of [`execute`] in all production code** that
723 /// needs outbox delivery guarantees. Plain `execute` silently drops any
724 /// outbox entries produced by the workflow handler — a crash between
725 /// `execute` and a subsequent manual `OutboxStore::enqueue` call would
726 /// lose the APERAK or UTILMD response permanently.
727 ///
728 /// For long event streams with periodic snapshots use
729 /// [`execute_and_enqueue_snapshot`] to reduce O(n) replay cost to O(k).
730 /// In concurrent environments where `VersionConflict` is expected, use
731 /// [`execute_and_enqueue_with_retry`] to retry automatically.
732 ///
733 /// # Example
734 ///
735 /// ```rust,ignore
736 /// use std::sync::Arc;
737 /// use mako_engine::process::Process;
738 /// use mako_engine::version::WorkflowId;
739 /// use mako_engine::ids::TenantId;
740 ///
741 /// // SlateDbStore implements AtomicAppend — required for execute_and_enqueue.
742 /// let store = Arc::new(SlateDbStore::open_in_memory().await?);
743 /// let tenant_id = TenantId::from_party_id("9904231000007");
744 /// let workflow_id = WorkflowId::new("gpke-supplier-change", fv);
745 ///
746 /// let process = Process::<GpkeSupplierChangeWorkflow, _>::new(
747 /// Arc::clone(&store),
748 /// tenant_id,
749 /// workflow_id,
750 /// );
751 ///
752 /// // The workflow handle emits a PendingOutbox APERAK entry alongside the event.
753 /// // execute_and_enqueue writes both in one WriteBatch — no partial-write window.
754 /// let events = process
755 /// .execute_and_enqueue(GpkeCommand::ReceiveUtilmd { pid: 55001, payload })
756 /// .await?;
757 ///
758 /// assert!(!events.is_empty(), "at least one event was persisted");
759 ///
760 /// // The APERAK outbox entry is now visible to the outbox worker:
761 /// let pending = store.peek_outbox(tenant_id, 10).await?;
762 /// assert_eq!(pending.len(), 1, "APERAK enqueued atomically with the event");
763 /// ```
764 ///
765 /// # Errors
766 ///
767 /// - [`EngineError::VersionConflict`] — stream was modified concurrently;
768 /// retry with [`execute_and_enqueue_with_retry`].
769 /// - [`EngineError::Workflow`] — the command was rejected by the workflow.
770 /// - [`EngineError::Store`] / [`EngineError::Outbox`] — storage failure.
771 ///
772 /// [`PendingOutbox`]: crate::outbox::PendingOutbox
773 /// [`execute`]: Process::execute
774 /// [`execute_and_enqueue_snapshot`]: Process::execute_and_enqueue_snapshot
775 /// [`execute_and_enqueue_with_retry`]: Process::execute_and_enqueue_with_retry
776 pub async fn execute_and_enqueue(
777 &self,
778 command: W::Command,
779 ) -> Result<Vec<EventEnvelope>, EngineError>
780 where
781 S: crate::event_store::AtomicAppend,
782 {
783 let ctx = CommandContext::new(self.tenant_id, self.process_id, self.workflow_id.clone());
784 crate::workflow::execute_command_atomic::<W, S>(&self.store, &self.stream_id, command, &ctx)
785 .await
786 }
787
788 /// Like [`execute_and_enqueue`] but co-persists `deadlines` in the same
789 /// atomic write as events and outbox entries.
790 ///
791 /// On `SlateDbStore` (requires `slatedb` feature) this writes events, outbox entries, **and** deadlines
792 /// in a single SSI transaction. On in-memory test stores the default
793 /// fallback is used: events and outbox are written atomically, deadlines are
794 /// **not** persisted here and must be registered separately.
795 ///
796 /// Use this method for commands that must register a regulatory deadline
797 /// (GPKE 24h APERAK, WiM 5 WT, GeLi Gas / WiM Gas 10 WT, MABIS 1 WT).
798 ///
799 /// [`execute_and_enqueue`]: Process::execute_and_enqueue
800 ///
801 /// # Errors
802 ///
803 /// Returns [`EngineError`] on storage or command handling failure.
804 pub async fn execute_and_enqueue_with_deadlines(
805 &self,
806 command: W::Command,
807 deadlines: &[crate::deadline::Deadline],
808 ) -> Result<Vec<EventEnvelope>, EngineError>
809 where
810 S: crate::event_store::AtomicAppend,
811 {
812 let ctx = CommandContext::new(self.tenant_id, self.process_id, self.workflow_id.clone());
813 crate::workflow::execute_command_atomic_with_deadlines::<W, S>(
814 &self.store,
815 &self.stream_id,
816 command,
817 &ctx,
818 deadlines,
819 )
820 .await
821 }
822
823 /// Like [`execute_and_enqueue`] but uses a snapshot to accelerate replay.
824 ///
825 /// Atomically persists events and outbox entries while starting state
826 /// reconstruction from the most recent snapshot. For long streams with
827 /// periodic snapshots this reduces replay cost from O(n) to O(k).
828 ///
829 /// [`execute_and_enqueue`]: Process::execute_and_enqueue
830 ///
831 /// # Errors
832 ///
833 /// Returns [`EngineError`] on storage or command handling failure.
834 pub async fn execute_and_enqueue_snapshot<Snap>(
835 &self,
836 command: W::Command,
837 snap_store: &Snap,
838 ) -> Result<Vec<EventEnvelope>, EngineError>
839 where
840 W::State: serde::de::DeserializeOwned,
841 S: crate::event_store::AtomicAppend,
842 Snap: SnapshotStore,
843 {
844 let ctx = CommandContext::new(self.tenant_id, self.process_id, self.workflow_id.clone());
845 crate::workflow::execute_command_atomic_with_snapshot::<W, S, Snap>(
846 &self.store,
847 snap_store,
848 &self.stream_id,
849 command,
850 &ctx,
851 )
852 .await
853 }
854
855 /// Dispatch the compensation command returned by [`Workflow::on_deadline`].
856 ///
857 /// Reconstructs the current process state, calls
858 /// `W::on_deadline(deadline, &state)`, and — if the hook returns
859 /// `Some(command)` — executes it via [`Process::execute_and_enqueue`],
860 /// which atomically persists events **and** any outbox entries (e.g.
861 /// APERAK Ablehnung) produced by the compensation handler.
862 ///
863 /// Returns `Ok(Some(events))` when compensation fired, `Ok(None)` when
864 /// the hook returned `None` (deadline acknowledged as no-op).
865 ///
866 /// This is the canonical way to wire deadline firings to workflow
867 /// compensation logic. Any [`WorkflowOutput::with_outbox`] entries
868 /// returned by `on_deadline` are guaranteed to be persisted atomically —
869 /// there is no window where the event is stored but the outbox entry is
870 /// lost.
871 ///
872 /// # Example
873 ///
874 /// ```rust,ignore
875 /// // In the deadline worker:
876 /// let overdue = ctx.deadline_store().due_now(50).await?;
877 /// for deadline in overdue {
878 /// let identity = ctx.registry()
879 /// .lookup(deadline.tenant_id(), &RegistryKey::from_process(deadline.process_id()))
880 /// .await?
881 /// .expect("process must be registered");
882 /// let process = ctx.resume::<GpkeSupplierChangeWorkflow>(identity);
883 /// if let Some(events) = process.execute_timeout(&deadline).await? {
884 /// // compensation command was dispatched — APERAK Ablehnung enqueued
885 /// tracing::info!(events = events.len(), "timeout compensation applied");
886 /// }
887 /// ctx.deadline_store().cancel(deadline.deadline_id()).await?;
888 /// }
889 /// ```
890 ///
891 /// # Errors
892 ///
893 /// Propagates [`EngineError::VersionConflict`], [`EngineError::Workflow`],
894 /// and storage errors from `execute_and_enqueue`. Use
895 /// [`execute_timeout_with_retry`] when `VersionConflict` retries are
896 /// required.
897 ///
898 /// [`Workflow::on_deadline`]: crate::workflow::Workflow::on_deadline
899 /// [`WorkflowOutput::with_outbox`]: crate::workflow::WorkflowOutput::with_outbox
900 /// [`execute_timeout_with_retry`]: Process::execute_timeout_with_retry
901 pub async fn execute_timeout(
902 &self,
903 deadline: &crate::deadline::Deadline,
904 ) -> Result<Option<Vec<EventEnvelope>>, EngineError>
905 where
906 S: crate::event_store::AtomicAppend,
907 {
908 let state = self.state().await?;
909 match W::on_deadline(deadline, &state) {
910 None => Ok(None),
911 Some(command) => self.execute_and_enqueue(command).await.map(Some),
912 }
913 }
914
915 /// Like [`execute_timeout`] but retries on [`VersionConflict`] up to
916 /// `max_attempts` times.
917 ///
918 /// Use this in production deadline workers where concurrent event appends
919 /// are expected. Outbox entries (e.g. APERAK Ablehnung) produced by the
920 /// compensation handler are persisted atomically on every attempt.
921 ///
922 /// [`execute_timeout`]: Process::execute_timeout
923 /// [`VersionConflict`]: crate::error::EngineError::VersionConflict
924 ///
925 /// # Errors
926 ///
927 /// Returns [`EngineError`] on storage or command handling failure.
928 ///
929 /// # Panics
930 ///
931 /// Panics if the deadline produces a command but the retry loop somehow
932 /// exhausts without capturing an error (unreachable in practice).
933 pub async fn execute_timeout_with_retry(
934 &self,
935 deadline: &crate::deadline::Deadline,
936 max_attempts: u32,
937 ) -> Result<Option<Vec<EventEnvelope>>, EngineError>
938 where
939 S: crate::event_store::AtomicAppend,
940 W::Command: Clone,
941 {
942 let state = self.state().await?;
943 match W::on_deadline(deadline, &state) {
944 None => Ok(None),
945 Some(command) => self
946 .execute_and_enqueue_with_retry(command, max_attempts)
947 .await
948 .map(Some),
949 }
950 }
951
952 /// Like [`execute_and_enqueue`] but retries on [`crate::error::EngineError::VersionConflict`] up to
953 /// `max_attempts` times.
954 ///
955 /// [`execute_and_enqueue`]: Process::execute_and_enqueue
956 ///
957 /// # Errors
958 ///
959 /// Returns [`EngineError`] on storage or command handling failure.
960 ///
961 /// # Panics
962 ///
963 /// Panics if `max_attempts` is 0 and the guard is bypassed (unreachable).
964 pub async fn execute_and_enqueue_with_retry(
965 &self,
966 command: W::Command,
967 max_attempts: u32,
968 ) -> Result<Vec<EventEnvelope>, EngineError>
969 where
970 S: crate::event_store::AtomicAppend,
971 W::Command: Clone,
972 {
973 if max_attempts == 0 {
974 return Err(EngineError::store("max_attempts must be >= 1"));
975 }
976 let ctx = CommandContext::new(self.tenant_id, self.process_id, self.workflow_id.clone());
977 let mut conflict_err: Option<EngineError> = None;
978 for _ in 0..max_attempts {
979 match crate::workflow::execute_command_atomic::<W, S>(
980 &self.store,
981 &self.stream_id,
982 command.clone(),
983 &ctx,
984 )
985 .await
986 {
987 Ok(envs) => return Ok(envs),
988 Err(e) if e.is_version_conflict() => conflict_err = Some(e),
989 Err(e) => return Err(e),
990 }
991 }
992 Err(conflict_err.expect("loop ran at least once"))
993 }
994
995 /// Execute `command` atomically with outbox, then automatically snapshot
996 /// if the event-count threshold is reached.
997 ///
998 /// Combines [`execute_and_enqueue`] with [`take_snapshot`]: after a
999 /// successful write, checks whether `event_count % snapshot_interval == 0`
1000 /// and, if so, serialises and saves a snapshot via `snap_store`.
1001 ///
1002 /// Pass `snapshot_interval = 0` to disable auto-snapshotting; the call
1003 /// then behaves identically to [`execute_and_enqueue`].
1004 ///
1005 /// Returns `(events, snapshot_taken)` where `snapshot_taken` is `true` when
1006 /// a snapshot was written this call.
1007 ///
1008 /// # Errors
1009 ///
1010 /// - [`EngineError::VersionConflict`] — stream was modified concurrently;
1011 /// retry with [`execute_and_enqueue_with_retry`].
1012 /// - [`EngineError::Workflow`] — the command was rejected by the workflow.
1013 /// - [`EngineError::Store`] / [`EngineError::Outbox`] — storage failure.
1014 /// - [`EngineError::Serialization`] — state serialisation failed during snapshot.
1015 ///
1016 /// [`execute_and_enqueue`]: Process::execute_and_enqueue
1017 /// [`take_snapshot`]: Process::take_snapshot
1018 /// [`execute_and_enqueue_with_retry`]: Process::execute_and_enqueue_with_retry
1019 pub async fn execute_and_enqueue_with_snapshot<Snap>(
1020 &self,
1021 command: W::Command,
1022 snap_store: &Snap,
1023 snapshot_interval: u64,
1024 ) -> Result<(Vec<EventEnvelope>, bool), EngineError>
1025 where
1026 S: crate::event_store::AtomicAppend,
1027 Snap: crate::snapshot::SnapshotStore,
1028 W::State: serde::Serialize,
1029 {
1030 let events = self.execute_and_enqueue(command).await?;
1031 let snapped = if snapshot_interval > 0 {
1032 self.take_snapshot(snap_store, snapshot_interval).await?
1033 } else {
1034 false
1035 };
1036 Ok((events, snapped))
1037 }
1038
1039 /// Like [`execute_and_enqueue_with_snapshot`] but retries on
1040 /// [`crate::error::EngineError::VersionConflict`] up to `max_attempts` times.
1041 ///
1042 /// [`execute_and_enqueue_with_retry`]: Process::execute_and_enqueue_with_retry
1043 ///
1044 /// # Errors
1045 ///
1046 /// - [`EngineError::VersionConflict`] — stream was modified concurrently;
1047 /// retry with [`execute_and_enqueue_with_snapshot_and_retry`].
1048 /// - [`EngineError::Workflow`] — the command was rejected by the workflow.
1049 /// - [`EngineError::Store`] / [`EngineError::Outbox`] — storage failure.
1050 /// - [`EngineError::Serialization`] — state serialisation failed during snapshot.
1051 ///
1052 /// # Panics
1053 ///
1054 /// Panics if `max_attempts` is 0 and the loop guard is bypassed (unreachable).
1055 ///
1056 /// [`execute_and_enqueue_with_snapshot`]: Process::execute_and_enqueue_with_snapshot
1057 /// [`execute_and_enqueue_with_snapshot_and_retry`]: Process::execute_and_enqueue_with_snapshot_and_retry
1058 pub async fn execute_and_enqueue_with_snapshot_and_retry<Snap>(
1059 &self,
1060 command: W::Command,
1061 max_attempts: u32,
1062 snap_store: &Snap,
1063 snapshot_interval: u64,
1064 ) -> Result<(Vec<EventEnvelope>, bool), EngineError>
1065 where
1066 S: crate::event_store::AtomicAppend,
1067 W::Command: Clone,
1068 Snap: crate::snapshot::SnapshotStore,
1069 W::State: serde::Serialize,
1070 {
1071 let events = self
1072 .execute_and_enqueue_with_retry(command, max_attempts)
1073 .await?;
1074 let snapped = if snapshot_interval > 0 {
1075 self.take_snapshot(snap_store, snapshot_interval).await?
1076 } else {
1077 false
1078 };
1079 Ok((events, snapped))
1080 }
1081}
1082
1083impl<W: Workflow, S: EventStore + Clone> Clone for Process<W, S> {
1084 fn clone(&self) -> Self {
1085 Self {
1086 stream_id: self.stream_id.clone(),
1087 process_id: self.process_id,
1088 tenant_id: self.tenant_id,
1089 workflow_id: self.workflow_id.clone(),
1090 store: self.store.clone(),
1091 _phantom: PhantomData,
1092 }
1093 }
1094}
1095
1096impl<W: Workflow, S: EventStore + std::fmt::Debug> std::fmt::Debug for Process<W, S> {
1097 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1098 f.debug_struct("Process")
1099 .field("stream_id", &self.stream_id)
1100 .field("process_id", &self.process_id)
1101 .field("workflow_id", &self.workflow_id)
1102 .finish_non_exhaustive()
1103 }
1104}
1105
1106// ── Unit tests ────────────────────────────────────────────────────────────────
1107
1108#[cfg(test)]
1109mod tests {
1110 use super::*;
1111 use crate::{
1112 envelope::NewEvent,
1113 error::WorkflowError,
1114 event_store::{EventStore, ExpectedVersion, InMemoryEventStore},
1115 ids::{ConversationId, CorrelationId, TenantId},
1116 snapshot::{InMemorySnapshotStore, NoopSnapshotStore},
1117 version::WorkflowId,
1118 workflow::{CommandPayload, EventPayload},
1119 };
1120
1121 // ── Minimal test workflow ─────────────────────────────────────────────────
1122
1123 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
1124 enum CounterEvent {
1125 Incremented { by: u32 },
1126 Reset,
1127 }
1128
1129 impl EventPayload for CounterEvent {
1130 fn event_type(&self) -> &'static str {
1131 match self {
1132 Self::Incremented { .. } => "Incremented",
1133 Self::Reset => "Reset",
1134 }
1135 }
1136 }
1137
1138 #[derive(Debug, Clone)]
1139 enum CounterCommand {
1140 Increment { by: u32 },
1141 Reset,
1142 }
1143
1144 impl CommandPayload for CounterCommand {}
1145
1146 #[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
1147 struct CounterState {
1148 value: u32,
1149 }
1150
1151 struct CounterWorkflow;
1152
1153 impl Workflow for CounterWorkflow {
1154 type State = CounterState;
1155 type Event = CounterEvent;
1156 type Command = CounterCommand;
1157
1158 fn apply(mut state: CounterState, event: &CounterEvent) -> CounterState {
1159 match event {
1160 CounterEvent::Incremented { by } => state.value += by,
1161 CounterEvent::Reset => state.value = 0,
1162 }
1163 state
1164 }
1165
1166 fn handle(
1167 _state: &CounterState,
1168 command: CounterCommand,
1169 ) -> Result<crate::workflow::WorkflowOutput<CounterEvent>, WorkflowError> {
1170 Ok(match command {
1171 CounterCommand::Increment { by } => vec![CounterEvent::Incremented { by }].into(),
1172 CounterCommand::Reset => vec![CounterEvent::Reset].into(),
1173 })
1174 }
1175 }
1176
1177 fn make_process() -> Process<CounterWorkflow, InMemoryEventStore> {
1178 Process::new(
1179 InMemoryEventStore::new(),
1180 TenantId::new(),
1181 WorkflowId::new("counter", "FV2024-10-01"),
1182 )
1183 }
1184
1185 // ── execute + state round-trip ────────────────────────────────────────────
1186
1187 #[tokio::test]
1188 async fn execute_then_state_round_trip() {
1189 let p = make_process();
1190
1191 p.execute(CounterCommand::Increment { by: 3 })
1192 .await
1193 .unwrap();
1194 p.execute(CounterCommand::Increment { by: 7 })
1195 .await
1196 .unwrap();
1197
1198 let state = p.state().await.unwrap();
1199 assert_eq!(state.value, 10);
1200 }
1201
1202 #[tokio::test]
1203 async fn event_count_matches_dispatched_commands() {
1204 let p = make_process();
1205
1206 assert_eq!(p.event_count().await.unwrap(), 0);
1207 p.execute(CounterCommand::Increment { by: 1 })
1208 .await
1209 .unwrap();
1210 assert_eq!(p.event_count().await.unwrap(), 1);
1211 p.execute(CounterCommand::Reset).await.unwrap();
1212 assert_eq!(p.event_count().await.unwrap(), 2);
1213 }
1214
1215 // ── identity round-trip ───────────────────────────────────────────────────
1216
1217 #[tokio::test]
1218 async fn identity_round_trip_via_from_identity() {
1219 let store = InMemoryEventStore::new();
1220 let p1 = Process::<CounterWorkflow, _>::new(
1221 store.clone(),
1222 TenantId::new(),
1223 WorkflowId::new("counter", "FV2024-10-01"),
1224 );
1225
1226 p1.execute(CounterCommand::Increment { by: 5 })
1227 .await
1228 .unwrap();
1229
1230 let identity = p1.identity();
1231 assert_eq!(*identity.stream_id(), *p1.stream_id());
1232 assert_eq!(identity.process_id, p1.process_id());
1233
1234 // Re-attach from identity and confirm state is visible.
1235 let p2 = Process::<CounterWorkflow, _>::from_identity(store, identity);
1236 let state = p2.state().await.unwrap();
1237 assert_eq!(state.value, 5);
1238 }
1239
1240 #[test]
1241 fn process_identity_is_serializable() {
1242 let p = make_process();
1243 let id = p.identity();
1244 let json = serde_json::to_string(&id).expect("ProcessIdentity must be serializable");
1245 let back: ProcessIdentity = serde_json::from_str(&json).unwrap();
1246 assert_eq!(*back.stream_id(), *id.stream_id());
1247 assert_eq!(back.process_id, id.process_id);
1248 }
1249
1250 // ── snapshot-accelerated state reconstruction ─────────────────────────────
1251
1252 #[tokio::test]
1253 async fn take_snapshot_and_state_with_snapshot() {
1254 let snap_store = InMemorySnapshotStore::new();
1255 let p = make_process();
1256
1257 // Dispatch 4 commands; the interval is 4.
1258 for i in 1u32..=4 {
1259 p.execute(CounterCommand::Increment { by: i })
1260 .await
1261 .unwrap();
1262 }
1263
1264 let took = p.take_snapshot(&snap_store, 4).await.unwrap();
1265 assert!(took, "snapshot must be taken at event_count = 4");
1266
1267 // Dispatch one more command after the snapshot.
1268 p.execute(CounterCommand::Increment { by: 10 })
1269 .await
1270 .unwrap();
1271
1272 let state = p.state_with_snapshot(&snap_store).await.unwrap();
1273 // 1+2+3+4 = 10, plus the final +10 = 20.
1274 assert_eq!(state.value, 20);
1275 }
1276
1277 #[tokio::test]
1278 async fn state_with_snapshot_falls_back_to_full_replay() {
1279 let p = make_process();
1280 p.execute(CounterCommand::Increment { by: 42 })
1281 .await
1282 .unwrap();
1283
1284 // NoopSnapshotStore always returns None → full replay.
1285 let state = p.state_with_snapshot(&NoopSnapshotStore).await.unwrap();
1286 assert_eq!(state.value, 42);
1287 }
1288
1289 #[tokio::test]
1290 async fn take_snapshot_skipped_between_intervals() {
1291 let snap_store = InMemorySnapshotStore::new();
1292 let p = make_process();
1293
1294 p.execute(CounterCommand::Increment { by: 1 })
1295 .await
1296 .unwrap();
1297 p.execute(CounterCommand::Increment { by: 1 })
1298 .await
1299 .unwrap();
1300 p.execute(CounterCommand::Increment { by: 1 })
1301 .await
1302 .unwrap();
1303
1304 // 3 events, interval = 4 → must not take.
1305 let took = p.take_snapshot(&snap_store, 4).await.unwrap();
1306 assert!(!took);
1307 assert!(snap_store.is_empty().await);
1308 }
1309
1310 /// Regression test for when a persisted snapshot carries a
1311 /// `state_schema_version` that does not match the current workflow's
1312 /// `state_schema_version()`, `state_with_snapshot` must silently discard
1313 /// the stale snapshot and fall back to full event replay.
1314 ///
1315 /// This guards against silent data corruption when state layout changes
1316 /// incompatibly — e.g. after adding a new required field to `CounterState`.
1317 #[tokio::test]
1318 async fn stale_snapshot_schema_version_falls_back_to_full_replay() {
1319 // CounterWorkflow uses state_schema_version() == 1 (the default).
1320 // We simulate a "migrated" workflow by injecting a snapshot whose
1321 // state_schema_version is bumped to 99, representing a schema that the
1322 // current workflow code does not understand.
1323 let snap_store = InMemorySnapshotStore::new();
1324 let p = make_process();
1325
1326 // Dispatch some events so there is something to replay.
1327 p.execute(CounterCommand::Increment { by: 5 })
1328 .await
1329 .unwrap();
1330 p.execute(CounterCommand::Increment { by: 3 })
1331 .await
1332 .unwrap();
1333
1334 // Manually save a stale snapshot with schema_version = 99.
1335 // The state payload is intentionally wrong — it should never be used.
1336 let stale = crate::snapshot::Snapshot::new(
1337 p.stream_id().clone(),
1338 2, // sequence_number after 2 events
1339 99, // ← unknown schema version
1340 serde_json::json!({ "value": 9999 }), // ← wrong value; must not be read
1341 );
1342 snap_store.save(&stale).await.unwrap();
1343
1344 // state_with_snapshot must discard the stale snapshot and replay all
1345 // events from sequence 0, producing the correct state (5+3=8).
1346 let current_state = p.state_with_snapshot(&snap_store).await.unwrap();
1347 assert_eq!(
1348 current_state.value, 8,
1349 "stale snapshot must be discarded; full replay must yield correct state"
1350 );
1351 }
1352
1353 // ── execute_with_retry ────────────────────────────────────────────────────
1354
1355 #[tokio::test]
1356 async fn execute_with_retry_succeeds_on_first_attempt() {
1357 let p = make_process();
1358 let envs = p
1359 .execute_with_retry(CounterCommand::Increment { by: 99 }, 3)
1360 .await
1361 .unwrap();
1362 assert_eq!(envs.len(), 1);
1363 assert_eq!(p.state().await.unwrap().value, 99);
1364 }
1365
1366 #[tokio::test]
1367 async fn execute_with_retry_returns_err_on_zero_attempts() {
1368 let p = make_process();
1369 let err = p
1370 .execute_with_retry(CounterCommand::Increment { by: 1 }, 0)
1371 .await
1372 .unwrap_err();
1373 assert!(
1374 matches!(err, EngineError::Store { ref message, .. } if message.contains("max_attempts")),
1375 "expected Store error about max_attempts, got: {err:?}",
1376 );
1377 }
1378
1379 // ── execute_with (explicit context) ──────────────────────────────────────
1380
1381 #[tokio::test]
1382 async fn execute_with_explicit_context_propagates_ids() {
1383 use crate::ids::{ConversationId, CorrelationId};
1384 let p = make_process();
1385
1386 let corr = CorrelationId::new();
1387 let conv = ConversationId::new();
1388 let ctx = CommandContext::new(p.tenant_id(), p.process_id(), p.workflow_id().clone())
1389 .with_correlation(corr)
1390 .with_conversation(conv);
1391
1392 let envs = p
1393 .execute_with(CounterCommand::Increment { by: 1 }, ctx)
1394 .await
1395 .unwrap();
1396 assert_eq!(envs.len(), 1);
1397 assert_eq!(envs[0].correlation_id, corr);
1398 assert_eq!(envs[0].conversation_id, conv);
1399 }
1400
1401 // ── upcast / schema-migration ─────────────────────────────────────────────
1402 //
1403 // A v2 workflow adds a `label: String` field to its single event.
1404 // Old (v1) events stored without `label` must be migrated by `upcast`.
1405 //
1406 // `#[serde(untagged)]` is used so the serialized payload is the flat
1407 // inner struct `{"count": N, "label": "..."}` rather than the externally-
1408 // tagged `{"Tagged": {"count": N}}` form. This matches the common
1409 // real-world pattern where each `EventPayload::event_type()` discriminant
1410 // IS the variant selector stored in the envelope, and the payload holds
1411 // only the fields.
1412
1413 #[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
1414 struct TagState {
1415 total: u32,
1416 last_label: String,
1417 }
1418
1419 /// v1 schema (legacy): `{ "count": u32 }` — `label` field absent.
1420 /// v2 schema: `{ "count": u32, "label": String }`.
1421 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
1422 #[serde(untagged)]
1423 enum TagEvent {
1424 Tagged { count: u32, label: String },
1425 }
1426
1427 impl EventPayload for TagEvent {
1428 fn event_type(&self) -> &'static str {
1429 "Tagged"
1430 }
1431 fn schema_version(&self) -> u32 {
1432 2
1433 }
1434 }
1435
1436 #[derive(Debug, Clone)]
1437 struct TagCommand {
1438 count: u32,
1439 label: String,
1440 }
1441 impl CommandPayload for TagCommand {}
1442
1443 struct TagWorkflow;
1444
1445 impl Workflow for TagWorkflow {
1446 type State = TagState;
1447 type Event = TagEvent;
1448 type Command = TagCommand;
1449
1450 fn apply(mut state: TagState, event: &TagEvent) -> TagState {
1451 let TagEvent::Tagged { count, label } = event;
1452 state.total += count;
1453 state.last_label = label.clone();
1454 state
1455 }
1456
1457 fn handle(
1458 _state: &TagState,
1459 cmd: TagCommand,
1460 ) -> Result<crate::workflow::WorkflowOutput<TagEvent>, WorkflowError> {
1461 Ok(vec![TagEvent::Tagged {
1462 count: cmd.count,
1463 label: cmd.label,
1464 }]
1465 .into())
1466 }
1467
1468 /// Migrate v1 `Tagged` events (missing `label`) to v2.
1469 ///
1470 /// v1 payload: `{"count": N}` (no `label` field)
1471 /// v2 payload: `{"count": N, "label": ""}` (default empty string)
1472 ///
1473 /// Because the event uses `#[serde(untagged)]`, the envelope payload
1474 /// is the flat struct — variant discrimination comes from `event_type`.
1475 fn upcast(
1476 event_type: &str,
1477 from_version: u32,
1478 mut payload: serde_json::Value,
1479 ) -> Result<serde_json::Value, EngineError> {
1480 if event_type == "Tagged"
1481 && from_version == 1
1482 && let Some(obj) = payload.as_object_mut()
1483 {
1484 obj.entry("label")
1485 .or_insert_with(|| serde_json::Value::String(String::new()));
1486 }
1487 Ok(payload)
1488 }
1489 }
1490
1491 /// Inject a raw v1 event (no `label` field) directly into the store and
1492 /// confirm that `state()` replays it correctly via `upcast`.
1493 #[tokio::test]
1494 async fn upcast_v1_event_adds_default_label() {
1495 let store = InMemoryEventStore::new();
1496 let p = Process::<TagWorkflow, _>::new(
1497 store.clone(), // shares the underlying Arc<RwLock<_>>
1498 TenantId::new(),
1499 WorkflowId::new("tag", "FV2025-10-01"),
1500 );
1501
1502 // v1 payload: flat struct fields, no `label` (untagged serde repr).
1503 let v1_payload = serde_json::json!({ "count": 7 });
1504 let raw = NewEvent {
1505 correlation_id: CorrelationId::new(),
1506 causation_id: None,
1507 conversation_id: ConversationId::new(),
1508 process_id: p.process_id(),
1509 tenant_id: p.tenant_id(),
1510 workflow_id: p.workflow_id().clone(),
1511 event_type: "Tagged".into(),
1512 schema_version: 1, // ← schema_version 1 (old format)
1513 payload: v1_payload,
1514 };
1515 store
1516 .append(p.stream_id(), ExpectedVersion::Any, &[raw])
1517 .await
1518 .expect("inject v1 event");
1519
1520 // Replay via the v2 workflow — `upcast` must fill in `label: ""`.
1521 let state = p.state().await.expect("state must replay without error");
1522 assert_eq!(state.total, 7, "count must be accumulated");
1523 assert_eq!(
1524 state.last_label, "",
1525 "missing v1 label must default to empty string"
1526 );
1527
1528 // Also verify that a normally-executed v2 event round-trips correctly.
1529 p.execute(TagCommand {
1530 count: 3,
1531 label: "hello".into(),
1532 })
1533 .await
1534 .unwrap();
1535 let state2 = p.state().await.unwrap();
1536 assert_eq!(state2.total, 10);
1537 assert_eq!(state2.last_label, "hello");
1538 }
1539}