mako_engine/builder.rs
1//! [`EngineModule`] trait, [`EngineBuilder`], and [`EngineContext`].
2//!
3//! # Summary
4//!
5//! `EngineBuilder` assembles all engine infrastructure into a single
6//! [`EngineContext`] value. Domain modules (GPKE, WiM, GeLi Gas, …) register
7//! themselves at startup via the [`EngineModule`] trait, making their names
8//! visible in diagnostics and health checks.
9//!
10//! # Type-state guarantee
11//!
12//! [`EngineBuilder::build`] is only available when the event store type
13//! parameter `ES` implements [`EventStore`]. Forgetting to call
14//! [`with_event_store`] is a **compile-time error**, not a runtime panic.
15//!
16//! All other stores default to their respective `Noop` implementations:
17//!
18//! | Store | Default |
19//! |-------|---------|
20//! | Snapshot store | [`NoopSnapshotStore`] |
21//! | Outbox store | [`NoopOutboxStore`] |
22//! | Deadline store | [`NoopDeadlineStore`] |
23//! | Process registry | [`NoopProcessRegistry`] |
24//!
25//! # Assembly example
26//!
27//! ```rust,ignore
28//! use mako_engine::builder::{EngineBuilder, EngineModule};
29//! use mako_engine::event_store::InMemoryEventStore;
30//! use mako_engine::outbox::InMemoryOutboxStore;
31//! use mako_engine::deadline::InMemoryDeadlineStore;
32//! use mako_engine::registry::InMemoryProcessRegistry;
33//! use mako_engine::snapshot::InMemorySnapshotStore;
34//!
35//! struct GpkeModule;
36//! impl EngineModule for GpkeModule { fn name(&self) -> &'static str { "gpke" } }
37//!
38//! let ctx = EngineBuilder::new()
39//! .with_event_store(InMemoryEventStore::new())
40//! .with_snapshot_store(InMemorySnapshotStore::new())
41//! .with_outbox_store(InMemoryOutboxStore::new())
42//! .with_deadline_store(InMemoryDeadlineStore::new())
43//! .with_registry(InMemoryProcessRegistry::new())
44//! .register(Box::new(GpkeModule))
45//! .build();
46//!
47//! // Spawn a fresh process:
48//! let p = ctx.spawn::<SupplierChangeWorkflow>(tenant_id, workflow_id);
49//! p.execute(ReceiveUtilmd { .. }).await?;
50//!
51//! // Resume an existing process from a persisted identity:
52//! let identity = ctx.registry.lookup(&conv_id.to_string()).await?.unwrap();
53//! let p = ctx.resume::<SupplierChangeWorkflow>(identity);
54//!
55//! // Access stores for delivery workers / schedulers:
56//! let pending = ctx.outbox_store.pending_now(50).await?;
57//! let overdue = ctx.deadline_store.due_now(50).await?;
58//! ```
59//!
60//! [`with_event_store`]: EngineBuilder::with_event_store
61
62// Type-state generics can produce long signatures that trip up the
63// `type_complexity` lint; suppress it for this module only.
64#![allow(clippy::type_complexity)]
65
66// The Noop* types are marked #[deprecated] to guard against accidental
67// production use. The builder is the only place they're instantiated as
68// defaults; suppress the lint here explicitly.
69#[allow(deprecated)]
70use crate::{
71 dead_letter::{DeadLetterSink, LogDeadLetterSink},
72 deadline::{Deadline, DeadlineStore, NoopDeadlineStore},
73 error::EngineError,
74 event_store::EventStore,
75 ids::{ProcessIdentity, TenantId},
76 marktrolle::DeploymentRoles,
77 outbox::{NoopOutboxStore, OutboxMessage, OutboxStore},
78 pid_router::PidRouter,
79 process::Process,
80 registry::{NoopProcessRegistry, ProcessRegistry},
81 snapshot::{NoopSnapshotStore, SnapshotStore},
82 version::WorkflowId,
83 workflow::Workflow,
84};
85
86use std::sync::Arc;
87
88// ── EngineModule ──────────────────────────────────────────────────────────────
89
90/// A self-contained domain module that registers with the engine at startup.
91///
92/// Domain crates implement this trait to declare their presence in the engine.
93/// The module name is surfaced in [`EngineContext::registered_modules`] for
94/// diagnostics, health checks, and log output.
95///
96/// ## Startup validation
97///
98/// Override [`configure`] to perform adapter coverage checks at engine startup
99/// time. The engine calls [`configure`] for every registered module during
100/// [`EngineBuilder::build`] and panics with an actionable message if any
101/// module returns `Err`. This surfaces missing adapter registrations as a
102/// startup failure rather than a silent runtime error.
103///
104/// ## Example
105///
106/// ```rust,ignore
107/// pub struct GpkeModule;
108///
109/// impl EngineModule for GpkeModule {
110/// fn name(&self) -> &'static str { "gpke" }
111///
112/// fn configure(&self) -> Result<(), String> {
113/// // Validate that every known BDEW format version has an adapter:
114/// GPKE_ADAPTER_REGISTRY
115/// .validate_policy(&GpkeWorkflow::version_policy(), &KNOWN_FVS)
116/// .map_err(|uncovered| format!(
117/// "gpke: missing adapters for format versions: {:?}",
118/// uncovered
119/// ))
120/// }
121/// }
122///
123/// let ctx = EngineBuilder::new()
124/// .with_event_store(my_store)
125/// .register(Box::new(GpkeModule))
126/// .build(); // panics if GpkeModule::configure returns Err
127///
128/// assert_eq!(ctx.registered_modules(), &["gpke"]);
129/// ```
130///
131/// [`configure`]: EngineModule::configure
132pub trait EngineModule: Send + 'static {
133 /// Stable, unique name for this domain module.
134 ///
135 /// Used in diagnostics, health checks, and structured log output.
136 /// Choose a short lowercase identifier (e.g. `"gpke"`, `"wim"`,
137 /// `"geli"`).
138 fn name(&self) -> &'static str;
139
140 /// Register all PIDs this module handles into the shared [`PidRouter`].
141 ///
142 /// # Mutability contract
143 ///
144 /// This method is called **exactly once** by [`EngineBuilder::build`],
145 /// before the resulting [`EngineContext`] is handed to the caller. The
146 /// `&mut PidRouter` reference is only available here, at build time.
147 /// After `build` returns the router is **sealed** — the engine provides
148 /// only a shared `&PidRouter` reference, with no mutation path at runtime.
149 ///
150 /// Consequence: **all PIDs a module will ever need must be registered
151 /// here**. Do not attempt to register PIDs lazily from async handlers or
152 /// after the engine has started — there is no API for that by design.
153 ///
154 /// Duplicate registrations (same PID from two modules) silently overwrite
155 /// the previous mapping; the last module to register wins. Use
156 /// `cargo xtask validate-pruefids` to catch accidental PID conflicts
157 /// between modules before they reach production.
158 ///
159 /// For role-conditional registration (PIDs that should only be active for
160 /// specific BDEW Marktrollen), override [`register_pids_with_roles`] instead.
161 ///
162 /// # Example
163 ///
164 /// ```rust,ignore
165 /// fn register_pids(&self, router: &mut PidRouter) {
166 /// // GPKE Lieferantenwechsel / Lieferbeginn (BK6-22-024, PIDs 55001, 55002, 55017)
167 /// for &pid in &[55001_u32, 55002, 55017] {
168 /// router.register(pid, "gpke-supplier-change");
169 /// }
170 /// }
171 /// ```
172 ///
173 /// [`register_pids_with_roles`]: EngineModule::register_pids_with_roles
174 fn register_pids(&self, _router: &mut PidRouter) {}
175
176 /// Register PIDs with role-context awareness.
177 ///
178 /// This is the **preferred override** for modules that have role-conditional
179 /// PID registrations — PIDs that should only be active when this `makod`
180 /// instance holds a specific [`Marktrolle`].
181 ///
182 /// The default implementation calls [`register_pids`] (role-agnostic) so
183 /// existing modules that override `register_pids` continue to work without
184 /// changes.
185 ///
186 /// Override this method instead of `register_pids` when any PID registration
187 /// should be conditional on the deployment role:
188 ///
189 /// ```rust,ignore
190 /// use mako_engine::marktrolle::Marktrolle;
191 ///
192 /// fn register_pids_with_roles(&self, router: &mut PidRouter, roles: &DeploymentRoles) {
193 /// // Always register: 55001, 55002 (not role-specific)
194 /// for pid in [55001_u32, 55002] { router.register_with_module(pid, "gpke-supplier-change", self.name()); }
195 ///
196 /// // Only when NB role: 19001/19002 inbound ORDRSP from MSB
197 /// if roles.contains(Marktrolle::Nb) {
198 /// for pid in [19001_u32, 19002] { router.register_with_module(pid, "gpke-konfiguration", self.name()); }
199 /// }
200 /// }
201 /// ```
202 ///
203 /// # Conflict guard
204 ///
205 /// Use [`PidRouter::register_with_module`] (not `register`) inside this
206 /// method. The conflict guard panics at build time if two modules register
207 /// the same PID to different workflows — this makes role misconfigurations
208 /// visible at startup rather than silently misrouting messages.
209 ///
210 /// [`Marktrolle`]: crate::marktrolle::Marktrolle
211 /// [`register_pids`]: EngineModule::register_pids
212 fn register_pids_with_roles(&self, router: &mut PidRouter, _roles: &DeploymentRoles) {
213 self.register_pids(router);
214 }
215
216 /// Workflow names this module handles for deadline dispatch.
217 ///
218 /// Return the same name strings that [`register_pids`] maps PIDs to.
219 /// These names are stored in [`EngineContext::registered_workflows`] and
220 /// used to validate that every workflow that has deadlines scheduled is
221 /// covered by the deadline scheduler dispatch function at runtime.
222 ///
223 /// The default implementation returns an empty slice. Override it to
224 /// declare all workflow names that may fire deadlines:
225 ///
226 /// ```rust,ignore
227 /// fn workflow_names(&self) -> &'static [&'static str] {
228 /// &["gpke-supplier-change", "gpke-abrechnung"]
229 /// }
230 /// ```
231 ///
232 /// [`register_pids`]: EngineModule::register_pids
233 /// [`EngineContext::registered_workflows`]: crate::builder::EngineContext::registered_workflows
234 fn workflow_names(&self) -> &'static [&'static str] {
235 &[]
236 }
237
238 /// Declare the EDIFACT profile types this module requires at runtime.
239 ///
240 /// Returning a non-empty slice causes [`EngineBuilder::build`] to call the
241 /// registered profile validator for each requirement. If no active profile
242 /// exists for a required message type, `build` panics with an actionable
243 /// error so deployment fails fast rather than silently.
244 ///
245 /// **This replaces the previous pattern** of calling
246 /// `edi_energy::registry::ReleaseRegistry::global()` inside `configure()`.
247 /// Domain crates no longer need `edi-energy` in their production
248 /// `[dependencies]` — they just declare their requirements here.
249 ///
250 /// ```rust,ignore
251 /// fn profile_requirements(&self) -> &'static [ProfileRequirement] {
252 /// &[
253 /// ProfileRequirement { message_type: "UTILMD", label: "UTILMD Strom (GPKE)" },
254 /// ProfileRequirement { message_type: "INVOIC", label: "INVOIC Abrechnung (GPKE)" },
255 /// ]
256 /// }
257 /// ```
258 ///
259 /// [`ProfileRequirement`]: crate::profile::ProfileRequirement
260 fn profile_requirements(&self) -> &'static [crate::profile::ProfileRequirement] {
261 &[]
262 }
263
264 /// Validate adapter coverage and configuration at engine startup.
265 ///
266 /// Called by [`EngineBuilder::build`] after all modules are registered.
267 /// Return `Ok(())` when the module is fully configured. Return `Err(msg)`
268 /// with an actionable description when an adapter or configuration is
269 /// missing — the engine will panic with that message so the deployment
270 /// fails early rather than silently.
271 ///
272 /// The default implementation is a no-op (always returns `Ok(())`).
273 /// Override it in domain crates to call
274 /// [`AdapterRegistry::validate_policy`] and emit structured errors.
275 ///
276 /// Note: if your validation needs access to the edi-energy profile
277 /// registry, use [`profile_requirements`] instead — it does not require
278 /// importing `edi-energy` in domain crates.
279 ///
280 /// [`AdapterRegistry::validate_policy`]: crate::message_adapter::AdapterRegistry::validate_policy
281 /// [`profile_requirements`]: EngineModule::profile_requirements
282 ///
283 /// # Errors
284 ///
285 /// Returns a descriptive error string when the module's configuration is invalid.
286 fn configure(&self) -> Result<(), String> {
287 Ok(())
288 }
289}
290
291// ── EngineContext ─────────────────────────────────────────────────────────────
292
293/// Assembled engine infrastructure returned by [`EngineBuilder::build`].
294///
295/// `EngineContext` bundles all stores and the process registry into a single
296/// value. It is the root dependency for:
297///
298/// - Spawning new processes ([`spawn`])
299/// - Resuming existing processes ([`resume`])
300/// - Running outbox delivery workers (`outbox_store.pending_now(…)`)
301/// - Driving the deadline scheduler (`deadline_store.due_now(…)`)
302///
303/// ## Generic parameters
304///
305/// | Param | Role | Default |
306/// |-------|------|---------|
307/// | `ES` | [`EventStore`] backend | — (required) |
308/// | `SS` | [`SnapshotStore`] backend | [`NoopSnapshotStore`] |
309/// | `OS` | [`OutboxStore`] backend | [`NoopOutboxStore`] |
310/// | `DS` | [`DeadlineStore`] backend | [`NoopDeadlineStore`] |
311/// | `PR` | [`ProcessRegistry`] backend | [`NoopProcessRegistry`] |
312///
313/// In most codebases all type parameters are inferred from the builder calls.
314///
315/// [`spawn`]: EngineContext::spawn
316/// [`resume`]: EngineContext::resume
317pub struct EngineContext<
318 ES,
319 SS = NoopSnapshotStore,
320 OS = NoopOutboxStore,
321 DS = NoopDeadlineStore,
322 PR = NoopProcessRegistry,
323> {
324 event_store: Arc<ES>,
325 snapshot_store: SS,
326 outbox_store: OS,
327 deadline_store: DS,
328 registry: PR,
329 /// Dead-letter sink for unroutable or unprocessable inbound messages.
330 ///
331 /// Stored as `Arc<dyn DeadLetterSink>` so callers can share it across
332 /// tasks without an extra type parameter on `EngineContext`.
333 pub dead_letter_sink: Arc<dyn DeadLetterSink>,
334 /// PID-to-workflow routing table, populated from all registered modules.
335 pid_router: PidRouter,
336 registered_modules: Vec<&'static str>,
337 /// Workflow names declared by all registered modules via
338 /// [`EngineModule::workflow_names`]. Used to validate deadline scheduler
339 /// coverage at runtime (see [`EngineContext::registered_workflows`]).
340 registered_workflows: Vec<&'static str>,
341}
342
343// ── Type aliases ──────────────────────────────────────────────────────────────
344
345/// An [`EngineContext`] with all optional subsystems disabled.
346///
347/// Uses `NoopSnapshotStore` and, in `testing`-enabled builds, Noop
348/// implementations for outbox, deadline, and process registry. Suitable for
349/// tests and minimal deployments where only a durable event store is required.
350///
351/// All five type parameters are inferred from context when used with
352/// [`EngineBuilder`]:
353///
354/// ```rust,ignore
355/// // Only available in test / testing-feature builds:
356/// use mako_engine::builder::{EngineBuilder, MinimalEngine};
357/// use mako_engine::event_store::InMemoryEventStore;
358///
359/// let ctx: MinimalEngine<InMemoryEventStore> = EngineBuilder::new()
360/// .with_event_store(InMemoryEventStore::new())
361/// .build();
362/// ```
363pub type MinimalEngine<ES> = EngineContext<ES>;
364
365impl<ES, SS, OS, DS, PR> EngineContext<ES, SS, OS, DS, PR>
366where
367 ES: EventStore,
368{
369 /// Spawn a new process and return a typed `Process<W, Arc<ES>>` handle.
370 ///
371 /// No `ES: Clone` bound is required — the engine stores the event store
372 /// behind an `Arc` so spawning is always a cheap pointer clone.
373 ///
374 /// ```rust,ignore
375 /// let p = ctx.spawn::<SupplierChangeWorkflow>(tenant_id, workflow_id);
376 /// p.execute(ReceiveUtilmd { .. }).await?;
377 /// ```
378 #[must_use]
379 pub fn spawn<W: Workflow>(
380 &self,
381 tenant_id: TenantId,
382 workflow_id: WorkflowId,
383 ) -> Process<W, Arc<ES>> {
384 Process::new(Arc::clone(&self.event_store), tenant_id, workflow_id)
385 }
386
387 /// Resume an existing process from a [`ProcessIdentity`].
388 ///
389 /// ```rust,ignore
390 /// let identity = ctx.registry()
391 /// .lookup(tenant_id, &conv_id.to_string())
392 /// .await?
393 /// .ok_or(EngineError::Registry("unknown conversation".into()))?;
394 /// let p = ctx.resume::<SupplierChangeWorkflow>(identity);
395 /// p.execute(HandleAperak { .. }).await?;
396 /// ```
397 #[must_use]
398 pub fn resume<W: Workflow>(&self, identity: ProcessIdentity) -> Process<W, Arc<ES>> {
399 Process::from_identity(Arc::clone(&self.event_store), identity)
400 }
401
402 /// Names of all domain modules registered with the builder, in
403 /// registration order.
404 #[must_use]
405 pub fn registered_modules(&self) -> &[&'static str] {
406 &self.registered_modules
407 }
408
409 /// Workflow names declared by all registered modules, in registration order.
410 ///
411 /// Use this in the deadline scheduler dispatch function to detect unknown
412 /// workflow names at startup. If a deadline fires for a workflow name that
413 /// is not in this list, the scheduler's dispatch function should emit an
414 /// error rather than silently dropping the deadline:
415 ///
416 /// ```rust,ignore
417 /// let known = ctx.registered_workflows().iter().copied().collect::<HashSet<_>>();
418 /// let scheduler = ctx.run_deadline_scheduler(
419 /// move |deadline| {
420 /// let wf = deadline.workflow_id().name.as_ref();
421 /// if !known.contains(wf) {
422 /// tracing::error!(workflow = %wf, "deadline fired for unregistered workflow");
423 /// return Box::pin(async { Ok(()) });
424 /// }
425 /// // dispatch by workflow name …
426 /// Box::pin(async { Ok(()) })
427 /// },
428 /// 100,
429 /// Duration::from_secs(30),
430 /// );
431 /// ```
432 #[must_use]
433 pub fn registered_workflows(&self) -> &[&'static str] {
434 &self.registered_workflows
435 }
436
437 /// The event store backend (behind an `Arc`).
438 #[must_use]
439 pub fn event_store(&self) -> &Arc<ES> {
440 &self.event_store
441 }
442
443 /// The snapshot store backend.
444 #[must_use]
445 pub fn snapshot_store(&self) -> &SS {
446 &self.snapshot_store
447 }
448
449 /// The outbox store backend.
450 ///
451 /// Poll `outbox_store().pending_now(limit)` in a background task to drain
452 /// the delivery queue.
453 #[must_use]
454 pub fn outbox_store(&self) -> &OS {
455 &self.outbox_store
456 }
457
458 /// The deadline store backend.
459 ///
460 /// Poll `deadline_store().due_now(limit)` in a background scheduler to
461 /// fire overdue process timers.
462 #[must_use]
463 pub fn deadline_store(&self) -> &DS {
464 &self.deadline_store
465 }
466
467 /// The process routing registry.
468 ///
469 /// Register a [`ProcessIdentity`] under a `(tenant_id, key)` pair at
470 /// process creation, then `lookup` it when routing inbound messages.
471 #[must_use]
472 pub fn registry(&self) -> &PR {
473 &self.registry
474 }
475
476 /// The dead-letter sink for unroutable or unprocessable messages.
477 ///
478 /// Call [`DeadLetterSink::reject`] when an inbound message cannot be
479 /// dispatched to any workflow. The default sink emits `tracing::warn!`
480 /// so rejections are always visible in the log output.
481 #[must_use]
482 pub fn dead_letter_sink(&self) -> &Arc<dyn DeadLetterSink> {
483 &self.dead_letter_sink
484 }
485
486 /// Assert that no Noop store is active — call this during production startup.
487 ///
488 /// Checks the type names of `OS`, `DS`, and `PR` against the string `"Noop"`.
489 /// Panics with a human-readable message if any match, directing the operator
490 /// to configure a persistent backend.
491 ///
492 /// # When to call
493 ///
494 /// Call this early in `makod`'s startup path (and `--check` mode) to catch
495 /// deployments where a Noop store was accidentally wired — e.g. the
496 /// `[outbox]`, `[deadline]`, or `[registry]` configuration section was
497 /// omitted from `makod.toml`. The check is defence-in-depth: in release
498 /// builds without the `testing` feature, Noop stores cannot implement the
499 /// required traits at all and the compiler would have already rejected them.
500 ///
501 /// # Panics
502 ///
503 /// Panics when any of `OS`, `DS`, or `PR` is a Noop implementation.
504 pub fn assert_production_stores(&self) {
505 let checks: &[(&str, &str)] = &[
506 ("OutboxStore", std::any::type_name::<OS>()),
507 ("DeadlineStore", std::any::type_name::<DS>()),
508 ("ProcessRegistry", std::any::type_name::<PR>()),
509 ];
510 for (trait_name, type_name) in checks {
511 assert!(
512 !type_name.contains("Noop"),
513 "makod: Noop{trait_name} is active — \
514 configure a persistent {trait_name} backend in makod.toml. \
515 Type resolved to: {type_name}"
516 );
517 }
518 }
519
520 /// The PID-to-workflow routing table.
521 ///
522 /// Populated **once** during [`EngineBuilder::build`] by calling
523 /// [`EngineModule::register_pids`] on every registered module in
524 /// registration order. After `build` returns the table is **sealed** —
525 /// it is read-only for the lifetime of the `EngineContext` and may be
526 /// freely shared across async tasks without synchronisation.
527 ///
528 /// # Mutability contract
529 ///
530 /// There is intentionally no `pid_router_mut()` accessor. Adding PIDs
531 /// after the engine is built would create a TOCTOU race between the
532 /// dispatch path (which calls `route(pid)`) and any hypothetical
533 /// concurrent mutator. Instead, register all PIDs during the build phase
534 /// via `EngineModule::register_pids`.
535 ///
536 /// If a new process family needs to be added without restarting the
537 /// binary, rebuild and restart `makod` — hot-swap of PID routing is not
538 /// supported.
539 ///
540 /// # Example — dispatch at the AS4 reception boundary
541 ///
542 /// ```rust,ignore
543 /// let workflow_name = ctx.pid_router().route(pid)
544 /// .ok_or_else(|| EngineError::Workflow(WorkflowError::InvalidCommand(
545 /// format!("no workflow registered for PID {pid}").into()
546 /// )))?;
547 ///
548 /// match workflow_name {
549 /// "gpke-supplier-change" => dispatch::<GpkeSupplierChangeWorkflow>(&ctx, pid, payload).await,
550 /// "wim-device-change" => dispatch::<WimDeviceChangeWorkflow>(&ctx, pid, payload).await,
551 /// other => Err(EngineError::Workflow(WorkflowError::InvalidCommand(
552 /// format!("unhandled workflow name: {other}").into()
553 /// ))),
554 /// }
555 /// ```
556 #[must_use]
557 pub fn pid_router(&self) -> &PidRouter {
558 &self.pid_router
559 }
560}
561
562// ── As4Sender ─────────────────────────────────────────────────────────────────
563
564/// Sends a single AS4 / EDIINT-over-HTTP outbound message.
565///
566/// Implement this trait for your AS4 gateway client and pass it to
567/// [`EngineContext::run_outbox_worker`].
568///
569/// # Contract
570///
571/// Return `Ok(())` only after the message has been **durably accepted** by the
572/// receiving MSH. Return `Err(…)` on transient or permanent failure — the
573/// outbox worker calls [`OutboxStore::reschedule`] so the message is retried.
574pub trait As4Sender: Send + Sync + 'static {
575 /// Transmit `msg` and return when the remote MSH has accepted it.
576 fn send(
577 &self,
578 msg: &OutboxMessage,
579 ) -> impl std::future::Future<Output = Result<(), EngineError>> + Send;
580}
581
582// ── OutboxWorker ──────────────────────────────────────────────────────────────
583
584/// A background worker that drains the outbox by polling pending
585/// [`OutboxMessage`]s and dispatching them via an [`As4Sender`].
586///
587/// Obtain via [`EngineContext::run_outbox_worker`] and drive by spawning
588/// [`OutboxWorker::run`] in a Tokio task.
589///
590/// # Polling behaviour
591///
592/// When the poll returns an empty batch the worker sleeps for `poll_interval`
593/// before polling again. Non-empty batches are processed immediately.
594///
595/// # Error handling
596///
597/// Successful sends are acknowledged via [`OutboxStore::acknowledge`].
598/// Failed sends are rescheduled via [`OutboxStore::reschedule`] using
599/// **full-jitter exponential backoff**: `delay = rand(0, min(MAX, BASE * 2^n))`
600/// where `n = attempt_count`. This avoids thundering-herd when multiple
601/// `makod` instances restart simultaneously after a receiver outage.
602///
603/// When `attempt_count >= max_attempts`, the message is **acknowledged** (removed
604/// from the outbox) and a [`DeadLetterReason::OutboxExhausted`] record is written
605/// to the dead-letter sink. This prevents permanently-undeliverable messages
606/// from clogging the outbox forever.
607///
608/// All errors are emitted as structured `tracing` events at `warn` / `error`
609/// level rather than `eprintln!`, so they appear in the application's log
610/// pipeline with full context (message_id, error).
611///
612/// # Example
613///
614/// ```rust,ignore
615/// use std::time::Duration;
616///
617/// let worker = ctx.run_outbox_worker(my_sender, 50, Duration::from_secs(1));
618/// tokio::spawn(async move { worker.run().await });
619/// ```
620///
621/// [`DeadLetterReason::OutboxExhausted`]: crate::dead_letter::DeadLetterReason::OutboxExhausted
622pub struct OutboxWorker<OS: OutboxStore, S: As4Sender> {
623 store: OS,
624 sender: S,
625 batch_size: usize,
626 poll_interval: std::time::Duration,
627 /// Maximum total delivery attempts before a message is dead-lettered.
628 ///
629 /// Default: 48 (covers ~4 hours at the 300 s backoff cap).
630 /// Set to `u32::MAX` to disable the cap (not recommended for production).
631 max_attempts: u32,
632 /// Sink for messages that exceed `max_attempts`.
633 dead_letter_sink: std::sync::Arc<dyn crate::dead_letter::DeadLetterSink>,
634 /// Optional liveness heartbeat — stores the current UTC Unix timestamp
635 /// (seconds) after each poll cycle so health probes can detect stale workers.
636 heartbeat: Option<std::sync::Arc<std::sync::atomic::AtomicI64>>,
637}
638
639/// Compute a full-jitter exponential backoff delay.
640///
641/// `attempt` is the number of prior attempts (0 = first retry).
642/// `entropy` provides randomness; derive from a stable message identifier
643/// (e.g. hash of `message_id`) rather than the current timestamp — a
644/// timestamp-derived value is deterministic within a single batch, which
645/// defeats jitter when multiple messages fail simultaneously.
646///
647/// | attempt | window (s) | expected delay (s) |
648/// |---------|------------|-------------------|
649/// | 0 | 5 | 2.5 |
650/// | 1 | 10 | 5 |
651/// | 2 | 20 | 10 |
652/// | 3 | 40 | 20 |
653/// | 4 | 80 | 40 |
654/// | 5+ | 300 (cap) | 150 |
655fn backoff_delay(attempt: u32, entropy: u64) -> std::time::Duration {
656 const BASE_SECS: u64 = 5;
657 const MAX_SECS: u64 = 300;
658 // Exponential window: BASE * 2^attempt, capped at MAX.
659 let window = BASE_SECS
660 .saturating_mul(1u64.wrapping_shl(attempt.min(5)))
661 .min(MAX_SECS);
662 // Full jitter: uniform random in [0, window).
663 let jitter_secs = if window == 0 { 0 } else { entropy % window };
664 std::time::Duration::from_secs(jitter_secs)
665}
666
667impl<OS: OutboxStore, S: As4Sender> OutboxWorker<OS, S> {
668 /// Run the outbox drain loop until the task is cancelled.
669 ///
670 /// # Panics
671 ///
672 /// Panics if `time::Duration::try_from(delay)` overflows (unreachable for
673 /// the delay values produced by `backoff_delay`).
674 #[allow(clippy::too_many_lines)]
675 pub async fn run(self) {
676 loop {
677 let batch = match self.store.pending_now(self.batch_size).await {
678 Ok(b) => b,
679 Err(e) => {
680 tracing::warn!(error = %e, "outbox worker: store error polling pending messages (will retry)");
681 tokio::time::sleep(self.poll_interval).await;
682 continue;
683 }
684 };
685
686 if batch.is_empty() {
687 tokio::time::sleep(self.poll_interval).await;
688 continue;
689 }
690
691 for msg in batch {
692 // ── Max-attempt cap ───────────────────────────────────
693 // `attempt_count` starts at 0 and is incremented on each
694 // `reschedule` call. When it reaches `max_attempts` the
695 // message is considered permanently undeliverable: acknowledge
696 // it (remove from outbox) and dead-letter it so the regulatory
697 // audit trail is preserved.
698 if msg.attempt_count >= self.max_attempts {
699 tracing::error!(
700 message_id = %msg.message_id,
701 message_type = %msg.message_type,
702 recipient = %msg.recipient,
703 attempts = msg.attempt_count,
704 max_attempts = self.max_attempts,
705 "outbox worker: max delivery attempts reached; dead-lettering message",
706 );
707 self.dead_letter_sink.reject(
708 &crate::dead_letter::DeadLetterReason::OutboxExhausted {
709 message_id: msg.message_id,
710 message_type: msg.message_type.to_string(),
711 recipient: msg.recipient.to_string(),
712 last_error: format!(
713 "delivery exhausted after {} attempts",
714 msg.attempt_count
715 ),
716 attempts: msg.attempt_count,
717 },
718 );
719 if let Err(e) = self.store.acknowledge(msg.message_id).await {
720 tracing::error!(
721 message_id = %msg.message_id,
722 error = %e,
723 "outbox worker: acknowledge after exhaust failed; message may reappear",
724 );
725 }
726 continue;
727 }
728
729 match self.sender.send(&msg).await {
730 Ok(()) => {
731 if let Err(e) = self.store.acknowledge(msg.message_id).await {
732 tracing::warn!(
733 message_id = %msg.message_id,
734 error = %e,
735 "outbox worker: acknowledge failed",
736 );
737 }
738 // CONTRL AHB 1.0 §1.2: the CONTRL must be delivered
739 // within 6 wall-clock hours of interchange receipt.
740 // `msg.created_at` is when the PendingOutbox was
741 // materialised (which should equal the ingest timestamp
742 // for transport-layer CONTRL obligations).
743 if msg.message_type.as_ref() == "CONTRL" {
744 let elapsed = time::OffsetDateTime::now_utc() - msg.created_at;
745 if elapsed > time::Duration::hours(crate::fristen::CONTRL_FRIST_HOURS) {
746 tracing::warn!(
747 message_id = %msg.message_id,
748 elapsed_secs = elapsed.whole_seconds(),
749 max_secs = crate::fristen::CONTRL_FRIST_HOURS * 3600,
750 "outbox worker: CONTRL delivered OUTSIDE the 6h Übertragungsfrist \
751 (CONTRL AHB 1.0 §1.2) — this is a BNetzA compliance violation"
752 );
753 }
754 }
755 // APERAK AHB 1.0 §2.4.1: Strom UTILMD/ORDERS APERAK must be
756 // delivered within 45 minutes on weekdays, or by Sunday 12:00
757 // if received on Saturday. Log a compliance warning if the
758 // delivery window was missed so operators can investigate.
759 if msg.message_type.as_ref() == "APERAK" {
760 let elapsed = time::OffsetDateTime::now_utc() - msg.created_at;
761 if elapsed
762 > time::Duration::minutes(
763 crate::fristen::APERAK_STROM_WEEKDAY_MINUTES,
764 )
765 {
766 tracing::warn!(
767 message_id = %msg.message_id,
768 elapsed_mins = elapsed.whole_minutes(),
769 "outbox worker: APERAK delivered after the 45-minute Strom \
770 sending window (APERAK AHB 1.0 §2.4.1) — \
771 check OutboxWorker and AS4 transport health"
772 );
773 }
774 }
775 }
776 // Permanent error: dead-letter immediately without retrying.
777 // PartnerUnknown requires operator intervention (add --as4-partner);
778 // Serialization errors will never succeed on retry.
779 Err(ref e)
780 if e.is_partner_unknown() || matches!(e, EngineError::Serialization(_)) =>
781 {
782 tracing::error!(
783 message_id = %msg.message_id,
784 message_type = %msg.message_type,
785 recipient = %msg.recipient,
786 error = %e,
787 "outbox worker: permanent send failure; dead-lettering without retry",
788 );
789 self.dead_letter_sink.reject(
790 &crate::dead_letter::DeadLetterReason::OutboxExhausted {
791 message_id: msg.message_id,
792 message_type: msg.message_type.to_string(),
793 recipient: msg.recipient.to_string(),
794 last_error: e.to_string(),
795 attempts: msg.attempt_count,
796 },
797 );
798 if let Err(re) = self.store.acknowledge(msg.message_id).await {
799 tracing::error!(
800 message_id = %msg.message_id,
801 error = %re,
802 "outbox worker: acknowledge after permanent failure failed",
803 );
804 }
805 }
806 Err(e) => {
807 // Stable jitter entropy derived from the UUID bytes of
808 // `message_id`. Using the last 8 bytes as a `u64` gives
809 // uniform entropy across message IDs (UUIDs are random in
810 // all 128 bits for v4) and is stable across Rust versions —
811 // unlike `DefaultHasher`, whose algorithm is explicitly
812 // documented as unstable.
813 let entropy = {
814 let uuid = msg.message_id.as_uuid();
815 let bytes = uuid.as_bytes();
816 u64::from_le_bytes(bytes[8..16].try_into().unwrap())
817 };
818 let delay = backoff_delay(msg.attempt_count, entropy);
819 let retry_at = time::OffsetDateTime::now_utc()
820 + time::Duration::try_from(delay).unwrap_or(time::Duration::minutes(5));
821 tracing::warn!(
822 message_id = %msg.message_id,
823 attempt = msg.attempt_count,
824 max_attempts = self.max_attempts,
825 retry_in = ?delay,
826 error = %e,
827 "outbox worker: send failed; rescheduling with backoff",
828 );
829 if let Err(re) = self.store.reschedule(msg.message_id, retry_at).await {
830 tracing::error!(
831 message_id = %msg.message_id,
832 error = %re,
833 "outbox worker: reschedule failed; message may be stuck",
834 );
835 }
836 }
837 }
838 }
839 // Tick liveness heartbeat at the end of every poll cycle so the
840 // health endpoint can detect a stale (hung) outbox worker.
841 if let Some(ref hb) = self.heartbeat {
842 hb.store(
843 time::OffsetDateTime::now_utc().unix_timestamp(),
844 std::sync::atomic::Ordering::Relaxed,
845 );
846 }
847 }
848 }
849}
850
851impl<ES, SS, OS, DS, PR> EngineContext<ES, SS, OS, DS, PR>
852where
853 ES: EventStore,
854 OS: OutboxStore + Clone,
855{
856 /// Construct an [`OutboxWorker`] that drains the outbox via `sender`.
857 ///
858 /// `batch_size` — messages fetched per poll cycle.
859 /// `poll_interval` — sleep duration when the batch is empty.
860 ///
861 /// `max_attempts` — maximum total delivery attempts before dead-lettering.
862 /// Pass `48` for a ~4-hour retry budget at the 300 s backoff cap, or
863 /// `u32::MAX` to disable the cap (not recommended for production).
864 ///
865 /// ```rust,ignore
866 /// use std::time::Duration;
867 ///
868 /// let worker = ctx.run_outbox_worker(my_sender, 50, Duration::from_secs(1), 48);
869 /// tokio::spawn(async move { worker.run().await });
870 /// ```
871 #[must_use]
872 pub fn run_outbox_worker<S: As4Sender>(
873 &self,
874 sender: S,
875 batch_size: usize,
876 poll_interval: std::time::Duration,
877 max_attempts: u32,
878 ) -> OutboxWorker<OS, S> {
879 OutboxWorker {
880 store: self.outbox_store.clone(),
881 sender,
882 batch_size,
883 poll_interval,
884 max_attempts,
885 dead_letter_sink: self.dead_letter_sink.clone(),
886 heartbeat: None,
887 }
888 }
889}
890
891impl<OS: OutboxStore, S: As4Sender> OutboxWorker<OS, S> {
892 /// Attach a liveness heartbeat to this worker.
893 ///
894 /// The worker will store the current UTC Unix timestamp (seconds) into
895 /// `heartbeat` at the end of every poll cycle. Pass the same
896 /// `Arc<AtomicI64>` to the health endpoint so it can detect stale workers.
897 #[must_use]
898 pub fn with_heartbeat(
899 mut self,
900 heartbeat: std::sync::Arc<std::sync::atomic::AtomicI64>,
901 ) -> Self {
902 self.heartbeat = Some(heartbeat);
903 self
904 }
905}
906
907impl<ES, SS, OS, DS, PR> std::fmt::Debug for EngineContext<ES, SS, OS, DS, PR>
908where
909 ES: std::fmt::Debug,
910 SS: std::fmt::Debug,
911 OS: std::fmt::Debug,
912 DS: std::fmt::Debug,
913 PR: std::fmt::Debug,
914{
915 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
916 f.debug_struct("EngineContext")
917 .field("registered_modules", &self.registered_modules)
918 .field("registered_workflows", &self.registered_workflows)
919 .field("pid_router_len", &self.pid_router.len())
920 .finish_non_exhaustive()
921 }
922}
923
924// ── NoopAs4Sender / LogAs4Sender ──────────────────────────────────────────────
925
926/// An [`As4Sender`] that succeeds immediately without sending anything.
927///
928/// Use in tests and environments where outbound AS4 delivery is not yet
929/// wired. All outbox messages are acknowledged (removed from the queue)
930/// without being transmitted.
931///
932/// # ⚠️ Data loss warning
933///
934/// Every outbox message is **silently discarded** — no EDIFACT message is
935/// sent to any counterparty. Do not use in production.
936#[derive(Debug, Clone, Copy, Default)]
937#[must_use = "NoopAs4Sender discards all outbound messages silently — use a real AS4 gateway in production"]
938pub struct NoopAs4Sender;
939
940impl As4Sender for NoopAs4Sender {
941 async fn send(&self, _msg: &OutboxMessage) -> Result<(), EngineError> {
942 Ok(())
943 }
944}
945
946/// An [`As4Sender`] that logs every outbound message at `warn` level and
947/// succeeds without transmitting.
948///
949/// Useful for development and integration-testing environments where the
950/// full AS4 stack is not yet available but message visibility is desired.
951/// All outbox messages are acknowledged (removed from the queue) after logging.
952///
953/// # ⚠️ Data loss warning
954///
955/// No EDIFACT message is sent to any counterparty. Do not use in production.
956#[derive(Debug, Clone, Copy, Default)]
957#[must_use = "LogAs4Sender discards all outbound messages — use a real AS4 gateway in production"]
958pub struct LogAs4Sender;
959
960impl As4Sender for LogAs4Sender {
961 async fn send(&self, msg: &OutboxMessage) -> Result<(), EngineError> {
962 tracing::warn!(
963 message_id = %msg.message_id,
964 message_type = %msg.message_type,
965 recipient = %msg.recipient,
966 "LogAs4Sender: outbox message dropped — configure a real AS4 gateway for production",
967 );
968 Ok(())
969 }
970}
971
972// ── DeadlineScheduler ─────────────────────────────────────────────────────────
973
974/// A background task that polls [`DeadlineStore::due_now`] and dispatches
975/// deadline commands to the owning processes via a caller-supplied function.
976///
977/// Obtain via [`EngineContext::run_deadline_scheduler`] and drive by spawning
978/// [`DeadlineScheduler::run`] in a Tokio task.
979///
980/// # Dispatch function
981///
982/// The `dispatch` function receives a fired [`Deadline`] and returns a future
983/// that dispatches the appropriate timeout command to the process. The function
984/// is responsible for resuming the correct workflow and calling `execute`.
985/// After the future completes, the scheduler cancels the deadline from the
986/// store regardless of the dispatch outcome (to prevent re-firing).
987///
988/// ```rust,ignore
989/// use std::time::Duration;
990///
991/// let scheduler = ctx.run_deadline_scheduler(
992/// |deadline| async move {
993/// tracing::warn!(
994/// deadline_id = %deadline.deadline_id(),
995/// label = %deadline.label(),
996/// "deadline fired",
997/// );
998/// Ok(())
999/// },
1000/// 100,
1001/// Duration::from_secs(30),
1002/// );
1003/// tokio::spawn(async move { scheduler.run().await });
1004/// ```
1005pub struct DeadlineScheduler<DS: DeadlineStore> {
1006 store: DS,
1007 dispatch: Box<
1008 dyn Fn(
1009 Deadline,
1010 ) -> std::pin::Pin<
1011 Box<dyn std::future::Future<Output = Result<(), EngineError>> + Send>,
1012 > + Send
1013 + Sync,
1014 >,
1015 batch_size: usize,
1016 poll_interval: std::time::Duration,
1017 /// Optional liveness heartbeat — stores the current UTC Unix timestamp
1018 /// (seconds) after each poll cycle.
1019 heartbeat: Option<std::sync::Arc<std::sync::atomic::AtomicI64>>,
1020}
1021
1022impl<DS: DeadlineStore> DeadlineScheduler<DS> {
1023 /// Run the deadline poll loop until the task is cancelled.
1024 pub async fn run(self) {
1025 loop {
1026 let result = match self.store.due_now(self.batch_size).await {
1027 Ok(r) => r,
1028 Err(e) => {
1029 tracing::warn!(
1030 error = %e,
1031 "deadline scheduler: store error polling due deadlines (will retry)",
1032 );
1033 tokio::time::sleep(self.poll_interval).await;
1034 continue;
1035 }
1036 };
1037
1038 if result.deadlines.is_empty() {
1039 tokio::time::sleep(self.poll_interval).await;
1040 continue;
1041 }
1042
1043 for deadline in result.deadlines {
1044 let id = deadline.deadline_id();
1045 let label = deadline.label().to_owned();
1046 let should_cancel = match (self.dispatch)(deadline).await {
1047 Ok(()) => true,
1048 Err(ref e) if e.is_version_conflict() => {
1049 // The process was modified concurrently; the timeout
1050 // command will be retried on the next poll cycle.
1051 // Do NOT cancel — let the deadline remain due so it
1052 // fires again until a non-conflict dispatch succeeds.
1053 tracing::warn!(
1054 deadline_id = %id,
1055 label = %label,
1056 "deadline scheduler: VersionConflict; will retry on next poll",
1057 );
1058 false
1059 }
1060 Err(e) => {
1061 tracing::warn!(
1062 deadline_id = %id,
1063 label = %label,
1064 error = %e,
1065 "deadline scheduler: dispatch failed (permanent); cancelling",
1066 );
1067 true
1068 }
1069 };
1070 if should_cancel && let Err(e) = self.store.cancel(id).await {
1071 tracing::error!(
1072 deadline_id = %id,
1073 error = %e,
1074 "deadline scheduler: cancel failed; deadline may fire again",
1075 );
1076 }
1077 }
1078
1079 // If has_more, loop immediately to drain the batch.
1080
1081 // Tick liveness heartbeat at the end of every poll cycle so the
1082 // health endpoint can detect a stale (hung) deadline scheduler.
1083 if let Some(ref hb) = self.heartbeat {
1084 hb.store(
1085 time::OffsetDateTime::now_utc().unix_timestamp(),
1086 std::sync::atomic::Ordering::Relaxed,
1087 );
1088 }
1089 }
1090 }
1091}
1092
1093impl<DS: DeadlineStore> DeadlineScheduler<DS> {
1094 /// Attach a liveness heartbeat to this scheduler.
1095 ///
1096 /// The scheduler will store the current UTC Unix timestamp (seconds) into
1097 /// `heartbeat` at the end of every poll cycle.
1098 #[must_use]
1099 pub fn with_heartbeat(
1100 mut self,
1101 heartbeat: std::sync::Arc<std::sync::atomic::AtomicI64>,
1102 ) -> Self {
1103 self.heartbeat = Some(heartbeat);
1104 self
1105 }
1106}
1107
1108impl<ES, SS, OS, DS, PR> EngineContext<ES, SS, OS, DS, PR>
1109where
1110 ES: EventStore,
1111 DS: DeadlineStore + Clone,
1112{
1113 /// Construct a [`DeadlineScheduler`] that polls the deadline store and
1114 /// dispatches fired deadlines via `dispatch`.
1115 ///
1116 /// The `dispatch` function is called for every fired deadline. It should
1117 /// resume the owning process and execute the appropriate timeout command.
1118 ///
1119 /// `batch_size` — deadlines fetched per poll cycle.
1120 /// `poll_interval` — sleep duration when no deadlines are due.
1121 ///
1122 /// ```rust,ignore
1123 /// use std::time::Duration;
1124 ///
1125 /// let scheduler = ctx.run_deadline_scheduler(
1126 /// |d| async move {
1127 /// tracing::info!(label = %d.label(), "firing deadline");
1128 /// Ok(())
1129 /// },
1130 /// 100,
1131 /// Duration::from_secs(30),
1132 /// );
1133 /// tokio::spawn(async move { scheduler.run().await });
1134 /// ```
1135 #[must_use]
1136 pub fn run_deadline_scheduler<F, Fut>(
1137 &self,
1138 dispatch: F,
1139 batch_size: usize,
1140 poll_interval: std::time::Duration,
1141 ) -> DeadlineScheduler<DS>
1142 where
1143 F: Fn(Deadline) -> Fut + Send + Sync + 'static,
1144 Fut: std::future::Future<Output = Result<(), EngineError>> + Send + 'static,
1145 {
1146 DeadlineScheduler {
1147 store: self.deadline_store.clone(),
1148 dispatch: Box::new(move |d| Box::pin(dispatch(d))),
1149 batch_size,
1150 poll_interval,
1151 heartbeat: None,
1152 }
1153 }
1154}
1155
1156// ── EngineBuilder ─────────────────────────────────────────────────────────────
1157
1158/// Assembles engine infrastructure and produces an [`EngineContext`].
1159///
1160/// Uses type-state to enforce that an event store is provided before
1161/// [`build`] can be called. All other stores default to `Noop`
1162/// implementations.
1163///
1164/// ## Quick start
1165///
1166/// ```rust,ignore
1167/// // Minimal — event store only, all others are Noop:
1168/// let ctx = EngineBuilder::new()
1169/// .with_event_store(InMemoryEventStore::new())
1170/// .build();
1171///
1172/// // Full infrastructure:
1173/// let ctx = EngineBuilder::new()
1174/// .with_event_store(InMemoryEventStore::new())
1175/// .with_snapshot_store(InMemorySnapshotStore::new())
1176/// .with_outbox_store(InMemoryOutboxStore::new())
1177/// .with_deadline_store(InMemoryDeadlineStore::new())
1178/// .with_registry(InMemoryProcessRegistry::new())
1179/// .register(Box::new(GpkeModule))
1180/// .build();
1181/// ```
1182///
1183/// [`build`]: EngineBuilder::build
1184pub struct EngineBuilder<
1185 ES = (),
1186 SS = NoopSnapshotStore,
1187 OS = NoopOutboxStore,
1188 DS = NoopDeadlineStore,
1189 PR = NoopProcessRegistry,
1190> {
1191 event_store: ES,
1192 snapshot_store: SS,
1193 outbox_store: OS,
1194 deadline_store: DS,
1195 registry: PR,
1196 dead_letter_sink: Arc<dyn DeadLetterSink>,
1197 modules: Vec<Box<dyn EngineModule>>,
1198 /// Active [`DeploymentRoles`] for this engine instance.
1199 ///
1200 /// Controls role-conditional PID registration via
1201 /// [`EngineModule::register_pids_with_roles`]. Defaults to
1202 /// [`DeploymentRoles::all()`] for backward compatibility.
1203 deployment_roles: DeploymentRoles,
1204 /// Optional profile validator injected by `makod` or callers that have
1205 /// access to `edi-energy`. When `Some`, called for each
1206 /// [`ProfileRequirement`] declared by registered modules. When `None`,
1207 /// profile requirements are not validated (safe in unit tests).
1208 ///
1209 /// Signature: `fn(message_type: &str) -> bool`
1210 ///
1211 /// [`ProfileRequirement`]: crate::profile::ProfileRequirement
1212 profile_validator: Option<Box<dyn Fn(&str) -> bool + Send + Sync>>,
1213}
1214#[cfg(any(test, feature = "testing"))]
1215impl Default
1216 for EngineBuilder<
1217 (),
1218 NoopSnapshotStore,
1219 NoopOutboxStore,
1220 NoopDeadlineStore,
1221 NoopProcessRegistry,
1222 >
1223{
1224 fn default() -> Self {
1225 Self {
1226 event_store: (),
1227 snapshot_store: NoopSnapshotStore,
1228 outbox_store: NoopOutboxStore,
1229 deadline_store: NoopDeadlineStore,
1230 registry: NoopProcessRegistry,
1231 dead_letter_sink: Arc::new(LogDeadLetterSink),
1232 modules: Vec::new(),
1233 deployment_roles: DeploymentRoles::all(),
1234 profile_validator: None,
1235 }
1236 }
1237}
1238
1239#[cfg(any(test, feature = "testing"))]
1240impl EngineBuilder {
1241 /// Create a new builder with all `Noop` defaults.
1242 ///
1243 /// Only available in `#[cfg(test)]` or with the `testing` feature enabled,
1244 /// because the Noop defaults silently discard outbox messages, deadlines,
1245 /// and process registry entries. Production binaries must wire real stores
1246 /// via the `with_*` builder methods.
1247 ///
1248 /// Call [`with_event_store`] before [`build`] — the event store is
1249 /// **required**.
1250 ///
1251 /// [`with_event_store`]: EngineBuilder::with_event_store
1252 /// [`build`]: EngineBuilder::build
1253 #[must_use]
1254 pub fn new() -> Self {
1255 Self::default()
1256 }
1257}
1258
1259impl<OS, DS, PR> EngineBuilder<(), NoopSnapshotStore, OS, DS, PR>
1260where
1261 OS: OutboxStore,
1262 DS: DeadlineStore,
1263 PR: ProcessRegistry,
1264{
1265 /// Create a production-ready builder with explicit stores for outbox,
1266 /// deadline, and process registry.
1267 ///
1268 /// This constructor is available in all build configurations including
1269 /// production binaries. It enforces that the three stores that can cause
1270 /// silent data loss (`OutboxStore`, `DeadlineStore`, `ProcessRegistry`)
1271 /// are provided explicitly — there is no Noop fallback.
1272 ///
1273 /// `NoopSnapshotStore` is used as the snapshot default because it is safe
1274 /// for production: skipping snapshots means full replay, but no data loss.
1275 /// Override with [`with_snapshot_store`] to enable snapshot-accelerated
1276 /// replay.
1277 ///
1278 /// Call [`with_event_store`] before [`build`] — the event store is
1279 /// **required**.
1280 ///
1281 /// ```rust,ignore
1282 /// let ctx = EngineBuilder::with_stores(outbox, deadline, registry)
1283 /// .with_event_store(store.clone())
1284 /// .with_snapshot_store(InMemorySnapshotStore::new())
1285 /// .build();
1286 /// ```
1287 ///
1288 /// [`with_snapshot_store`]: EngineBuilder::with_snapshot_store
1289 /// [`with_event_store`]: EngineBuilder::with_event_store
1290 /// [`build`]: EngineBuilder::build
1291 #[must_use]
1292 pub fn with_stores(outbox_store: OS, deadline_store: DS, registry: PR) -> Self {
1293 Self {
1294 event_store: (),
1295 snapshot_store: NoopSnapshotStore,
1296 outbox_store,
1297 deadline_store,
1298 registry,
1299 dead_letter_sink: Arc::new(LogDeadLetterSink),
1300 modules: Vec::new(),
1301 deployment_roles: DeploymentRoles::all(),
1302 profile_validator: None,
1303 }
1304 }
1305}
1306
1307impl<ES, SS, OS, DS, PR> EngineBuilder<ES, SS, OS, DS, PR> {
1308 /// Set the event store. **Required** — `build()` is only available once
1309 /// this has been called with a type that implements [`EventStore`].
1310 ///
1311 /// Replaces any previously set event store (type-state transition).
1312 #[must_use]
1313 pub fn with_event_store<ES2: EventStore>(
1314 self,
1315 store: ES2,
1316 ) -> EngineBuilder<ES2, SS, OS, DS, PR> {
1317 EngineBuilder {
1318 event_store: store,
1319 snapshot_store: self.snapshot_store,
1320 outbox_store: self.outbox_store,
1321 deadline_store: self.deadline_store,
1322 registry: self.registry,
1323 dead_letter_sink: self.dead_letter_sink,
1324 modules: self.modules,
1325 deployment_roles: self.deployment_roles,
1326 profile_validator: self.profile_validator,
1327 }
1328 }
1329
1330 /// Set the snapshot store (default: [`NoopSnapshotStore`]).
1331 ///
1332 /// ## Default: `NoopSnapshotStore`
1333 ///
1334 /// Without calling this method the builder uses [`NoopSnapshotStore`],
1335 /// which silently discards all snapshot writes and returns `None` for
1336 /// every snapshot read. The engine still functions correctly — every
1337 /// command handling call replays the full event log from the beginning
1338 /// instead of starting from a stored snapshot. For low-volume processes
1339 /// this is fine; for long-lived processes with many events the replay cost
1340 /// can become significant.
1341 ///
1342 /// Enable snapshotting in production by providing a real [`SnapshotStore`]
1343 /// implementation (e.g. the SlateDB-backed store in `makod`). In tests,
1344 /// `InMemorySnapshotStore` is available behind the `testing` feature flag.
1345 ///
1346 /// Note: [`Process::state_with_snapshot`][crate::process::Process::state_with_snapshot]
1347 /// is a compile-time no-op when the snapshot store is `NoopSnapshotStore`
1348 /// — it never calls the store and always returns `None`, so no snapshot is
1349 /// ever saved or loaded.
1350 #[must_use]
1351 pub fn with_snapshot_store<SS2: SnapshotStore>(
1352 self,
1353 store: SS2,
1354 ) -> EngineBuilder<ES, SS2, OS, DS, PR> {
1355 EngineBuilder {
1356 event_store: self.event_store,
1357 snapshot_store: store,
1358 outbox_store: self.outbox_store,
1359 deadline_store: self.deadline_store,
1360 registry: self.registry,
1361 dead_letter_sink: self.dead_letter_sink,
1362 modules: self.modules,
1363 deployment_roles: self.deployment_roles,
1364 profile_validator: self.profile_validator,
1365 }
1366 }
1367
1368 /// Set the outbox store (default: [`NoopOutboxStore`]).
1369 #[must_use]
1370 pub fn with_outbox_store<OS2: OutboxStore>(
1371 self,
1372 store: OS2,
1373 ) -> EngineBuilder<ES, SS, OS2, DS, PR> {
1374 EngineBuilder {
1375 event_store: self.event_store,
1376 snapshot_store: self.snapshot_store,
1377 outbox_store: store,
1378 deadline_store: self.deadline_store,
1379 registry: self.registry,
1380 dead_letter_sink: self.dead_letter_sink,
1381 modules: self.modules,
1382 deployment_roles: self.deployment_roles,
1383 profile_validator: self.profile_validator,
1384 }
1385 }
1386
1387 /// Set the deadline store (default: [`NoopDeadlineStore`]).
1388 #[must_use]
1389 pub fn with_deadline_store<DS2: DeadlineStore>(
1390 self,
1391 store: DS2,
1392 ) -> EngineBuilder<ES, SS, OS, DS2, PR> {
1393 EngineBuilder {
1394 event_store: self.event_store,
1395 snapshot_store: self.snapshot_store,
1396 outbox_store: self.outbox_store,
1397 deadline_store: store,
1398 registry: self.registry,
1399 dead_letter_sink: self.dead_letter_sink,
1400 modules: self.modules,
1401 deployment_roles: self.deployment_roles,
1402 profile_validator: self.profile_validator,
1403 }
1404 }
1405
1406 /// Set the process registry (default: [`NoopProcessRegistry`]).
1407 #[must_use]
1408 pub fn with_registry<PR2: ProcessRegistry>(
1409 self,
1410 registry: PR2,
1411 ) -> EngineBuilder<ES, SS, OS, DS, PR2> {
1412 EngineBuilder {
1413 event_store: self.event_store,
1414 snapshot_store: self.snapshot_store,
1415 outbox_store: self.outbox_store,
1416 deadline_store: self.deadline_store,
1417 registry,
1418 dead_letter_sink: self.dead_letter_sink,
1419 modules: self.modules,
1420 deployment_roles: self.deployment_roles,
1421 profile_validator: self.profile_validator,
1422 }
1423 }
1424
1425 /// Set the dead-letter sink (default: [`LogDeadLetterSink`]).
1426 ///
1427 /// The dead-letter sink receives every message that cannot be routed to a
1428 /// workflow. The default [`LogDeadLetterSink`] emits `tracing::warn!`
1429 /// events, making rejections visible in log output without configuration.
1430 ///
1431 /// Override with a persistent DLQ implementation in production:
1432 ///
1433 /// ```rust,ignore
1434 /// use mako_engine::dead_letter::LogDeadLetterSink;
1435 ///
1436 /// let ctx = EngineBuilder::new()
1437 /// .with_event_store(my_store)
1438 /// .with_dead_letter_sink(MyPersistentDlq::new())
1439 /// .build();
1440 /// ```
1441 ///
1442 /// [`LogDeadLetterSink`]: crate::dead_letter::LogDeadLetterSink
1443 #[must_use]
1444 pub fn with_dead_letter_sink(mut self, sink: impl DeadLetterSink) -> Self {
1445 self.dead_letter_sink = Arc::new(sink);
1446 self
1447 }
1448
1449 /// Register an `edi-energy` profile validator for startup profile checks.
1450 ///
1451 /// The closure receives a message-type string (e.g. `"UTILMD"`) and must
1452 /// return `true` if at least one active profile for that message type is
1453 /// registered for today's date.
1454 ///
1455 /// Wire this in `makod` using the `edi-energy` global registry:
1456 ///
1457 /// ```rust,ignore
1458 /// use edi_energy::registry::ReleaseRegistry;
1459 ///
1460 /// let today = time::OffsetDateTime::now_utc().date();
1461 /// builder.with_profile_validator(move |msg_type| {
1462 /// ReleaseRegistry::global()
1463 /// .profiles_for_str(msg_type)
1464 /// .any(|p| match (p.valid_from(), p.valid_until()) {
1465 /// (Some(f), Some(u)) => f <= today && today <= u,
1466 /// (Some(f), None) => f <= today,
1467 /// (None, _) => true,
1468 /// })
1469 /// })
1470 /// ```
1471 ///
1472 /// Domain crates do **not** need to call this — they only declare
1473 /// [`profile_requirements`].
1474 ///
1475 /// [`profile_requirements`]: EngineModule::profile_requirements
1476 #[must_use]
1477 pub fn with_profile_validator(
1478 mut self,
1479 validator: impl Fn(&str) -> bool + Send + Sync + 'static,
1480 ) -> Self {
1481 self.profile_validator = Some(Box::new(validator));
1482 self
1483 }
1484
1485 /// Register a domain module.
1486 ///
1487 /// The module name becomes visible in
1488 /// [`EngineContext::registered_modules`] after [`build`] is called.
1489 ///
1490 /// [`build`]: EngineBuilder::build
1491 #[must_use]
1492 pub fn register(mut self, module: Box<dyn EngineModule>) -> Self {
1493 self.modules.push(module);
1494 self
1495 }
1496
1497 /// Register multiple [`EngineModule`]s at once from a pre-built `Vec`.
1498 ///
1499 /// Equivalent to calling [`register`] in a loop. Useful when the set of
1500 /// modules is assembled conditionally (e.g. via `#[cfg]`-gated pushes to a
1501 /// `Vec<Box<dyn EngineModule>>`) before the builder chain starts.
1502 ///
1503 /// [`register`]: EngineBuilder::register
1504 #[must_use]
1505 pub fn register_many(mut self, modules: Vec<Box<dyn EngineModule>>) -> Self {
1506 self.modules.extend(modules);
1507 self
1508 }
1509
1510 /// Set the active [`DeploymentRoles`] for this engine instance.
1511 ///
1512 /// Controls role-conditional PID registration in [`EngineModule::register_pids_with_roles`].
1513 ///
1514 /// The default is [`DeploymentRoles::all()`], which registers every PID unconditionally
1515 /// — identical to the pre-role-aware behavior. Providing an explicit role set
1516 /// restricts role-conditional blocks to only the declared roles:
1517 ///
1518 /// - **NB-only** (`DeploymentRoles::nb()`): 19001/19002 route to `gpke-konfiguration`;
1519 /// WiM nMSB blocks are skipped.
1520 /// - **nMSB-only** (`DeploymentRoles::nmsb()`): 19001/19002 route to `wim-geraeteubernahme`;
1521 /// GPKE NB blocks are skipped.
1522 /// - **NB + gMSB** (`DeploymentRoles::nb_msb()`): most common Stadtwerke combination.
1523 ///
1524 /// # Conflict guard
1525 ///
1526 /// When two modules would register the same PID to **different** workflows, the
1527 /// engine panics during [`build`]. Set explicit roles to prevent both modules from
1528 /// activating the same PID simultaneously:
1529 ///
1530 /// ```rust,ignore
1531 /// use mako_engine::marktrolle::DeploymentRoles;
1532 ///
1533 /// let ctx = EngineBuilder::with_stores(outbox, deadline, registry)
1534 /// .with_event_store(store)
1535 /// .with_deployment_roles(DeploymentRoles::nb()) // only NB: GPKE gets 19001/19002
1536 /// .register(Box::new(GpkeModule))
1537 /// .register(Box::new(WimModule)) // nMSB block skipped — no conflict
1538 /// .build();
1539 /// ```
1540 ///
1541 /// [`build`]: EngineBuilder::build
1542 #[must_use]
1543 pub fn with_deployment_roles(mut self, roles: DeploymentRoles) -> Self {
1544 self.deployment_roles = roles;
1545 self
1546 }
1547}
1548
1549impl<ES, SS, OS, DS, PR> EngineBuilder<ES, SS, OS, DS, PR>
1550where
1551 ES: EventStore,
1552 SS: SnapshotStore,
1553 OS: OutboxStore,
1554 DS: DeadlineStore,
1555 PR: ProcessRegistry,
1556{
1557 /// Build the [`EngineContext`].
1558 ///
1559 /// Consumes the builder. All registered modules and configured stores are
1560 /// moved into the returned [`EngineContext`].
1561 ///
1562 /// This method is only available when `ES` implements [`EventStore`].
1563 /// If you have not called [`with_event_store`], this will not compile.
1564 ///
1565 /// # Panics
1566 ///
1567 /// Panics when any registered module returns `Err` from
1568 /// [`EngineModule::configure`]. The panic message includes the module
1569 /// name and the error string so the deployment failure is actionable.
1570 ///
1571 /// [`with_event_store`]: EngineBuilder::with_event_store
1572 #[must_use]
1573 #[allow(clippy::too_many_lines)]
1574 pub fn build(self) -> EngineContext<ES, SS, OS, DS, PR> {
1575 // ── Noop store safety checks ──────────────────────────────────────────
1576 //
1577 // Noop stores lose data silently: NoopDeadlineStore drops every APERAK
1578 // deadline (BNetzA violation), NoopOutboxStore discards all outbound
1579 // messages, NoopProcessRegistry loses conversation routing on restart.
1580 //
1581 // In production builds (no `testing` feature, not running under
1582 // `#[test]`), the Noop constructors are cfg-gated out so this branch
1583 // is dead code and compiles away. In test/testing/tracing builds we
1584 // emit warnings so test harnesses see the configuration in log output.
1585 //
1586 // IMPORTANT: if you are reading this because a panic fired in production,
1587 // it means the `testing` feature was accidentally enabled in the binary.
1588 // Remove it from the production Cargo.toml feature list immediately.
1589 {
1590 let os_name = std::any::type_name::<OS>();
1591 let ds_name = std::any::type_name::<DS>();
1592 let pr_name = std::any::type_name::<PR>();
1593
1594 // Regulatory-critical stores: panic in any build context if these
1595 // are noop. OutboxStore and DeadlineStore must be durable in
1596 // production; ProcessRegistry must survive restarts.
1597 #[cfg(not(any(test, feature = "testing")))]
1598 {
1599 assert!(
1600 !ds_name.contains("NoopDeadlineStore"),
1601 "EngineBuilder::build: NoopDeadlineStore is active in a \
1602 non-testing build. This silently discards all APERAK deadlines, \
1603 which is an immediately reportable BNetzA violation \
1604 (BK6-22-024 §5, BK7-24-01-009). \
1605 Call .with_deadline_store(SlateDbStore::as_deadline_store()) \
1606 in your production engine assembly. \
1607 If this is a test, enable the 'testing' feature."
1608 );
1609 assert!(
1610 !os_name.contains("NoopOutboxStore"),
1611 "EngineBuilder::build: NoopOutboxStore is active in a \
1612 non-testing build. This silently discards all outbound \
1613 APERAK, CONTRL, and UTILMD messages. \
1614 Call .with_outbox_store(SlateDbStore::as_outbox_store()) \
1615 in your production engine assembly. \
1616 If this is a test, enable the 'testing' feature."
1617 );
1618 assert!(
1619 !pr_name.contains("NoopProcessRegistry"),
1620 "EngineBuilder::build: NoopProcessRegistry is active in a \
1621 non-testing build. This means conversation routing \
1622 (PID → stream_id lookup) is lost on every restart, \
1623 breaking all WiM, GeLi Gas, and GPKE in-flight processes. \
1624 Call .with_registry(SlateDbStore::as_process_registry()) \
1625 in your production engine assembly. \
1626 If this is a test, enable the 'testing' feature."
1627 );
1628 }
1629
1630 // In test/testing/tracing builds: emit warnings instead of panicking.
1631 #[cfg(any(test, feature = "testing", feature = "tracing"))]
1632 {
1633 let ss_name = std::any::type_name::<SS>();
1634 if ss_name.contains("NoopSnapshotStore") {
1635 tracing::warn!(
1636 store = ss_name,
1637 "EngineBuilder: NoopSnapshotStore is active — snapshots will not be \
1638 persisted. Use SlateDbStore::as_snapshot_store() in production."
1639 );
1640 }
1641 if os_name.contains("NoopOutboxStore") {
1642 tracing::warn!(
1643 store = os_name,
1644 "EngineBuilder: NoopOutboxStore is active — outbound messages will be \
1645 silently discarded. Use SlateDbStore::as_outbox_store() in production."
1646 );
1647 }
1648 if ds_name.contains("NoopDeadlineStore") {
1649 tracing::warn!(
1650 store = ds_name,
1651 "EngineBuilder: NoopDeadlineStore is active — scheduled deadlines will \
1652 not fire after restart. Use SlateDbStore::as_deadline_store() in production."
1653 );
1654 }
1655 if pr_name.contains("NoopProcessRegistry") {
1656 tracing::warn!(
1657 store = pr_name,
1658 "EngineBuilder: NoopProcessRegistry is active — process routing will be \
1659 lost on restart. Use SlateDbStore::as_process_registry() in production."
1660 );
1661 }
1662 }
1663 }
1664 // Validate every module before assembling the context.
1665 // A missing adapter or misconfigured module fails at startup (not at
1666 // first inbound message), making deployment failures observable immediately.
1667 for module in &self.modules {
1668 if let Err(msg) = module.configure() {
1669 panic!(
1670 "EngineBuilder::build: module '{}' failed configuration validation: {}",
1671 module.name(),
1672 msg
1673 );
1674 }
1675 // Validate profile requirements via the injected validator.
1676 // Domain crates declare requirements; only the binary crate (makod)
1677 // injects the edi-energy registry — domain crates need no edi-energy
1678 // import for this check.
1679 if let Some(ref validator) = self.profile_validator {
1680 for req in module.profile_requirements() {
1681 assert!(
1682 validator(req.message_type),
1683 "EngineBuilder::build: module '{}' requires an active edi-energy \
1684 profile for '{}' ({}) but none is registered for today's date. \
1685 Run `cargo xtask codegen` to add the missing profile.",
1686 module.name(),
1687 req.message_type,
1688 req.label,
1689 );
1690 }
1691 }
1692 }
1693 // Build the PID router from all registered modules.
1694 // Also assert that no two modules claim the same PID — a PID overlap
1695 // is always a configuration error: one module's messages would be
1696 // silently swallowed by another's workflow, producing missing-process
1697 // errors or incorrect audit trails.
1698 let mut pid_router = PidRouter::new();
1699 let mut pid_owners: std::collections::HashMap<u32, &str> = std::collections::HashMap::new();
1700 // Keep each module's scratch router so we can build `pid_router` from
1701 // them in a second pass with the resolved ownership table.
1702 let mut module_scratches: Vec<PidRouter> = Vec::with_capacity(self.modules.len());
1703
1704 // Pass 1 — detect conflicts, determine PID ownership (first-wins for
1705 // explicit roles, last-wins for DeploymentRoles::all()).
1706 for module in &self.modules {
1707 // Temporarily build a scratch router to read this module's PIDs
1708 // for cross-module overlap detection (module-ownership level).
1709 let mut scratch = PidRouter::new();
1710 module.register_pids_with_roles(&mut scratch, &self.deployment_roles);
1711 for pid in scratch.registered_pids() {
1712 if let Some(prev) = pid_owners.insert(pid, module.name()) {
1713 if self.deployment_roles.is_all() {
1714 // With DeploymentRoles::all() (the default), role-conditional PIDs
1715 // are registered by all modules that claim them, producing last-wins
1716 // semantics. This is acceptable for single-role and dev/test deployments.
1717 //
1718 // In production multi-role deployments where both an NB and nMSB role
1719 // are served by the same instance, set explicit roles via
1720 // `EngineBuilder::with_deployment_roles` to prevent silent misrouting.
1721 //
1722 // We emit a debug-level log here (not warn) because the vast majority
1723 // of deployments are single-role and this overlap is expected/harmless.
1724 #[cfg(feature = "tracing")]
1725 tracing::debug!(
1726 pid,
1727 previous_module = prev,
1728 current_module = module.name(),
1729 "PID registered by multiple modules with DeploymentRoles::all(); \
1730 last module wins (use with_deployment_roles for strict routing)",
1731 );
1732 let _ = prev; // suppress unused-variable warning when tracing is off
1733 } else {
1734 // Explicit roles: the FIRST module to register a PID retains ownership.
1735 // Restore the previous (first) owner and emit a warning so the operator
1736 // can investigate. A panic would be too strict: some shared PIDs
1737 // (e.g. REMADV 33001/33002) are legitimately claimed by both GPKE and
1738 // WiM billing; conversation-ID routing is the long-term solution, but
1739 // first-wins gives correct behaviour for all current deployments.
1740 pid_owners.insert(pid, prev); // restore first owner
1741 #[cfg(feature = "tracing")]
1742 tracing::warn!(
1743 pid,
1744 first_module = prev,
1745 second_module = module.name(),
1746 "PID {pid} claimed by both '{prev}' and '{}' with explicit \
1747 DeploymentRoles; first module ('{prev}') retains ownership. \
1748 Verify PID registration is correct for this deployment.",
1749 module.name(),
1750 );
1751 #[cfg(not(feature = "tracing"))]
1752 let _ = prev; // suppress unused-variable warning when tracing is off
1753 }
1754 }
1755 }
1756 module_scratches.push(scratch);
1757 }
1758
1759 // Pass 2 — build the real `pid_router` from the scratch pads, respecting
1760 // the ownership table built in pass 1.
1761 for (module, scratch) in self.modules.iter().zip(module_scratches.iter()) {
1762 // Unambiguous (Sparte-agnostic) entries: only register if this module
1763 // owns the PID in the resolved ownership table.
1764 for pid in scratch.registered_pids() {
1765 if pid_owners.get(&pid).copied() == Some(module.name())
1766 && let Some(wf) = scratch.route(pid)
1767 {
1768 pid_router.register(pid, wf);
1769 }
1770 }
1771 // Commodity (Sparte-qualified) entries use distinct (pid, Sparte) keys
1772 // and never conflict across modules; register them all unconditionally.
1773 for (pid, sparte, wf) in scratch.registered_commodity_entries() {
1774 pid_router.register_with_sparte(pid, sparte, wf);
1775 }
1776 }
1777 let registered_modules = self.modules.iter().map(|m| m.name()).collect();
1778 let registered_workflows = self
1779 .modules
1780 .iter()
1781 .flat_map(|m| m.workflow_names().iter().copied())
1782 .collect();
1783 EngineContext {
1784 event_store: Arc::new(self.event_store),
1785 snapshot_store: self.snapshot_store,
1786 outbox_store: self.outbox_store,
1787 deadline_store: self.deadline_store,
1788 registry: self.registry,
1789 dead_letter_sink: self.dead_letter_sink,
1790 pid_router,
1791 registered_modules,
1792 registered_workflows,
1793 }
1794 }
1795}
1796
1797#[cfg(test)]
1798mod tests {
1799 use super::*;
1800 use crate::{
1801 deadline::InMemoryDeadlineStore,
1802 error::WorkflowError,
1803 event_store::InMemoryEventStore,
1804 ids::TenantId,
1805 outbox::InMemoryOutboxStore,
1806 pid_router::PidRouter,
1807 registry::InMemoryProcessRegistry,
1808 snapshot::InMemorySnapshotStore,
1809 version::WorkflowId,
1810 workflow::{CommandPayload, EventPayload, Workflow},
1811 };
1812
1813 // ── Minimal workflow for spawn/resume tests ───────────────────────────────
1814
1815 #[derive(serde::Serialize, serde::Deserialize)]
1816 struct PingEvent;
1817
1818 impl EventPayload for PingEvent {
1819 fn event_type(&self) -> &'static str {
1820 "Ping"
1821 }
1822 }
1823
1824 struct PingCommand;
1825
1826 impl CommandPayload for PingCommand {}
1827
1828 #[derive(Default, Clone)]
1829 struct PingState;
1830
1831 struct PingWorkflow;
1832
1833 impl Workflow for PingWorkflow {
1834 type State = PingState;
1835 type Event = PingEvent;
1836 type Command = PingCommand;
1837
1838 fn apply(state: PingState, _: &PingEvent) -> PingState {
1839 state
1840 }
1841
1842 fn handle(
1843 _: &PingState,
1844 _: PingCommand,
1845 ) -> Result<crate::workflow::WorkflowOutput<PingEvent>, WorkflowError> {
1846 Ok(vec![PingEvent].into())
1847 }
1848 }
1849
1850 struct TestModule;
1851
1852 impl EngineModule for TestModule {
1853 fn name(&self) -> &'static str {
1854 "test-module"
1855 }
1856 }
1857
1858 // ── Tests ─────────────────────────────────────────────────────────────────
1859
1860 #[test]
1861 fn build_with_event_store_only() {
1862 let ctx = EngineBuilder::new()
1863 .with_event_store(InMemoryEventStore::new())
1864 .build();
1865 assert!(ctx.registered_modules().is_empty());
1866 }
1867
1868 #[test]
1869 fn build_with_all_stores_and_module() {
1870 let ctx = EngineBuilder::new()
1871 .with_event_store(InMemoryEventStore::new())
1872 .with_snapshot_store(InMemorySnapshotStore::new())
1873 .with_outbox_store(InMemoryOutboxStore::new())
1874 .with_deadline_store(InMemoryDeadlineStore::new())
1875 .with_registry(InMemoryProcessRegistry::new())
1876 .register(Box::new(TestModule))
1877 .build();
1878 assert_eq!(ctx.registered_modules(), &["test-module"]);
1879 }
1880
1881 #[test]
1882 fn multiple_modules_ordered() {
1883 struct ModA;
1884 impl EngineModule for ModA {
1885 fn name(&self) -> &'static str {
1886 "mod-a"
1887 }
1888 }
1889 struct ModB;
1890 impl EngineModule for ModB {
1891 fn name(&self) -> &'static str {
1892 "mod-b"
1893 }
1894 }
1895
1896 let ctx = EngineBuilder::new()
1897 .with_event_store(InMemoryEventStore::new())
1898 .register(Box::new(ModA))
1899 .register(Box::new(ModB))
1900 .build();
1901 assert_eq!(ctx.registered_modules(), &["mod-a", "mod-b"]);
1902 }
1903
1904 #[tokio::test]
1905 async fn spawn_creates_independent_processes() {
1906 let ctx = EngineBuilder::new()
1907 .with_event_store(InMemoryEventStore::new())
1908 .build();
1909 let wf_id = WorkflowId::new("ping", "FV2024-10-01");
1910
1911 let p1 = ctx.spawn::<PingWorkflow>(TenantId::new(), wf_id.clone());
1912 let p2 = ctx.spawn::<PingWorkflow>(TenantId::new(), wf_id);
1913
1914 assert_ne!(p1.process_id(), p2.process_id());
1915 }
1916
1917 #[tokio::test]
1918 async fn resume_sees_previously_appended_events() {
1919 let store = InMemoryEventStore::new();
1920 let ctx = EngineBuilder::new().with_event_store(store).build();
1921
1922 let p = ctx.spawn::<PingWorkflow>(TenantId::new(), WorkflowId::new("ping", "FV2024-10-01"));
1923 p.execute(PingCommand).await.unwrap();
1924
1925 let identity = p.identity();
1926 let resumed = ctx.resume::<PingWorkflow>(identity);
1927 assert_eq!(resumed.event_count().await.unwrap(), 1);
1928 }
1929
1930 #[tokio::test]
1931 async fn registry_routes_process_via_conversation_key() {
1932 use crate::registry::RegistryKey;
1933 let ctx = EngineBuilder::new()
1934 .with_event_store(InMemoryEventStore::new())
1935 .with_registry(InMemoryProcessRegistry::new())
1936 .build();
1937
1938 let p = ctx.spawn::<PingWorkflow>(TenantId::new(), WorkflowId::new("ping", "FV2024-10-01"));
1939 let tenant = p.tenant_id();
1940 let conv_key = RegistryKey::parse("conv:test-conversation-123").expect("valid key");
1941 ctx.registry()
1942 .register(tenant, &conv_key, p.identity())
1943 .await
1944 .unwrap();
1945
1946 let found = ctx
1947 .registry()
1948 .lookup(tenant, &conv_key)
1949 .await
1950 .unwrap()
1951 .expect("must be registered");
1952 let resumed = ctx.resume::<PingWorkflow>(found);
1953 assert_eq!(resumed.process_id(), p.process_id());
1954 }
1955
1956 #[test]
1957 fn pid_router_populated_by_module_register_pids() {
1958 struct PidModule;
1959 impl EngineModule for PidModule {
1960 fn name(&self) -> &'static str {
1961 "pid-module"
1962 }
1963 fn register_pids(&self, router: &mut PidRouter) {
1964 router.register(55001, "gpke-supplier-change");
1965 router.register(55002, "gpke-supplier-change");
1966 }
1967 }
1968
1969 let ctx = EngineBuilder::new()
1970 .with_event_store(InMemoryEventStore::new())
1971 .register(Box::new(PidModule))
1972 .build();
1973
1974 assert_eq!(ctx.pid_router().route(55001), Some("gpke-supplier-change"));
1975 assert_eq!(ctx.pid_router().route(55002), Some("gpke-supplier-change"));
1976 assert!(ctx.pid_router().route(99999).is_none());
1977 assert_eq!(ctx.pid_router().len(), 2);
1978 }
1979
1980 /// Verify that `register_pids_with_roles` gates PIDs behind role checks.
1981 ///
1982 /// Scenario: two modules share PID 19001.
1983 /// - ModuleA registers 19001 → "workflow-a" when role `Nb` is present.
1984 /// - ModuleB registers 19001 → "workflow-b" when role `Nmsb` is explicitly set
1985 /// (not on `all()`).
1986 ///
1987 /// - `all()`: ModuleA fires (Nb ∈ all), ModuleB does NOT (is_all → skip).
1988 /// → 19001 routes to "workflow-a".
1989 /// - `from_roles([Nb])`: ModuleA fires, ModuleB skips.
1990 /// → 19001 routes to "workflow-a".
1991 /// - `from_roles([Nmsb])`: ModuleA skips, ModuleB fires.
1992 /// → 19001 routes to "workflow-b".
1993 #[test]
1994 fn register_pids_with_roles_gates_pids_correctly() {
1995 use crate::marktrolle::{DeploymentRoles, Marktrolle};
1996
1997 struct ModuleA;
1998 impl EngineModule for ModuleA {
1999 fn name(&self) -> &'static str {
2000 "module-a"
2001 }
2002 fn register_pids_with_roles(&self, router: &mut PidRouter, roles: &DeploymentRoles) {
2003 if roles.contains(Marktrolle::Nb) {
2004 router.register(19_001, "workflow-a");
2005 }
2006 }
2007 }
2008
2009 struct ModuleB;
2010 impl EngineModule for ModuleB {
2011 fn name(&self) -> &'static str {
2012 "module-b"
2013 }
2014 fn register_pids_with_roles(&self, router: &mut PidRouter, roles: &DeploymentRoles) {
2015 // Only fires on explicit Nmsb, not on all() (backward-compat sentinel).
2016 if !roles.is_all() && roles.contains(Marktrolle::Nmsb) {
2017 router.register(19_001, "workflow-b");
2018 router.register(19_015, "workflow-b");
2019 }
2020 }
2021 }
2022
2023 let build = |roles: DeploymentRoles| {
2024 EngineBuilder::new()
2025 .with_event_store(InMemoryEventStore::new())
2026 .with_deployment_roles(roles)
2027 .register(Box::new(ModuleA))
2028 .register(Box::new(ModuleB))
2029 .build()
2030 };
2031
2032 // all() → backward compat: ModuleA registers 19001 (Nb ∈ all), ModuleB skips.
2033 let ctx = build(DeploymentRoles::all());
2034 assert_eq!(ctx.pid_router().route(19_001), Some("workflow-a"));
2035 assert!(ctx.pid_router().route(19_015).is_none());
2036
2037 // Explicit Nb → same result: ModuleA registers, ModuleB (nMSB) skips.
2038 let ctx = build(DeploymentRoles::nb());
2039 assert_eq!(ctx.pid_router().route(19_001), Some("workflow-a"));
2040 assert!(ctx.pid_router().route(19_015).is_none());
2041
2042 // Explicit Nmsb → ModuleA skips (Nb ∉ roles), ModuleB registers.
2043 let ctx = build(DeploymentRoles::nmsb());
2044 assert_eq!(ctx.pid_router().route(19_001), Some("workflow-b"));
2045 assert_eq!(ctx.pid_router().route(19_015), Some("workflow-b"));
2046 }
2047
2048 /// Verify that explicit roles with two conflicting modules use first-wins semantics
2049 /// (the first module to register a PID retains ownership; the second is silently skipped).
2050 #[test]
2051 fn register_pids_with_roles_conflict_uses_first_wins_with_explicit_roles() {
2052 use crate::marktrolle::{DeploymentRoles, Marktrolle};
2053
2054 struct ConflictA;
2055 impl EngineModule for ConflictA {
2056 fn name(&self) -> &'static str {
2057 "conflict-a"
2058 }
2059 fn register_pids_with_roles(&self, router: &mut PidRouter, roles: &DeploymentRoles) {
2060 if roles.contains(Marktrolle::Nb) {
2061 router.register(19_001, "workflow-a");
2062 }
2063 }
2064 }
2065
2066 struct ConflictB;
2067 impl EngineModule for ConflictB {
2068 fn name(&self) -> &'static str {
2069 "conflict-b"
2070 }
2071 fn register_pids_with_roles(&self, router: &mut PidRouter, roles: &DeploymentRoles) {
2072 if !roles.is_all() && roles.contains(Marktrolle::Nmsb) {
2073 router.register(19_001, "workflow-b"); // same PID, different workflow
2074 }
2075 }
2076 }
2077
2078 // from_roles([Nb, Nmsb]): both modules fire for PID 19_001.
2079 // First-wins: ConflictA (registered first) retains ownership → "workflow-a".
2080 let ctx = EngineBuilder::new()
2081 .with_event_store(InMemoryEventStore::new())
2082 .with_deployment_roles(DeploymentRoles::from_roles([
2083 Marktrolle::Nb,
2084 Marktrolle::Nmsb,
2085 ]))
2086 .register(Box::new(ConflictA))
2087 .register(Box::new(ConflictB))
2088 .build();
2089 assert_eq!(
2090 ctx.pid_router().route(19_001),
2091 Some("workflow-a"),
2092 "first module should win on PID conflict with explicit roles"
2093 );
2094 }
2095}