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 /// Two modules claiming one PID for different workflows panics in
161 /// [`PidRouter::register_with_module`] while the engine is being built, so
162 /// the conflict stops the daemon rather than routing a message to whichever
163 /// module registered last.
164 ///
165 /// [`PidRouter::register_with_module`]: crate::pid_router::PidRouter::register_with_module
166 ///
167 /// For role-conditional registration (PIDs that should only be active for
168 /// specific BDEW Marktrollen), override [`register_pids_with_roles`] instead.
169 ///
170 /// # Example
171 ///
172 /// ```rust,ignore
173 /// fn register_pids(&self, router: &mut PidRouter) {
174 /// // GPKE Lieferantenwechsel / Lieferbeginn (BK6-22-024, PIDs 55001, 55002, 55017)
175 /// for &pid in &[55001_u32, 55002, 55017] {
176 /// router.register(pid, "gpke-supplier-change");
177 /// }
178 /// }
179 /// ```
180 ///
181 /// [`register_pids_with_roles`]: EngineModule::register_pids_with_roles
182 fn register_pids(&self, _router: &mut PidRouter) {}
183
184 /// Register PIDs with role-context awareness.
185 ///
186 /// This is the **preferred override** for modules that have role-conditional
187 /// PID registrations — PIDs that should only be active when this `makod`
188 /// instance holds a specific [`Marktrolle`].
189 ///
190 /// The default implementation calls [`register_pids`] (role-agnostic) so
191 /// existing modules that override `register_pids` continue to work without
192 /// changes.
193 ///
194 /// Override this method instead of `register_pids` when any PID registration
195 /// should be conditional on the deployment role:
196 ///
197 /// ```rust,ignore
198 /// use mako_engine::marktrolle::Marktrolle;
199 ///
200 /// fn register_pids_with_roles(&self, router: &mut PidRouter, roles: &DeploymentRoles) {
201 /// // Always register: 55001, 55002 (not role-specific)
202 /// for pid in [55001_u32, 55002] { router.register_with_module(pid, "gpke-supplier-change", self.name()); }
203 ///
204 /// // Only when NB role: 19001/19002 inbound ORDRSP from MSB
205 /// if roles.contains(Marktrolle::Nb) {
206 /// for pid in [19001_u32, 19002] { router.register_with_module(pid, "gpke-konfiguration", self.name()); }
207 /// }
208 /// }
209 /// ```
210 ///
211 /// # Conflict guard
212 ///
213 /// Use [`PidRouter::register_with_module`] (not `register`) inside this
214 /// method. The conflict guard panics at build time if two modules register
215 /// the same PID to different workflows — this makes role misconfigurations
216 /// visible at startup rather than silently misrouting messages.
217 ///
218 /// [`Marktrolle`]: crate::marktrolle::Marktrolle
219 /// [`register_pids`]: EngineModule::register_pids
220 fn register_pids_with_roles(&self, router: &mut PidRouter, _roles: &DeploymentRoles) {
221 self.register_pids(router);
222 }
223
224 /// Every workflow this module owns, named.
225 ///
226 /// These names are what [`EngineContext::registered_workflows`] collects,
227 /// and consumers build their deadline-dispatch coverage from that list — so
228 /// this declaration, not [`register_pids`], is what makes a workflow's
229 /// Fristen checkable.
230 ///
231 /// # The invariant
232 ///
233 /// **Every name [`register_pids`] routes to must appear here.**
234 /// [`EngineBuilder::build`] panics otherwise, per module. A workflow that
235 /// is routed but undeclared still runs — it just becomes invisible to every
236 /// check made over the declarations, so a deadline it registers is never
237 /// held against a dispatch arm and fires into nothing.
238 ///
239 /// The converse is deliberately allowed: a command-initiated workflow (one
240 /// an ERP starts over the command API) declares a name and routes no
241 /// inbound Prüfidentifikator.
242 ///
243 /// Prefer each module's own `WORKFLOW_NAME` constant over a string literal.
244 /// A literal here can disagree with the name `register_pids` routes to, and
245 /// the two are only compared at build time:
246 ///
247 /// ```rust,ignore
248 /// fn workflow_names(&self) -> &'static [&'static str] {
249 /// &[wechselprozesse::WORKFLOW_NAME, abrechnung::WORKFLOW_NAME]
250 /// }
251 /// ```
252 ///
253 /// The default implementation returns an empty slice, which is correct only
254 /// for a module that routes no PIDs at all.
255 ///
256 /// [`register_pids`]: EngineModule::register_pids
257 /// [`EngineContext::registered_workflows`]: crate::builder::EngineContext::registered_workflows
258 fn workflow_names(&self) -> &'static [&'static str] {
259 &[]
260 }
261
262 /// Declare the EDIFACT profile types this module requires at runtime.
263 ///
264 /// Returning a non-empty slice causes [`EngineBuilder::build`] to call the
265 /// registered profile validator for each requirement. If no active profile
266 /// exists for a required message type, `build` panics with an actionable
267 /// error so deployment fails fast rather than silently.
268 ///
269 /// Domain crates declare their format requirements here rather than
270 /// calling `edi_energy::registry::ReleaseRegistry::global()` inside
271 /// `configure()`, so `edi-energy` stays out of their production
272 /// `[dependencies]`.
273 ///
274 /// ```rust,ignore
275 /// fn profile_requirements(&self) -> &'static [ProfileRequirement] {
276 /// &[
277 /// ProfileRequirement { message_type: "UTILMD", label: "UTILMD Strom (GPKE)" },
278 /// ProfileRequirement { message_type: "INVOIC", label: "INVOIC Abrechnung (GPKE)" },
279 /// ]
280 /// }
281 /// ```
282 ///
283 /// [`ProfileRequirement`]: crate::profile::ProfileRequirement
284 fn profile_requirements(&self) -> &'static [crate::profile::ProfileRequirement] {
285 &[]
286 }
287
288 /// Validate adapter coverage and configuration at engine startup.
289 ///
290 /// Called by [`EngineBuilder::build`] after all modules are registered.
291 /// Return `Ok(())` when the module is fully configured. Return `Err(msg)`
292 /// with an actionable description when an adapter or configuration is
293 /// missing — the engine will panic with that message so the deployment
294 /// fails early rather than silently.
295 ///
296 /// The default implementation is a no-op (always returns `Ok(())`).
297 /// Override it in domain crates to call
298 /// [`AdapterRegistry::validate_policy`] and emit structured errors.
299 ///
300 /// Note: if your validation needs access to the edi-energy profile
301 /// registry, use [`profile_requirements`] instead — it does not require
302 /// importing `edi-energy` in domain crates.
303 ///
304 /// [`AdapterRegistry::validate_policy`]: crate::message_adapter::AdapterRegistry::validate_policy
305 /// [`profile_requirements`]: EngineModule::profile_requirements
306 ///
307 /// # Errors
308 ///
309 /// Returns a descriptive error string when the module's configuration is invalid.
310 fn configure(&self) -> Result<(), String> {
311 Ok(())
312 }
313}
314
315// ── EngineContext ─────────────────────────────────────────────────────────────
316
317/// Assembled engine infrastructure returned by [`EngineBuilder::build`].
318///
319/// `EngineContext` bundles all stores and the process registry into a single
320/// value. It is the root dependency for:
321///
322/// - Spawning new processes ([`spawn`])
323/// - Resuming existing processes ([`resume`])
324/// - Running outbox delivery workers (`outbox_store.pending_now(…)`)
325/// - Driving the deadline scheduler (`deadline_store.due_now(…)`)
326///
327/// ## Generic parameters
328///
329/// | Param | Role | Default |
330/// |-------|------|---------|
331/// | `ES` | [`EventStore`] backend | — (required) |
332/// | `SS` | [`SnapshotStore`] backend | [`NoopSnapshotStore`] |
333/// | `OS` | [`OutboxStore`] backend | [`NoopOutboxStore`] |
334/// | `DS` | [`DeadlineStore`] backend | [`NoopDeadlineStore`] |
335/// | `PR` | [`ProcessRegistry`] backend | [`NoopProcessRegistry`] |
336///
337/// In most codebases all type parameters are inferred from the builder calls.
338///
339/// [`spawn`]: EngineContext::spawn
340/// [`resume`]: EngineContext::resume
341pub struct EngineContext<
342 ES,
343 SS = NoopSnapshotStore,
344 OS = NoopOutboxStore,
345 DS = NoopDeadlineStore,
346 PR = NoopProcessRegistry,
347> {
348 event_store: Arc<ES>,
349 snapshot_store: SS,
350 outbox_store: OS,
351 deadline_store: DS,
352 registry: PR,
353 /// Dead-letter sink for unroutable or unprocessable inbound messages.
354 ///
355 /// Stored as `Arc<dyn DeadLetterSink>` so callers can share it across
356 /// tasks without an extra type parameter on `EngineContext`.
357 pub dead_letter_sink: Arc<dyn DeadLetterSink>,
358 /// PID-to-workflow routing table, populated from all registered modules.
359 pid_router: PidRouter,
360 registered_modules: Vec<&'static str>,
361 /// Workflow names declared by all registered modules via
362 /// [`EngineModule::workflow_names`]. Used to validate deadline scheduler
363 /// coverage at runtime (see [`EngineContext::registered_workflows`]).
364 registered_workflows: Vec<&'static str>,
365}
366
367// ── Type aliases ──────────────────────────────────────────────────────────────
368
369/// An [`EngineContext`] with all optional subsystems disabled.
370///
371/// Uses `NoopSnapshotStore` and, in `testing`-enabled builds, Noop
372/// implementations for outbox, deadline, and process registry. Suitable for
373/// tests and minimal deployments where only a durable event store is required.
374///
375/// All five type parameters are inferred from context when used with
376/// [`EngineBuilder`]:
377///
378/// ```rust,ignore
379/// // Only available in test / testing-feature builds:
380/// use mako_engine::builder::{EngineBuilder, MinimalEngine};
381/// use mako_engine::event_store::InMemoryEventStore;
382///
383/// let ctx: MinimalEngine<InMemoryEventStore> = EngineBuilder::new()
384/// .with_event_store(InMemoryEventStore::new())
385/// .build();
386/// ```
387pub type MinimalEngine<ES> = EngineContext<ES>;
388
389impl<ES, SS, OS, DS, PR> EngineContext<ES, SS, OS, DS, PR>
390where
391 ES: EventStore,
392{
393 /// Spawn a new process and return a typed `Process<W, Arc<ES>>` handle.
394 ///
395 /// No `ES: Clone` bound is required — the engine stores the event store
396 /// behind an `Arc` so spawning is always a cheap pointer clone.
397 ///
398 /// ```rust,ignore
399 /// let p = ctx.spawn::<SupplierChangeWorkflow>(tenant_id, workflow_id);
400 /// p.execute(ReceiveUtilmd { .. }).await?;
401 /// ```
402 #[must_use]
403 pub fn spawn<W: Workflow>(
404 &self,
405 tenant_id: TenantId,
406 workflow_id: WorkflowId,
407 ) -> Process<W, Arc<ES>> {
408 Process::new(Arc::clone(&self.event_store), tenant_id, workflow_id)
409 }
410
411 /// Resume an existing process from a [`ProcessIdentity`].
412 ///
413 /// ```rust,ignore
414 /// let identity = ctx.registry()
415 /// .lookup(tenant_id, &conv_id.to_string())
416 /// .await?
417 /// .ok_or(EngineError::Registry("unknown conversation".into()))?;
418 /// let p = ctx.resume::<SupplierChangeWorkflow>(identity);
419 /// p.execute(HandleAperak { .. }).await?;
420 /// ```
421 #[must_use]
422 pub fn resume<W: Workflow>(&self, identity: ProcessIdentity) -> Process<W, Arc<ES>> {
423 Process::from_identity(Arc::clone(&self.event_store), identity)
424 }
425
426 /// Names of all domain modules registered with the builder, in
427 /// registration order.
428 #[must_use]
429 pub fn registered_modules(&self) -> &[&'static str] {
430 &self.registered_modules
431 }
432
433 /// Workflow names declared by all registered modules, in registration order.
434 ///
435 /// Use this in the deadline scheduler dispatch function to detect unknown
436 /// workflow names at startup. If a deadline fires for a workflow name that
437 /// is not in this list, the scheduler's dispatch function should emit an
438 /// error rather than silently dropping the deadline:
439 ///
440 /// ```rust,ignore
441 /// let known = ctx.registered_workflows().iter().copied().collect::<HashSet<_>>();
442 /// let scheduler = ctx.run_deadline_scheduler(
443 /// move |deadline| {
444 /// let wf = deadline.workflow_id().name.as_ref();
445 /// if !known.contains(wf) {
446 /// tracing::error!(workflow = %wf, "deadline fired for unregistered workflow");
447 /// return Box::pin(async { Ok(()) });
448 /// }
449 /// // dispatch by workflow name …
450 /// Box::pin(async { Ok(()) })
451 /// },
452 /// 100,
453 /// Duration::from_secs(30),
454 /// );
455 /// ```
456 #[must_use]
457 pub fn registered_workflows(&self) -> &[&'static str] {
458 &self.registered_workflows
459 }
460
461 /// The event store backend (behind an `Arc`).
462 #[must_use]
463 pub fn event_store(&self) -> &Arc<ES> {
464 &self.event_store
465 }
466
467 /// The snapshot store backend.
468 #[must_use]
469 pub fn snapshot_store(&self) -> &SS {
470 &self.snapshot_store
471 }
472
473 /// The outbox store backend.
474 ///
475 /// Poll `outbox_store().pending_now(limit)` in a background task to drain
476 /// the delivery queue.
477 #[must_use]
478 pub fn outbox_store(&self) -> &OS {
479 &self.outbox_store
480 }
481
482 /// The deadline store backend.
483 ///
484 /// Poll `deadline_store().due_now(limit)` in a background scheduler to
485 /// fire overdue process timers.
486 #[must_use]
487 pub fn deadline_store(&self) -> &DS {
488 &self.deadline_store
489 }
490
491 /// The process routing registry.
492 ///
493 /// Register a [`ProcessIdentity`] under a `(tenant_id, key)` pair at
494 /// process creation, then `lookup` it when routing inbound messages.
495 #[must_use]
496 pub fn registry(&self) -> &PR {
497 &self.registry
498 }
499
500 /// The dead-letter sink for unroutable or unprocessable messages.
501 ///
502 /// Call [`DeadLetterSink::reject`] when an inbound message cannot be
503 /// dispatched to any workflow. The default sink emits `tracing::warn!`
504 /// so rejections are always visible in the log output.
505 #[must_use]
506 pub fn dead_letter_sink(&self) -> &Arc<dyn DeadLetterSink> {
507 &self.dead_letter_sink
508 }
509
510 /// Assert that no Noop store is active — call this during production startup.
511 ///
512 /// Checks the type names of `OS`, `DS`, and `PR` against the string `"Noop"`.
513 /// Panics with a human-readable message if any match, directing the operator
514 /// to configure a persistent backend.
515 ///
516 /// # When to call
517 ///
518 /// Call this early in `makod`'s startup path (and `--check` mode) to catch
519 /// deployments where a Noop store was accidentally wired — e.g. the
520 /// `[outbox]`, `[deadline]`, or `[registry]` configuration section was
521 /// omitted from `makod.toml`. The check is defence-in-depth: in release
522 /// builds without the `testing` feature, Noop stores cannot implement the
523 /// required traits at all and the compiler would have already rejected them.
524 ///
525 /// # Panics
526 ///
527 /// Panics when any of `OS`, `DS`, or `PR` is a Noop implementation.
528 pub fn assert_production_stores(&self) {
529 let checks: &[(&str, &str)] = &[
530 ("OutboxStore", std::any::type_name::<OS>()),
531 ("DeadlineStore", std::any::type_name::<DS>()),
532 ("ProcessRegistry", std::any::type_name::<PR>()),
533 ];
534 for (trait_name, type_name) in checks {
535 assert!(
536 !type_name.contains("Noop"),
537 "makod: Noop{trait_name} is active — \
538 configure a persistent {trait_name} backend in makod.toml. \
539 Type resolved to: {type_name}"
540 );
541 }
542 }
543
544 /// The PID-to-workflow routing table.
545 ///
546 /// Populated **once** during [`EngineBuilder::build`] by calling
547 /// [`EngineModule::register_pids`] on every registered module in
548 /// registration order. After `build` returns the table is **sealed** —
549 /// it is read-only for the lifetime of the `EngineContext` and may be
550 /// freely shared across async tasks without synchronisation.
551 ///
552 /// # Mutability contract
553 ///
554 /// There is intentionally no `pid_router_mut()` accessor. Adding PIDs
555 /// after the engine is built would create a TOCTOU race between the
556 /// dispatch path (which calls `route(pid)`) and any hypothetical
557 /// concurrent mutator. Instead, register all PIDs during the build phase
558 /// via `EngineModule::register_pids`.
559 ///
560 /// If a new process family needs to be added without restarting the
561 /// binary, rebuild and restart `makod` — hot-swap of PID routing is not
562 /// supported.
563 ///
564 /// # Example — dispatch at the AS4 reception boundary
565 ///
566 /// ```rust,ignore
567 /// let workflow_name = ctx.pid_router().route(pid)
568 /// .ok_or_else(|| EngineError::Workflow(WorkflowError::InvalidCommand(
569 /// format!("no workflow registered for PID {pid}").into()
570 /// )))?;
571 ///
572 /// match workflow_name {
573 /// "gpke-supplier-change" => dispatch::<GpkeSupplierChangeWorkflow>(&ctx, pid, payload).await,
574 /// "wim-device-change" => dispatch::<WimDeviceChangeWorkflow>(&ctx, pid, payload).await,
575 /// other => Err(EngineError::Workflow(WorkflowError::InvalidCommand(
576 /// format!("unhandled workflow name: {other}").into()
577 /// ))),
578 /// }
579 /// ```
580 #[must_use]
581 pub fn pid_router(&self) -> &PidRouter {
582 &self.pid_router
583 }
584}
585
586// ── As4Sender ─────────────────────────────────────────────────────────────────
587
588/// Sends a single AS4 / EDIINT-over-HTTP outbound message.
589///
590/// Implement this trait for your AS4 gateway client and pass it to
591/// [`EngineContext::run_outbox_worker`].
592///
593/// # Contract
594///
595/// Return `Ok(())` only after the message has been **durably accepted** by the
596/// receiving MSH. Return `Err(…)` on transient or permanent failure — the
597/// outbox worker calls [`OutboxStore::reschedule`] so the message is retried.
598pub trait As4Sender: Send + Sync + 'static {
599 /// Transmit `msg` and return when the remote MSH has accepted it.
600 fn send(
601 &self,
602 msg: &OutboxMessage,
603 ) -> impl std::future::Future<Output = Result<(), EngineError>> + Send;
604
605 /// Whether this sender owns `msg`.
606 ///
607 /// One outbox can feed more than one consumer — a wire transport and an ERP
608 /// notifier, say — and nothing else in the store says which message belongs
609 /// to which. Without an ownership rule every consumer picks up every
610 /// message: the ERP notifier skipped what it did not recognise, but the
611 /// transport had no such filter and put internal lifecycle notifications on
612 /// the wire to the market partner, as raw JSON, because they have no
613 /// EDIFACT renderer.
614 ///
615 /// Returning `false` makes the worker leave the message untouched — not
616 /// rescheduled, not dead-lettered, not counted as an attempt — for whichever
617 /// consumer does own it. The default claims everything, which is right for
618 /// the single-consumer deployments this trait started with.
619 fn handles(&self, msg: &OutboxMessage) -> bool {
620 let _ = msg;
621 true
622 }
623}
624
625// ── OutboxWorker ──────────────────────────────────────────────────────────────
626
627/// A background worker that drains the outbox by polling pending
628/// [`OutboxMessage`]s and dispatching them via an [`As4Sender`].
629///
630/// Obtain via [`EngineContext::run_outbox_worker`] and drive by spawning
631/// [`OutboxWorker::run`] in a Tokio task.
632///
633/// # Polling behaviour
634///
635/// When the poll returns an empty batch the worker sleeps for `poll_interval`
636/// before polling again. Non-empty batches are processed immediately.
637///
638/// # Error handling
639///
640/// Successful sends are acknowledged via [`OutboxStore::acknowledge`].
641/// Failed sends are rescheduled via [`OutboxStore::reschedule`] using
642/// **full-jitter exponential backoff**: `delay = rand(0, min(MAX, BASE * 2^n))`
643/// where `n = attempt_count`. This avoids thundering-herd when multiple
644/// `makod` instances restart simultaneously after a receiver outage.
645///
646/// When `attempt_count >= max_attempts`, the message is **acknowledged** (removed
647/// from the outbox) and a [`DeadLetterReason::OutboxExhausted`] record is written
648/// to the dead-letter sink. This prevents permanently-undeliverable messages
649/// from clogging the outbox forever.
650///
651/// All errors are emitted as structured `tracing` events at `warn` / `error`
652/// level rather than `eprintln!`, so they appear in the application's log
653/// pipeline with full context (message_id, error).
654///
655/// # Example
656///
657/// ```rust,ignore
658/// use std::time::Duration;
659///
660/// let worker = ctx.run_outbox_worker(my_sender, 50, Duration::from_secs(1));
661/// tokio::spawn(async move { worker.run().await });
662/// ```
663///
664/// [`DeadLetterReason::OutboxExhausted`]: crate::dead_letter::DeadLetterReason::OutboxExhausted
665pub struct OutboxWorker<OS: OutboxStore, S: As4Sender, DS: DeadlineStore> {
666 store: OS,
667 sender: S,
668 /// Used to discharge a delivery-window deadline once the message it was
669 /// watching has actually been sent — see [`OutboxWorker::run`].
670 deadline_store: DS,
671 batch_size: usize,
672 poll_interval: std::time::Duration,
673 /// Maximum total delivery attempts before a message is dead-lettered — a
674 /// runaway belt, not the budget. The budget is [`Self::max_retry_window`]:
675 /// the backoff is full-jitter, so an attempt *count* cannot promise a
676 /// retry *duration*, and the BDEW retry duty is stated in hours.
677 max_attempts: u32,
678 /// Maximum age (from `created_at`) a message is retried for before it is
679 /// dead-lettered. This is what honours a time-stated retry duty (BDEW AS4
680 /// Kommunikationshandbuch: 72 h for unacknowledged messages) — see
681 /// `mako_as4::constants::MAX_RETRY_DURATION_SECS`.
682 ///
683 /// Checked only after at least one attempt: a message that aged in a
684 /// stopped worker still gets its first try rather than being buried
685 /// unsent.
686 max_retry_window: std::time::Duration,
687 /// Sink for messages that exceed `max_attempts` or `max_retry_window`.
688 dead_letter_sink: std::sync::Arc<dyn crate::dead_letter::DeadLetterSink>,
689 /// Optional liveness heartbeat — stores the current UTC Unix timestamp
690 /// (seconds) after each poll cycle so health probes can detect stale workers.
691 heartbeat: Option<std::sync::Arc<std::sync::atomic::AtomicI64>>,
692 /// Graceful-shutdown signal. When cancelled the worker finishes the message
693 /// it is delivering, then returns from [`OutboxWorker::run`] — see
694 /// [`OutboxWorker::with_shutdown`].
695 shutdown: Option<tokio_util::sync::CancellationToken>,
696}
697
698/// Sleep for `dur`, returning early if `token` is cancelled.
699///
700/// Returns `true` when the sleep completed and the caller should keep looping,
701/// `false` when the token was cancelled and the caller must return.
702///
703/// A worker that sleeps on a bare `tokio::time::sleep` cannot observe a
704/// shutdown until its poll interval elapses. For the deadline scheduler that is
705/// 30 seconds by default — longer than a typical container termination grace
706/// period, which turns a graceful drain into a SIGKILL.
707///
708/// Public so that binaries running their own poll-loop workers alongside the
709/// engine's (projection catch-up, webhook delivery, retention purges) can honour
710/// the same token and stop before the store is closed.
711pub async fn sleep_or_cancel(
712 dur: std::time::Duration,
713 token: Option<&tokio_util::sync::CancellationToken>,
714) -> bool {
715 let Some(t) = token else {
716 tokio::time::sleep(dur).await;
717 return true;
718 };
719 tokio::select! {
720 () = tokio::time::sleep(dur) => true,
721 () = t.cancelled() => false,
722 }
723}
724
725/// Compute a full-jitter exponential backoff delay.
726///
727/// `attempt` is the number of prior attempts (0 = first retry).
728/// `entropy` provides randomness; derive from a stable message identifier
729/// (e.g. hash of `message_id`) rather than the current timestamp — a
730/// timestamp-derived value is deterministic within a single batch, which
731/// defeats jitter when multiple messages fail simultaneously.
732///
733/// | attempt | window (s) | expected delay (s) |
734/// |---------|------------|-------------------|
735/// | 0 | 5 | 2.5 |
736/// | 1 | 10 | 5 |
737/// | 2 | 20 | 10 |
738/// | 3 | 40 | 20 |
739/// | 4 | 80 | 40 |
740/// | 5+ | 300 (cap) | 150 |
741fn backoff_delay(attempt: u32, entropy: u64) -> std::time::Duration {
742 const BASE_SECS: u64 = 5;
743 const MAX_SECS: u64 = 300;
744 // Exponential window: BASE * 2^attempt, capped at MAX.
745 let window = BASE_SECS
746 .saturating_mul(1u64.wrapping_shl(attempt.min(5)))
747 .min(MAX_SECS);
748 // Full jitter: uniform random in [0, window).
749 let jitter_secs = if window == 0 { 0 } else { entropy % window };
750 std::time::Duration::from_secs(jitter_secs)
751}
752
753impl<OS: OutboxStore, S: As4Sender, DS: DeadlineStore> OutboxWorker<OS, S, DS> {
754 /// Run the outbox drain loop until the shutdown token is cancelled.
755 ///
756 /// Without a token (see [`OutboxWorker::with_shutdown`]) the loop runs until
757 /// the task is aborted or the process exits. With one, cancellation is
758 /// observed between messages and during the idle sleep, so an in-flight
759 /// delivery is always finished and acknowledged before the worker returns —
760 /// dropping it mid-`send` would risk a duplicate AS4 delivery on restart.
761 ///
762 /// # Panics
763 ///
764 /// Panics if `time::Duration::try_from(delay)` overflows (unreachable for
765 /// the delay values produced by `backoff_delay`).
766 #[allow(clippy::too_many_lines)]
767 pub async fn run(self) {
768 loop {
769 if self
770 .shutdown
771 .as_ref()
772 .is_some_and(tokio_util::sync::CancellationToken::is_cancelled)
773 {
774 tracing::info!("outbox worker: shutdown signalled; stopping");
775 return;
776 }
777 // Tick liveness at the *start* of every poll cycle, ahead of the
778 // early-`continue` paths below. An idle worker (empty outbox) and
779 // one retrying after a store error are both alive and must keep
780 // ticking; only a worker genuinely hung inside an `.await` stops.
781 if let Some(ref hb) = self.heartbeat {
782 hb.store(
783 time::OffsetDateTime::now_utc().unix_timestamp(),
784 std::sync::atomic::Ordering::Relaxed,
785 );
786 }
787
788 let batch = match self.store.pending_now(self.batch_size).await {
789 Ok(b) => b,
790 Err(e) => {
791 tracing::warn!(error = %e, "outbox worker: store error polling pending messages (will retry)");
792 if !sleep_or_cancel(self.poll_interval, self.shutdown.as_ref()).await {
793 return;
794 }
795 continue;
796 }
797 };
798
799 if batch.is_empty() {
800 if !sleep_or_cancel(self.poll_interval, self.shutdown.as_ref()).await {
801 return;
802 }
803 continue;
804 }
805
806 // A batch of nothing but other consumers' messages must still sleep.
807 // Polling a queue that is full of another worker's traffic and
808 // looping straight back is a busy spin that burns a core and starves
809 // the runtime — the exact opposite of what the ownership rule is for.
810 let mut handled_any = false;
811 for msg in batch {
812 // Between messages, not inside one: a `send` that is already in
813 // flight must run to its `acknowledge`, or the counterparty
814 // receives a message the outbox still believes is pending and
815 // redelivers it after the restart.
816 if self
817 .shutdown
818 .as_ref()
819 .is_some_and(tokio_util::sync::CancellationToken::is_cancelled)
820 {
821 tracing::info!(
822 "outbox worker: shutdown signalled mid-batch; \
823 remaining messages stay queued for the next start"
824 );
825 return;
826 }
827 // ── Ownership ─────────────────────────────────────────
828 // Not this sender's message. Leave it exactly as it is:
829 // another consumer of the same outbox owns it, and touching
830 // its attempt count or dead-lettering it here would consume a
831 // retry budget that is not ours to spend.
832 if !self.sender.handles(&msg) {
833 continue;
834 }
835 handled_any = true;
836 // ── Retry budget ──────────────────────────────────────
837 // `attempt_count` starts at 0 and is incremented on each
838 // `reschedule` call. The message is permanently undeliverable
839 // when the retry *window* has elapsed (the BDEW duty is stated
840 // in hours, and full-jitter backoff makes a count no proxy for
841 // a duration) or the attempt belt is exhausted: acknowledge it
842 // (remove from outbox) and dead-letter it so the regulatory
843 // audit trail is preserved. The window is only consulted after
844 // a first attempt, so a message that aged while the worker was
845 // down still gets tried once.
846 let age = time::OffsetDateTime::now_utc() - msg.created_at;
847 let window_elapsed = msg.attempt_count > 0
848 && age
849 >= time::Duration::try_from(self.max_retry_window)
850 .unwrap_or(time::Duration::hours(72));
851 if msg.attempt_count >= self.max_attempts || window_elapsed {
852 tracing::error!(
853 message_id = %msg.message_id,
854 message_type = %msg.message_type,
855 recipient = %msg.recipient,
856 attempts = msg.attempt_count,
857 max_attempts = self.max_attempts,
858 age_secs = age.whole_seconds(),
859 window_secs = self.max_retry_window.as_secs(),
860 "outbox worker: retry budget exhausted; dead-lettering message",
861 );
862 self.dead_letter_sink.reject(
863 &crate::dead_letter::DeadLetterReason::OutboxExhausted {
864 message_id: msg.message_id,
865 message_type: msg.message_type.to_string(),
866 recipient: msg.recipient.to_string(),
867 last_error: format!(
868 "delivery exhausted after {} attempts",
869 msg.attempt_count
870 ),
871 attempts: msg.attempt_count,
872 },
873 );
874 if let Err(e) = self.store.acknowledge(msg.message_id).await {
875 tracing::error!(
876 message_id = %msg.message_id,
877 error = %e,
878 "outbox worker: acknowledge after exhaust failed; message may reappear",
879 );
880 }
881 continue;
882 }
883
884 match self.sender.send(&msg).await {
885 Ok(()) => {
886 if let Err(e) = self.store.acknowledge(msg.message_id).await {
887 tracing::warn!(
888 message_id = %msg.message_id,
889 error = %e,
890 "outbox worker: acknowledge failed",
891 );
892 }
893 // The message is out, so any delivery window that was
894 // watching for it has been answered — retire it.
895 //
896 // Nothing else cancels these. A monitoring deadline that
897 // outlives the obligation it monitors fires for every
898 // process, including every one that answered on time,
899 // and the scheduler cannot tell those apart because a
900 // deadline reaching `due_now` is late by construction.
901 // Leaving them registered turns the miss counters into
902 // counts of *processes started*.
903 self.discharge_delivery_window(&msg).await;
904 }
905 // Permanent error: dead-letter immediately without retrying.
906 // PartnerUnknown requires operator intervention (add --as4-partner);
907 // Serialization errors will never succeed on retry; a missing
908 // wire-format renderer cannot appear between attempts — its own
909 // documentation promises immediate dead-lettering, and until this
910 // arm matched it, that promise was broken and the message burned
911 // the whole retry budget first.
912 Err(ref e)
913 if e.is_partner_unknown()
914 || e.is_renderer_not_implemented()
915 || matches!(e, EngineError::Serialization(_)) =>
916 {
917 tracing::error!(
918 message_id = %msg.message_id,
919 message_type = %msg.message_type,
920 recipient = %msg.recipient,
921 error = %e,
922 "outbox worker: permanent send failure; dead-lettering without retry",
923 );
924 self.dead_letter_sink.reject(
925 &crate::dead_letter::DeadLetterReason::OutboxExhausted {
926 message_id: msg.message_id,
927 message_type: msg.message_type.to_string(),
928 recipient: msg.recipient.to_string(),
929 last_error: e.to_string(),
930 attempts: msg.attempt_count,
931 },
932 );
933 if let Err(re) = self.store.acknowledge(msg.message_id).await {
934 tracing::error!(
935 message_id = %msg.message_id,
936 error = %re,
937 "outbox worker: acknowledge after permanent failure failed",
938 );
939 }
940 }
941 Err(e) => {
942 // Stable jitter entropy derived from the UUID bytes of
943 // `message_id`. Using the last 8 bytes as a `u64` gives
944 // uniform entropy across message IDs (UUIDs are random in
945 // all 128 bits for v4) and is stable across Rust versions —
946 // unlike `DefaultHasher`, whose algorithm is explicitly
947 // documented as unstable.
948 let entropy = {
949 let uuid = msg.message_id.as_uuid();
950 let bytes = uuid.as_bytes();
951 u64::from_le_bytes(bytes[8..16].try_into().unwrap())
952 };
953 let delay = backoff_delay(msg.attempt_count, entropy);
954 let retry_at = time::OffsetDateTime::now_utc()
955 + time::Duration::try_from(delay).unwrap_or(time::Duration::minutes(5));
956 tracing::warn!(
957 message_id = %msg.message_id,
958 attempt = msg.attempt_count,
959 max_attempts = self.max_attempts,
960 retry_in = ?delay,
961 error = %e,
962 "outbox worker: send failed; rescheduling with backoff",
963 );
964 if let Err(re) = self.store.reschedule(msg.message_id, retry_at).await {
965 tracing::error!(
966 message_id = %msg.message_id,
967 error = %re,
968 "outbox worker: reschedule failed; message may be stuck",
969 );
970 }
971 }
972 }
973 }
974
975 // Nothing in this batch was ours: sleep before polling again, or a
976 // queue held by the other consumer turns this loop into a spin.
977 if !handled_any && !sleep_or_cancel(self.poll_interval, self.shutdown.as_ref()).await {
978 return;
979 }
980 }
981 }
982}
983
984impl<ES, SS, OS, DS, PR> EngineContext<ES, SS, OS, DS, PR>
985where
986 ES: EventStore,
987 OS: OutboxStore + Clone,
988{
989 /// Construct an [`OutboxWorker`] that drains the outbox via `sender`.
990 ///
991 /// `batch_size` — messages fetched per poll cycle.
992 /// `poll_interval` — sleep duration when the batch is empty.
993 ///
994 /// `max_attempts` — attempt belt against runaway loops; the real budget is
995 /// `max_retry_window`, the message age after which delivery is abandoned.
996 /// The BDEW AS4 retry duty is stated in *hours* (72 h for unacknowledged
997 /// messages — `mako_as4::constants::MAX_RETRY_DURATION_SECS`), and the
998 /// full-jitter backoff makes an attempt count no proxy for a duration, so
999 /// both are taken and either exhausts the message.
1000 ///
1001 /// ```rust,ignore
1002 /// use std::time::Duration;
1003 ///
1004 /// let worker = ctx.run_outbox_worker(
1005 /// my_sender, 50, Duration::from_secs(1),
1006 /// 10_000, Duration::from_secs(72 * 3600),
1007 /// );
1008 /// tokio::spawn(async move { worker.run().await });
1009 /// ```
1010 #[must_use]
1011 pub fn run_outbox_worker<S: As4Sender>(
1012 &self,
1013 sender: S,
1014 batch_size: usize,
1015 poll_interval: std::time::Duration,
1016 max_attempts: u32,
1017 max_retry_window: std::time::Duration,
1018 ) -> OutboxWorker<OS, S, DS>
1019 where
1020 DS: DeadlineStore + Clone,
1021 {
1022 OutboxWorker {
1023 store: self.outbox_store.clone(),
1024 sender,
1025 deadline_store: self.deadline_store.clone(),
1026 batch_size,
1027 poll_interval,
1028 max_attempts,
1029 max_retry_window,
1030 dead_letter_sink: self.dead_letter_sink.clone(),
1031 heartbeat: None,
1032 shutdown: None,
1033 }
1034 }
1035}
1036
1037impl<OS: OutboxStore, S: As4Sender, DS: DeadlineStore> OutboxWorker<OS, S, DS> {
1038 /// Attach a liveness heartbeat to this worker.
1039 ///
1040 /// The worker will store the current UTC Unix timestamp (seconds) into
1041 /// `heartbeat` at the end of every poll cycle. Pass the same
1042 /// `Arc<AtomicI64>` to the health endpoint so it can detect stale workers.
1043 #[must_use]
1044 pub fn with_heartbeat(
1045 mut self,
1046 heartbeat: std::sync::Arc<std::sync::atomic::AtomicI64>,
1047 ) -> Self {
1048 self.heartbeat = Some(heartbeat);
1049 self
1050 }
1051
1052 /// Attach a graceful-shutdown token.
1053 ///
1054 /// Cancelling it makes [`OutboxWorker::run`] return at the next message
1055 /// boundary or immediately out of its idle sleep. Await the worker's
1056 /// `JoinHandle` afterwards: the point of the token is that the caller can
1057 /// close the event store *after* the worker has stopped writing to it.
1058 #[must_use]
1059 pub fn with_shutdown(mut self, shutdown: tokio_util::sync::CancellationToken) -> Self {
1060 self.shutdown = Some(shutdown);
1061 self
1062 }
1063
1064 /// Retire the delivery window `msg` was being watched by, if one is open.
1065 ///
1066 /// A delivery-window deadline exists to answer one question: *did this
1067 /// message go out in time?* Once it has gone out the question is settled,
1068 /// and leaving the deadline registered only guarantees a false alarm later.
1069 /// [`fristen::discharges_delivery_window`] decides which labels a given
1070 /// message type answers for; deadlines that merely share the stream (a
1071 /// process-response window, say) are left alone.
1072 ///
1073 /// Best-effort: a failure here costs a spurious alert at the window's close,
1074 /// never a lost or duplicated message, so it is logged rather than
1075 /// propagated — the delivery itself has already been acknowledged.
1076 ///
1077 /// [`fristen::discharges_delivery_window`]: mako_fristen::discharges_delivery_window
1078 async fn discharge_delivery_window(&self, msg: &crate::outbox::OutboxMessage) {
1079 let open = match self.deadline_store.for_stream(&msg.stream_id).await {
1080 Ok(deadlines) => deadlines,
1081 Err(e) => {
1082 tracing::warn!(
1083 message_id = %msg.message_id,
1084 message_type = %msg.message_type,
1085 error = %e,
1086 "outbox worker: could not read deadlines to discharge the delivery \
1087 window; it may fire a spurious regulatory alert",
1088 );
1089 return;
1090 }
1091 };
1092
1093 let now = time::OffsetDateTime::now_utc();
1094 for deadline in open
1095 .iter()
1096 .filter(|d| mako_fristen::discharges_delivery_window(&msg.message_type, d.label()))
1097 {
1098 // Delivered, but after the window closed. The scheduler will not see
1099 // this one — the deadline is retired below — so the miss is recorded
1100 // here or nowhere. The window comes off the deadline rather than
1101 // from a duration constant, because neither the CONTRL nor the
1102 // APERAK window is one number: a Strom Syntaxfehlermeldung on a
1103 // UTILMD is 15 minutes, an ALOCAT CONTRL 45, the Regelfall 6 hours,
1104 // and a Saturday APERAK runs to Sunday noon.
1105 if now > deadline.due_at() {
1106 tracing::warn!(
1107 message_id = %msg.message_id,
1108 message_type = %msg.message_type,
1109 label = %deadline.label(),
1110 due_at = %deadline.due_at(),
1111 late_secs = (now - deadline.due_at()).whole_seconds(),
1112 "outbox worker: delivered after its delivery window closed — \
1113 a missed Übertragungsfrist (CONTRL AHB 1.0 §2.3.1/§2.4.1, \
1114 APERAK AHB 1.0 §2.3/§2.4)"
1115 );
1116 }
1117 if let Err(e) = self.deadline_store.cancel(deadline.deadline_id()).await {
1118 tracing::warn!(
1119 message_id = %msg.message_id,
1120 deadline_id = %deadline.deadline_id(),
1121 label = %deadline.label(),
1122 error = %e,
1123 "outbox worker: could not discharge the delivery window; \
1124 it may fire a spurious regulatory alert",
1125 );
1126 } else {
1127 tracing::debug!(
1128 message_id = %msg.message_id,
1129 message_type = %msg.message_type,
1130 deadline_id = %deadline.deadline_id(),
1131 label = %deadline.label(),
1132 "outbox worker: message delivered — delivery window discharged",
1133 );
1134 }
1135 }
1136 }
1137}
1138
1139impl<ES, SS, OS, DS, PR> std::fmt::Debug for EngineContext<ES, SS, OS, DS, PR>
1140where
1141 ES: std::fmt::Debug,
1142 SS: std::fmt::Debug,
1143 OS: std::fmt::Debug,
1144 DS: std::fmt::Debug,
1145 PR: std::fmt::Debug,
1146{
1147 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1148 f.debug_struct("EngineContext")
1149 .field("registered_modules", &self.registered_modules)
1150 .field("registered_workflows", &self.registered_workflows)
1151 .field("pid_router_len", &self.pid_router.len())
1152 .finish_non_exhaustive()
1153 }
1154}
1155
1156// ── NoopAs4Sender / LogAs4Sender ──────────────────────────────────────────────
1157
1158/// An [`As4Sender`] that succeeds immediately without sending anything.
1159///
1160/// Use in tests and environments where outbound AS4 delivery is not yet
1161/// wired. All outbox messages are acknowledged (removed from the queue)
1162/// without being transmitted.
1163///
1164/// # ⚠️ Data loss warning
1165///
1166/// Every outbox message is **silently discarded** — no EDIFACT message is
1167/// sent to any counterparty. Do not use in production.
1168#[derive(Debug, Clone, Copy, Default)]
1169#[must_use = "NoopAs4Sender discards all outbound messages silently — use a real AS4 gateway in production"]
1170#[cfg_attr(
1171 not(any(test, feature = "testing")),
1172 deprecated = "NoopAs4Sender must not be wired in production builds; every \
1173 outbound EDIFACT message would be silently discarded. Use \
1174 a real As4Sender implementation instead."
1175)]
1176pub struct NoopAs4Sender;
1177
1178// The trait impl is test/testing-only: a release build without the `testing`
1179// feature cannot wire NoopAs4Sender into an outbox worker at all.
1180#[cfg(any(test, feature = "testing"))]
1181impl As4Sender for NoopAs4Sender {
1182 async fn send(&self, _msg: &OutboxMessage) -> Result<(), EngineError> {
1183 Ok(())
1184 }
1185}
1186
1187/// An [`As4Sender`] that logs every outbound message at `warn` level and
1188/// succeeds without transmitting.
1189///
1190/// Useful for development and integration-testing environments where the
1191/// full AS4 stack is not yet available but message visibility is desired.
1192/// All outbox messages are acknowledged (removed from the queue) after logging.
1193///
1194/// # ⚠️ Data loss warning
1195///
1196/// No EDIFACT message is sent to any counterparty. Do not use in production.
1197#[derive(Debug, Clone, Copy, Default)]
1198#[must_use = "LogAs4Sender discards all outbound messages — use a real AS4 gateway in production"]
1199pub struct LogAs4Sender;
1200
1201impl As4Sender for LogAs4Sender {
1202 async fn send(&self, msg: &OutboxMessage) -> Result<(), EngineError> {
1203 tracing::warn!(
1204 message_id = %msg.message_id,
1205 message_type = %msg.message_type,
1206 recipient = %msg.recipient,
1207 "LogAs4Sender: outbox message dropped — configure a real AS4 gateway for production",
1208 );
1209 Ok(())
1210 }
1211}
1212
1213// ── DeadlineScheduler ─────────────────────────────────────────────────────────
1214
1215/// A background task that polls [`DeadlineStore::due_now`] and dispatches
1216/// deadline commands to the owning processes via a caller-supplied function.
1217///
1218/// Obtain via [`EngineContext::run_deadline_scheduler`] and drive by spawning
1219/// [`DeadlineScheduler::run`] in a Tokio task.
1220///
1221/// # Dispatch function
1222///
1223/// The `dispatch` function receives a fired [`Deadline`] and returns a future
1224/// that dispatches the appropriate timeout command to the process. The function
1225/// is responsible for resuming the correct workflow and calling `execute`.
1226/// After the future completes, the scheduler cancels the deadline from the
1227/// store regardless of the dispatch outcome (to prevent re-firing).
1228///
1229/// ```rust,ignore
1230/// use std::time::Duration;
1231///
1232/// let scheduler = ctx.run_deadline_scheduler(
1233/// |deadline| async move {
1234/// tracing::warn!(
1235/// deadline_id = %deadline.deadline_id(),
1236/// label = %deadline.label(),
1237/// "deadline fired",
1238/// );
1239/// Ok(())
1240/// },
1241/// 100,
1242/// Duration::from_secs(30),
1243/// );
1244/// tokio::spawn(async move { scheduler.run().await });
1245/// ```
1246pub struct DeadlineScheduler<DS: DeadlineStore> {
1247 store: DS,
1248 dispatch: Box<
1249 dyn Fn(
1250 Deadline,
1251 ) -> std::pin::Pin<
1252 Box<dyn std::future::Future<Output = Result<(), EngineError>> + Send>,
1253 > + Send
1254 + Sync,
1255 >,
1256 batch_size: usize,
1257 poll_interval: std::time::Duration,
1258 /// Optional liveness heartbeat — stores the current UTC Unix timestamp
1259 /// (seconds) after each poll cycle.
1260 heartbeat: Option<std::sync::Arc<std::sync::atomic::AtomicI64>>,
1261 /// Graceful-shutdown signal — see [`DeadlineScheduler::with_shutdown`].
1262 shutdown: Option<tokio_util::sync::CancellationToken>,
1263}
1264
1265impl<DS: DeadlineStore> DeadlineScheduler<DS> {
1266 /// Run the deadline poll loop until the shutdown token is cancelled.
1267 ///
1268 /// Cancellation is observed between deadlines and during the idle sleep, so
1269 /// a deadline already being dispatched runs to completion. A deadline left
1270 /// undispatched stays registered and fires on the next start — it is due, so
1271 /// the next `due_now` returns it again.
1272 pub async fn run(self) {
1273 loop {
1274 if self
1275 .shutdown
1276 .as_ref()
1277 .is_some_and(tokio_util::sync::CancellationToken::is_cancelled)
1278 {
1279 tracing::info!("deadline scheduler: shutdown signalled; stopping");
1280 return;
1281 }
1282 // Tick liveness at the *start* of every poll cycle, ahead of the
1283 // early-`continue` paths below. An idle scheduler (no due
1284 // deadlines) is alive and must keep ticking; only one genuinely
1285 // hung inside an `.await` stops.
1286 if let Some(ref hb) = self.heartbeat {
1287 hb.store(
1288 time::OffsetDateTime::now_utc().unix_timestamp(),
1289 std::sync::atomic::Ordering::Relaxed,
1290 );
1291 }
1292
1293 let result = match self.store.due_now(self.batch_size).await {
1294 Ok(r) => r,
1295 Err(e) => {
1296 tracing::warn!(
1297 error = %e,
1298 "deadline scheduler: store error polling due deadlines (will retry)",
1299 );
1300 if !sleep_or_cancel(self.poll_interval, self.shutdown.as_ref()).await {
1301 return;
1302 }
1303 continue;
1304 }
1305 };
1306
1307 if result.deadlines.is_empty() {
1308 if !sleep_or_cancel(self.poll_interval, self.shutdown.as_ref()).await {
1309 return;
1310 }
1311 continue;
1312 }
1313
1314 for deadline in result.deadlines {
1315 // Between deadlines, not inside one: a dispatch already running
1316 // must finish so its events and outbox entries commit together.
1317 if self
1318 .shutdown
1319 .as_ref()
1320 .is_some_and(tokio_util::sync::CancellationToken::is_cancelled)
1321 {
1322 tracing::info!(
1323 "deadline scheduler: shutdown signalled mid-batch; \
1324 undispatched deadlines remain due and fire on the next start"
1325 );
1326 return;
1327 }
1328 let id = deadline.deadline_id();
1329 let label = deadline.label().to_owned();
1330
1331 // An APERAK delivery window that reaches this point is a
1332 // regulatory violation under APERAK AHB 1.0 §2.4.1 (Strom
1333 // 45 min) / §2.3.1 (Gas 1 Werktag): the outbox worker discharges
1334 // the window the moment the APERAK goes out, so one that is
1335 // still registered when it comes due was never answered.
1336 //
1337 // Do NOT re-test `now > due_at` here. `due_now` selects on
1338 // `due_at <= now`, so that comparison is true by construction and
1339 // says nothing about compliance — it was the reason this counter
1340 // once tracked "Strom processes started" rather than "APERAKs
1341 // missed". The discharge is what carries the meaning.
1342 if label.starts_with(mako_fristen::APERAK_WINDOW_LABEL_PREFIX) {
1343 let now = time::OffsetDateTime::now_utc();
1344 crate::metrics::EngineMetrics::global().aperak_missed(&label);
1345 tracing::error!(
1346 deadline_id = %id,
1347 label = %label,
1348 due_at = %deadline.due_at(),
1349 fired_at = %now,
1350 overdue_secs = (now - deadline.due_at()).whole_seconds(),
1351 "APERAK delivery window closed with no delivery — regulatory \
1352 violation (APERAK AHB 1.0 §2.4.1 Strom / §2.3.1 Gas). \
1353 Counter: makod_aperak_missed_total",
1354 );
1355 }
1356
1357 let should_cancel = match (self.dispatch)(deadline).await {
1358 Ok(()) => true,
1359 Err(ref e) if e.is_version_conflict() => {
1360 // The process was modified concurrently; the timeout
1361 // command will be retried on the next poll cycle.
1362 // Do NOT cancel — let the deadline remain due so it
1363 // fires again until a non-conflict dispatch succeeds.
1364 tracing::warn!(
1365 deadline_id = %id,
1366 label = %label,
1367 "deadline scheduler: VersionConflict; will retry on next poll",
1368 );
1369 false
1370 }
1371 Err(e) => {
1372 tracing::warn!(
1373 deadline_id = %id,
1374 label = %label,
1375 error = %e,
1376 "deadline scheduler: dispatch failed (permanent); cancelling",
1377 );
1378 true
1379 }
1380 };
1381 if should_cancel && let Err(e) = self.store.cancel(id).await {
1382 tracing::error!(
1383 deadline_id = %id,
1384 error = %e,
1385 "deadline scheduler: cancel failed; deadline may fire again",
1386 );
1387 }
1388 }
1389
1390 // If has_more, loop immediately to drain the batch.
1391 }
1392 }
1393}
1394
1395impl<DS: DeadlineStore> DeadlineScheduler<DS> {
1396 /// Attach a liveness heartbeat to this scheduler.
1397 ///
1398 /// The scheduler will store the current UTC Unix timestamp (seconds) into
1399 /// `heartbeat` at the end of every poll cycle.
1400 #[must_use]
1401 pub fn with_heartbeat(
1402 mut self,
1403 heartbeat: std::sync::Arc<std::sync::atomic::AtomicI64>,
1404 ) -> Self {
1405 self.heartbeat = Some(heartbeat);
1406 self
1407 }
1408
1409 /// Attach a graceful-shutdown token.
1410 ///
1411 /// Cancelling it makes [`DeadlineScheduler::run`] return at the next
1412 /// deadline boundary or immediately out of its idle sleep, so the caller can
1413 /// close the event store once the scheduler has stopped writing to it.
1414 #[must_use]
1415 pub fn with_shutdown(mut self, shutdown: tokio_util::sync::CancellationToken) -> Self {
1416 self.shutdown = Some(shutdown);
1417 self
1418 }
1419}
1420
1421impl<ES, SS, OS, DS, PR> EngineContext<ES, SS, OS, DS, PR>
1422where
1423 ES: EventStore,
1424 DS: DeadlineStore + Clone,
1425{
1426 /// Construct a [`DeadlineScheduler`] that polls the deadline store and
1427 /// dispatches fired deadlines via `dispatch`.
1428 ///
1429 /// The `dispatch` function is called for every fired deadline. It should
1430 /// resume the owning process and execute the appropriate timeout command.
1431 ///
1432 /// `batch_size` — deadlines fetched per poll cycle.
1433 /// `poll_interval` — sleep duration when no deadlines are due.
1434 ///
1435 /// ```rust,ignore
1436 /// use std::time::Duration;
1437 ///
1438 /// let scheduler = ctx.run_deadline_scheduler(
1439 /// |d| async move {
1440 /// tracing::info!(label = %d.label(), "firing deadline");
1441 /// Ok(())
1442 /// },
1443 /// 100,
1444 /// Duration::from_secs(30),
1445 /// );
1446 /// tokio::spawn(async move { scheduler.run().await });
1447 /// ```
1448 #[must_use]
1449 pub fn run_deadline_scheduler<F, Fut>(
1450 &self,
1451 dispatch: F,
1452 batch_size: usize,
1453 poll_interval: std::time::Duration,
1454 ) -> DeadlineScheduler<DS>
1455 where
1456 F: Fn(Deadline) -> Fut + Send + Sync + 'static,
1457 Fut: std::future::Future<Output = Result<(), EngineError>> + Send + 'static,
1458 {
1459 DeadlineScheduler {
1460 store: self.deadline_store.clone(),
1461 dispatch: Box::new(move |d| Box::pin(dispatch(d))),
1462 batch_size,
1463 poll_interval,
1464 heartbeat: None,
1465 shutdown: None,
1466 }
1467 }
1468}
1469
1470// ── EngineBuilder ─────────────────────────────────────────────────────────────
1471
1472/// Assembles engine infrastructure and produces an [`EngineContext`].
1473///
1474/// Uses type-state to enforce that an event store is provided before
1475/// [`build`] can be called. All other stores default to `Noop`
1476/// implementations.
1477///
1478/// ## Quick start
1479///
1480/// ```rust,ignore
1481/// // Minimal — event store only, all others are Noop:
1482/// let ctx = EngineBuilder::new()
1483/// .with_event_store(InMemoryEventStore::new())
1484/// .build();
1485///
1486/// // Full infrastructure:
1487/// let ctx = EngineBuilder::new()
1488/// .with_event_store(InMemoryEventStore::new())
1489/// .with_snapshot_store(InMemorySnapshotStore::new())
1490/// .with_outbox_store(InMemoryOutboxStore::new())
1491/// .with_deadline_store(InMemoryDeadlineStore::new())
1492/// .with_registry(InMemoryProcessRegistry::new())
1493/// .register(Box::new(GpkeModule))
1494/// .build();
1495/// ```
1496///
1497/// [`build`]: EngineBuilder::build
1498pub struct EngineBuilder<
1499 ES = (),
1500 SS = NoopSnapshotStore,
1501 OS = NoopOutboxStore,
1502 DS = NoopDeadlineStore,
1503 PR = NoopProcessRegistry,
1504> {
1505 event_store: ES,
1506 snapshot_store: SS,
1507 outbox_store: OS,
1508 deadline_store: DS,
1509 registry: PR,
1510 dead_letter_sink: Arc<dyn DeadLetterSink>,
1511 modules: Vec<Box<dyn EngineModule>>,
1512 /// Active [`DeploymentRoles`] for this engine instance.
1513 ///
1514 /// Controls role-conditional PID registration via
1515 /// [`EngineModule::register_pids_with_roles`]. Defaults to
1516 /// [`DeploymentRoles::all()`]: an engine that names no roles registers
1517 /// every PID its modules declare, which is what a test harness and a
1518 /// combined-role deployment both want.
1519 deployment_roles: DeploymentRoles,
1520 /// Optional profile validator injected by `makod` or callers that have
1521 /// access to `edi-energy`. When `Some`, called for each
1522 /// [`ProfileRequirement`] declared by registered modules. When `None`,
1523 /// profile requirements are not validated (safe in unit tests).
1524 ///
1525 /// Signature: `fn(message_type: &str) -> bool`
1526 ///
1527 /// [`ProfileRequirement`]: crate::profile::ProfileRequirement
1528 profile_validator: Option<Box<dyn Fn(&str) -> bool + Send + Sync>>,
1529}
1530#[cfg(any(test, feature = "testing"))]
1531impl Default
1532 for EngineBuilder<
1533 (),
1534 NoopSnapshotStore,
1535 NoopOutboxStore,
1536 NoopDeadlineStore,
1537 NoopProcessRegistry,
1538 >
1539{
1540 fn default() -> Self {
1541 Self {
1542 event_store: (),
1543 snapshot_store: NoopSnapshotStore,
1544 outbox_store: NoopOutboxStore,
1545 deadline_store: NoopDeadlineStore,
1546 registry: NoopProcessRegistry,
1547 dead_letter_sink: Arc::new(LogDeadLetterSink),
1548 modules: Vec::new(),
1549 deployment_roles: DeploymentRoles::all(),
1550 profile_validator: None,
1551 }
1552 }
1553}
1554
1555#[cfg(any(test, feature = "testing"))]
1556impl EngineBuilder {
1557 /// Create a new builder with all `Noop` defaults.
1558 ///
1559 /// Only available in `#[cfg(test)]` or with the `testing` feature enabled,
1560 /// because the Noop defaults silently discard outbox messages, deadlines,
1561 /// and process registry entries. Production binaries must wire real stores
1562 /// via the `with_*` builder methods.
1563 ///
1564 /// Call [`with_event_store`] before [`build`] — the event store is
1565 /// **required**.
1566 ///
1567 /// [`with_event_store`]: EngineBuilder::with_event_store
1568 /// [`build`]: EngineBuilder::build
1569 #[must_use]
1570 pub fn new() -> Self {
1571 Self::default()
1572 }
1573}
1574
1575impl<OS, DS, PR> EngineBuilder<(), NoopSnapshotStore, OS, DS, PR>
1576where
1577 OS: OutboxStore,
1578 DS: DeadlineStore,
1579 PR: ProcessRegistry,
1580{
1581 /// Create a production-ready builder with explicit stores for outbox,
1582 /// deadline, and process registry.
1583 ///
1584 /// This constructor is available in all build configurations including
1585 /// production binaries. It enforces that the three stores that can cause
1586 /// silent data loss (`OutboxStore`, `DeadlineStore`, `ProcessRegistry`)
1587 /// are provided explicitly — there is no Noop fallback.
1588 ///
1589 /// `NoopSnapshotStore` is used as the snapshot default because it is safe
1590 /// for production: skipping snapshots means full replay, but no data loss.
1591 /// Override with [`with_snapshot_store`] to enable snapshot-accelerated
1592 /// replay.
1593 ///
1594 /// Call [`with_event_store`] before [`build`] — the event store is
1595 /// **required**.
1596 ///
1597 /// ```rust,ignore
1598 /// let ctx = EngineBuilder::with_stores(outbox, deadline, registry)
1599 /// .with_event_store(store.clone())
1600 /// .with_snapshot_store(InMemorySnapshotStore::new())
1601 /// .build();
1602 /// ```
1603 ///
1604 /// [`with_snapshot_store`]: EngineBuilder::with_snapshot_store
1605 /// [`with_event_store`]: EngineBuilder::with_event_store
1606 /// [`build`]: EngineBuilder::build
1607 #[must_use]
1608 pub fn with_stores(outbox_store: OS, deadline_store: DS, registry: PR) -> Self {
1609 Self {
1610 event_store: (),
1611 snapshot_store: NoopSnapshotStore,
1612 outbox_store,
1613 deadline_store,
1614 registry,
1615 dead_letter_sink: Arc::new(LogDeadLetterSink),
1616 modules: Vec::new(),
1617 deployment_roles: DeploymentRoles::all(),
1618 profile_validator: None,
1619 }
1620 }
1621}
1622
1623impl<ES, SS, OS, DS, PR> EngineBuilder<ES, SS, OS, DS, PR> {
1624 /// Set the event store. **Required** — `build()` is only available once
1625 /// this has been called with a type that implements [`EventStore`].
1626 ///
1627 /// Replaces any previously set event store (type-state transition).
1628 #[must_use]
1629 pub fn with_event_store<ES2: EventStore>(
1630 self,
1631 store: ES2,
1632 ) -> EngineBuilder<ES2, SS, OS, DS, PR> {
1633 EngineBuilder {
1634 event_store: store,
1635 snapshot_store: self.snapshot_store,
1636 outbox_store: self.outbox_store,
1637 deadline_store: self.deadline_store,
1638 registry: self.registry,
1639 dead_letter_sink: self.dead_letter_sink,
1640 modules: self.modules,
1641 deployment_roles: self.deployment_roles,
1642 profile_validator: self.profile_validator,
1643 }
1644 }
1645
1646 /// Set the snapshot store (default: [`NoopSnapshotStore`]).
1647 ///
1648 /// ## Default: `NoopSnapshotStore`
1649 ///
1650 /// Without calling this method the builder uses [`NoopSnapshotStore`],
1651 /// which silently discards all snapshot writes and returns `None` for
1652 /// every snapshot read. The engine still functions correctly — every
1653 /// command handling call replays the full event log from the beginning
1654 /// instead of starting from a stored snapshot. For low-volume processes
1655 /// this is fine; for long-lived processes with many events the replay cost
1656 /// can become significant.
1657 ///
1658 /// Enable snapshotting in production by providing a real [`SnapshotStore`]
1659 /// implementation (e.g. the SlateDB-backed store in `makod`). In tests,
1660 /// `InMemorySnapshotStore` is available behind the `testing` feature flag.
1661 ///
1662 /// Note: [`Process::state_with_snapshot`][crate::process::Process::state_with_snapshot]
1663 /// is a compile-time no-op when the snapshot store is `NoopSnapshotStore`
1664 /// — it never calls the store and always returns `None`, so no snapshot is
1665 /// ever saved or loaded.
1666 #[must_use]
1667 pub fn with_snapshot_store<SS2: SnapshotStore>(
1668 self,
1669 store: SS2,
1670 ) -> EngineBuilder<ES, SS2, OS, DS, PR> {
1671 EngineBuilder {
1672 event_store: self.event_store,
1673 snapshot_store: store,
1674 outbox_store: self.outbox_store,
1675 deadline_store: self.deadline_store,
1676 registry: self.registry,
1677 dead_letter_sink: self.dead_letter_sink,
1678 modules: self.modules,
1679 deployment_roles: self.deployment_roles,
1680 profile_validator: self.profile_validator,
1681 }
1682 }
1683
1684 /// Set the outbox store (default: [`NoopOutboxStore`]).
1685 #[must_use]
1686 pub fn with_outbox_store<OS2: OutboxStore>(
1687 self,
1688 store: OS2,
1689 ) -> EngineBuilder<ES, SS, OS2, DS, PR> {
1690 EngineBuilder {
1691 event_store: self.event_store,
1692 snapshot_store: self.snapshot_store,
1693 outbox_store: store,
1694 deadline_store: self.deadline_store,
1695 registry: self.registry,
1696 dead_letter_sink: self.dead_letter_sink,
1697 modules: self.modules,
1698 deployment_roles: self.deployment_roles,
1699 profile_validator: self.profile_validator,
1700 }
1701 }
1702
1703 /// Set the deadline store (default: [`NoopDeadlineStore`]).
1704 #[must_use]
1705 pub fn with_deadline_store<DS2: DeadlineStore>(
1706 self,
1707 store: DS2,
1708 ) -> EngineBuilder<ES, SS, OS, DS2, PR> {
1709 EngineBuilder {
1710 event_store: self.event_store,
1711 snapshot_store: self.snapshot_store,
1712 outbox_store: self.outbox_store,
1713 deadline_store: store,
1714 registry: self.registry,
1715 dead_letter_sink: self.dead_letter_sink,
1716 modules: self.modules,
1717 deployment_roles: self.deployment_roles,
1718 profile_validator: self.profile_validator,
1719 }
1720 }
1721
1722 /// Set the process registry (default: [`NoopProcessRegistry`]).
1723 #[must_use]
1724 pub fn with_registry<PR2: ProcessRegistry>(
1725 self,
1726 registry: PR2,
1727 ) -> EngineBuilder<ES, SS, OS, DS, PR2> {
1728 EngineBuilder {
1729 event_store: self.event_store,
1730 snapshot_store: self.snapshot_store,
1731 outbox_store: self.outbox_store,
1732 deadline_store: self.deadline_store,
1733 registry,
1734 dead_letter_sink: self.dead_letter_sink,
1735 modules: self.modules,
1736 deployment_roles: self.deployment_roles,
1737 profile_validator: self.profile_validator,
1738 }
1739 }
1740
1741 /// Set the dead-letter sink (default: [`LogDeadLetterSink`]).
1742 ///
1743 /// The dead-letter sink receives every message that cannot be routed to a
1744 /// workflow. The default [`LogDeadLetterSink`] emits `tracing::warn!`
1745 /// events, making rejections visible in log output without configuration.
1746 ///
1747 /// Override with a persistent DLQ implementation in production:
1748 ///
1749 /// ```rust,ignore
1750 /// use mako_engine::dead_letter::LogDeadLetterSink;
1751 ///
1752 /// let ctx = EngineBuilder::new()
1753 /// .with_event_store(my_store)
1754 /// .with_dead_letter_sink(MyPersistentDlq::new())
1755 /// .build();
1756 /// ```
1757 ///
1758 /// [`LogDeadLetterSink`]: crate::dead_letter::LogDeadLetterSink
1759 #[must_use]
1760 pub fn with_dead_letter_sink(mut self, sink: impl DeadLetterSink) -> Self {
1761 self.dead_letter_sink = Arc::new(sink);
1762 self
1763 }
1764
1765 /// Register an `edi-energy` profile validator for startup profile checks.
1766 ///
1767 /// The closure receives a message-type string (e.g. `"UTILMD"`) and must
1768 /// return `true` if at least one active profile for that message type is
1769 /// registered for today's date.
1770 ///
1771 /// Wire this in `makod` using the `edi-energy` global registry:
1772 ///
1773 /// ```rust,ignore
1774 /// use edi_energy::registry::ReleaseRegistry;
1775 ///
1776 /// let today = mako_fristen::heute();
1777 /// builder.with_profile_validator(move |msg_type| {
1778 /// ReleaseRegistry::global()
1779 /// .profiles_for_str(msg_type)
1780 /// .any(|p| match (p.valid_from(), p.valid_until()) {
1781 /// (Some(f), Some(u)) => f <= today && today <= u,
1782 /// (Some(f), None) => f <= today,
1783 /// (None, _) => true,
1784 /// })
1785 /// })
1786 /// ```
1787 ///
1788 /// Domain crates do **not** need to call this — they only declare
1789 /// [`profile_requirements`].
1790 ///
1791 /// [`profile_requirements`]: EngineModule::profile_requirements
1792 #[must_use]
1793 pub fn with_profile_validator(
1794 mut self,
1795 validator: impl Fn(&str) -> bool + Send + Sync + 'static,
1796 ) -> Self {
1797 self.profile_validator = Some(Box::new(validator));
1798 self
1799 }
1800
1801 /// Register a domain module.
1802 ///
1803 /// The module name becomes visible in
1804 /// [`EngineContext::registered_modules`] after [`build`] is called.
1805 ///
1806 /// [`build`]: EngineBuilder::build
1807 #[must_use]
1808 pub fn register(mut self, module: Box<dyn EngineModule>) -> Self {
1809 self.modules.push(module);
1810 self
1811 }
1812
1813 /// Register multiple [`EngineModule`]s at once from a pre-built `Vec`.
1814 ///
1815 /// Equivalent to calling [`register`] in a loop. Useful when the set of
1816 /// modules is assembled conditionally (e.g. via `#[cfg]`-gated pushes to a
1817 /// `Vec<Box<dyn EngineModule>>`) before the builder chain starts.
1818 ///
1819 /// [`register`]: EngineBuilder::register
1820 #[must_use]
1821 pub fn register_many(mut self, modules: Vec<Box<dyn EngineModule>>) -> Self {
1822 self.modules.extend(modules);
1823 self
1824 }
1825
1826 /// Set the active [`DeploymentRoles`] for this engine instance.
1827 ///
1828 /// Controls role-conditional PID registration in [`EngineModule::register_pids_with_roles`].
1829 ///
1830 /// The default is [`DeploymentRoles::all()`], which registers every PID unconditionally
1831 /// — identical to the pre-role-aware behavior. Providing an explicit role set
1832 /// restricts role-conditional blocks to only the declared roles:
1833 ///
1834 /// - **NB-only** (`DeploymentRoles::nb()`): 19001/19002 route to `gpke-konfiguration`;
1835 /// WiM nMSB blocks are skipped.
1836 /// - **nMSB-only** (`DeploymentRoles::nmsb()`): 19001/19002 route to `wim-geraeteubernahme`;
1837 /// GPKE NB blocks are skipped.
1838 /// - **NB + gMSB** (`DeploymentRoles::nb_msb()`): most common Stadtwerke combination.
1839 ///
1840 /// # Conflict guard
1841 ///
1842 /// When two modules would register the same PID to **different** workflows, the
1843 /// engine panics during [`build`]. Set explicit roles to prevent both modules from
1844 /// activating the same PID simultaneously:
1845 ///
1846 /// ```rust,ignore
1847 /// use mako_engine::marktrolle::DeploymentRoles;
1848 ///
1849 /// let ctx = EngineBuilder::with_stores(outbox, deadline, registry)
1850 /// .with_event_store(store)
1851 /// .with_deployment_roles(DeploymentRoles::nb()) // only NB: GPKE gets 19001/19002
1852 /// .register(Box::new(GpkeModule))
1853 /// .register(Box::new(WimModule)) // nMSB block skipped — no conflict
1854 /// .build();
1855 /// ```
1856 ///
1857 /// [`build`]: EngineBuilder::build
1858 #[must_use]
1859 pub fn with_deployment_roles(mut self, roles: DeploymentRoles) -> Self {
1860 self.deployment_roles = roles;
1861 self
1862 }
1863}
1864
1865impl<ES, SS, OS, DS, PR> EngineBuilder<ES, SS, OS, DS, PR>
1866where
1867 ES: EventStore,
1868 SS: SnapshotStore,
1869 OS: OutboxStore,
1870 DS: DeadlineStore,
1871 PR: ProcessRegistry,
1872{
1873 /// Build the [`EngineContext`].
1874 ///
1875 /// Consumes the builder. All registered modules and configured stores are
1876 /// moved into the returned [`EngineContext`].
1877 ///
1878 /// This method is only available when `ES` implements [`EventStore`].
1879 /// If you have not called [`with_event_store`], this will not compile.
1880 ///
1881 /// # Panics
1882 ///
1883 /// Panics when any registered module returns `Err` from
1884 /// [`EngineModule::configure`]. The panic message includes the module
1885 /// name and the error string so the deployment failure is actionable.
1886 ///
1887 /// [`with_event_store`]: EngineBuilder::with_event_store
1888 #[must_use]
1889 #[allow(clippy::too_many_lines)]
1890 pub fn build(self) -> EngineContext<ES, SS, OS, DS, PR> {
1891 // ── Noop store safety checks ──────────────────────────────────────────
1892 //
1893 // Noop stores lose data silently: NoopDeadlineStore drops every APERAK
1894 // deadline (BNetzA violation), NoopOutboxStore discards all outbound
1895 // messages, NoopProcessRegistry loses conversation routing on restart.
1896 //
1897 // In production builds (no `testing` feature, not running under
1898 // `#[test]`), the Noop constructors are cfg-gated out so this branch
1899 // is dead code and compiles away. In test/testing/tracing builds we
1900 // emit warnings so test harnesses see the configuration in log output.
1901 //
1902 // IMPORTANT: if you are reading this because a panic fired in production,
1903 // it means the `testing` feature was accidentally enabled in the binary.
1904 // Remove it from the production Cargo.toml feature list immediately.
1905 {
1906 let os_name = std::any::type_name::<OS>();
1907 let ds_name = std::any::type_name::<DS>();
1908 let pr_name = std::any::type_name::<PR>();
1909
1910 // Regulatory-critical stores: panic in any build context if these
1911 // are noop. OutboxStore and DeadlineStore must be durable in
1912 // production; ProcessRegistry must survive restarts.
1913 #[cfg(not(any(test, feature = "testing")))]
1914 {
1915 assert!(
1916 !ds_name.contains("NoopDeadlineStore"),
1917 "EngineBuilder::build: NoopDeadlineStore is active in a \
1918 non-testing build. This silently discards all APERAK deadlines, \
1919 which is an immediately reportable BNetzA violation \
1920 (APERAK AHB 1.0 §2.4.1, AWH GeLi Gas BK7-24-01-009). \
1921 Call .with_deadline_store(SlateDbStore::as_deadline_store()) \
1922 in your production engine assembly. \
1923 If this is a test, enable the 'testing' feature."
1924 );
1925 assert!(
1926 !os_name.contains("NoopOutboxStore"),
1927 "EngineBuilder::build: NoopOutboxStore is active in a \
1928 non-testing build. This silently discards all outbound \
1929 APERAK, CONTRL, and UTILMD messages. \
1930 Call .with_outbox_store(SlateDbStore::as_outbox_store()) \
1931 in your production engine assembly. \
1932 If this is a test, enable the 'testing' feature."
1933 );
1934 assert!(
1935 !pr_name.contains("NoopProcessRegistry"),
1936 "EngineBuilder::build: NoopProcessRegistry is active in a \
1937 non-testing build. This means conversation routing \
1938 (PID → stream_id lookup) is lost on every restart, \
1939 breaking all WiM, GeLi Gas, and GPKE in-flight processes. \
1940 Call .with_registry(SlateDbStore::as_process_registry()) \
1941 in your production engine assembly. \
1942 If this is a test, enable the 'testing' feature."
1943 );
1944 }
1945
1946 // In test/testing/tracing builds: emit warnings instead of panicking.
1947 #[cfg(any(test, feature = "testing", feature = "tracing"))]
1948 {
1949 let ss_name = std::any::type_name::<SS>();
1950 if ss_name.contains("NoopSnapshotStore") {
1951 tracing::warn!(
1952 store = ss_name,
1953 "EngineBuilder: NoopSnapshotStore is active — snapshots will not be \
1954 persisted. Use SlateDbStore::as_snapshot_store() in production."
1955 );
1956 }
1957 if os_name.contains("NoopOutboxStore") {
1958 tracing::warn!(
1959 store = os_name,
1960 "EngineBuilder: NoopOutboxStore is active — outbound messages will be \
1961 silently discarded. Use SlateDbStore::as_outbox_store() in production."
1962 );
1963 }
1964 if ds_name.contains("NoopDeadlineStore") {
1965 tracing::warn!(
1966 store = ds_name,
1967 "EngineBuilder: NoopDeadlineStore is active — scheduled deadlines will \
1968 not fire after restart. Use SlateDbStore::as_deadline_store() in production."
1969 );
1970 }
1971 if pr_name.contains("NoopProcessRegistry") {
1972 tracing::warn!(
1973 store = pr_name,
1974 "EngineBuilder: NoopProcessRegistry is active — process routing will be \
1975 lost on restart. Use SlateDbStore::as_process_registry() in production."
1976 );
1977 }
1978 }
1979 }
1980 // Validate every module before assembling the context.
1981 // A missing adapter or misconfigured module fails at startup (not at
1982 // first inbound message), making deployment failures observable immediately.
1983 for module in &self.modules {
1984 if let Err(msg) = module.configure() {
1985 panic!(
1986 "EngineBuilder::build: module '{}' failed configuration validation: {}",
1987 module.name(),
1988 msg
1989 );
1990 }
1991 // Validate profile requirements via the injected validator.
1992 // Domain crates declare requirements; only the binary crate (makod)
1993 // injects the edi-energy registry — domain crates need no edi-energy
1994 // import for this check.
1995 if let Some(ref validator) = self.profile_validator {
1996 for req in module.profile_requirements() {
1997 assert!(
1998 validator(req.message_type),
1999 "EngineBuilder::build: module '{}' requires an active edi-energy \
2000 profile for '{}' ({}) but none is registered for today's date. \
2001 Run `cargo xtask import-profiles` to add the missing profile.",
2002 module.name(),
2003 req.message_type,
2004 req.label,
2005 );
2006 }
2007 }
2008 }
2009 // Build the PID router from all registered modules.
2010 // Also assert that no two modules claim the same PID — a PID overlap
2011 // is always a configuration error: one module's messages would be
2012 // silently swallowed by another's workflow, producing missing-process
2013 // errors or incorrect audit trails.
2014 let mut pid_router = PidRouter::new();
2015 let mut pid_owners: std::collections::HashMap<u32, &str> = std::collections::HashMap::new();
2016 // Keep each module's scratch router so we can build `pid_router` from
2017 // them in a second pass with the resolved ownership table.
2018 let mut module_scratches: Vec<PidRouter> = Vec::with_capacity(self.modules.len());
2019
2020 // Pass 1 — detect conflicts, determine PID ownership (first-wins for
2021 // explicit roles, last-wins for DeploymentRoles::all()).
2022 for module in &self.modules {
2023 // Temporarily build a scratch router to read this module's PIDs
2024 // for cross-module overlap detection (module-ownership level).
2025 let mut scratch = PidRouter::new();
2026 module.register_pids_with_roles(&mut scratch, &self.deployment_roles);
2027
2028 // A module names its workflows twice — once by routing a PID to a
2029 // name, once by declaring the name — and only the declared list is
2030 // reachable from `EngineContext::registered_workflows`. Consumers
2031 // build their deadline-dispatch coverage from that list, so a
2032 // routed-but-undeclared workflow runs while being invisible to
2033 // every check made over the declarations: its Fristen fire into a
2034 // scheduler arm that was never required to exist.
2035 //
2036 // The converse is legitimate and not checked — a command-initiated
2037 // workflow declares a name and routes no inbound PID.
2038 let declared: std::collections::HashSet<&str> =
2039 module.workflow_names().iter().copied().collect();
2040 let mut undeclared: Vec<&str> = scratch
2041 .workflow_names()
2042 .into_iter()
2043 .filter(|name| !declared.contains(name))
2044 .collect();
2045 undeclared.sort_unstable();
2046 undeclared.dedup();
2047 assert!(
2048 undeclared.is_empty(),
2049 "EngineBuilder::build: module '{}' routes PIDs to workflows it does not \
2050 declare in `workflow_names()`: {}. A workflow missing from that list is \
2051 excluded from `EngineContext::registered_workflows`, so any deadline it \
2052 registers is never checked for a dispatch arm. Add each name to the \
2053 module's `workflow_names()`.",
2054 module.name(),
2055 undeclared.join(", "),
2056 );
2057
2058 for pid in scratch.registered_pids() {
2059 if let Some(prev) = pid_owners.insert(pid, module.name()) {
2060 if self.deployment_roles.is_all() {
2061 // With DeploymentRoles::all() (the default), role-conditional PIDs
2062 // are registered by all modules that claim them, producing last-wins
2063 // semantics. This is acceptable for single-role and dev/test deployments.
2064 //
2065 // In production multi-role deployments where both an NB and nMSB role
2066 // are served by the same instance, set explicit roles via
2067 // `EngineBuilder::with_deployment_roles` to prevent silent misrouting.
2068 //
2069 // We emit a debug-level log here (not warn) because the vast majority
2070 // of deployments are single-role and this overlap is expected/harmless.
2071 tracing::debug!(
2072 pid,
2073 previous_module = prev,
2074 current_module = module.name(),
2075 "PID registered by multiple modules with DeploymentRoles::all(); \
2076 last module wins (use with_deployment_roles for strict routing)",
2077 );
2078 } else {
2079 // Explicit roles: the FIRST module to register a PID retains ownership.
2080 // Restore the previous (first) owner and emit a warning so the operator
2081 // can investigate. A panic would be too strict: some shared PIDs
2082 // (e.g. REMADV 33001/33002) are legitimately claimed by both GPKE and
2083 // WiM billing; conversation-ID routing is the long-term solution, but
2084 // first-wins gives correct behaviour for all current deployments.
2085 pid_owners.insert(pid, prev); // restore first owner
2086 tracing::warn!(
2087 pid,
2088 first_module = prev,
2089 second_module = module.name(),
2090 "PID {pid} claimed by both '{prev}' and '{}' with explicit \
2091 DeploymentRoles; first module ('{prev}') retains ownership. \
2092 Verify PID registration is correct for this deployment.",
2093 module.name(),
2094 );
2095 }
2096 }
2097 }
2098 module_scratches.push(scratch);
2099 }
2100
2101 // Pass 2 — build the real `pid_router` from the scratch pads, respecting
2102 // the ownership table built in pass 1.
2103 for (module, scratch) in self.modules.iter().zip(module_scratches.iter()) {
2104 // Unambiguous (Sparte-agnostic) entries: only register if this module
2105 // owns the PID in the resolved ownership table.
2106 for pid in scratch.registered_pids() {
2107 if pid_owners.get(&pid).copied() == Some(module.name())
2108 && let Some(wf) = scratch.route(pid)
2109 {
2110 pid_router.register(pid, wf);
2111 }
2112 }
2113 // Commodity (Sparte-qualified) entries are keyed on (pid, Sparte), so
2114 // a PID split across the two Sparten cannot collide. Two modules of
2115 // the *same* Sparte claiming one PID still can — COMDIS 29001 is
2116 // claimed by both GPKE and WiM billing — and that pair is resolved
2117 // by conversation-ID correlation at ingest, not by this table. What
2118 // the key buys is that neither of them can be displaced by the Gas
2119 // claim on the same PID.
2120 for (pid, sparte, wf) in scratch.registered_commodity_entries() {
2121 pid_router.register_with_sparte(pid, sparte, wf);
2122 }
2123 }
2124 let registered_modules = self.modules.iter().map(|m| m.name()).collect();
2125 let registered_workflows = self
2126 .modules
2127 .iter()
2128 .flat_map(|m| m.workflow_names().iter().copied())
2129 .collect();
2130 EngineContext {
2131 event_store: Arc::new(self.event_store),
2132 snapshot_store: self.snapshot_store,
2133 outbox_store: self.outbox_store,
2134 deadline_store: self.deadline_store,
2135 registry: self.registry,
2136 dead_letter_sink: self.dead_letter_sink,
2137 pid_router,
2138 registered_modules,
2139 registered_workflows,
2140 }
2141 }
2142}
2143
2144#[cfg(test)]
2145mod tests {
2146 use super::*;
2147 use crate::{
2148 deadline::InMemoryDeadlineStore,
2149 error::WorkflowError,
2150 event_store::InMemoryEventStore,
2151 ids::TenantId,
2152 outbox::InMemoryOutboxStore,
2153 pid_router::PidRouter,
2154 registry::InMemoryProcessRegistry,
2155 snapshot::InMemorySnapshotStore,
2156 version::WorkflowId,
2157 workflow::{CommandPayload, EventPayload, Workflow},
2158 };
2159
2160 // ── Minimal workflow for spawn/resume tests ───────────────────────────────
2161
2162 #[derive(serde::Serialize, serde::Deserialize)]
2163 struct PingEvent;
2164
2165 impl EventPayload for PingEvent {
2166 fn event_type(&self) -> &'static str {
2167 "Ping"
2168 }
2169 }
2170
2171 struct PingCommand;
2172
2173 impl CommandPayload for PingCommand {}
2174
2175 #[derive(Default, Clone)]
2176 struct PingState;
2177
2178 struct PingWorkflow;
2179
2180 impl Workflow for PingWorkflow {
2181 type State = PingState;
2182 type Event = PingEvent;
2183 type Command = PingCommand;
2184
2185 fn apply(state: PingState, _: &PingEvent) -> PingState {
2186 state
2187 }
2188
2189 fn handle(
2190 _: &PingState,
2191 _: PingCommand,
2192 ) -> Result<crate::workflow::WorkflowOutput<PingEvent>, WorkflowError> {
2193 Ok(vec![PingEvent].into())
2194 }
2195 }
2196
2197 struct TestModule;
2198
2199 impl EngineModule for TestModule {
2200 fn name(&self) -> &'static str {
2201 "test-module"
2202 }
2203 }
2204
2205 // ── Tests ─────────────────────────────────────────────────────────────────
2206
2207 #[test]
2208 fn build_with_event_store_only() {
2209 let ctx = EngineBuilder::new()
2210 .with_event_store(InMemoryEventStore::new())
2211 .build();
2212 assert!(ctx.registered_modules().is_empty());
2213 }
2214
2215 #[test]
2216 fn build_with_all_stores_and_module() {
2217 let ctx = EngineBuilder::new()
2218 .with_event_store(InMemoryEventStore::new())
2219 .with_snapshot_store(InMemorySnapshotStore::new())
2220 .with_outbox_store(InMemoryOutboxStore::new())
2221 .with_deadline_store(InMemoryDeadlineStore::new())
2222 .with_registry(InMemoryProcessRegistry::new())
2223 .register(Box::new(TestModule))
2224 .build();
2225 assert_eq!(ctx.registered_modules(), &["test-module"]);
2226 }
2227
2228 #[test]
2229 fn multiple_modules_ordered() {
2230 struct ModA;
2231 impl EngineModule for ModA {
2232 fn name(&self) -> &'static str {
2233 "mod-a"
2234 }
2235 }
2236 struct ModB;
2237 impl EngineModule for ModB {
2238 fn name(&self) -> &'static str {
2239 "mod-b"
2240 }
2241 }
2242
2243 let ctx = EngineBuilder::new()
2244 .with_event_store(InMemoryEventStore::new())
2245 .register(Box::new(ModA))
2246 .register(Box::new(ModB))
2247 .build();
2248 assert_eq!(ctx.registered_modules(), &["mod-a", "mod-b"]);
2249 }
2250
2251 #[tokio::test]
2252 async fn spawn_creates_independent_processes() {
2253 let ctx = EngineBuilder::new()
2254 .with_event_store(InMemoryEventStore::new())
2255 .build();
2256 let wf_id = WorkflowId::new("ping", "FV2024-10-01");
2257
2258 let p1 = ctx.spawn::<PingWorkflow>(TenantId::new(), wf_id.clone());
2259 let p2 = ctx.spawn::<PingWorkflow>(TenantId::new(), wf_id);
2260
2261 assert_ne!(p1.process_id(), p2.process_id());
2262 }
2263
2264 #[tokio::test]
2265 async fn resume_sees_previously_appended_events() {
2266 let store = InMemoryEventStore::new();
2267 let ctx = EngineBuilder::new().with_event_store(store).build();
2268
2269 let p = ctx.spawn::<PingWorkflow>(TenantId::new(), WorkflowId::new("ping", "FV2024-10-01"));
2270 p.execute(PingCommand).await.unwrap();
2271
2272 let identity = p.identity();
2273 let resumed = ctx.resume::<PingWorkflow>(identity);
2274 assert_eq!(resumed.event_count().await.unwrap(), 1);
2275 }
2276
2277 #[tokio::test]
2278 async fn registry_routes_process_via_conversation_key() {
2279 use crate::registry::RegistryKey;
2280 let ctx = EngineBuilder::new()
2281 .with_event_store(InMemoryEventStore::new())
2282 .with_registry(InMemoryProcessRegistry::new())
2283 .build();
2284
2285 let p = ctx.spawn::<PingWorkflow>(TenantId::new(), WorkflowId::new("ping", "FV2024-10-01"));
2286 let tenant = p.tenant_id();
2287 let conv_key = RegistryKey::parse("conv:test-conversation-123").expect("valid key");
2288 ctx.registry()
2289 .register(tenant, &conv_key, p.identity())
2290 .await
2291 .unwrap();
2292
2293 let found = ctx
2294 .registry()
2295 .lookup(tenant, &conv_key)
2296 .await
2297 .unwrap()
2298 .expect("must be registered");
2299 let resumed = ctx.resume::<PingWorkflow>(found);
2300 assert_eq!(resumed.process_id(), p.process_id());
2301 }
2302
2303 #[test]
2304 fn pid_router_populated_by_module_register_pids() {
2305 struct PidModule;
2306 impl EngineModule for PidModule {
2307 fn name(&self) -> &'static str {
2308 "pid-module"
2309 }
2310 fn workflow_names(&self) -> &'static [&'static str] {
2311 &["gpke-supplier-change"]
2312 }
2313 fn register_pids(&self, router: &mut PidRouter) {
2314 router.register(55001, "gpke-supplier-change");
2315 router.register(55002, "gpke-supplier-change");
2316 }
2317 }
2318
2319 let ctx = EngineBuilder::new()
2320 .with_event_store(InMemoryEventStore::new())
2321 .register(Box::new(PidModule))
2322 .build();
2323
2324 assert_eq!(ctx.pid_router().route(55001), Some("gpke-supplier-change"));
2325 assert_eq!(ctx.pid_router().route(55002), Some("gpke-supplier-change"));
2326 assert!(ctx.pid_router().route(99999).is_none());
2327 assert_eq!(ctx.pid_router().len(), 2);
2328 }
2329
2330 /// A workflow a module routes but does not declare is a build failure.
2331 ///
2332 /// The two lists are written independently — `register_pids` binds a PID to
2333 /// a name, `workflow_names` declares it — and only the declared one reaches
2334 /// [`EngineContext::registered_workflows`], which is where consumers build
2335 /// their deadline-dispatch coverage from. An undeclared workflow therefore
2336 /// runs while being exempt from every check made over the declarations, so
2337 /// a Frist it registers can fire into a dispatch arm nobody required to
2338 /// exist. Four workflows had drifted this way before the check existed.
2339 #[test]
2340 #[should_panic(expected = "routes PIDs to workflows it does not declare")]
2341 fn a_routed_workflow_must_be_declared() {
2342 struct Undeclaring;
2343 impl EngineModule for Undeclaring {
2344 fn name(&self) -> &'static str {
2345 "undeclaring"
2346 }
2347 fn workflow_names(&self) -> &'static [&'static str] {
2348 &["declared-workflow"]
2349 }
2350 fn register_pids(&self, router: &mut PidRouter) {
2351 router.register(55_001, "declared-workflow");
2352 router.register(55_002, "routed-but-undeclared");
2353 }
2354 }
2355
2356 let _ = EngineBuilder::new()
2357 .with_event_store(InMemoryEventStore::new())
2358 .register(Box::new(Undeclaring))
2359 .build();
2360 }
2361
2362 /// Declaring a workflow that routes no PID is legitimate and must build.
2363 ///
2364 /// A command-initiated workflow — one an ERP starts over the command API —
2365 /// has no inbound Prüfidentifikator, so the containment only holds in one
2366 /// direction. Checking the reverse would refuse every such workflow.
2367 #[test]
2368 fn a_declared_workflow_need_not_route_a_pid() {
2369 struct CommandInitiated;
2370 impl EngineModule for CommandInitiated {
2371 fn name(&self) -> &'static str {
2372 "command-initiated"
2373 }
2374 fn workflow_names(&self) -> &'static [&'static str] {
2375 &["routed", "erp-initiated-only"]
2376 }
2377 fn register_pids(&self, router: &mut PidRouter) {
2378 router.register(55_001, "routed");
2379 }
2380 }
2381
2382 let ctx = EngineBuilder::new()
2383 .with_event_store(InMemoryEventStore::new())
2384 .register(Box::new(CommandInitiated))
2385 .build();
2386
2387 assert_eq!(ctx.registered_workflows().len(), 2);
2388 assert_eq!(ctx.pid_router().workflow_names().len(), 1);
2389 }
2390
2391 /// Verify that `register_pids_with_roles` gates PIDs behind role checks.
2392 ///
2393 /// Scenario: two modules share PID 19001.
2394 /// - ModuleA registers 19001 → "workflow-a" when role `Nb` is present.
2395 /// - ModuleB registers 19001 → "workflow-b" when role `Nmsb` is explicitly set
2396 /// (not on `all()`).
2397 ///
2398 /// - `all()`: ModuleA fires (Nb ∈ all), ModuleB does NOT (is_all → skip).
2399 /// → 19001 routes to "workflow-a".
2400 /// - `from_roles([Nb])`: ModuleA fires, ModuleB skips.
2401 /// → 19001 routes to "workflow-a".
2402 /// - `from_roles([Nmsb])`: ModuleA skips, ModuleB fires.
2403 /// → 19001 routes to "workflow-b".
2404 #[test]
2405 fn register_pids_with_roles_gates_pids_correctly() {
2406 use crate::marktrolle::{DeploymentRoles, Marktrolle};
2407
2408 struct ModuleA;
2409 impl EngineModule for ModuleA {
2410 fn name(&self) -> &'static str {
2411 "module-a"
2412 }
2413 fn workflow_names(&self) -> &'static [&'static str] {
2414 &["workflow-a"]
2415 }
2416 fn register_pids_with_roles(&self, router: &mut PidRouter, roles: &DeploymentRoles) {
2417 if roles.contains(Marktrolle::Nb) {
2418 router.register(19_001, "workflow-a");
2419 }
2420 }
2421 }
2422
2423 struct ModuleB;
2424 impl EngineModule for ModuleB {
2425 fn name(&self) -> &'static str {
2426 "module-b"
2427 }
2428 fn workflow_names(&self) -> &'static [&'static str] {
2429 &["workflow-b"]
2430 }
2431 fn register_pids_with_roles(&self, router: &mut PidRouter, roles: &DeploymentRoles) {
2432 // Only fires on explicit Nmsb, not on all() (backward-compat sentinel).
2433 if !roles.is_all() && roles.contains(Marktrolle::Nmsb) {
2434 router.register(19_001, "workflow-b");
2435 router.register(19_015, "workflow-b");
2436 }
2437 }
2438 }
2439
2440 let build = |roles: DeploymentRoles| {
2441 EngineBuilder::new()
2442 .with_event_store(InMemoryEventStore::new())
2443 .with_deployment_roles(roles)
2444 .register(Box::new(ModuleA))
2445 .register(Box::new(ModuleB))
2446 .build()
2447 };
2448
2449 // all() → backward compat: ModuleA registers 19001 (Nb ∈ all), ModuleB skips.
2450 let ctx = build(DeploymentRoles::all());
2451 assert_eq!(ctx.pid_router().route(19_001), Some("workflow-a"));
2452 assert!(ctx.pid_router().route(19_015).is_none());
2453
2454 // Explicit Nb → same result: ModuleA registers, ModuleB (nMSB) skips.
2455 let ctx = build(DeploymentRoles::nb());
2456 assert_eq!(ctx.pid_router().route(19_001), Some("workflow-a"));
2457 assert!(ctx.pid_router().route(19_015).is_none());
2458
2459 // Explicit Nmsb → ModuleA skips (Nb ∉ roles), ModuleB registers.
2460 let ctx = build(DeploymentRoles::nmsb());
2461 assert_eq!(ctx.pid_router().route(19_001), Some("workflow-b"));
2462 assert_eq!(ctx.pid_router().route(19_015), Some("workflow-b"));
2463 }
2464
2465 /// Verify that explicit roles with two conflicting modules use first-wins semantics
2466 /// (the first module to register a PID retains ownership; the second is silently skipped).
2467 #[test]
2468 fn register_pids_with_roles_conflict_uses_first_wins_with_explicit_roles() {
2469 use crate::marktrolle::{DeploymentRoles, Marktrolle};
2470
2471 struct ConflictA;
2472 impl EngineModule for ConflictA {
2473 fn name(&self) -> &'static str {
2474 "conflict-a"
2475 }
2476 fn workflow_names(&self) -> &'static [&'static str] {
2477 &["workflow-a"]
2478 }
2479 fn register_pids_with_roles(&self, router: &mut PidRouter, roles: &DeploymentRoles) {
2480 if roles.contains(Marktrolle::Nb) {
2481 router.register(19_001, "workflow-a");
2482 }
2483 }
2484 }
2485
2486 struct ConflictB;
2487 impl EngineModule for ConflictB {
2488 fn name(&self) -> &'static str {
2489 "conflict-b"
2490 }
2491 fn workflow_names(&self) -> &'static [&'static str] {
2492 &["workflow-b"]
2493 }
2494 fn register_pids_with_roles(&self, router: &mut PidRouter, roles: &DeploymentRoles) {
2495 if !roles.is_all() && roles.contains(Marktrolle::Nmsb) {
2496 router.register(19_001, "workflow-b"); // same PID, different workflow
2497 }
2498 }
2499 }
2500
2501 // from_roles([Nb, Nmsb]): both modules fire for PID 19_001.
2502 // First-wins: ConflictA (registered first) retains ownership → "workflow-a".
2503 let ctx = EngineBuilder::new()
2504 .with_event_store(InMemoryEventStore::new())
2505 .with_deployment_roles(DeploymentRoles::from_roles([
2506 Marktrolle::Nb,
2507 Marktrolle::Nmsb,
2508 ]))
2509 .register(Box::new(ConflictA))
2510 .register(Box::new(ConflictB))
2511 .build();
2512 assert_eq!(
2513 ctx.pid_router().route(19_001),
2514 Some("workflow-a"),
2515 "first module should win on PID conflict with explicit roles"
2516 );
2517 }
2518
2519 // ── Graceful shutdown ─────────────────────────────────────────────────────
2520
2521 /// Cancelling the token must make `run` return.
2522 ///
2523 /// A worker that does not read the token loops until the process exits,
2524 /// and dropping its `JoinHandle` does not abort a Tokio task — so the event
2525 /// store would close underneath a worker still running. An outbox
2526 /// `acknowledge` losing that race leaves the counterparty holding a message
2527 /// the outbox still shows as pending, and the next start delivers it
2528 /// again.
2529 #[tokio::test]
2530 async fn a_cancelled_outbox_worker_returns() {
2531 let worker = OutboxWorker {
2532 store: InMemoryOutboxStore::new(),
2533 sender: AlwaysDelivers,
2534 deadline_store: InMemoryDeadlineStore::new(),
2535 batch_size: 10,
2536 // Far longer than the timeout below: the point is that cancellation
2537 // interrupts the idle sleep rather than being noticed after it.
2538 poll_interval: std::time::Duration::from_secs(300),
2539 max_attempts: 48,
2540 max_retry_window: std::time::Duration::from_secs(72 * 3600),
2541 dead_letter_sink: std::sync::Arc::new(crate::dead_letter::LogDeadLetterSink),
2542 heartbeat: None,
2543 shutdown: None,
2544 };
2545 let token = tokio_util::sync::CancellationToken::new();
2546 let worker = worker.with_shutdown(token.clone());
2547
2548 let handle = tokio::spawn(worker.run());
2549 // Let it reach the sleep, then signal.
2550 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2551 token.cancel();
2552
2553 tokio::time::timeout(std::time::Duration::from_secs(5), handle)
2554 .await
2555 .expect("outbox worker must return promptly after cancellation")
2556 .expect("outbox worker must not panic");
2557 }
2558
2559 #[tokio::test]
2560 async fn a_cancelled_deadline_scheduler_returns() {
2561 let scheduler = DeadlineScheduler {
2562 store: InMemoryDeadlineStore::new(),
2563 dispatch: Box::new(|_| Box::pin(async { Ok(()) })),
2564 batch_size: 100,
2565 poll_interval: std::time::Duration::from_secs(300),
2566 heartbeat: None,
2567 shutdown: None,
2568 };
2569 let token = tokio_util::sync::CancellationToken::new();
2570 let scheduler = scheduler.with_shutdown(token.clone());
2571
2572 let handle = tokio::spawn(scheduler.run());
2573 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2574 token.cancel();
2575
2576 tokio::time::timeout(std::time::Duration::from_secs(5), handle)
2577 .await
2578 .expect("deadline scheduler must return promptly after cancellation")
2579 .expect("deadline scheduler must not panic");
2580 }
2581
2582 /// A token cancelled before the first poll must stop the worker without it
2583 /// touching the store at all — the case where shutdown arrives during boot.
2584 #[tokio::test]
2585 async fn a_worker_cancelled_before_it_starts_does_no_work() {
2586 let outbox = InMemoryOutboxStore::new();
2587 let stream_id = crate::ids::StreamId::new("gpke/shutdown-test");
2588 let msg = outbox_message(&stream_id, "UTILMD");
2589 outbox.enqueue(std::slice::from_ref(&msg)).await.unwrap();
2590
2591 let token = tokio_util::sync::CancellationToken::new();
2592 token.cancel();
2593
2594 let worker = OutboxWorker {
2595 store: outbox.clone(),
2596 sender: AlwaysDelivers,
2597 deadline_store: InMemoryDeadlineStore::new(),
2598 batch_size: 10,
2599 poll_interval: std::time::Duration::from_millis(5),
2600 max_attempts: 48,
2601 max_retry_window: std::time::Duration::from_secs(72 * 3600),
2602 dead_letter_sink: std::sync::Arc::new(crate::dead_letter::LogDeadLetterSink),
2603 heartbeat: None,
2604 shutdown: None,
2605 }
2606 .with_shutdown(token);
2607
2608 tokio::time::timeout(std::time::Duration::from_secs(5), worker.run())
2609 .await
2610 .expect("an already-cancelled worker must return immediately");
2611
2612 assert_eq!(
2613 outbox.pending_now(10).await.unwrap().len(),
2614 1,
2615 "the message must stay queued for the next start, not be delivered \
2616 by a worker that was told to stop",
2617 );
2618 }
2619
2620 // ── APERAK delivery-window discharge ──────────────────────────────────────
2621
2622 /// A sender that always succeeds, so the worker takes the delivery path.
2623 struct AlwaysDelivers;
2624 impl As4Sender for AlwaysDelivers {
2625 async fn send(&self, _msg: &crate::outbox::OutboxMessage) -> Result<(), EngineError> {
2626 Ok(())
2627 }
2628 }
2629
2630 fn outbox_message(
2631 stream_id: &crate::ids::StreamId,
2632 message_type: &str,
2633 ) -> crate::outbox::OutboxMessage {
2634 crate::outbox::OutboxMessage::new(
2635 stream_id.clone(),
2636 crate::ids::ProcessId::new(),
2637 TenantId::new(),
2638 crate::ids::CorrelationId::new(),
2639 crate::ids::ConversationId::new(),
2640 crate::ids::EventId::new(),
2641 message_type,
2642 "9900357000004",
2643 serde_json::json!({}),
2644 )
2645 }
2646
2647 fn deadline_on(
2648 stream_id: &crate::ids::StreamId,
2649 msg: &crate::outbox::OutboxMessage,
2650 label: &str,
2651 ) -> Deadline {
2652 Deadline::new(
2653 stream_id.clone(),
2654 msg.process_id,
2655 msg.tenant_id,
2656 WorkflowId::new("gpke-supplier-change", "FV2025-10-01"),
2657 label,
2658 time::OffsetDateTime::now_utc() + time::Duration::hours(6),
2659 )
2660 }
2661
2662 /// Deliver `msg` through the worker's real loop and return the labels that
2663 /// survive on its stream.
2664 ///
2665 /// Drives `run` rather than calling the discharge directly — the wiring is
2666 /// the thing under test, and calling the method straight passes even when
2667 /// `run` never invokes it.
2668 async fn labels_surviving_delivery(
2669 msg: &crate::outbox::OutboxMessage,
2670 stream_id: &crate::ids::StreamId,
2671 registered: &[&str],
2672 ) -> Vec<String> {
2673 let deadlines = InMemoryDeadlineStore::new();
2674 for label in registered {
2675 deadlines
2676 .register(&deadline_on(stream_id, msg, label))
2677 .await
2678 .unwrap();
2679 }
2680 let outbox = InMemoryOutboxStore::new();
2681 outbox.enqueue(std::slice::from_ref(msg)).await.unwrap();
2682
2683 let worker = OutboxWorker {
2684 store: outbox.clone(),
2685 sender: AlwaysDelivers,
2686 deadline_store: deadlines.clone(),
2687 batch_size: 10,
2688 poll_interval: std::time::Duration::from_millis(5),
2689 max_attempts: 48,
2690 max_retry_window: std::time::Duration::from_secs(72 * 3600),
2691 dead_letter_sink: std::sync::Arc::new(crate::dead_letter::LogDeadLetterSink),
2692 heartbeat: None,
2693 shutdown: None,
2694 };
2695 // `run` never returns; give it enough cycles to drain the one message.
2696 let _ = tokio::time::timeout(std::time::Duration::from_millis(300), worker.run()).await;
2697 assert!(
2698 outbox.pending_now(10).await.unwrap().is_empty(),
2699 "the message must have been delivered and acknowledged",
2700 );
2701
2702 let mut left: Vec<String> = deadlines
2703 .for_stream(stream_id)
2704 .await
2705 .unwrap()
2706 .iter()
2707 .map(|d| d.label().to_owned())
2708 .collect();
2709 left.sort();
2710 left
2711 }
2712
2713 /// Delivering a message must retire the window that was watching for it.
2714 ///
2715 /// These windows are registered when the message is enqueued and nothing
2716 /// else ever cancels them, so without the discharge they fire for **every**
2717 /// process — including every one that answered on time. The scheduler cannot
2718 /// tell those apart (a deadline reaching `due_now` is late by construction),
2719 /// so the miss counters would track processes started, not obligations
2720 /// missed.
2721 #[tokio::test]
2722 async fn delivering_a_message_discharges_its_delivery_window() {
2723 // (message type, the window it answers for)
2724 for (message_type, window) in [
2725 ("APERAK", mako_fristen::APERAK_STROM_WINDOW_LABEL),
2726 ("APERAK", mako_fristen::APERAK_GAS_FOLGEPROZESS_LABEL),
2727 ("APERAK", mako_fristen::APERAK_GAS_INITIALPROZESS_LABEL),
2728 ("CONTRL", mako_fristen::CONTRL_FRIST_LABEL),
2729 ] {
2730 let stream_id = crate::ids::StreamId::new("gpke-supplier-change-1");
2731 let msg = outbox_message(&stream_id, message_type);
2732 // A process-response deadline shares the stream and must survive:
2733 // it is waiting on the counterparty, not on our delivery.
2734 let left =
2735 labels_surviving_delivery(&msg, &stream_id, &[window, "gpke-response-window"])
2736 .await;
2737 assert_eq!(
2738 left,
2739 vec!["gpke-response-window"],
2740 "delivering {message_type} must discharge `{window}` and leave \
2741 every other deadline alone",
2742 );
2743 }
2744 }
2745
2746 /// A delivery must not discharge a *different* message's window.
2747 ///
2748 /// The CONTRL and APERAK obligations run concurrently on the same
2749 /// interchange. Acknowledging syntax (CONTRL) says nothing about whether the
2750 /// application-level APERAK went out, so discharging both on one delivery
2751 /// would silence a real violation.
2752 #[tokio::test]
2753 async fn a_delivery_does_not_discharge_another_messages_window() {
2754 let stream_id = crate::ids::StreamId::new("gpke-supplier-change-1");
2755 let contrl = outbox_message(&stream_id, "CONTRL");
2756
2757 let left = labels_surviving_delivery(
2758 &contrl,
2759 &stream_id,
2760 &[
2761 mako_fristen::CONTRL_FRIST_LABEL,
2762 mako_fristen::APERAK_STROM_WINDOW_LABEL,
2763 ],
2764 )
2765 .await;
2766
2767 assert_eq!(
2768 left,
2769 vec![mako_fristen::APERAK_STROM_WINDOW_LABEL.to_owned()],
2770 "a delivered CONTRL discharges only the CONTRL window; the APERAK \
2771 obligation is still outstanding",
2772 );
2773 }
2774
2775 /// Every delivery-window label must be discharged by the message it watches.
2776 ///
2777 /// This is the invariant the miss counters rest on. A window label that
2778 /// `discharges_delivery_window` does not recognise is never retired, so it
2779 /// fires on every process and is counted as a regulatory violation each
2780 /// time — which is precisely how `makod_aperak_missed_total` once came to
2781 /// count Strom processes rather than missed APERAKs.
2782 ///
2783 /// Adding a delivery window means adding a row here.
2784 #[test]
2785 fn every_delivery_window_label_is_discharged_by_its_message() {
2786 for (message_type, label) in [
2787 ("APERAK", mako_fristen::APERAK_STROM_WINDOW_LABEL),
2788 ("APERAK", mako_fristen::APERAK_GAS_FOLGEPROZESS_LABEL),
2789 ("APERAK", mako_fristen::APERAK_GAS_INITIALPROZESS_LABEL),
2790 ("CONTRL", mako_fristen::CONTRL_FRIST_LABEL),
2791 ] {
2792 assert!(
2793 mako_fristen::discharges_delivery_window(message_type, label),
2794 "delivering {message_type} must discharge `{label}`, or the window \
2795 outlives its obligation and alerts on every process",
2796 );
2797 }
2798 }
2799
2800 // ── Retry-budget classification ───────────────────────────────────────────
2801
2802 /// Sink double that records every rejection's attempt count.
2803 #[derive(Default)]
2804 struct RecordingSink(std::sync::Mutex<Vec<u32>>);
2805 impl crate::dead_letter::DeadLetterSink for std::sync::Arc<RecordingSink> {
2806 fn reject(&self, reason: &crate::dead_letter::DeadLetterReason) {
2807 if let crate::dead_letter::DeadLetterReason::OutboxExhausted { attempts, .. } = reason {
2808 self.0
2809 .lock()
2810 .unwrap_or_else(std::sync::PoisonError::into_inner)
2811 .push(*attempts);
2812 }
2813 }
2814 }
2815
2816 struct NoRenderer;
2817 impl As4Sender for NoRenderer {
2818 async fn send(&self, msg: &crate::outbox::OutboxMessage) -> Result<(), EngineError> {
2819 Err(EngineError::RendererNotImplemented {
2820 message_type: msg.message_type.as_ref().into(),
2821 message_id: msg.message_id.to_string().into(),
2822 })
2823 }
2824 }
2825
2826 async fn drive_worker<S: As4Sender>(
2827 sender: S,
2828 msg: crate::outbox::OutboxMessage,
2829 ) -> (InMemoryOutboxStore, std::sync::Arc<RecordingSink>) {
2830 let outbox = InMemoryOutboxStore::new();
2831 outbox.enqueue(std::slice::from_ref(&msg)).await.unwrap();
2832 let sink = std::sync::Arc::new(RecordingSink::default());
2833 let worker = OutboxWorker {
2834 store: outbox.clone(),
2835 sender,
2836 deadline_store: InMemoryDeadlineStore::new(),
2837 batch_size: 10,
2838 poll_interval: std::time::Duration::from_millis(5),
2839 max_attempts: 48,
2840 max_retry_window: std::time::Duration::from_secs(72 * 3600),
2841 dead_letter_sink: std::sync::Arc::new(std::sync::Arc::clone(&sink)),
2842 heartbeat: None,
2843 shutdown: None,
2844 };
2845 let _ = tokio::time::timeout(std::time::Duration::from_millis(300), worker.run()).await;
2846 (outbox, sink)
2847 }
2848
2849 /// `RendererNotImplemented` is documented as permanent — the worker must
2850 /// dead-letter it on the *first* attempt, not burn the retry budget on a
2851 /// failure that cannot heal between attempts. Until the permanent arm
2852 /// matched it, this promise was broken.
2853 #[tokio::test(start_paused = true)]
2854 async fn a_missing_renderer_dead_letters_without_retrying() {
2855 let stream_id = crate::ids::StreamId::new("test-renderer-missing");
2856 let msg = outbox_message(&stream_id, "MSCONS");
2857 let (outbox, sink) = drive_worker(NoRenderer, msg).await;
2858
2859 assert!(
2860 outbox.pending_now(10).await.unwrap().is_empty(),
2861 "the message must be acknowledged, not left for another attempt",
2862 );
2863 let rejections = sink
2864 .0
2865 .lock()
2866 .unwrap_or_else(std::sync::PoisonError::into_inner)
2867 .clone();
2868 assert_eq!(
2869 rejections,
2870 vec![0],
2871 "exactly one dead-letter, on the first attempt (attempt_count 0)",
2872 );
2873 }
2874
2875 /// The retry budget is a *window*, not a count: a message whose age has
2876 /// exceeded it after at least one attempt is dead-lettered even though the
2877 /// attempt belt is nowhere near exhausted — full-jitter backoff makes a
2878 /// count no proxy for the 72 h duty.
2879 #[tokio::test(start_paused = true)]
2880 async fn an_aged_message_with_a_prior_attempt_is_dead_lettered() {
2881 let stream_id = crate::ids::StreamId::new("test-window-exhausted");
2882 let mut msg = outbox_message(&stream_id, "UTILMD");
2883 msg.created_at = time::OffsetDateTime::now_utc() - time::Duration::hours(73);
2884 msg.attempt_count = 1;
2885 let (outbox, sink) = drive_worker(AlwaysDelivers, msg).await;
2886
2887 assert!(
2888 outbox.pending_now(10).await.unwrap().is_empty(),
2889 "the exhausted message must leave the outbox",
2890 );
2891 let rejections = sink
2892 .0
2893 .lock()
2894 .unwrap_or_else(std::sync::PoisonError::into_inner)
2895 .clone();
2896 assert_eq!(
2897 rejections,
2898 vec![1],
2899 "the window, not the attempt belt, must have dead-lettered it",
2900 );
2901 }
2902
2903 /// A message that aged past the window while the worker was down still
2904 /// gets its first try — the window is only consulted after an attempt, so
2905 /// downtime never buries a message unsent.
2906 #[tokio::test(start_paused = true)]
2907 async fn an_aged_message_with_no_attempts_is_still_tried_once() {
2908 let stream_id = crate::ids::StreamId::new("test-aged-first-try");
2909 let mut msg = outbox_message(&stream_id, "UTILMD");
2910 msg.created_at = time::OffsetDateTime::now_utc() - time::Duration::hours(200);
2911 let (outbox, sink) = drive_worker(AlwaysDelivers, msg).await;
2912
2913 assert!(
2914 outbox.pending_now(10).await.unwrap().is_empty(),
2915 "the message must have been delivered and acknowledged",
2916 );
2917 assert!(
2918 sink.0
2919 .lock()
2920 .unwrap_or_else(std::sync::PoisonError::into_inner)
2921 .is_empty(),
2922 "delivery, not dead-lettering: age alone must never bury a message",
2923 );
2924 }
2925}