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 /// (the per-PID business Antwortfrist from `mako_fristen::antwort`).
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 self.execute_and_enqueue_with_deadlines_and_correlations(command, deadlines, &[])
813 .await
814 }
815
816 /// Like [`execute_and_enqueue_with_deadlines`] but also writes
817 /// correlation-index entries in the same atomic batch.
818 ///
819 /// This is the spawn path. A process is not usable when its events are
820 /// durable — it is usable when its business key resolves to it. Until then
821 /// the counterparty's reply finds no process and is skipped, and the next
822 /// thing to happen is the process's own Frist expiring as a false timeout.
823 /// Writing the key separately left exactly that window open.
824 ///
825 /// # Errors
826 ///
827 /// Returns [`EngineError`] on storage or command handling failure. A
828 /// malformed business key is rejected before anything is written.
829 ///
830 /// [`execute_and_enqueue_with_deadlines`]: Process::execute_and_enqueue_with_deadlines
831 pub async fn execute_and_enqueue_with_deadlines_and_correlations(
832 &self,
833 command: W::Command,
834 deadlines: &[crate::deadline::Deadline],
835 correlations: &[crate::event_store::CorrelationEntry],
836 ) -> Result<Vec<EventEnvelope>, EngineError>
837 where
838 S: crate::event_store::AtomicAppend,
839 {
840 let ctx = CommandContext::new(self.tenant_id, self.process_id, self.workflow_id.clone());
841 crate::workflow::execute_command_atomic_with_deadlines::<W, S>(
842 &self.store,
843 &self.stream_id,
844 command,
845 &ctx,
846 deadlines,
847 correlations,
848 )
849 .await
850 }
851
852 /// Like [`execute_and_enqueue`] but uses a snapshot to accelerate replay.
853 ///
854 /// Atomically persists events and outbox entries while starting state
855 /// reconstruction from the most recent snapshot. For long streams with
856 /// periodic snapshots this reduces replay cost from O(n) to O(k).
857 ///
858 /// [`execute_and_enqueue`]: Process::execute_and_enqueue
859 ///
860 /// # Errors
861 ///
862 /// Returns [`EngineError`] on storage or command handling failure.
863 pub async fn execute_and_enqueue_snapshot<Snap>(
864 &self,
865 command: W::Command,
866 snap_store: &Snap,
867 ) -> Result<Vec<EventEnvelope>, EngineError>
868 where
869 W::State: serde::de::DeserializeOwned,
870 S: crate::event_store::AtomicAppend,
871 Snap: SnapshotStore,
872 {
873 let ctx = CommandContext::new(self.tenant_id, self.process_id, self.workflow_id.clone());
874 crate::workflow::execute_command_atomic_with_snapshot::<W, S, Snap>(
875 &self.store,
876 snap_store,
877 &self.stream_id,
878 command,
879 &ctx,
880 )
881 .await
882 }
883
884 /// Dispatch the compensation command returned by [`Workflow::on_deadline`].
885 ///
886 /// Reconstructs the current process state, calls
887 /// `W::on_deadline(deadline, &state)`, and — if the hook returns
888 /// `Some(command)` — executes it via [`Process::execute_and_enqueue`],
889 /// which atomically persists events **and** any outbox entries (e.g.
890 /// APERAK Ablehnung) produced by the compensation handler.
891 ///
892 /// Returns `Ok(Some(events))` when compensation fired, `Ok(None)` when
893 /// the hook returned `None` (deadline acknowledged as no-op).
894 ///
895 /// This is the canonical way to wire deadline firings to workflow
896 /// compensation logic. Any [`WorkflowOutput::with_outbox`] entries
897 /// returned by `on_deadline` are guaranteed to be persisted atomically —
898 /// there is no window where the event is stored but the outbox entry is
899 /// lost.
900 ///
901 /// # Example
902 ///
903 /// ```rust,ignore
904 /// // In the deadline worker:
905 /// let overdue = ctx.deadline_store().due_now(50).await?;
906 /// for deadline in overdue {
907 /// let identity = ctx.registry()
908 /// .lookup(deadline.tenant_id(), &RegistryKey::from_process(deadline.process_id()))
909 /// .await?
910 /// .expect("process must be registered");
911 /// let process = ctx.resume::<GpkeSupplierChangeWorkflow>(identity);
912 /// if let Some(events) = process.execute_timeout(&deadline).await? {
913 /// // compensation command was dispatched — APERAK Ablehnung enqueued
914 /// tracing::info!(events = events.len(), "timeout compensation applied");
915 /// }
916 /// ctx.deadline_store().cancel(deadline.deadline_id()).await?;
917 /// }
918 /// ```
919 ///
920 /// # Errors
921 ///
922 /// Propagates [`EngineError::VersionConflict`], [`EngineError::Workflow`],
923 /// and storage errors from `execute_and_enqueue`. Use
924 /// [`execute_timeout_with_retry`] when `VersionConflict` retries are
925 /// required.
926 ///
927 /// [`Workflow::on_deadline`]: crate::workflow::Workflow::on_deadline
928 /// [`WorkflowOutput::with_outbox`]: crate::workflow::WorkflowOutput::with_outbox
929 /// [`execute_timeout_with_retry`]: Process::execute_timeout_with_retry
930 pub async fn execute_timeout(
931 &self,
932 deadline: &crate::deadline::Deadline,
933 ) -> Result<Option<Vec<EventEnvelope>>, EngineError>
934 where
935 S: crate::event_store::AtomicAppend,
936 {
937 let state = self.state().await?;
938 match W::on_deadline(deadline, &state) {
939 None => Ok(None),
940 Some(command) => self.execute_and_enqueue(command).await.map(Some),
941 }
942 }
943
944 /// Like [`execute_timeout`] but retries on [`VersionConflict`] up to
945 /// `max_attempts` times.
946 ///
947 /// Use this in production deadline workers where concurrent event appends
948 /// are expected. Outbox entries (e.g. APERAK Ablehnung) produced by the
949 /// compensation handler are persisted atomically on every attempt.
950 ///
951 /// [`execute_timeout`]: Process::execute_timeout
952 /// [`VersionConflict`]: crate::error::EngineError::VersionConflict
953 ///
954 /// # Errors
955 ///
956 /// Returns [`EngineError`] on storage or command handling failure.
957 ///
958 /// # Panics
959 ///
960 /// Panics if the deadline produces a command but the retry loop somehow
961 /// exhausts without capturing an error (unreachable in practice).
962 pub async fn execute_timeout_with_retry(
963 &self,
964 deadline: &crate::deadline::Deadline,
965 max_attempts: u32,
966 ) -> Result<Option<Vec<EventEnvelope>>, EngineError>
967 where
968 S: crate::event_store::AtomicAppend,
969 W::Command: Clone,
970 {
971 let state = self.state().await?;
972 match W::on_deadline(deadline, &state) {
973 None => Ok(None),
974 Some(command) => self
975 .execute_and_enqueue_with_retry(command, max_attempts)
976 .await
977 .map(Some),
978 }
979 }
980
981 /// Like [`execute_and_enqueue`] but retries on [`crate::error::EngineError::VersionConflict`] up to
982 /// `max_attempts` times.
983 ///
984 /// [`execute_and_enqueue`]: Process::execute_and_enqueue
985 ///
986 /// # Errors
987 ///
988 /// Returns [`EngineError`] on storage or command handling failure.
989 ///
990 /// # Panics
991 ///
992 /// Panics if `max_attempts` is 0 and the guard is bypassed (unreachable).
993 pub async fn execute_and_enqueue_with_retry(
994 &self,
995 command: W::Command,
996 max_attempts: u32,
997 ) -> Result<Vec<EventEnvelope>, EngineError>
998 where
999 S: crate::event_store::AtomicAppend,
1000 W::Command: Clone,
1001 {
1002 if max_attempts == 0 {
1003 return Err(EngineError::store("max_attempts must be >= 1"));
1004 }
1005 let ctx = CommandContext::new(self.tenant_id, self.process_id, self.workflow_id.clone());
1006 let mut conflict_err: Option<EngineError> = None;
1007 for _ in 0..max_attempts {
1008 match crate::workflow::execute_command_atomic::<W, S>(
1009 &self.store,
1010 &self.stream_id,
1011 command.clone(),
1012 &ctx,
1013 )
1014 .await
1015 {
1016 Ok(envs) => return Ok(envs),
1017 Err(e) if e.is_version_conflict() => conflict_err = Some(e),
1018 Err(e) => return Err(e),
1019 }
1020 }
1021 Err(conflict_err.expect("loop ran at least once"))
1022 }
1023
1024 /// Execute `command` atomically with outbox, then automatically snapshot
1025 /// if the event-count threshold is reached.
1026 ///
1027 /// Combines [`execute_and_enqueue`] with [`take_snapshot`]: after a
1028 /// successful write, checks whether `event_count % snapshot_interval == 0`
1029 /// and, if so, serialises and saves a snapshot via `snap_store`.
1030 ///
1031 /// Pass `snapshot_interval = 0` to disable auto-snapshotting; the call
1032 /// then behaves identically to [`execute_and_enqueue`].
1033 ///
1034 /// Returns `(events, snapshot_taken)` where `snapshot_taken` is `true` when
1035 /// a snapshot was written this call.
1036 ///
1037 /// # Errors
1038 ///
1039 /// - [`EngineError::VersionConflict`] — stream was modified concurrently;
1040 /// retry with [`execute_and_enqueue_with_retry`].
1041 /// - [`EngineError::Workflow`] — the command was rejected by the workflow.
1042 /// - [`EngineError::Store`] / [`EngineError::Outbox`] — storage failure.
1043 /// - [`EngineError::Serialization`] — state serialisation failed during snapshot.
1044 ///
1045 /// [`execute_and_enqueue`]: Process::execute_and_enqueue
1046 /// [`take_snapshot`]: Process::take_snapshot
1047 /// [`execute_and_enqueue_with_retry`]: Process::execute_and_enqueue_with_retry
1048 pub async fn execute_and_enqueue_with_snapshot<Snap>(
1049 &self,
1050 command: W::Command,
1051 snap_store: &Snap,
1052 snapshot_interval: u64,
1053 ) -> Result<(Vec<EventEnvelope>, bool), EngineError>
1054 where
1055 S: crate::event_store::AtomicAppend,
1056 Snap: crate::snapshot::SnapshotStore,
1057 W::State: serde::Serialize,
1058 {
1059 let events = self.execute_and_enqueue(command).await?;
1060 let snapped = if snapshot_interval > 0 {
1061 self.take_snapshot(snap_store, snapshot_interval).await?
1062 } else {
1063 false
1064 };
1065 Ok((events, snapped))
1066 }
1067
1068 /// Like [`execute_and_enqueue_with_snapshot`] but retries on
1069 /// [`crate::error::EngineError::VersionConflict`] up to `max_attempts` times.
1070 ///
1071 /// [`execute_and_enqueue_with_retry`]: Process::execute_and_enqueue_with_retry
1072 ///
1073 /// # Errors
1074 ///
1075 /// - [`EngineError::VersionConflict`] — stream was modified concurrently;
1076 /// retry with [`execute_and_enqueue_with_snapshot_and_retry`].
1077 /// - [`EngineError::Workflow`] — the command was rejected by the workflow.
1078 /// - [`EngineError::Store`] / [`EngineError::Outbox`] — storage failure.
1079 /// - [`EngineError::Serialization`] — state serialisation failed during snapshot.
1080 ///
1081 /// # Panics
1082 ///
1083 /// Panics if `max_attempts` is 0 and the loop guard is bypassed (unreachable).
1084 ///
1085 /// [`execute_and_enqueue_with_snapshot`]: Process::execute_and_enqueue_with_snapshot
1086 /// [`execute_and_enqueue_with_snapshot_and_retry`]: Process::execute_and_enqueue_with_snapshot_and_retry
1087 pub async fn execute_and_enqueue_with_snapshot_and_retry<Snap>(
1088 &self,
1089 command: W::Command,
1090 max_attempts: u32,
1091 snap_store: &Snap,
1092 snapshot_interval: u64,
1093 ) -> Result<(Vec<EventEnvelope>, bool), EngineError>
1094 where
1095 S: crate::event_store::AtomicAppend,
1096 W::Command: Clone,
1097 Snap: crate::snapshot::SnapshotStore,
1098 W::State: serde::Serialize,
1099 {
1100 let events = self
1101 .execute_and_enqueue_with_retry(command, max_attempts)
1102 .await?;
1103 let snapped = if snapshot_interval > 0 {
1104 self.take_snapshot(snap_store, snapshot_interval).await?
1105 } else {
1106 false
1107 };
1108 Ok((events, snapped))
1109 }
1110}
1111
1112impl<W: Workflow, S: EventStore + Clone> Clone for Process<W, S> {
1113 fn clone(&self) -> Self {
1114 Self {
1115 stream_id: self.stream_id.clone(),
1116 process_id: self.process_id,
1117 tenant_id: self.tenant_id,
1118 workflow_id: self.workflow_id.clone(),
1119 store: self.store.clone(),
1120 _phantom: PhantomData,
1121 }
1122 }
1123}
1124
1125impl<W: Workflow, S: EventStore + std::fmt::Debug> std::fmt::Debug for Process<W, S> {
1126 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1127 f.debug_struct("Process")
1128 .field("stream_id", &self.stream_id)
1129 .field("process_id", &self.process_id)
1130 .field("workflow_id", &self.workflow_id)
1131 .finish_non_exhaustive()
1132 }
1133}
1134
1135// ── Unit tests ────────────────────────────────────────────────────────────────
1136
1137#[cfg(test)]
1138mod tests {
1139 use super::*;
1140 use crate::{
1141 envelope::NewEvent,
1142 error::WorkflowError,
1143 event_store::{EventStore, ExpectedVersion, InMemoryEventStore},
1144 ids::{ConversationId, CorrelationId, TenantId},
1145 snapshot::{InMemorySnapshotStore, NoopSnapshotStore},
1146 version::WorkflowId,
1147 workflow::{CommandPayload, EventPayload},
1148 };
1149
1150 // ── Minimal test workflow ─────────────────────────────────────────────────
1151
1152 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
1153 enum CounterEvent {
1154 Incremented { by: u32 },
1155 Reset,
1156 }
1157
1158 impl EventPayload for CounterEvent {
1159 fn event_type(&self) -> &'static str {
1160 match self {
1161 Self::Incremented { .. } => "Incremented",
1162 Self::Reset => "Reset",
1163 }
1164 }
1165 }
1166
1167 #[derive(Debug, Clone)]
1168 enum CounterCommand {
1169 Increment { by: u32 },
1170 Reset,
1171 }
1172
1173 impl CommandPayload for CounterCommand {}
1174
1175 #[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
1176 struct CounterState {
1177 value: u32,
1178 }
1179
1180 struct CounterWorkflow;
1181
1182 impl Workflow for CounterWorkflow {
1183 type State = CounterState;
1184 type Event = CounterEvent;
1185 type Command = CounterCommand;
1186
1187 fn apply(mut state: CounterState, event: &CounterEvent) -> CounterState {
1188 match event {
1189 CounterEvent::Incremented { by } => state.value += by,
1190 CounterEvent::Reset => state.value = 0,
1191 }
1192 state
1193 }
1194
1195 fn handle(
1196 _state: &CounterState,
1197 command: CounterCommand,
1198 ) -> Result<crate::workflow::WorkflowOutput<CounterEvent>, WorkflowError> {
1199 Ok(match command {
1200 CounterCommand::Increment { by } => vec![CounterEvent::Incremented { by }].into(),
1201 CounterCommand::Reset => vec![CounterEvent::Reset].into(),
1202 })
1203 }
1204 }
1205
1206 fn make_process() -> Process<CounterWorkflow, InMemoryEventStore> {
1207 Process::new(
1208 InMemoryEventStore::new(),
1209 TenantId::new(),
1210 WorkflowId::new("counter", "FV2024-10-01"),
1211 )
1212 }
1213
1214 // ── execute + state round-trip ────────────────────────────────────────────
1215
1216 #[tokio::test]
1217 async fn execute_then_state_round_trip() {
1218 let p = make_process();
1219
1220 p.execute(CounterCommand::Increment { by: 3 })
1221 .await
1222 .unwrap();
1223 p.execute(CounterCommand::Increment { by: 7 })
1224 .await
1225 .unwrap();
1226
1227 let state = p.state().await.unwrap();
1228 assert_eq!(state.value, 10);
1229 }
1230
1231 #[tokio::test]
1232 async fn event_count_matches_dispatched_commands() {
1233 let p = make_process();
1234
1235 assert_eq!(p.event_count().await.unwrap(), 0);
1236 p.execute(CounterCommand::Increment { by: 1 })
1237 .await
1238 .unwrap();
1239 assert_eq!(p.event_count().await.unwrap(), 1);
1240 p.execute(CounterCommand::Reset).await.unwrap();
1241 assert_eq!(p.event_count().await.unwrap(), 2);
1242 }
1243
1244 // ── identity round-trip ───────────────────────────────────────────────────
1245
1246 #[tokio::test]
1247 async fn identity_round_trip_via_from_identity() {
1248 let store = InMemoryEventStore::new();
1249 let p1 = Process::<CounterWorkflow, _>::new(
1250 store.clone(),
1251 TenantId::new(),
1252 WorkflowId::new("counter", "FV2024-10-01"),
1253 );
1254
1255 p1.execute(CounterCommand::Increment { by: 5 })
1256 .await
1257 .unwrap();
1258
1259 let identity = p1.identity();
1260 assert_eq!(*identity.stream_id(), *p1.stream_id());
1261 assert_eq!(identity.process_id, p1.process_id());
1262
1263 // Re-attach from identity and confirm state is visible.
1264 let p2 = Process::<CounterWorkflow, _>::from_identity(store, identity);
1265 let state = p2.state().await.unwrap();
1266 assert_eq!(state.value, 5);
1267 }
1268
1269 #[test]
1270 fn process_identity_is_serializable() {
1271 let p = make_process();
1272 let id = p.identity();
1273 let json = serde_json::to_string(&id).expect("ProcessIdentity must be serializable");
1274 let back: ProcessIdentity = serde_json::from_str(&json).unwrap();
1275 assert_eq!(*back.stream_id(), *id.stream_id());
1276 assert_eq!(back.process_id, id.process_id);
1277 }
1278
1279 // ── snapshot-accelerated state reconstruction ─────────────────────────────
1280
1281 #[tokio::test]
1282 async fn take_snapshot_and_state_with_snapshot() {
1283 let snap_store = InMemorySnapshotStore::new();
1284 let p = make_process();
1285
1286 // Dispatch 4 commands; the interval is 4.
1287 for i in 1u32..=4 {
1288 p.execute(CounterCommand::Increment { by: i })
1289 .await
1290 .unwrap();
1291 }
1292
1293 let took = p.take_snapshot(&snap_store, 4).await.unwrap();
1294 assert!(took, "snapshot must be taken at event_count = 4");
1295
1296 // Dispatch one more command after the snapshot.
1297 p.execute(CounterCommand::Increment { by: 10 })
1298 .await
1299 .unwrap();
1300
1301 let state = p.state_with_snapshot(&snap_store).await.unwrap();
1302 // 1+2+3+4 = 10, plus the final +10 = 20.
1303 assert_eq!(state.value, 20);
1304 }
1305
1306 #[tokio::test]
1307 async fn state_with_snapshot_falls_back_to_full_replay() {
1308 let p = make_process();
1309 p.execute(CounterCommand::Increment { by: 42 })
1310 .await
1311 .unwrap();
1312
1313 // NoopSnapshotStore always returns None → full replay.
1314 let state = p.state_with_snapshot(&NoopSnapshotStore).await.unwrap();
1315 assert_eq!(state.value, 42);
1316 }
1317
1318 #[tokio::test]
1319 async fn take_snapshot_skipped_between_intervals() {
1320 let snap_store = InMemorySnapshotStore::new();
1321 let p = make_process();
1322
1323 p.execute(CounterCommand::Increment { by: 1 })
1324 .await
1325 .unwrap();
1326 p.execute(CounterCommand::Increment { by: 1 })
1327 .await
1328 .unwrap();
1329 p.execute(CounterCommand::Increment { by: 1 })
1330 .await
1331 .unwrap();
1332
1333 // 3 events, interval = 4 → must not take.
1334 let took = p.take_snapshot(&snap_store, 4).await.unwrap();
1335 assert!(!took);
1336 assert!(snap_store.is_empty().await);
1337 }
1338
1339 /// Regression test for when a persisted snapshot carries a
1340 /// `state_schema_version` that does not match the current workflow's
1341 /// `state_schema_version()`, `state_with_snapshot` must silently discard
1342 /// the stale snapshot and fall back to full event replay.
1343 ///
1344 /// This guards against silent data corruption when state layout changes
1345 /// incompatibly — e.g. after adding a new required field to `CounterState`.
1346 #[tokio::test]
1347 async fn stale_snapshot_schema_version_falls_back_to_full_replay() {
1348 // CounterWorkflow uses state_schema_version() == 1 (the default).
1349 // We simulate a "migrated" workflow by injecting a snapshot whose
1350 // state_schema_version is bumped to 99, representing a schema that the
1351 // current workflow code does not understand.
1352 let snap_store = InMemorySnapshotStore::new();
1353 let p = make_process();
1354
1355 // Dispatch some events so there is something to replay.
1356 p.execute(CounterCommand::Increment { by: 5 })
1357 .await
1358 .unwrap();
1359 p.execute(CounterCommand::Increment { by: 3 })
1360 .await
1361 .unwrap();
1362
1363 // Manually save a stale snapshot with schema_version = 99.
1364 // The state payload is intentionally wrong — it should never be used.
1365 let stale = crate::snapshot::Snapshot::new(
1366 p.stream_id().clone(),
1367 2, // sequence_number after 2 events
1368 99, // ← unknown schema version
1369 serde_json::json!({ "value": 9999 }), // ← wrong value; must not be read
1370 );
1371 snap_store.save(&stale).await.unwrap();
1372
1373 // state_with_snapshot must discard the stale snapshot and replay all
1374 // events from sequence 0, producing the correct state (5+3=8).
1375 let current_state = p.state_with_snapshot(&snap_store).await.unwrap();
1376 assert_eq!(
1377 current_state.value, 8,
1378 "stale snapshot must be discarded; full replay must yield correct state"
1379 );
1380 }
1381
1382 // ── execute_with_retry ────────────────────────────────────────────────────
1383
1384 #[tokio::test]
1385 async fn execute_with_retry_succeeds_on_first_attempt() {
1386 let p = make_process();
1387 let envs = p
1388 .execute_with_retry(CounterCommand::Increment { by: 99 }, 3)
1389 .await
1390 .unwrap();
1391 assert_eq!(envs.len(), 1);
1392 assert_eq!(p.state().await.unwrap().value, 99);
1393 }
1394
1395 #[tokio::test]
1396 async fn execute_with_retry_returns_err_on_zero_attempts() {
1397 let p = make_process();
1398 let err = p
1399 .execute_with_retry(CounterCommand::Increment { by: 1 }, 0)
1400 .await
1401 .unwrap_err();
1402 assert!(
1403 matches!(err, EngineError::Store { ref message, .. } if message.contains("max_attempts")),
1404 "expected Store error about max_attempts, got: {err:?}",
1405 );
1406 }
1407
1408 // ── execute_with (explicit context) ──────────────────────────────────────
1409
1410 #[tokio::test]
1411 async fn execute_with_explicit_context_propagates_ids() {
1412 use crate::ids::{ConversationId, CorrelationId};
1413 let p = make_process();
1414
1415 let corr = CorrelationId::new();
1416 let conv = ConversationId::new();
1417 let ctx = CommandContext::new(p.tenant_id(), p.process_id(), p.workflow_id().clone())
1418 .with_correlation(corr)
1419 .with_conversation(conv);
1420
1421 let envs = p
1422 .execute_with(CounterCommand::Increment { by: 1 }, ctx)
1423 .await
1424 .unwrap();
1425 assert_eq!(envs.len(), 1);
1426 assert_eq!(envs[0].correlation_id, corr);
1427 assert_eq!(envs[0].conversation_id, conv);
1428 }
1429
1430 // ── upcast / schema-migration ─────────────────────────────────────────────
1431 //
1432 // A v2 workflow adds a `label: String` field to its single event.
1433 // Old (v1) events stored without `label` must be migrated by `upcast`.
1434 //
1435 // `#[serde(untagged)]` is used so the serialized payload is the flat
1436 // inner struct `{"count": N, "label": "..."}` rather than the externally-
1437 // tagged `{"Tagged": {"count": N}}` form. This matches the common
1438 // real-world pattern where each `EventPayload::event_type()` discriminant
1439 // IS the variant selector stored in the envelope, and the payload holds
1440 // only the fields.
1441
1442 #[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
1443 struct TagState {
1444 total: u32,
1445 last_label: String,
1446 }
1447
1448 /// v1 schema (legacy): `{ "count": u32 }` — `label` field absent.
1449 /// v2 schema: `{ "count": u32, "label": String }`.
1450 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
1451 #[serde(untagged)]
1452 enum TagEvent {
1453 Tagged { count: u32, label: String },
1454 }
1455
1456 impl EventPayload for TagEvent {
1457 fn event_type(&self) -> &'static str {
1458 "Tagged"
1459 }
1460 fn schema_version(&self) -> u32 {
1461 2
1462 }
1463 }
1464
1465 #[derive(Debug, Clone)]
1466 struct TagCommand {
1467 count: u32,
1468 label: String,
1469 }
1470 impl CommandPayload for TagCommand {}
1471
1472 struct TagWorkflow;
1473
1474 impl Workflow for TagWorkflow {
1475 type State = TagState;
1476 type Event = TagEvent;
1477 type Command = TagCommand;
1478
1479 fn apply(mut state: TagState, event: &TagEvent) -> TagState {
1480 let TagEvent::Tagged { count, label } = event;
1481 state.total += count;
1482 state.last_label = label.clone();
1483 state
1484 }
1485
1486 fn handle(
1487 _state: &TagState,
1488 cmd: TagCommand,
1489 ) -> Result<crate::workflow::WorkflowOutput<TagEvent>, WorkflowError> {
1490 Ok(vec![TagEvent::Tagged {
1491 count: cmd.count,
1492 label: cmd.label,
1493 }]
1494 .into())
1495 }
1496
1497 /// Migrate v1 `Tagged` events (missing `label`) to v2.
1498 ///
1499 /// v1 payload: `{"count": N}` (no `label` field)
1500 /// v2 payload: `{"count": N, "label": ""}` (default empty string)
1501 ///
1502 /// Because the event uses `#[serde(untagged)]`, the envelope payload
1503 /// is the flat struct — variant discrimination comes from `event_type`.
1504 fn upcast(
1505 event_type: &str,
1506 from_version: u32,
1507 mut payload: serde_json::Value,
1508 ) -> Result<serde_json::Value, EngineError> {
1509 if event_type == "Tagged"
1510 && from_version == 1
1511 && let Some(obj) = payload.as_object_mut()
1512 {
1513 obj.entry("label")
1514 .or_insert_with(|| serde_json::Value::String(String::new()));
1515 }
1516 Ok(payload)
1517 }
1518 }
1519
1520 /// Inject a raw v1 event (no `label` field) directly into the store and
1521 /// confirm that `state()` replays it correctly via `upcast`.
1522 #[tokio::test]
1523 async fn upcast_v1_event_adds_default_label() {
1524 let store = InMemoryEventStore::new();
1525 let p = Process::<TagWorkflow, _>::new(
1526 store.clone(), // shares the underlying Arc<RwLock<_>>
1527 TenantId::new(),
1528 WorkflowId::new("tag", "FV2025-10-01"),
1529 );
1530
1531 // v1 payload: flat struct fields, no `label` (untagged serde repr).
1532 let v1_payload = serde_json::json!({ "count": 7 });
1533 let raw = NewEvent {
1534 correlation_id: CorrelationId::new(),
1535 causation_id: None,
1536 conversation_id: ConversationId::new(),
1537 process_id: p.process_id(),
1538 tenant_id: p.tenant_id(),
1539 workflow_id: p.workflow_id().clone(),
1540 event_type: "Tagged".into(),
1541 schema_version: 1, // ← schema_version 1 (old format)
1542 payload: v1_payload,
1543 };
1544 store
1545 .append(p.stream_id(), ExpectedVersion::Any, &[raw])
1546 .await
1547 .expect("inject v1 event");
1548
1549 // Replay via the v2 workflow — `upcast` must fill in `label: ""`.
1550 let state = p.state().await.expect("state must replay without error");
1551 assert_eq!(state.total, 7, "count must be accumulated");
1552 assert_eq!(
1553 state.last_label, "",
1554 "missing v1 label must default to empty string"
1555 );
1556
1557 // Also verify that a normally-executed v2 event round-trips correctly.
1558 p.execute(TagCommand {
1559 count: 3,
1560 label: "hello".into(),
1561 })
1562 .await
1563 .unwrap();
1564 let state2 = p.state().await.unwrap();
1565 assert_eq!(state2.total, 10);
1566 assert_eq!(state2.last_label, "hello");
1567 }
1568}