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 // CONTRL AHB 1.0 §1.2: the CONTRL must be delivered
894 // within 6 wall-clock hours of interchange receipt.
895 // `msg.created_at` is when the PendingOutbox was
896 // materialised (which should equal the ingest timestamp
897 // for transport-layer CONTRL obligations).
898 if msg.message_type.as_ref() == "CONTRL" {
899 let elapsed = time::OffsetDateTime::now_utc() - msg.created_at;
900 if elapsed > time::Duration::hours(mako_fristen::CONTRL_FRIST_HOURS) {
901 tracing::warn!(
902 message_id = %msg.message_id,
903 elapsed_secs = elapsed.whole_seconds(),
904 max_secs = mako_fristen::CONTRL_FRIST_HOURS * 3600,
905 "outbox worker: CONTRL delivered OUTSIDE the 6h Übertragungsfrist \
906 (CONTRL AHB 1.0 §1.2) — this is a BNetzA compliance violation"
907 );
908 }
909 }
910 // APERAK AHB 1.0 §2.4.1: Strom UTILMD/ORDERS APERAK must be
911 // delivered within 45 minutes on weekdays, or by Sunday 12:00
912 // if received on Saturday. Log a compliance warning if the
913 // delivery window was missed so operators can investigate.
914 if msg.message_type.as_ref() == "APERAK" {
915 let elapsed = time::OffsetDateTime::now_utc() - msg.created_at;
916 if elapsed
917 > time::Duration::minutes(
918 mako_fristen::APERAK_STROM_WEEKDAY_MINUTES,
919 )
920 {
921 tracing::warn!(
922 message_id = %msg.message_id,
923 elapsed_mins = elapsed.whole_minutes(),
924 "outbox worker: APERAK delivered after the 45-minute Strom \
925 sending window (APERAK AHB 1.0 §2.4.1) — \
926 check OutboxWorker and AS4 transport health"
927 );
928 }
929 }
930 // The message is out, so any delivery window that was
931 // watching for it has been answered — retire it.
932 //
933 // Nothing else cancels these. A monitoring deadline that
934 // outlives the obligation it monitors fires for every
935 // process, including every one that answered on time,
936 // and the scheduler cannot tell those apart because a
937 // deadline reaching `due_now` is late by construction.
938 // Leaving them registered turns the miss counters into
939 // counts of *processes started*.
940 self.discharge_delivery_window(&msg).await;
941 }
942 // Permanent error: dead-letter immediately without retrying.
943 // PartnerUnknown requires operator intervention (add --as4-partner);
944 // Serialization errors will never succeed on retry; a missing
945 // wire-format renderer cannot appear between attempts — its own
946 // documentation promises immediate dead-lettering, and until this
947 // arm matched it, that promise was broken and the message burned
948 // the whole retry budget first.
949 Err(ref e)
950 if e.is_partner_unknown()
951 || e.is_renderer_not_implemented()
952 || matches!(e, EngineError::Serialization(_)) =>
953 {
954 tracing::error!(
955 message_id = %msg.message_id,
956 message_type = %msg.message_type,
957 recipient = %msg.recipient,
958 error = %e,
959 "outbox worker: permanent send failure; dead-lettering without retry",
960 );
961 self.dead_letter_sink.reject(
962 &crate::dead_letter::DeadLetterReason::OutboxExhausted {
963 message_id: msg.message_id,
964 message_type: msg.message_type.to_string(),
965 recipient: msg.recipient.to_string(),
966 last_error: e.to_string(),
967 attempts: msg.attempt_count,
968 },
969 );
970 if let Err(re) = self.store.acknowledge(msg.message_id).await {
971 tracing::error!(
972 message_id = %msg.message_id,
973 error = %re,
974 "outbox worker: acknowledge after permanent failure failed",
975 );
976 }
977 }
978 Err(e) => {
979 // Stable jitter entropy derived from the UUID bytes of
980 // `message_id`. Using the last 8 bytes as a `u64` gives
981 // uniform entropy across message IDs (UUIDs are random in
982 // all 128 bits for v4) and is stable across Rust versions —
983 // unlike `DefaultHasher`, whose algorithm is explicitly
984 // documented as unstable.
985 let entropy = {
986 let uuid = msg.message_id.as_uuid();
987 let bytes = uuid.as_bytes();
988 u64::from_le_bytes(bytes[8..16].try_into().unwrap())
989 };
990 let delay = backoff_delay(msg.attempt_count, entropy);
991 let retry_at = time::OffsetDateTime::now_utc()
992 + time::Duration::try_from(delay).unwrap_or(time::Duration::minutes(5));
993 tracing::warn!(
994 message_id = %msg.message_id,
995 attempt = msg.attempt_count,
996 max_attempts = self.max_attempts,
997 retry_in = ?delay,
998 error = %e,
999 "outbox worker: send failed; rescheduling with backoff",
1000 );
1001 if let Err(re) = self.store.reschedule(msg.message_id, retry_at).await {
1002 tracing::error!(
1003 message_id = %msg.message_id,
1004 error = %re,
1005 "outbox worker: reschedule failed; message may be stuck",
1006 );
1007 }
1008 }
1009 }
1010 }
1011
1012 // Nothing in this batch was ours: sleep before polling again, or a
1013 // queue held by the other consumer turns this loop into a spin.
1014 if !handled_any && !sleep_or_cancel(self.poll_interval, self.shutdown.as_ref()).await {
1015 return;
1016 }
1017 }
1018 }
1019}
1020
1021impl<ES, SS, OS, DS, PR> EngineContext<ES, SS, OS, DS, PR>
1022where
1023 ES: EventStore,
1024 OS: OutboxStore + Clone,
1025{
1026 /// Construct an [`OutboxWorker`] that drains the outbox via `sender`.
1027 ///
1028 /// `batch_size` — messages fetched per poll cycle.
1029 /// `poll_interval` — sleep duration when the batch is empty.
1030 ///
1031 /// `max_attempts` — attempt belt against runaway loops; the real budget is
1032 /// `max_retry_window`, the message age after which delivery is abandoned.
1033 /// The BDEW AS4 retry duty is stated in *hours* (72 h for unacknowledged
1034 /// messages — `mako_as4::constants::MAX_RETRY_DURATION_SECS`), and the
1035 /// full-jitter backoff makes an attempt count no proxy for a duration, so
1036 /// both are taken and either exhausts the message.
1037 ///
1038 /// ```rust,ignore
1039 /// use std::time::Duration;
1040 ///
1041 /// let worker = ctx.run_outbox_worker(
1042 /// my_sender, 50, Duration::from_secs(1),
1043 /// 10_000, Duration::from_secs(72 * 3600),
1044 /// );
1045 /// tokio::spawn(async move { worker.run().await });
1046 /// ```
1047 #[must_use]
1048 pub fn run_outbox_worker<S: As4Sender>(
1049 &self,
1050 sender: S,
1051 batch_size: usize,
1052 poll_interval: std::time::Duration,
1053 max_attempts: u32,
1054 max_retry_window: std::time::Duration,
1055 ) -> OutboxWorker<OS, S, DS>
1056 where
1057 DS: DeadlineStore + Clone,
1058 {
1059 OutboxWorker {
1060 store: self.outbox_store.clone(),
1061 sender,
1062 deadline_store: self.deadline_store.clone(),
1063 batch_size,
1064 poll_interval,
1065 max_attempts,
1066 max_retry_window,
1067 dead_letter_sink: self.dead_letter_sink.clone(),
1068 heartbeat: None,
1069 shutdown: None,
1070 }
1071 }
1072}
1073
1074impl<OS: OutboxStore, S: As4Sender, DS: DeadlineStore> OutboxWorker<OS, S, DS> {
1075 /// Attach a liveness heartbeat to this worker.
1076 ///
1077 /// The worker will store the current UTC Unix timestamp (seconds) into
1078 /// `heartbeat` at the end of every poll cycle. Pass the same
1079 /// `Arc<AtomicI64>` to the health endpoint so it can detect stale workers.
1080 #[must_use]
1081 pub fn with_heartbeat(
1082 mut self,
1083 heartbeat: std::sync::Arc<std::sync::atomic::AtomicI64>,
1084 ) -> Self {
1085 self.heartbeat = Some(heartbeat);
1086 self
1087 }
1088
1089 /// Attach a graceful-shutdown token.
1090 ///
1091 /// Cancelling it makes [`OutboxWorker::run`] return at the next message
1092 /// boundary or immediately out of its idle sleep. Await the worker's
1093 /// `JoinHandle` afterwards: the point of the token is that the caller can
1094 /// close the event store *after* the worker has stopped writing to it.
1095 #[must_use]
1096 pub fn with_shutdown(mut self, shutdown: tokio_util::sync::CancellationToken) -> Self {
1097 self.shutdown = Some(shutdown);
1098 self
1099 }
1100
1101 /// Retire the delivery window `msg` was being watched by, if one is open.
1102 ///
1103 /// A delivery-window deadline exists to answer one question: *did this
1104 /// message go out in time?* Once it has gone out the question is settled,
1105 /// and leaving the deadline registered only guarantees a false alarm later.
1106 /// [`fristen::discharges_delivery_window`] decides which labels a given
1107 /// message type answers for; deadlines that merely share the stream (a
1108 /// process-response window, say) are left alone.
1109 ///
1110 /// Best-effort: a failure here costs a spurious alert at the window's close,
1111 /// never a lost or duplicated message, so it is logged rather than
1112 /// propagated — the delivery itself has already been acknowledged.
1113 ///
1114 /// [`fristen::discharges_delivery_window`]: mako_fristen::discharges_delivery_window
1115 async fn discharge_delivery_window(&self, msg: &crate::outbox::OutboxMessage) {
1116 let open = match self.deadline_store.for_stream(&msg.stream_id).await {
1117 Ok(deadlines) => deadlines,
1118 Err(e) => {
1119 tracing::warn!(
1120 message_id = %msg.message_id,
1121 message_type = %msg.message_type,
1122 error = %e,
1123 "outbox worker: could not read deadlines to discharge the delivery \
1124 window; it may fire a spurious regulatory alert",
1125 );
1126 return;
1127 }
1128 };
1129
1130 for deadline in open
1131 .iter()
1132 .filter(|d| mako_fristen::discharges_delivery_window(&msg.message_type, d.label()))
1133 {
1134 if let Err(e) = self.deadline_store.cancel(deadline.deadline_id()).await {
1135 tracing::warn!(
1136 message_id = %msg.message_id,
1137 deadline_id = %deadline.deadline_id(),
1138 label = %deadline.label(),
1139 error = %e,
1140 "outbox worker: could not discharge the delivery window; \
1141 it may fire a spurious regulatory alert",
1142 );
1143 } else {
1144 tracing::debug!(
1145 message_id = %msg.message_id,
1146 message_type = %msg.message_type,
1147 deadline_id = %deadline.deadline_id(),
1148 label = %deadline.label(),
1149 "outbox worker: message delivered — delivery window discharged",
1150 );
1151 }
1152 }
1153 }
1154}
1155
1156impl<ES, SS, OS, DS, PR> std::fmt::Debug for EngineContext<ES, SS, OS, DS, PR>
1157where
1158 ES: std::fmt::Debug,
1159 SS: std::fmt::Debug,
1160 OS: std::fmt::Debug,
1161 DS: std::fmt::Debug,
1162 PR: std::fmt::Debug,
1163{
1164 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1165 f.debug_struct("EngineContext")
1166 .field("registered_modules", &self.registered_modules)
1167 .field("registered_workflows", &self.registered_workflows)
1168 .field("pid_router_len", &self.pid_router.len())
1169 .finish_non_exhaustive()
1170 }
1171}
1172
1173// ── NoopAs4Sender / LogAs4Sender ──────────────────────────────────────────────
1174
1175/// An [`As4Sender`] that succeeds immediately without sending anything.
1176///
1177/// Use in tests and environments where outbound AS4 delivery is not yet
1178/// wired. All outbox messages are acknowledged (removed from the queue)
1179/// without being transmitted.
1180///
1181/// # ⚠️ Data loss warning
1182///
1183/// Every outbox message is **silently discarded** — no EDIFACT message is
1184/// sent to any counterparty. Do not use in production.
1185#[derive(Debug, Clone, Copy, Default)]
1186#[must_use = "NoopAs4Sender discards all outbound messages silently — use a real AS4 gateway in production"]
1187#[cfg_attr(
1188 not(any(test, feature = "testing")),
1189 deprecated = "NoopAs4Sender must not be wired in production builds; every \
1190 outbound EDIFACT message would be silently discarded. Use \
1191 a real As4Sender implementation instead."
1192)]
1193pub struct NoopAs4Sender;
1194
1195// The trait impl is test/testing-only: a release build without the `testing`
1196// feature cannot wire NoopAs4Sender into an outbox worker at all.
1197#[cfg(any(test, feature = "testing"))]
1198impl As4Sender for NoopAs4Sender {
1199 async fn send(&self, _msg: &OutboxMessage) -> Result<(), EngineError> {
1200 Ok(())
1201 }
1202}
1203
1204/// An [`As4Sender`] that logs every outbound message at `warn` level and
1205/// succeeds without transmitting.
1206///
1207/// Useful for development and integration-testing environments where the
1208/// full AS4 stack is not yet available but message visibility is desired.
1209/// All outbox messages are acknowledged (removed from the queue) after logging.
1210///
1211/// # ⚠️ Data loss warning
1212///
1213/// No EDIFACT message is sent to any counterparty. Do not use in production.
1214#[derive(Debug, Clone, Copy, Default)]
1215#[must_use = "LogAs4Sender discards all outbound messages — use a real AS4 gateway in production"]
1216pub struct LogAs4Sender;
1217
1218impl As4Sender for LogAs4Sender {
1219 async fn send(&self, msg: &OutboxMessage) -> Result<(), EngineError> {
1220 tracing::warn!(
1221 message_id = %msg.message_id,
1222 message_type = %msg.message_type,
1223 recipient = %msg.recipient,
1224 "LogAs4Sender: outbox message dropped — configure a real AS4 gateway for production",
1225 );
1226 Ok(())
1227 }
1228}
1229
1230// ── DeadlineScheduler ─────────────────────────────────────────────────────────
1231
1232/// A background task that polls [`DeadlineStore::due_now`] and dispatches
1233/// deadline commands to the owning processes via a caller-supplied function.
1234///
1235/// Obtain via [`EngineContext::run_deadline_scheduler`] and drive by spawning
1236/// [`DeadlineScheduler::run`] in a Tokio task.
1237///
1238/// # Dispatch function
1239///
1240/// The `dispatch` function receives a fired [`Deadline`] and returns a future
1241/// that dispatches the appropriate timeout command to the process. The function
1242/// is responsible for resuming the correct workflow and calling `execute`.
1243/// After the future completes, the scheduler cancels the deadline from the
1244/// store regardless of the dispatch outcome (to prevent re-firing).
1245///
1246/// ```rust,ignore
1247/// use std::time::Duration;
1248///
1249/// let scheduler = ctx.run_deadline_scheduler(
1250/// |deadline| async move {
1251/// tracing::warn!(
1252/// deadline_id = %deadline.deadline_id(),
1253/// label = %deadline.label(),
1254/// "deadline fired",
1255/// );
1256/// Ok(())
1257/// },
1258/// 100,
1259/// Duration::from_secs(30),
1260/// );
1261/// tokio::spawn(async move { scheduler.run().await });
1262/// ```
1263pub struct DeadlineScheduler<DS: DeadlineStore> {
1264 store: DS,
1265 dispatch: Box<
1266 dyn Fn(
1267 Deadline,
1268 ) -> std::pin::Pin<
1269 Box<dyn std::future::Future<Output = Result<(), EngineError>> + Send>,
1270 > + Send
1271 + Sync,
1272 >,
1273 batch_size: usize,
1274 poll_interval: std::time::Duration,
1275 /// Optional liveness heartbeat — stores the current UTC Unix timestamp
1276 /// (seconds) after each poll cycle.
1277 heartbeat: Option<std::sync::Arc<std::sync::atomic::AtomicI64>>,
1278 /// Graceful-shutdown signal — see [`DeadlineScheduler::with_shutdown`].
1279 shutdown: Option<tokio_util::sync::CancellationToken>,
1280}
1281
1282impl<DS: DeadlineStore> DeadlineScheduler<DS> {
1283 /// Run the deadline poll loop until the shutdown token is cancelled.
1284 ///
1285 /// Cancellation is observed between deadlines and during the idle sleep, so
1286 /// a deadline already being dispatched runs to completion. A deadline left
1287 /// undispatched stays registered and fires on the next start — it is due, so
1288 /// the next `due_now` returns it again.
1289 pub async fn run(self) {
1290 loop {
1291 if self
1292 .shutdown
1293 .as_ref()
1294 .is_some_and(tokio_util::sync::CancellationToken::is_cancelled)
1295 {
1296 tracing::info!("deadline scheduler: shutdown signalled; stopping");
1297 return;
1298 }
1299 // Tick liveness at the *start* of every poll cycle, ahead of the
1300 // early-`continue` paths below. An idle scheduler (no due
1301 // deadlines) is alive and must keep ticking; only one genuinely
1302 // hung inside an `.await` stops.
1303 if let Some(ref hb) = self.heartbeat {
1304 hb.store(
1305 time::OffsetDateTime::now_utc().unix_timestamp(),
1306 std::sync::atomic::Ordering::Relaxed,
1307 );
1308 }
1309
1310 let result = match self.store.due_now(self.batch_size).await {
1311 Ok(r) => r,
1312 Err(e) => {
1313 tracing::warn!(
1314 error = %e,
1315 "deadline scheduler: store error polling due deadlines (will retry)",
1316 );
1317 if !sleep_or_cancel(self.poll_interval, self.shutdown.as_ref()).await {
1318 return;
1319 }
1320 continue;
1321 }
1322 };
1323
1324 if result.deadlines.is_empty() {
1325 if !sleep_or_cancel(self.poll_interval, self.shutdown.as_ref()).await {
1326 return;
1327 }
1328 continue;
1329 }
1330
1331 for deadline in result.deadlines {
1332 // Between deadlines, not inside one: a dispatch already running
1333 // must finish so its events and outbox entries commit together.
1334 if self
1335 .shutdown
1336 .as_ref()
1337 .is_some_and(tokio_util::sync::CancellationToken::is_cancelled)
1338 {
1339 tracing::info!(
1340 "deadline scheduler: shutdown signalled mid-batch; \
1341 undispatched deadlines remain due and fire on the next start"
1342 );
1343 return;
1344 }
1345 let id = deadline.deadline_id();
1346 let label = deadline.label().to_owned();
1347
1348 // An APERAK delivery window that reaches this point is a
1349 // regulatory violation under APERAK AHB 1.0 §2.4.1 (Strom
1350 // 45 min) / §2.3.1 (Gas 1 Werktag): the outbox worker discharges
1351 // the window the moment the APERAK goes out, so one that is
1352 // still registered when it comes due was never answered.
1353 //
1354 // Do NOT re-test `now > due_at` here. `due_now` selects on
1355 // `due_at <= now`, so that comparison is true by construction and
1356 // says nothing about compliance — it was the reason this counter
1357 // once tracked "Strom processes started" rather than "APERAKs
1358 // missed". The discharge is what carries the meaning.
1359 if label.starts_with(mako_fristen::APERAK_WINDOW_LABEL_PREFIX) {
1360 let now = time::OffsetDateTime::now_utc();
1361 crate::metrics::EngineMetrics::global().aperak_missed(&label);
1362 tracing::error!(
1363 deadline_id = %id,
1364 label = %label,
1365 due_at = %deadline.due_at(),
1366 fired_at = %now,
1367 overdue_secs = (now - deadline.due_at()).whole_seconds(),
1368 "APERAK delivery window closed with no delivery — regulatory \
1369 violation (APERAK AHB 1.0 §2.4.1 Strom / §2.3.1 Gas). \
1370 Counter: makod_aperak_missed_total",
1371 );
1372 }
1373
1374 let should_cancel = match (self.dispatch)(deadline).await {
1375 Ok(()) => true,
1376 Err(ref e) if e.is_version_conflict() => {
1377 // The process was modified concurrently; the timeout
1378 // command will be retried on the next poll cycle.
1379 // Do NOT cancel — let the deadline remain due so it
1380 // fires again until a non-conflict dispatch succeeds.
1381 tracing::warn!(
1382 deadline_id = %id,
1383 label = %label,
1384 "deadline scheduler: VersionConflict; will retry on next poll",
1385 );
1386 false
1387 }
1388 Err(e) => {
1389 tracing::warn!(
1390 deadline_id = %id,
1391 label = %label,
1392 error = %e,
1393 "deadline scheduler: dispatch failed (permanent); cancelling",
1394 );
1395 true
1396 }
1397 };
1398 if should_cancel && let Err(e) = self.store.cancel(id).await {
1399 tracing::error!(
1400 deadline_id = %id,
1401 error = %e,
1402 "deadline scheduler: cancel failed; deadline may fire again",
1403 );
1404 }
1405 }
1406
1407 // If has_more, loop immediately to drain the batch.
1408 }
1409 }
1410}
1411
1412impl<DS: DeadlineStore> DeadlineScheduler<DS> {
1413 /// Attach a liveness heartbeat to this scheduler.
1414 ///
1415 /// The scheduler will store the current UTC Unix timestamp (seconds) into
1416 /// `heartbeat` at the end of every poll cycle.
1417 #[must_use]
1418 pub fn with_heartbeat(
1419 mut self,
1420 heartbeat: std::sync::Arc<std::sync::atomic::AtomicI64>,
1421 ) -> Self {
1422 self.heartbeat = Some(heartbeat);
1423 self
1424 }
1425
1426 /// Attach a graceful-shutdown token.
1427 ///
1428 /// Cancelling it makes [`DeadlineScheduler::run`] return at the next
1429 /// deadline boundary or immediately out of its idle sleep, so the caller can
1430 /// close the event store once the scheduler has stopped writing to it.
1431 #[must_use]
1432 pub fn with_shutdown(mut self, shutdown: tokio_util::sync::CancellationToken) -> Self {
1433 self.shutdown = Some(shutdown);
1434 self
1435 }
1436}
1437
1438impl<ES, SS, OS, DS, PR> EngineContext<ES, SS, OS, DS, PR>
1439where
1440 ES: EventStore,
1441 DS: DeadlineStore + Clone,
1442{
1443 /// Construct a [`DeadlineScheduler`] that polls the deadline store and
1444 /// dispatches fired deadlines via `dispatch`.
1445 ///
1446 /// The `dispatch` function is called for every fired deadline. It should
1447 /// resume the owning process and execute the appropriate timeout command.
1448 ///
1449 /// `batch_size` — deadlines fetched per poll cycle.
1450 /// `poll_interval` — sleep duration when no deadlines are due.
1451 ///
1452 /// ```rust,ignore
1453 /// use std::time::Duration;
1454 ///
1455 /// let scheduler = ctx.run_deadline_scheduler(
1456 /// |d| async move {
1457 /// tracing::info!(label = %d.label(), "firing deadline");
1458 /// Ok(())
1459 /// },
1460 /// 100,
1461 /// Duration::from_secs(30),
1462 /// );
1463 /// tokio::spawn(async move { scheduler.run().await });
1464 /// ```
1465 #[must_use]
1466 pub fn run_deadline_scheduler<F, Fut>(
1467 &self,
1468 dispatch: F,
1469 batch_size: usize,
1470 poll_interval: std::time::Duration,
1471 ) -> DeadlineScheduler<DS>
1472 where
1473 F: Fn(Deadline) -> Fut + Send + Sync + 'static,
1474 Fut: std::future::Future<Output = Result<(), EngineError>> + Send + 'static,
1475 {
1476 DeadlineScheduler {
1477 store: self.deadline_store.clone(),
1478 dispatch: Box::new(move |d| Box::pin(dispatch(d))),
1479 batch_size,
1480 poll_interval,
1481 heartbeat: None,
1482 shutdown: None,
1483 }
1484 }
1485}
1486
1487// ── EngineBuilder ─────────────────────────────────────────────────────────────
1488
1489/// Assembles engine infrastructure and produces an [`EngineContext`].
1490///
1491/// Uses type-state to enforce that an event store is provided before
1492/// [`build`] can be called. All other stores default to `Noop`
1493/// implementations.
1494///
1495/// ## Quick start
1496///
1497/// ```rust,ignore
1498/// // Minimal — event store only, all others are Noop:
1499/// let ctx = EngineBuilder::new()
1500/// .with_event_store(InMemoryEventStore::new())
1501/// .build();
1502///
1503/// // Full infrastructure:
1504/// let ctx = EngineBuilder::new()
1505/// .with_event_store(InMemoryEventStore::new())
1506/// .with_snapshot_store(InMemorySnapshotStore::new())
1507/// .with_outbox_store(InMemoryOutboxStore::new())
1508/// .with_deadline_store(InMemoryDeadlineStore::new())
1509/// .with_registry(InMemoryProcessRegistry::new())
1510/// .register(Box::new(GpkeModule))
1511/// .build();
1512/// ```
1513///
1514/// [`build`]: EngineBuilder::build
1515pub struct EngineBuilder<
1516 ES = (),
1517 SS = NoopSnapshotStore,
1518 OS = NoopOutboxStore,
1519 DS = NoopDeadlineStore,
1520 PR = NoopProcessRegistry,
1521> {
1522 event_store: ES,
1523 snapshot_store: SS,
1524 outbox_store: OS,
1525 deadline_store: DS,
1526 registry: PR,
1527 dead_letter_sink: Arc<dyn DeadLetterSink>,
1528 modules: Vec<Box<dyn EngineModule>>,
1529 /// Active [`DeploymentRoles`] for this engine instance.
1530 ///
1531 /// Controls role-conditional PID registration via
1532 /// [`EngineModule::register_pids_with_roles`]. Defaults to
1533 /// [`DeploymentRoles::all()`]: an engine that names no roles registers
1534 /// every PID its modules declare, which is what a test harness and a
1535 /// combined-role deployment both want.
1536 deployment_roles: DeploymentRoles,
1537 /// Optional profile validator injected by `makod` or callers that have
1538 /// access to `edi-energy`. When `Some`, called for each
1539 /// [`ProfileRequirement`] declared by registered modules. When `None`,
1540 /// profile requirements are not validated (safe in unit tests).
1541 ///
1542 /// Signature: `fn(message_type: &str) -> bool`
1543 ///
1544 /// [`ProfileRequirement`]: crate::profile::ProfileRequirement
1545 profile_validator: Option<Box<dyn Fn(&str) -> bool + Send + Sync>>,
1546}
1547#[cfg(any(test, feature = "testing"))]
1548impl Default
1549 for EngineBuilder<
1550 (),
1551 NoopSnapshotStore,
1552 NoopOutboxStore,
1553 NoopDeadlineStore,
1554 NoopProcessRegistry,
1555 >
1556{
1557 fn default() -> Self {
1558 Self {
1559 event_store: (),
1560 snapshot_store: NoopSnapshotStore,
1561 outbox_store: NoopOutboxStore,
1562 deadline_store: NoopDeadlineStore,
1563 registry: NoopProcessRegistry,
1564 dead_letter_sink: Arc::new(LogDeadLetterSink),
1565 modules: Vec::new(),
1566 deployment_roles: DeploymentRoles::all(),
1567 profile_validator: None,
1568 }
1569 }
1570}
1571
1572#[cfg(any(test, feature = "testing"))]
1573impl EngineBuilder {
1574 /// Create a new builder with all `Noop` defaults.
1575 ///
1576 /// Only available in `#[cfg(test)]` or with the `testing` feature enabled,
1577 /// because the Noop defaults silently discard outbox messages, deadlines,
1578 /// and process registry entries. Production binaries must wire real stores
1579 /// via the `with_*` builder methods.
1580 ///
1581 /// Call [`with_event_store`] before [`build`] — the event store is
1582 /// **required**.
1583 ///
1584 /// [`with_event_store`]: EngineBuilder::with_event_store
1585 /// [`build`]: EngineBuilder::build
1586 #[must_use]
1587 pub fn new() -> Self {
1588 Self::default()
1589 }
1590}
1591
1592impl<OS, DS, PR> EngineBuilder<(), NoopSnapshotStore, OS, DS, PR>
1593where
1594 OS: OutboxStore,
1595 DS: DeadlineStore,
1596 PR: ProcessRegistry,
1597{
1598 /// Create a production-ready builder with explicit stores for outbox,
1599 /// deadline, and process registry.
1600 ///
1601 /// This constructor is available in all build configurations including
1602 /// production binaries. It enforces that the three stores that can cause
1603 /// silent data loss (`OutboxStore`, `DeadlineStore`, `ProcessRegistry`)
1604 /// are provided explicitly — there is no Noop fallback.
1605 ///
1606 /// `NoopSnapshotStore` is used as the snapshot default because it is safe
1607 /// for production: skipping snapshots means full replay, but no data loss.
1608 /// Override with [`with_snapshot_store`] to enable snapshot-accelerated
1609 /// replay.
1610 ///
1611 /// Call [`with_event_store`] before [`build`] — the event store is
1612 /// **required**.
1613 ///
1614 /// ```rust,ignore
1615 /// let ctx = EngineBuilder::with_stores(outbox, deadline, registry)
1616 /// .with_event_store(store.clone())
1617 /// .with_snapshot_store(InMemorySnapshotStore::new())
1618 /// .build();
1619 /// ```
1620 ///
1621 /// [`with_snapshot_store`]: EngineBuilder::with_snapshot_store
1622 /// [`with_event_store`]: EngineBuilder::with_event_store
1623 /// [`build`]: EngineBuilder::build
1624 #[must_use]
1625 pub fn with_stores(outbox_store: OS, deadline_store: DS, registry: PR) -> Self {
1626 Self {
1627 event_store: (),
1628 snapshot_store: NoopSnapshotStore,
1629 outbox_store,
1630 deadline_store,
1631 registry,
1632 dead_letter_sink: Arc::new(LogDeadLetterSink),
1633 modules: Vec::new(),
1634 deployment_roles: DeploymentRoles::all(),
1635 profile_validator: None,
1636 }
1637 }
1638}
1639
1640impl<ES, SS, OS, DS, PR> EngineBuilder<ES, SS, OS, DS, PR> {
1641 /// Set the event store. **Required** — `build()` is only available once
1642 /// this has been called with a type that implements [`EventStore`].
1643 ///
1644 /// Replaces any previously set event store (type-state transition).
1645 #[must_use]
1646 pub fn with_event_store<ES2: EventStore>(
1647 self,
1648 store: ES2,
1649 ) -> EngineBuilder<ES2, SS, OS, DS, PR> {
1650 EngineBuilder {
1651 event_store: store,
1652 snapshot_store: self.snapshot_store,
1653 outbox_store: self.outbox_store,
1654 deadline_store: self.deadline_store,
1655 registry: self.registry,
1656 dead_letter_sink: self.dead_letter_sink,
1657 modules: self.modules,
1658 deployment_roles: self.deployment_roles,
1659 profile_validator: self.profile_validator,
1660 }
1661 }
1662
1663 /// Set the snapshot store (default: [`NoopSnapshotStore`]).
1664 ///
1665 /// ## Default: `NoopSnapshotStore`
1666 ///
1667 /// Without calling this method the builder uses [`NoopSnapshotStore`],
1668 /// which silently discards all snapshot writes and returns `None` for
1669 /// every snapshot read. The engine still functions correctly — every
1670 /// command handling call replays the full event log from the beginning
1671 /// instead of starting from a stored snapshot. For low-volume processes
1672 /// this is fine; for long-lived processes with many events the replay cost
1673 /// can become significant.
1674 ///
1675 /// Enable snapshotting in production by providing a real [`SnapshotStore`]
1676 /// implementation (e.g. the SlateDB-backed store in `makod`). In tests,
1677 /// `InMemorySnapshotStore` is available behind the `testing` feature flag.
1678 ///
1679 /// Note: [`Process::state_with_snapshot`][crate::process::Process::state_with_snapshot]
1680 /// is a compile-time no-op when the snapshot store is `NoopSnapshotStore`
1681 /// — it never calls the store and always returns `None`, so no snapshot is
1682 /// ever saved or loaded.
1683 #[must_use]
1684 pub fn with_snapshot_store<SS2: SnapshotStore>(
1685 self,
1686 store: SS2,
1687 ) -> EngineBuilder<ES, SS2, OS, DS, PR> {
1688 EngineBuilder {
1689 event_store: self.event_store,
1690 snapshot_store: store,
1691 outbox_store: self.outbox_store,
1692 deadline_store: self.deadline_store,
1693 registry: self.registry,
1694 dead_letter_sink: self.dead_letter_sink,
1695 modules: self.modules,
1696 deployment_roles: self.deployment_roles,
1697 profile_validator: self.profile_validator,
1698 }
1699 }
1700
1701 /// Set the outbox store (default: [`NoopOutboxStore`]).
1702 #[must_use]
1703 pub fn with_outbox_store<OS2: OutboxStore>(
1704 self,
1705 store: OS2,
1706 ) -> EngineBuilder<ES, SS, OS2, DS, PR> {
1707 EngineBuilder {
1708 event_store: self.event_store,
1709 snapshot_store: self.snapshot_store,
1710 outbox_store: store,
1711 deadline_store: self.deadline_store,
1712 registry: self.registry,
1713 dead_letter_sink: self.dead_letter_sink,
1714 modules: self.modules,
1715 deployment_roles: self.deployment_roles,
1716 profile_validator: self.profile_validator,
1717 }
1718 }
1719
1720 /// Set the deadline store (default: [`NoopDeadlineStore`]).
1721 #[must_use]
1722 pub fn with_deadline_store<DS2: DeadlineStore>(
1723 self,
1724 store: DS2,
1725 ) -> EngineBuilder<ES, SS, OS, DS2, PR> {
1726 EngineBuilder {
1727 event_store: self.event_store,
1728 snapshot_store: self.snapshot_store,
1729 outbox_store: self.outbox_store,
1730 deadline_store: store,
1731 registry: self.registry,
1732 dead_letter_sink: self.dead_letter_sink,
1733 modules: self.modules,
1734 deployment_roles: self.deployment_roles,
1735 profile_validator: self.profile_validator,
1736 }
1737 }
1738
1739 /// Set the process registry (default: [`NoopProcessRegistry`]).
1740 #[must_use]
1741 pub fn with_registry<PR2: ProcessRegistry>(
1742 self,
1743 registry: PR2,
1744 ) -> EngineBuilder<ES, SS, OS, DS, PR2> {
1745 EngineBuilder {
1746 event_store: self.event_store,
1747 snapshot_store: self.snapshot_store,
1748 outbox_store: self.outbox_store,
1749 deadline_store: self.deadline_store,
1750 registry,
1751 dead_letter_sink: self.dead_letter_sink,
1752 modules: self.modules,
1753 deployment_roles: self.deployment_roles,
1754 profile_validator: self.profile_validator,
1755 }
1756 }
1757
1758 /// Set the dead-letter sink (default: [`LogDeadLetterSink`]).
1759 ///
1760 /// The dead-letter sink receives every message that cannot be routed to a
1761 /// workflow. The default [`LogDeadLetterSink`] emits `tracing::warn!`
1762 /// events, making rejections visible in log output without configuration.
1763 ///
1764 /// Override with a persistent DLQ implementation in production:
1765 ///
1766 /// ```rust,ignore
1767 /// use mako_engine::dead_letter::LogDeadLetterSink;
1768 ///
1769 /// let ctx = EngineBuilder::new()
1770 /// .with_event_store(my_store)
1771 /// .with_dead_letter_sink(MyPersistentDlq::new())
1772 /// .build();
1773 /// ```
1774 ///
1775 /// [`LogDeadLetterSink`]: crate::dead_letter::LogDeadLetterSink
1776 #[must_use]
1777 pub fn with_dead_letter_sink(mut self, sink: impl DeadLetterSink) -> Self {
1778 self.dead_letter_sink = Arc::new(sink);
1779 self
1780 }
1781
1782 /// Register an `edi-energy` profile validator for startup profile checks.
1783 ///
1784 /// The closure receives a message-type string (e.g. `"UTILMD"`) and must
1785 /// return `true` if at least one active profile for that message type is
1786 /// registered for today's date.
1787 ///
1788 /// Wire this in `makod` using the `edi-energy` global registry:
1789 ///
1790 /// ```rust,ignore
1791 /// use edi_energy::registry::ReleaseRegistry;
1792 ///
1793 /// let today = mako_fristen::heute();
1794 /// builder.with_profile_validator(move |msg_type| {
1795 /// ReleaseRegistry::global()
1796 /// .profiles_for_str(msg_type)
1797 /// .any(|p| match (p.valid_from(), p.valid_until()) {
1798 /// (Some(f), Some(u)) => f <= today && today <= u,
1799 /// (Some(f), None) => f <= today,
1800 /// (None, _) => true,
1801 /// })
1802 /// })
1803 /// ```
1804 ///
1805 /// Domain crates do **not** need to call this — they only declare
1806 /// [`profile_requirements`].
1807 ///
1808 /// [`profile_requirements`]: EngineModule::profile_requirements
1809 #[must_use]
1810 pub fn with_profile_validator(
1811 mut self,
1812 validator: impl Fn(&str) -> bool + Send + Sync + 'static,
1813 ) -> Self {
1814 self.profile_validator = Some(Box::new(validator));
1815 self
1816 }
1817
1818 /// Register a domain module.
1819 ///
1820 /// The module name becomes visible in
1821 /// [`EngineContext::registered_modules`] after [`build`] is called.
1822 ///
1823 /// [`build`]: EngineBuilder::build
1824 #[must_use]
1825 pub fn register(mut self, module: Box<dyn EngineModule>) -> Self {
1826 self.modules.push(module);
1827 self
1828 }
1829
1830 /// Register multiple [`EngineModule`]s at once from a pre-built `Vec`.
1831 ///
1832 /// Equivalent to calling [`register`] in a loop. Useful when the set of
1833 /// modules is assembled conditionally (e.g. via `#[cfg]`-gated pushes to a
1834 /// `Vec<Box<dyn EngineModule>>`) before the builder chain starts.
1835 ///
1836 /// [`register`]: EngineBuilder::register
1837 #[must_use]
1838 pub fn register_many(mut self, modules: Vec<Box<dyn EngineModule>>) -> Self {
1839 self.modules.extend(modules);
1840 self
1841 }
1842
1843 /// Set the active [`DeploymentRoles`] for this engine instance.
1844 ///
1845 /// Controls role-conditional PID registration in [`EngineModule::register_pids_with_roles`].
1846 ///
1847 /// The default is [`DeploymentRoles::all()`], which registers every PID unconditionally
1848 /// — identical to the pre-role-aware behavior. Providing an explicit role set
1849 /// restricts role-conditional blocks to only the declared roles:
1850 ///
1851 /// - **NB-only** (`DeploymentRoles::nb()`): 19001/19002 route to `gpke-konfiguration`;
1852 /// WiM nMSB blocks are skipped.
1853 /// - **nMSB-only** (`DeploymentRoles::nmsb()`): 19001/19002 route to `wim-geraeteubernahme`;
1854 /// GPKE NB blocks are skipped.
1855 /// - **NB + gMSB** (`DeploymentRoles::nb_msb()`): most common Stadtwerke combination.
1856 ///
1857 /// # Conflict guard
1858 ///
1859 /// When two modules would register the same PID to **different** workflows, the
1860 /// engine panics during [`build`]. Set explicit roles to prevent both modules from
1861 /// activating the same PID simultaneously:
1862 ///
1863 /// ```rust,ignore
1864 /// use mako_engine::marktrolle::DeploymentRoles;
1865 ///
1866 /// let ctx = EngineBuilder::with_stores(outbox, deadline, registry)
1867 /// .with_event_store(store)
1868 /// .with_deployment_roles(DeploymentRoles::nb()) // only NB: GPKE gets 19001/19002
1869 /// .register(Box::new(GpkeModule))
1870 /// .register(Box::new(WimModule)) // nMSB block skipped — no conflict
1871 /// .build();
1872 /// ```
1873 ///
1874 /// [`build`]: EngineBuilder::build
1875 #[must_use]
1876 pub fn with_deployment_roles(mut self, roles: DeploymentRoles) -> Self {
1877 self.deployment_roles = roles;
1878 self
1879 }
1880}
1881
1882impl<ES, SS, OS, DS, PR> EngineBuilder<ES, SS, OS, DS, PR>
1883where
1884 ES: EventStore,
1885 SS: SnapshotStore,
1886 OS: OutboxStore,
1887 DS: DeadlineStore,
1888 PR: ProcessRegistry,
1889{
1890 /// Build the [`EngineContext`].
1891 ///
1892 /// Consumes the builder. All registered modules and configured stores are
1893 /// moved into the returned [`EngineContext`].
1894 ///
1895 /// This method is only available when `ES` implements [`EventStore`].
1896 /// If you have not called [`with_event_store`], this will not compile.
1897 ///
1898 /// # Panics
1899 ///
1900 /// Panics when any registered module returns `Err` from
1901 /// [`EngineModule::configure`]. The panic message includes the module
1902 /// name and the error string so the deployment failure is actionable.
1903 ///
1904 /// [`with_event_store`]: EngineBuilder::with_event_store
1905 #[must_use]
1906 #[allow(clippy::too_many_lines)]
1907 pub fn build(self) -> EngineContext<ES, SS, OS, DS, PR> {
1908 // ── Noop store safety checks ──────────────────────────────────────────
1909 //
1910 // Noop stores lose data silently: NoopDeadlineStore drops every APERAK
1911 // deadline (BNetzA violation), NoopOutboxStore discards all outbound
1912 // messages, NoopProcessRegistry loses conversation routing on restart.
1913 //
1914 // In production builds (no `testing` feature, not running under
1915 // `#[test]`), the Noop constructors are cfg-gated out so this branch
1916 // is dead code and compiles away. In test/testing/tracing builds we
1917 // emit warnings so test harnesses see the configuration in log output.
1918 //
1919 // IMPORTANT: if you are reading this because a panic fired in production,
1920 // it means the `testing` feature was accidentally enabled in the binary.
1921 // Remove it from the production Cargo.toml feature list immediately.
1922 {
1923 let os_name = std::any::type_name::<OS>();
1924 let ds_name = std::any::type_name::<DS>();
1925 let pr_name = std::any::type_name::<PR>();
1926
1927 // Regulatory-critical stores: panic in any build context if these
1928 // are noop. OutboxStore and DeadlineStore must be durable in
1929 // production; ProcessRegistry must survive restarts.
1930 #[cfg(not(any(test, feature = "testing")))]
1931 {
1932 assert!(
1933 !ds_name.contains("NoopDeadlineStore"),
1934 "EngineBuilder::build: NoopDeadlineStore is active in a \
1935 non-testing build. This silently discards all APERAK deadlines, \
1936 which is an immediately reportable BNetzA violation \
1937 (BK6-22-024 §5, BK7-24-01-009). \
1938 Call .with_deadline_store(SlateDbStore::as_deadline_store()) \
1939 in your production engine assembly. \
1940 If this is a test, enable the 'testing' feature."
1941 );
1942 assert!(
1943 !os_name.contains("NoopOutboxStore"),
1944 "EngineBuilder::build: NoopOutboxStore is active in a \
1945 non-testing build. This silently discards all outbound \
1946 APERAK, CONTRL, and UTILMD messages. \
1947 Call .with_outbox_store(SlateDbStore::as_outbox_store()) \
1948 in your production engine assembly. \
1949 If this is a test, enable the 'testing' feature."
1950 );
1951 assert!(
1952 !pr_name.contains("NoopProcessRegistry"),
1953 "EngineBuilder::build: NoopProcessRegistry is active in a \
1954 non-testing build. This means conversation routing \
1955 (PID → stream_id lookup) is lost on every restart, \
1956 breaking all WiM, GeLi Gas, and GPKE in-flight processes. \
1957 Call .with_registry(SlateDbStore::as_process_registry()) \
1958 in your production engine assembly. \
1959 If this is a test, enable the 'testing' feature."
1960 );
1961 }
1962
1963 // In test/testing/tracing builds: emit warnings instead of panicking.
1964 #[cfg(any(test, feature = "testing", feature = "tracing"))]
1965 {
1966 let ss_name = std::any::type_name::<SS>();
1967 if ss_name.contains("NoopSnapshotStore") {
1968 tracing::warn!(
1969 store = ss_name,
1970 "EngineBuilder: NoopSnapshotStore is active — snapshots will not be \
1971 persisted. Use SlateDbStore::as_snapshot_store() in production."
1972 );
1973 }
1974 if os_name.contains("NoopOutboxStore") {
1975 tracing::warn!(
1976 store = os_name,
1977 "EngineBuilder: NoopOutboxStore is active — outbound messages will be \
1978 silently discarded. Use SlateDbStore::as_outbox_store() in production."
1979 );
1980 }
1981 if ds_name.contains("NoopDeadlineStore") {
1982 tracing::warn!(
1983 store = ds_name,
1984 "EngineBuilder: NoopDeadlineStore is active — scheduled deadlines will \
1985 not fire after restart. Use SlateDbStore::as_deadline_store() in production."
1986 );
1987 }
1988 if pr_name.contains("NoopProcessRegistry") {
1989 tracing::warn!(
1990 store = pr_name,
1991 "EngineBuilder: NoopProcessRegistry is active — process routing will be \
1992 lost on restart. Use SlateDbStore::as_process_registry() in production."
1993 );
1994 }
1995 }
1996 }
1997 // Validate every module before assembling the context.
1998 // A missing adapter or misconfigured module fails at startup (not at
1999 // first inbound message), making deployment failures observable immediately.
2000 for module in &self.modules {
2001 if let Err(msg) = module.configure() {
2002 panic!(
2003 "EngineBuilder::build: module '{}' failed configuration validation: {}",
2004 module.name(),
2005 msg
2006 );
2007 }
2008 // Validate profile requirements via the injected validator.
2009 // Domain crates declare requirements; only the binary crate (makod)
2010 // injects the edi-energy registry — domain crates need no edi-energy
2011 // import for this check.
2012 if let Some(ref validator) = self.profile_validator {
2013 for req in module.profile_requirements() {
2014 assert!(
2015 validator(req.message_type),
2016 "EngineBuilder::build: module '{}' requires an active edi-energy \
2017 profile for '{}' ({}) but none is registered for today's date. \
2018 Run `cargo xtask import-profiles` to add the missing profile.",
2019 module.name(),
2020 req.message_type,
2021 req.label,
2022 );
2023 }
2024 }
2025 }
2026 // Build the PID router from all registered modules.
2027 // Also assert that no two modules claim the same PID — a PID overlap
2028 // is always a configuration error: one module's messages would be
2029 // silently swallowed by another's workflow, producing missing-process
2030 // errors or incorrect audit trails.
2031 let mut pid_router = PidRouter::new();
2032 let mut pid_owners: std::collections::HashMap<u32, &str> = std::collections::HashMap::new();
2033 // Keep each module's scratch router so we can build `pid_router` from
2034 // them in a second pass with the resolved ownership table.
2035 let mut module_scratches: Vec<PidRouter> = Vec::with_capacity(self.modules.len());
2036
2037 // Pass 1 — detect conflicts, determine PID ownership (first-wins for
2038 // explicit roles, last-wins for DeploymentRoles::all()).
2039 for module in &self.modules {
2040 // Temporarily build a scratch router to read this module's PIDs
2041 // for cross-module overlap detection (module-ownership level).
2042 let mut scratch = PidRouter::new();
2043 module.register_pids_with_roles(&mut scratch, &self.deployment_roles);
2044
2045 // A module names its workflows twice — once by routing a PID to a
2046 // name, once by declaring the name — and only the declared list is
2047 // reachable from `EngineContext::registered_workflows`. Consumers
2048 // build their deadline-dispatch coverage from that list, so a
2049 // routed-but-undeclared workflow runs while being invisible to
2050 // every check made over the declarations: its Fristen fire into a
2051 // scheduler arm that was never required to exist.
2052 //
2053 // The converse is legitimate and not checked — a command-initiated
2054 // workflow declares a name and routes no inbound PID.
2055 let declared: std::collections::HashSet<&str> =
2056 module.workflow_names().iter().copied().collect();
2057 let mut undeclared: Vec<&str> = scratch
2058 .workflow_names()
2059 .into_iter()
2060 .filter(|name| !declared.contains(name))
2061 .collect();
2062 undeclared.sort_unstable();
2063 undeclared.dedup();
2064 assert!(
2065 undeclared.is_empty(),
2066 "EngineBuilder::build: module '{}' routes PIDs to workflows it does not \
2067 declare in `workflow_names()`: {}. A workflow missing from that list is \
2068 excluded from `EngineContext::registered_workflows`, so any deadline it \
2069 registers is never checked for a dispatch arm. Add each name to the \
2070 module's `workflow_names()`.",
2071 module.name(),
2072 undeclared.join(", "),
2073 );
2074
2075 for pid in scratch.registered_pids() {
2076 if let Some(prev) = pid_owners.insert(pid, module.name()) {
2077 if self.deployment_roles.is_all() {
2078 // With DeploymentRoles::all() (the default), role-conditional PIDs
2079 // are registered by all modules that claim them, producing last-wins
2080 // semantics. This is acceptable for single-role and dev/test deployments.
2081 //
2082 // In production multi-role deployments where both an NB and nMSB role
2083 // are served by the same instance, set explicit roles via
2084 // `EngineBuilder::with_deployment_roles` to prevent silent misrouting.
2085 //
2086 // We emit a debug-level log here (not warn) because the vast majority
2087 // of deployments are single-role and this overlap is expected/harmless.
2088 #[cfg(feature = "tracing")]
2089 tracing::debug!(
2090 pid,
2091 previous_module = prev,
2092 current_module = module.name(),
2093 "PID registered by multiple modules with DeploymentRoles::all(); \
2094 last module wins (use with_deployment_roles for strict routing)",
2095 );
2096 let _ = prev; // suppress unused-variable warning when tracing is off
2097 } else {
2098 // Explicit roles: the FIRST module to register a PID retains ownership.
2099 // Restore the previous (first) owner and emit a warning so the operator
2100 // can investigate. A panic would be too strict: some shared PIDs
2101 // (e.g. REMADV 33001/33002) are legitimately claimed by both GPKE and
2102 // WiM billing; conversation-ID routing is the long-term solution, but
2103 // first-wins gives correct behaviour for all current deployments.
2104 pid_owners.insert(pid, prev); // restore first owner
2105 #[cfg(feature = "tracing")]
2106 tracing::warn!(
2107 pid,
2108 first_module = prev,
2109 second_module = module.name(),
2110 "PID {pid} claimed by both '{prev}' and '{}' with explicit \
2111 DeploymentRoles; first module ('{prev}') retains ownership. \
2112 Verify PID registration is correct for this deployment.",
2113 module.name(),
2114 );
2115 #[cfg(not(feature = "tracing"))]
2116 let _ = prev; // suppress unused-variable warning when tracing is off
2117 }
2118 }
2119 }
2120 module_scratches.push(scratch);
2121 }
2122
2123 // Pass 2 — build the real `pid_router` from the scratch pads, respecting
2124 // the ownership table built in pass 1.
2125 for (module, scratch) in self.modules.iter().zip(module_scratches.iter()) {
2126 // Unambiguous (Sparte-agnostic) entries: only register if this module
2127 // owns the PID in the resolved ownership table.
2128 for pid in scratch.registered_pids() {
2129 if pid_owners.get(&pid).copied() == Some(module.name())
2130 && let Some(wf) = scratch.route(pid)
2131 {
2132 pid_router.register(pid, wf);
2133 }
2134 }
2135 // Commodity (Sparte-qualified) entries use distinct (pid, Sparte) keys
2136 // and never conflict across modules; register them all unconditionally.
2137 for (pid, sparte, wf) in scratch.registered_commodity_entries() {
2138 pid_router.register_with_sparte(pid, sparte, wf);
2139 }
2140 }
2141 let registered_modules = self.modules.iter().map(|m| m.name()).collect();
2142 let registered_workflows = self
2143 .modules
2144 .iter()
2145 .flat_map(|m| m.workflow_names().iter().copied())
2146 .collect();
2147 EngineContext {
2148 event_store: Arc::new(self.event_store),
2149 snapshot_store: self.snapshot_store,
2150 outbox_store: self.outbox_store,
2151 deadline_store: self.deadline_store,
2152 registry: self.registry,
2153 dead_letter_sink: self.dead_letter_sink,
2154 pid_router,
2155 registered_modules,
2156 registered_workflows,
2157 }
2158 }
2159}
2160
2161#[cfg(test)]
2162mod tests {
2163 use super::*;
2164 use crate::{
2165 deadline::InMemoryDeadlineStore,
2166 error::WorkflowError,
2167 event_store::InMemoryEventStore,
2168 ids::TenantId,
2169 outbox::InMemoryOutboxStore,
2170 pid_router::PidRouter,
2171 registry::InMemoryProcessRegistry,
2172 snapshot::InMemorySnapshotStore,
2173 version::WorkflowId,
2174 workflow::{CommandPayload, EventPayload, Workflow},
2175 };
2176
2177 // ── Minimal workflow for spawn/resume tests ───────────────────────────────
2178
2179 #[derive(serde::Serialize, serde::Deserialize)]
2180 struct PingEvent;
2181
2182 impl EventPayload for PingEvent {
2183 fn event_type(&self) -> &'static str {
2184 "Ping"
2185 }
2186 }
2187
2188 struct PingCommand;
2189
2190 impl CommandPayload for PingCommand {}
2191
2192 #[derive(Default, Clone)]
2193 struct PingState;
2194
2195 struct PingWorkflow;
2196
2197 impl Workflow for PingWorkflow {
2198 type State = PingState;
2199 type Event = PingEvent;
2200 type Command = PingCommand;
2201
2202 fn apply(state: PingState, _: &PingEvent) -> PingState {
2203 state
2204 }
2205
2206 fn handle(
2207 _: &PingState,
2208 _: PingCommand,
2209 ) -> Result<crate::workflow::WorkflowOutput<PingEvent>, WorkflowError> {
2210 Ok(vec![PingEvent].into())
2211 }
2212 }
2213
2214 struct TestModule;
2215
2216 impl EngineModule for TestModule {
2217 fn name(&self) -> &'static str {
2218 "test-module"
2219 }
2220 }
2221
2222 // ── Tests ─────────────────────────────────────────────────────────────────
2223
2224 #[test]
2225 fn build_with_event_store_only() {
2226 let ctx = EngineBuilder::new()
2227 .with_event_store(InMemoryEventStore::new())
2228 .build();
2229 assert!(ctx.registered_modules().is_empty());
2230 }
2231
2232 #[test]
2233 fn build_with_all_stores_and_module() {
2234 let ctx = EngineBuilder::new()
2235 .with_event_store(InMemoryEventStore::new())
2236 .with_snapshot_store(InMemorySnapshotStore::new())
2237 .with_outbox_store(InMemoryOutboxStore::new())
2238 .with_deadline_store(InMemoryDeadlineStore::new())
2239 .with_registry(InMemoryProcessRegistry::new())
2240 .register(Box::new(TestModule))
2241 .build();
2242 assert_eq!(ctx.registered_modules(), &["test-module"]);
2243 }
2244
2245 #[test]
2246 fn multiple_modules_ordered() {
2247 struct ModA;
2248 impl EngineModule for ModA {
2249 fn name(&self) -> &'static str {
2250 "mod-a"
2251 }
2252 }
2253 struct ModB;
2254 impl EngineModule for ModB {
2255 fn name(&self) -> &'static str {
2256 "mod-b"
2257 }
2258 }
2259
2260 let ctx = EngineBuilder::new()
2261 .with_event_store(InMemoryEventStore::new())
2262 .register(Box::new(ModA))
2263 .register(Box::new(ModB))
2264 .build();
2265 assert_eq!(ctx.registered_modules(), &["mod-a", "mod-b"]);
2266 }
2267
2268 #[tokio::test]
2269 async fn spawn_creates_independent_processes() {
2270 let ctx = EngineBuilder::new()
2271 .with_event_store(InMemoryEventStore::new())
2272 .build();
2273 let wf_id = WorkflowId::new("ping", "FV2024-10-01");
2274
2275 let p1 = ctx.spawn::<PingWorkflow>(TenantId::new(), wf_id.clone());
2276 let p2 = ctx.spawn::<PingWorkflow>(TenantId::new(), wf_id);
2277
2278 assert_ne!(p1.process_id(), p2.process_id());
2279 }
2280
2281 #[tokio::test]
2282 async fn resume_sees_previously_appended_events() {
2283 let store = InMemoryEventStore::new();
2284 let ctx = EngineBuilder::new().with_event_store(store).build();
2285
2286 let p = ctx.spawn::<PingWorkflow>(TenantId::new(), WorkflowId::new("ping", "FV2024-10-01"));
2287 p.execute(PingCommand).await.unwrap();
2288
2289 let identity = p.identity();
2290 let resumed = ctx.resume::<PingWorkflow>(identity);
2291 assert_eq!(resumed.event_count().await.unwrap(), 1);
2292 }
2293
2294 #[tokio::test]
2295 async fn registry_routes_process_via_conversation_key() {
2296 use crate::registry::RegistryKey;
2297 let ctx = EngineBuilder::new()
2298 .with_event_store(InMemoryEventStore::new())
2299 .with_registry(InMemoryProcessRegistry::new())
2300 .build();
2301
2302 let p = ctx.spawn::<PingWorkflow>(TenantId::new(), WorkflowId::new("ping", "FV2024-10-01"));
2303 let tenant = p.tenant_id();
2304 let conv_key = RegistryKey::parse("conv:test-conversation-123").expect("valid key");
2305 ctx.registry()
2306 .register(tenant, &conv_key, p.identity())
2307 .await
2308 .unwrap();
2309
2310 let found = ctx
2311 .registry()
2312 .lookup(tenant, &conv_key)
2313 .await
2314 .unwrap()
2315 .expect("must be registered");
2316 let resumed = ctx.resume::<PingWorkflow>(found);
2317 assert_eq!(resumed.process_id(), p.process_id());
2318 }
2319
2320 #[test]
2321 fn pid_router_populated_by_module_register_pids() {
2322 struct PidModule;
2323 impl EngineModule for PidModule {
2324 fn name(&self) -> &'static str {
2325 "pid-module"
2326 }
2327 fn workflow_names(&self) -> &'static [&'static str] {
2328 &["gpke-supplier-change"]
2329 }
2330 fn register_pids(&self, router: &mut PidRouter) {
2331 router.register(55001, "gpke-supplier-change");
2332 router.register(55002, "gpke-supplier-change");
2333 }
2334 }
2335
2336 let ctx = EngineBuilder::new()
2337 .with_event_store(InMemoryEventStore::new())
2338 .register(Box::new(PidModule))
2339 .build();
2340
2341 assert_eq!(ctx.pid_router().route(55001), Some("gpke-supplier-change"));
2342 assert_eq!(ctx.pid_router().route(55002), Some("gpke-supplier-change"));
2343 assert!(ctx.pid_router().route(99999).is_none());
2344 assert_eq!(ctx.pid_router().len(), 2);
2345 }
2346
2347 /// A workflow a module routes but does not declare is a build failure.
2348 ///
2349 /// The two lists are written independently — `register_pids` binds a PID to
2350 /// a name, `workflow_names` declares it — and only the declared one reaches
2351 /// [`EngineContext::registered_workflows`], which is where consumers build
2352 /// their deadline-dispatch coverage from. An undeclared workflow therefore
2353 /// runs while being exempt from every check made over the declarations, so
2354 /// a Frist it registers can fire into a dispatch arm nobody required to
2355 /// exist. Four workflows had drifted this way before the check existed.
2356 #[test]
2357 #[should_panic(expected = "routes PIDs to workflows it does not declare")]
2358 fn a_routed_workflow_must_be_declared() {
2359 struct Undeclaring;
2360 impl EngineModule for Undeclaring {
2361 fn name(&self) -> &'static str {
2362 "undeclaring"
2363 }
2364 fn workflow_names(&self) -> &'static [&'static str] {
2365 &["declared-workflow"]
2366 }
2367 fn register_pids(&self, router: &mut PidRouter) {
2368 router.register(55_001, "declared-workflow");
2369 router.register(55_002, "routed-but-undeclared");
2370 }
2371 }
2372
2373 let _ = EngineBuilder::new()
2374 .with_event_store(InMemoryEventStore::new())
2375 .register(Box::new(Undeclaring))
2376 .build();
2377 }
2378
2379 /// Declaring a workflow that routes no PID is legitimate and must build.
2380 ///
2381 /// A command-initiated workflow — one an ERP starts over the command API —
2382 /// has no inbound Prüfidentifikator, so the containment only holds in one
2383 /// direction. Checking the reverse would refuse every such workflow.
2384 #[test]
2385 fn a_declared_workflow_need_not_route_a_pid() {
2386 struct CommandInitiated;
2387 impl EngineModule for CommandInitiated {
2388 fn name(&self) -> &'static str {
2389 "command-initiated"
2390 }
2391 fn workflow_names(&self) -> &'static [&'static str] {
2392 &["routed", "erp-initiated-only"]
2393 }
2394 fn register_pids(&self, router: &mut PidRouter) {
2395 router.register(55_001, "routed");
2396 }
2397 }
2398
2399 let ctx = EngineBuilder::new()
2400 .with_event_store(InMemoryEventStore::new())
2401 .register(Box::new(CommandInitiated))
2402 .build();
2403
2404 assert_eq!(ctx.registered_workflows().len(), 2);
2405 assert_eq!(ctx.pid_router().workflow_names().len(), 1);
2406 }
2407
2408 /// Verify that `register_pids_with_roles` gates PIDs behind role checks.
2409 ///
2410 /// Scenario: two modules share PID 19001.
2411 /// - ModuleA registers 19001 → "workflow-a" when role `Nb` is present.
2412 /// - ModuleB registers 19001 → "workflow-b" when role `Nmsb` is explicitly set
2413 /// (not on `all()`).
2414 ///
2415 /// - `all()`: ModuleA fires (Nb ∈ all), ModuleB does NOT (is_all → skip).
2416 /// → 19001 routes to "workflow-a".
2417 /// - `from_roles([Nb])`: ModuleA fires, ModuleB skips.
2418 /// → 19001 routes to "workflow-a".
2419 /// - `from_roles([Nmsb])`: ModuleA skips, ModuleB fires.
2420 /// → 19001 routes to "workflow-b".
2421 #[test]
2422 fn register_pids_with_roles_gates_pids_correctly() {
2423 use crate::marktrolle::{DeploymentRoles, Marktrolle};
2424
2425 struct ModuleA;
2426 impl EngineModule for ModuleA {
2427 fn name(&self) -> &'static str {
2428 "module-a"
2429 }
2430 fn workflow_names(&self) -> &'static [&'static str] {
2431 &["workflow-a"]
2432 }
2433 fn register_pids_with_roles(&self, router: &mut PidRouter, roles: &DeploymentRoles) {
2434 if roles.contains(Marktrolle::Nb) {
2435 router.register(19_001, "workflow-a");
2436 }
2437 }
2438 }
2439
2440 struct ModuleB;
2441 impl EngineModule for ModuleB {
2442 fn name(&self) -> &'static str {
2443 "module-b"
2444 }
2445 fn workflow_names(&self) -> &'static [&'static str] {
2446 &["workflow-b"]
2447 }
2448 fn register_pids_with_roles(&self, router: &mut PidRouter, roles: &DeploymentRoles) {
2449 // Only fires on explicit Nmsb, not on all() (backward-compat sentinel).
2450 if !roles.is_all() && roles.contains(Marktrolle::Nmsb) {
2451 router.register(19_001, "workflow-b");
2452 router.register(19_015, "workflow-b");
2453 }
2454 }
2455 }
2456
2457 let build = |roles: DeploymentRoles| {
2458 EngineBuilder::new()
2459 .with_event_store(InMemoryEventStore::new())
2460 .with_deployment_roles(roles)
2461 .register(Box::new(ModuleA))
2462 .register(Box::new(ModuleB))
2463 .build()
2464 };
2465
2466 // all() → backward compat: ModuleA registers 19001 (Nb ∈ all), ModuleB skips.
2467 let ctx = build(DeploymentRoles::all());
2468 assert_eq!(ctx.pid_router().route(19_001), Some("workflow-a"));
2469 assert!(ctx.pid_router().route(19_015).is_none());
2470
2471 // Explicit Nb → same result: ModuleA registers, ModuleB (nMSB) skips.
2472 let ctx = build(DeploymentRoles::nb());
2473 assert_eq!(ctx.pid_router().route(19_001), Some("workflow-a"));
2474 assert!(ctx.pid_router().route(19_015).is_none());
2475
2476 // Explicit Nmsb → ModuleA skips (Nb ∉ roles), ModuleB registers.
2477 let ctx = build(DeploymentRoles::nmsb());
2478 assert_eq!(ctx.pid_router().route(19_001), Some("workflow-b"));
2479 assert_eq!(ctx.pid_router().route(19_015), Some("workflow-b"));
2480 }
2481
2482 /// Verify that explicit roles with two conflicting modules use first-wins semantics
2483 /// (the first module to register a PID retains ownership; the second is silently skipped).
2484 #[test]
2485 fn register_pids_with_roles_conflict_uses_first_wins_with_explicit_roles() {
2486 use crate::marktrolle::{DeploymentRoles, Marktrolle};
2487
2488 struct ConflictA;
2489 impl EngineModule for ConflictA {
2490 fn name(&self) -> &'static str {
2491 "conflict-a"
2492 }
2493 fn workflow_names(&self) -> &'static [&'static str] {
2494 &["workflow-a"]
2495 }
2496 fn register_pids_with_roles(&self, router: &mut PidRouter, roles: &DeploymentRoles) {
2497 if roles.contains(Marktrolle::Nb) {
2498 router.register(19_001, "workflow-a");
2499 }
2500 }
2501 }
2502
2503 struct ConflictB;
2504 impl EngineModule for ConflictB {
2505 fn name(&self) -> &'static str {
2506 "conflict-b"
2507 }
2508 fn workflow_names(&self) -> &'static [&'static str] {
2509 &["workflow-b"]
2510 }
2511 fn register_pids_with_roles(&self, router: &mut PidRouter, roles: &DeploymentRoles) {
2512 if !roles.is_all() && roles.contains(Marktrolle::Nmsb) {
2513 router.register(19_001, "workflow-b"); // same PID, different workflow
2514 }
2515 }
2516 }
2517
2518 // from_roles([Nb, Nmsb]): both modules fire for PID 19_001.
2519 // First-wins: ConflictA (registered first) retains ownership → "workflow-a".
2520 let ctx = EngineBuilder::new()
2521 .with_event_store(InMemoryEventStore::new())
2522 .with_deployment_roles(DeploymentRoles::from_roles([
2523 Marktrolle::Nb,
2524 Marktrolle::Nmsb,
2525 ]))
2526 .register(Box::new(ConflictA))
2527 .register(Box::new(ConflictB))
2528 .build();
2529 assert_eq!(
2530 ctx.pid_router().route(19_001),
2531 Some("workflow-a"),
2532 "first module should win on PID conflict with explicit roles"
2533 );
2534 }
2535
2536 // ── Graceful shutdown ─────────────────────────────────────────────────────
2537
2538 /// Cancelling the token must make `run` return.
2539 ///
2540 /// A worker that does not read the token loops until the process exits,
2541 /// and dropping its `JoinHandle` does not abort a Tokio task — so the event
2542 /// store would close underneath a worker still running. An outbox
2543 /// `acknowledge` losing that race leaves the counterparty holding a message
2544 /// the outbox still shows as pending, and the next start delivers it
2545 /// again.
2546 #[tokio::test]
2547 async fn a_cancelled_outbox_worker_returns() {
2548 let worker = OutboxWorker {
2549 store: InMemoryOutboxStore::new(),
2550 sender: AlwaysDelivers,
2551 deadline_store: InMemoryDeadlineStore::new(),
2552 batch_size: 10,
2553 // Far longer than the timeout below: the point is that cancellation
2554 // interrupts the idle sleep rather than being noticed after it.
2555 poll_interval: std::time::Duration::from_secs(300),
2556 max_attempts: 48,
2557 max_retry_window: std::time::Duration::from_secs(72 * 3600),
2558 dead_letter_sink: std::sync::Arc::new(crate::dead_letter::LogDeadLetterSink),
2559 heartbeat: None,
2560 shutdown: None,
2561 };
2562 let token = tokio_util::sync::CancellationToken::new();
2563 let worker = worker.with_shutdown(token.clone());
2564
2565 let handle = tokio::spawn(worker.run());
2566 // Let it reach the sleep, then signal.
2567 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2568 token.cancel();
2569
2570 tokio::time::timeout(std::time::Duration::from_secs(5), handle)
2571 .await
2572 .expect("outbox worker must return promptly after cancellation")
2573 .expect("outbox worker must not panic");
2574 }
2575
2576 #[tokio::test]
2577 async fn a_cancelled_deadline_scheduler_returns() {
2578 let scheduler = DeadlineScheduler {
2579 store: InMemoryDeadlineStore::new(),
2580 dispatch: Box::new(|_| Box::pin(async { Ok(()) })),
2581 batch_size: 100,
2582 poll_interval: std::time::Duration::from_secs(300),
2583 heartbeat: None,
2584 shutdown: None,
2585 };
2586 let token = tokio_util::sync::CancellationToken::new();
2587 let scheduler = scheduler.with_shutdown(token.clone());
2588
2589 let handle = tokio::spawn(scheduler.run());
2590 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2591 token.cancel();
2592
2593 tokio::time::timeout(std::time::Duration::from_secs(5), handle)
2594 .await
2595 .expect("deadline scheduler must return promptly after cancellation")
2596 .expect("deadline scheduler must not panic");
2597 }
2598
2599 /// A token cancelled before the first poll must stop the worker without it
2600 /// touching the store at all — the case where shutdown arrives during boot.
2601 #[tokio::test]
2602 async fn a_worker_cancelled_before_it_starts_does_no_work() {
2603 let outbox = InMemoryOutboxStore::new();
2604 let stream_id = crate::ids::StreamId::new("gpke/shutdown-test");
2605 let msg = outbox_message(&stream_id, "UTILMD");
2606 outbox.enqueue(std::slice::from_ref(&msg)).await.unwrap();
2607
2608 let token = tokio_util::sync::CancellationToken::new();
2609 token.cancel();
2610
2611 let worker = OutboxWorker {
2612 store: outbox.clone(),
2613 sender: AlwaysDelivers,
2614 deadline_store: InMemoryDeadlineStore::new(),
2615 batch_size: 10,
2616 poll_interval: std::time::Duration::from_millis(5),
2617 max_attempts: 48,
2618 max_retry_window: std::time::Duration::from_secs(72 * 3600),
2619 dead_letter_sink: std::sync::Arc::new(crate::dead_letter::LogDeadLetterSink),
2620 heartbeat: None,
2621 shutdown: None,
2622 }
2623 .with_shutdown(token);
2624
2625 tokio::time::timeout(std::time::Duration::from_secs(5), worker.run())
2626 .await
2627 .expect("an already-cancelled worker must return immediately");
2628
2629 assert_eq!(
2630 outbox.pending_now(10).await.unwrap().len(),
2631 1,
2632 "the message must stay queued for the next start, not be delivered \
2633 by a worker that was told to stop",
2634 );
2635 }
2636
2637 // ── APERAK delivery-window discharge ──────────────────────────────────────
2638
2639 /// A sender that always succeeds, so the worker takes the delivery path.
2640 struct AlwaysDelivers;
2641 impl As4Sender for AlwaysDelivers {
2642 async fn send(&self, _msg: &crate::outbox::OutboxMessage) -> Result<(), EngineError> {
2643 Ok(())
2644 }
2645 }
2646
2647 fn outbox_message(
2648 stream_id: &crate::ids::StreamId,
2649 message_type: &str,
2650 ) -> crate::outbox::OutboxMessage {
2651 crate::outbox::OutboxMessage::new(
2652 stream_id.clone(),
2653 crate::ids::ProcessId::new(),
2654 TenantId::new(),
2655 crate::ids::CorrelationId::new(),
2656 crate::ids::ConversationId::new(),
2657 crate::ids::EventId::new(),
2658 message_type,
2659 "9900357000004",
2660 serde_json::json!({}),
2661 )
2662 }
2663
2664 fn deadline_on(
2665 stream_id: &crate::ids::StreamId,
2666 msg: &crate::outbox::OutboxMessage,
2667 label: &str,
2668 ) -> Deadline {
2669 Deadline::new(
2670 stream_id.clone(),
2671 msg.process_id,
2672 msg.tenant_id,
2673 WorkflowId::new("gpke-supplier-change", "FV2025-10-01"),
2674 label,
2675 time::OffsetDateTime::now_utc() + time::Duration::hours(6),
2676 )
2677 }
2678
2679 /// Deliver `msg` through the worker's real loop and return the labels that
2680 /// survive on its stream.
2681 ///
2682 /// Drives `run` rather than calling the discharge directly — the wiring is
2683 /// the thing under test, and calling the method straight passes even when
2684 /// `run` never invokes it.
2685 async fn labels_surviving_delivery(
2686 msg: &crate::outbox::OutboxMessage,
2687 stream_id: &crate::ids::StreamId,
2688 registered: &[&str],
2689 ) -> Vec<String> {
2690 let deadlines = InMemoryDeadlineStore::new();
2691 for label in registered {
2692 deadlines
2693 .register(&deadline_on(stream_id, msg, label))
2694 .await
2695 .unwrap();
2696 }
2697 let outbox = InMemoryOutboxStore::new();
2698 outbox.enqueue(std::slice::from_ref(msg)).await.unwrap();
2699
2700 let worker = OutboxWorker {
2701 store: outbox.clone(),
2702 sender: AlwaysDelivers,
2703 deadline_store: deadlines.clone(),
2704 batch_size: 10,
2705 poll_interval: std::time::Duration::from_millis(5),
2706 max_attempts: 48,
2707 max_retry_window: std::time::Duration::from_secs(72 * 3600),
2708 dead_letter_sink: std::sync::Arc::new(crate::dead_letter::LogDeadLetterSink),
2709 heartbeat: None,
2710 shutdown: None,
2711 };
2712 // `run` never returns; give it enough cycles to drain the one message.
2713 let _ = tokio::time::timeout(std::time::Duration::from_millis(300), worker.run()).await;
2714 assert!(
2715 outbox.pending_now(10).await.unwrap().is_empty(),
2716 "the message must have been delivered and acknowledged",
2717 );
2718
2719 let mut left: Vec<String> = deadlines
2720 .for_stream(stream_id)
2721 .await
2722 .unwrap()
2723 .iter()
2724 .map(|d| d.label().to_owned())
2725 .collect();
2726 left.sort();
2727 left
2728 }
2729
2730 /// Delivering a message must retire the window that was watching for it.
2731 ///
2732 /// These windows are registered when the message is enqueued and nothing
2733 /// else ever cancels them, so without the discharge they fire for **every**
2734 /// process — including every one that answered on time. The scheduler cannot
2735 /// tell those apart (a deadline reaching `due_now` is late by construction),
2736 /// so the miss counters would track processes started, not obligations
2737 /// missed.
2738 #[tokio::test]
2739 async fn delivering_a_message_discharges_its_delivery_window() {
2740 // (message type, the window it answers for)
2741 for (message_type, window) in [
2742 ("APERAK", mako_fristen::APERAK_STROM_WINDOW_LABEL),
2743 ("APERAK", mako_fristen::APERAK_GAS_FOLGEPROZESS_LABEL),
2744 ("APERAK", mako_fristen::APERAK_GAS_INITIALPROZESS_LABEL),
2745 ("CONTRL", mako_fristen::CONTRL_FRIST_LABEL),
2746 ] {
2747 let stream_id = crate::ids::StreamId::new("gpke-supplier-change-1");
2748 let msg = outbox_message(&stream_id, message_type);
2749 // A process-response deadline shares the stream and must survive:
2750 // it is waiting on the counterparty, not on our delivery.
2751 let left =
2752 labels_surviving_delivery(&msg, &stream_id, &[window, "gpke-response-window"])
2753 .await;
2754 assert_eq!(
2755 left,
2756 vec!["gpke-response-window"],
2757 "delivering {message_type} must discharge `{window}` and leave \
2758 every other deadline alone",
2759 );
2760 }
2761 }
2762
2763 /// A delivery must not discharge a *different* message's window.
2764 ///
2765 /// The CONTRL and APERAK obligations run concurrently on the same
2766 /// interchange. Acknowledging syntax (CONTRL) says nothing about whether the
2767 /// application-level APERAK went out, so discharging both on one delivery
2768 /// would silence a real violation.
2769 #[tokio::test]
2770 async fn a_delivery_does_not_discharge_another_messages_window() {
2771 let stream_id = crate::ids::StreamId::new("gpke-supplier-change-1");
2772 let contrl = outbox_message(&stream_id, "CONTRL");
2773
2774 let left = labels_surviving_delivery(
2775 &contrl,
2776 &stream_id,
2777 &[
2778 mako_fristen::CONTRL_FRIST_LABEL,
2779 mako_fristen::APERAK_STROM_WINDOW_LABEL,
2780 ],
2781 )
2782 .await;
2783
2784 assert_eq!(
2785 left,
2786 vec![mako_fristen::APERAK_STROM_WINDOW_LABEL.to_owned()],
2787 "a delivered CONTRL discharges only the CONTRL window; the APERAK \
2788 obligation is still outstanding",
2789 );
2790 }
2791
2792 /// Every delivery-window label must be discharged by the message it watches.
2793 ///
2794 /// This is the invariant the miss counters rest on. A window label that
2795 /// `discharges_delivery_window` does not recognise is never retired, so it
2796 /// fires on every process and is counted as a regulatory violation each
2797 /// time — which is precisely how `makod_aperak_missed_total` once came to
2798 /// count Strom processes rather than missed APERAKs.
2799 ///
2800 /// Adding a delivery window means adding a row here.
2801 #[test]
2802 fn every_delivery_window_label_is_discharged_by_its_message() {
2803 for (message_type, label) in [
2804 ("APERAK", mako_fristen::APERAK_STROM_WINDOW_LABEL),
2805 ("APERAK", mako_fristen::APERAK_GAS_FOLGEPROZESS_LABEL),
2806 ("APERAK", mako_fristen::APERAK_GAS_INITIALPROZESS_LABEL),
2807 ("CONTRL", mako_fristen::CONTRL_FRIST_LABEL),
2808 ] {
2809 assert!(
2810 mako_fristen::discharges_delivery_window(message_type, label),
2811 "delivering {message_type} must discharge `{label}`, or the window \
2812 outlives its obligation and alerts on every process",
2813 );
2814 }
2815 }
2816
2817 // ── Retry-budget classification ───────────────────────────────────────────
2818
2819 /// Sink double that records every rejection's attempt count.
2820 #[derive(Default)]
2821 struct RecordingSink(std::sync::Mutex<Vec<u32>>);
2822 impl crate::dead_letter::DeadLetterSink for std::sync::Arc<RecordingSink> {
2823 fn reject(&self, reason: &crate::dead_letter::DeadLetterReason) {
2824 if let crate::dead_letter::DeadLetterReason::OutboxExhausted { attempts, .. } = reason {
2825 self.0
2826 .lock()
2827 .unwrap_or_else(std::sync::PoisonError::into_inner)
2828 .push(*attempts);
2829 }
2830 }
2831 }
2832
2833 struct NoRenderer;
2834 impl As4Sender for NoRenderer {
2835 async fn send(&self, msg: &crate::outbox::OutboxMessage) -> Result<(), EngineError> {
2836 Err(EngineError::RendererNotImplemented {
2837 message_type: msg.message_type.as_ref().into(),
2838 message_id: msg.message_id.to_string().into(),
2839 })
2840 }
2841 }
2842
2843 async fn drive_worker<S: As4Sender>(
2844 sender: S,
2845 msg: crate::outbox::OutboxMessage,
2846 ) -> (InMemoryOutboxStore, std::sync::Arc<RecordingSink>) {
2847 let outbox = InMemoryOutboxStore::new();
2848 outbox.enqueue(std::slice::from_ref(&msg)).await.unwrap();
2849 let sink = std::sync::Arc::new(RecordingSink::default());
2850 let worker = OutboxWorker {
2851 store: outbox.clone(),
2852 sender,
2853 deadline_store: InMemoryDeadlineStore::new(),
2854 batch_size: 10,
2855 poll_interval: std::time::Duration::from_millis(5),
2856 max_attempts: 48,
2857 max_retry_window: std::time::Duration::from_secs(72 * 3600),
2858 dead_letter_sink: std::sync::Arc::new(std::sync::Arc::clone(&sink)),
2859 heartbeat: None,
2860 shutdown: None,
2861 };
2862 let _ = tokio::time::timeout(std::time::Duration::from_millis(300), worker.run()).await;
2863 (outbox, sink)
2864 }
2865
2866 /// `RendererNotImplemented` is documented as permanent — the worker must
2867 /// dead-letter it on the *first* attempt, not burn the retry budget on a
2868 /// failure that cannot heal between attempts. Until the permanent arm
2869 /// matched it, this promise was broken.
2870 #[tokio::test(start_paused = true)]
2871 async fn a_missing_renderer_dead_letters_without_retrying() {
2872 let stream_id = crate::ids::StreamId::new("test-renderer-missing");
2873 let msg = outbox_message(&stream_id, "MSCONS");
2874 let (outbox, sink) = drive_worker(NoRenderer, msg).await;
2875
2876 assert!(
2877 outbox.pending_now(10).await.unwrap().is_empty(),
2878 "the message must be acknowledged, not left for another attempt",
2879 );
2880 let rejections = sink
2881 .0
2882 .lock()
2883 .unwrap_or_else(std::sync::PoisonError::into_inner)
2884 .clone();
2885 assert_eq!(
2886 rejections,
2887 vec![0],
2888 "exactly one dead-letter, on the first attempt (attempt_count 0)",
2889 );
2890 }
2891
2892 /// The retry budget is a *window*, not a count: a message whose age has
2893 /// exceeded it after at least one attempt is dead-lettered even though the
2894 /// attempt belt is nowhere near exhausted — full-jitter backoff makes a
2895 /// count no proxy for the 72 h duty.
2896 #[tokio::test(start_paused = true)]
2897 async fn an_aged_message_with_a_prior_attempt_is_dead_lettered() {
2898 let stream_id = crate::ids::StreamId::new("test-window-exhausted");
2899 let mut msg = outbox_message(&stream_id, "UTILMD");
2900 msg.created_at = time::OffsetDateTime::now_utc() - time::Duration::hours(73);
2901 msg.attempt_count = 1;
2902 let (outbox, sink) = drive_worker(AlwaysDelivers, msg).await;
2903
2904 assert!(
2905 outbox.pending_now(10).await.unwrap().is_empty(),
2906 "the exhausted message must leave the outbox",
2907 );
2908 let rejections = sink
2909 .0
2910 .lock()
2911 .unwrap_or_else(std::sync::PoisonError::into_inner)
2912 .clone();
2913 assert_eq!(
2914 rejections,
2915 vec![1],
2916 "the window, not the attempt belt, must have dead-lettered it",
2917 );
2918 }
2919
2920 /// A message that aged past the window while the worker was down still
2921 /// gets its first try — the window is only consulted after an attempt, so
2922 /// downtime never buries a message unsent.
2923 #[tokio::test(start_paused = true)]
2924 async fn an_aged_message_with_no_attempts_is_still_tried_once() {
2925 let stream_id = crate::ids::StreamId::new("test-aged-first-try");
2926 let mut msg = outbox_message(&stream_id, "UTILMD");
2927 msg.created_at = time::OffsetDateTime::now_utc() - time::Duration::hours(200);
2928 let (outbox, sink) = drive_worker(AlwaysDelivers, msg).await;
2929
2930 assert!(
2931 outbox.pending_now(10).await.unwrap().is_empty(),
2932 "the message must have been delivered and acknowledged",
2933 );
2934 assert!(
2935 sink.0
2936 .lock()
2937 .unwrap_or_else(std::sync::PoisonError::into_inner)
2938 .is_empty(),
2939 "delivery, not dead-lettering: age alone must never bury a message",
2940 );
2941 }
2942}