hypercall_engine/traits.rs
1//! Trait boundary between engine-core and the runtime.
2//!
3//! This module defines the traits that separate the pure engine logic from the
4//! runtime (tokio, channels, WAL, NATS, persistence). The engine emits events
5//! and journal entries through these traits without knowing anything about the
6//! transport or storage backend.
7//!
8//! The `hypercall` crate provides production implementations backed by:
9//! - `tokio::sync::broadcast` channels for [`EventSink`]
10//! - WAL files with CRC validation for [`JournalWriter`]
11//! - NATS for [`CommandPublisher`]
12//!
13//! In tests, these can be stubbed with simple in-memory implementations
14//! (e.g., a `Vec<EngineEvent>` behind a mutex).
15//!
16//! This module also defines the data types that flow through these traits:
17//! [`EngineEvent`], [`JournalEntry`], and [`JournalError`].
18
19/// Sink for engine events emitted after command processing.
20///
21/// The runtime implements this trait to route events to:
22/// - WebSocket clients (real-time order/fill notifications)
23/// - Database persistence (trade log, balance changes)
24/// - Portfolio projection caches
25/// - Downstream analytics
26///
27/// # Default implementation
28///
29/// [`emit_batch`](Self::emit_batch) has a default implementation that calls
30/// [`emit`](Self::emit) in a loop. Implementations may override this for
31/// batch-optimized delivery.
32pub trait EventSink: Send + Sync {
33 /// Emit a single engine event.
34 fn emit(&self, event: EngineEvent);
35
36 /// Emit a batch of engine events.
37 ///
38 /// Default implementation calls [`emit`](Self::emit) for each event.
39 /// Override for batch-optimized delivery (e.g., a single channel send
40 /// for the whole batch).
41 fn emit_batch(&self, events: &[EngineEvent]) {
42 for event in events {
43 self.emit(event.clone());
44 }
45 }
46}
47
48/// Durable journal writer for crash recovery.
49///
50/// After processing each command, the runtime writes a [`JournalEntry`] to a
51/// write-ahead log (WAL). On restart, the journal is replayed from the last
52/// snapshot to recover the engine state.
53///
54/// # Durability guarantee
55///
56/// Implementations must ensure that a successful `write()` return means the
57/// entry is durable (fsync'd or equivalent). If the write fails, the runtime
58/// must not advance the engine state, preserving the invariant that the
59/// journal and engine state are always consistent.
60///
61/// # Ordering
62///
63/// Entries must be written in `command_id` order. The journal reader relies
64/// on monotonically increasing IDs for replay correctness.
65pub trait JournalWriter: Send + Sync {
66 /// Write a journal entry durably.
67 ///
68 /// Returns `Ok(())` on successful durable write, or a [`JournalError`]
69 /// if the write failed.
70 fn write(&self, entry: &JournalEntry) -> Result<(), JournalError>;
71}
72
73/// Publisher for command replication to standby instances.
74///
75/// After processing each command, the runtime publishes the raw command bytes
76/// so that standby engines can replay the same command stream and maintain
77/// identical state. Standby instances compare their state hash
78/// ([`ApplyOutput::hash`](crate::ApplyOutput::hash)) against the primary's
79/// published hash to detect divergence.
80///
81/// In production, this is typically backed by NATS JetStream for ordered,
82/// persistent delivery.
83pub trait CommandPublisher: Send + Sync {
84 /// Publish a command for replication.
85 ///
86 /// - `cmd_type`: Numeric command type tag for deserialization routing.
87 /// - `data`: Serialized command bytes.
88 fn publish(&self, cmd_type: u8, data: &[u8]);
89}
90
91/// Events emitted by the engine after command processing.
92///
93/// This is the minimal event set that the engine-core needs to emit. The full
94/// `EngineMessage` enum in the `hypercall` crate contains additional
95/// runtime-specific variants (snapshot published, journal compacted, etc.)
96/// that are not part of the core engine's concern.
97#[derive(Debug, Clone)]
98pub enum EngineEvent {
99 /// An order was filled (a trade occurred).
100 ///
101 /// Emitted once per fill. Both taker and maker wallets are included
102 /// so the runtime can notify both parties and persist the trade.
103 OrderFilled {
104 /// Full fill payload.
105 fill: hypercall_types::Fill,
106 /// Deterministic accounting effects for this fill.
107 accounting: crate::FillAccounting,
108 },
109 /// A position expired and was settled.
110 ///
111 /// Emitted during `TickExpiry` processing for each position that
112 /// reaches its expiry. The settlement PnL is reflected in a
113 /// corresponding `BalanceChanged` event.
114 PositionExpired {
115 /// Wallet that held the expired position.
116 wallet: hypercall_types::WalletAddress,
117 /// Instrument symbol of the expired position.
118 symbol: String,
119 },
120 /// A wallet's balance changed.
121 ///
122 /// Emitted after deposits, withdrawals, trade settlements, expiry
123 /// settlements, and fee deductions. The `new_balance` is the balance
124 /// after the change, not the delta.
125 BalanceChanged {
126 /// Wallet whose balance changed.
127 wallet: hypercall_types::WalletAddress,
128 /// Signed balance delta applied to the wallet.
129 delta: rust_decimal::Decimal,
130 /// New balance after the change.
131 new_balance: rust_decimal::Decimal,
132 },
133}
134
135/// A journal entry representing a single command for WAL persistence.
136///
137/// Journal entries are written sequentially by the [`JournalWriter`] and
138/// replayed on startup to reconstruct engine state from the last snapshot.
139/// Each entry is self-describing via `command_type` and carries the raw
140/// serialized command bytes.
141#[derive(Debug, Clone)]
142pub struct JournalEntry {
143 /// Monotonically increasing command identifier. Used for ordering
144 /// during replay and for detecting gaps.
145 pub command_id: u64,
146 /// Numeric tag identifying the command variant for deserialization.
147 pub command_type: u8,
148 /// Serialized command payload.
149 pub command_data: Vec<u8>,
150 /// Timestamp when the command was received, in milliseconds since epoch.
151 pub timestamp_ms: u64,
152}
153
154/// Error type for journal write failures.
155///
156/// The runtime must handle these errors by halting the engine or retrying.
157/// A failed journal write means the command was not durably persisted, so
158/// the engine state must not advance past this point.
159#[derive(Debug)]
160pub enum JournalError {
161 /// Underlying I/O error (disk full, permission denied, etc.).
162 Io(std::io::Error),
163 /// The journal has reached its maximum size and must be compacted
164 /// before more entries can be written.
165 Full,
166}
167
168impl std::fmt::Display for JournalError {
169 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170 match self {
171 JournalError::Io(e) => write!(f, "journal IO error: {}", e),
172 JournalError::Full => write!(f, "journal is full"),
173 }
174 }
175}
176
177impl std::error::Error for JournalError {}