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