dataflow_rs/engine/message.rs
1use crate::engine::error::{DataflowError, ErrorInfo};
2use chrono::{DateTime, Utc};
3use datavalue::OwnedDataValue;
4use serde::{Deserialize, Serialize};
5use serde_json::Value as JsonValue;
6use std::sync::Arc;
7use uuid::Uuid;
8
9/// A message flowing through the dataflow engine.
10///
11/// Construct via [`Message::builder`] for the full API, or use the shortcuts
12/// [`Message::new`] (already-owned `Arc<OwnedDataValue>` payload — the perf
13/// path) and [`Message::from_value`] (bridges from `serde_json::Value`).
14///
15/// `context` is held as an [`OwnedDataValue`] tree (not `serde_json::Value`)
16/// so the JSONLogic evaluator can borrow it into its arena via
17/// `OwnedDataValue::to_arena` with a single deep walk in, and project the
18/// result back via `DataValue::to_owned` with a single deep walk out — no
19/// `serde_json::Value` round-trip in the hot path. The on-the-wire JSON
20/// shape is preserved by datavalue's native `Serialize` / `Deserialize`
21/// impls.
22///
23/// Every other field is encapsulated — read via `id()`, `payload()`,
24/// `audit_trail()`, `errors()`, `capture_changes()`; mutate `errors` via
25/// [`Message::add_error`]; mutate `context` via [`crate::TaskContext::set`].
26/// Direct mutation of `audit_trail` is engine-internal.
27/// Internal id storage for [`Message`]. The default UUID v7 id is written
28/// into a fixed inline buffer (36 hyphenated lowercase ASCII bytes) — no
29/// per-message heap allocation; a caller-supplied id keeps its `String`.
30/// Private repr: every public surface exposes it as `&str` via
31/// [`Message::id`], and the serialized wire shape (a JSON string) is
32/// unchanged.
33#[derive(Clone)]
34pub(crate) enum MessageId {
35 Uuid([u8; 36]),
36 Custom(String),
37}
38
39impl MessageId {
40 /// Generate a fresh UUID v7 id directly into the inline buffer.
41 fn new_uuid_v7() -> Self {
42 let mut buf = [0u8; 36];
43 Uuid::now_v7().hyphenated().encode_lower(&mut buf);
44 Self::Uuid(buf)
45 }
46
47 fn as_str(&self) -> &str {
48 match self {
49 // encode_lower fills all 36 bytes with hyphenated lowercase
50 // ASCII, so the buffer is always valid UTF-8.
51 Self::Uuid(buf) => std::str::from_utf8(buf).expect("uuid ids are ascii"),
52 Self::Custom(s) => s,
53 }
54 }
55}
56
57impl std::fmt::Debug for MessageId {
58 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59 std::fmt::Debug::fmt(self.as_str(), f)
60 }
61}
62
63#[derive(Debug, Clone)]
64pub struct Message {
65 pub(crate) id: MessageId,
66 pub(crate) payload: Arc<OwnedDataValue>,
67 /// Unified context containing `data`, `metadata`, and `temp_data` keys.
68 /// Always an `OwnedDataValue::Object`; the engine populates the three
69 /// top-level keys at construction. Public for read access (tests do
70 /// `message.context["data"]["x"]` lookups); inside handlers prefer
71 /// [`crate::TaskContext::set`] which records audit-trail changes.
72 pub context: OwnedDataValue,
73 pub(crate) audit_trail: Vec<AuditTrail>,
74 /// Errors that occurred during message processing. Read via
75 /// `errors()`, append via `add_error()`.
76 pub(crate) errors: Vec<ErrorInfo>,
77 /// When `true` (default), built-in functions emit per-write `Change`
78 /// entries into `audit_trail`, capturing `old_value` and `new_value` deep
79 /// clones. When `false`, `AuditTrail` entries are still recorded
80 /// (workflow_id, task_id, status, timestamp) but `changes` is empty —
81 /// the bulk-pipeline fast path. UI debug consumers should leave this at
82 /// `true`. Wire shape is unchanged either way.
83 pub(crate) capture_changes: bool,
84}
85
86// Custom Serialize: stable wire format ({id, payload, context, audit_trail, errors}).
87// `capture_changes` is an in-memory hint only — never serialized.
88impl Serialize for Message {
89 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
90 where
91 S: serde::Serializer,
92 {
93 use serde::ser::SerializeStruct;
94 let mut state = serializer.serialize_struct("Message", 5)?;
95 state.serialize_field("id", &self.id.as_str())?;
96 state.serialize_field("payload", &self.payload)?;
97 state.serialize_field("context", &self.context)?;
98 state.serialize_field("audit_trail", &self.audit_trail)?;
99 state.serialize_field("errors", &self.errors)?;
100 state.end()
101 }
102}
103
104// Custom Deserialize: mirrors the Serialize shape; no cache field to seed.
105// `capture_changes` defaults to `true` for back-compat.
106impl<'de> Deserialize<'de> for Message {
107 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
108 where
109 D: serde::Deserializer<'de>,
110 {
111 #[derive(Deserialize)]
112 struct MessageData {
113 id: String,
114 payload: Arc<OwnedDataValue>,
115 context: OwnedDataValue,
116 audit_trail: Vec<AuditTrail>,
117 errors: Vec<ErrorInfo>,
118 }
119
120 let data = MessageData::deserialize(deserializer)?;
121 Ok(Message {
122 id: MessageId::Custom(data.id),
123 payload: data.payload,
124 context: data.context,
125 audit_trail: data.audit_trail,
126 errors: data.errors,
127 capture_changes: true,
128 })
129 }
130}
131
132impl Message {
133 /// Start building a message. The recommended constructor — chains
134 /// `.id(...)`, `.payload(...)` / `.payload_json(...)`, and
135 /// `.capture_changes(...)` calls, then `.build()`.
136 pub fn builder() -> MessageBuilder {
137 MessageBuilder::new()
138 }
139
140 /// Construct a message from an already-owned payload `Arc`. The perf
141 /// path: zero `serde_json::Value` walk, one Arc refcount bump per
142 /// message. Use this from a hot loop with a payload `Arc` shared across
143 /// messages (e.g. a benchmark harness or an HTTP handler that receives
144 /// already-parsed payloads).
145 pub fn new(payload: Arc<OwnedDataValue>) -> Self {
146 Self {
147 // UUID v7: ms-precision timestamp in the high bits, random tail.
148 // Time-ordered and sortable — better for databases/logs than v4
149 // (random-only) and the same `rng` backend cost. Encoded into the
150 // inline id buffer — no `String` allocation per message.
151 id: MessageId::new_uuid_v7(),
152 payload,
153 context: empty_context(),
154 audit_trail: vec![],
155 errors: vec![],
156 capture_changes: true,
157 }
158 }
159
160 /// Construct a message from a `serde_json::Value` payload. Convenience
161 /// for code that already speaks serde_json; goes through the
162 /// `OwnedDataValue::from(&Value)` bridge (one deep walk).
163 pub fn from_value(payload: &JsonValue) -> Self {
164 Self::new(Arc::new(OwnedDataValue::from(payload)))
165 }
166
167 /// Construct a message from a JSON payload string. Parses with
168 /// `serde_json` and bridges into `OwnedDataValue`. Returns
169 /// `DataflowError::Deserialization` on parse failure.
170 pub fn from_json_str(payload: &str) -> crate::engine::error::Result<Self> {
171 let value: JsonValue = serde_json::from_str(payload).map_err(DataflowError::from_serde)?;
172 Ok(Self::from_value(&value))
173 }
174
175 /// Add an error to the message
176 pub fn add_error(&mut self, error: ErrorInfo) {
177 self.errors.push(error);
178 }
179
180 /// Check if message has errors
181 pub fn has_errors(&self) -> bool {
182 !self.errors.is_empty()
183 }
184
185 /// Message id (UUID v7 string by default; caller-supplied if set via
186 /// [`MessageBuilder::id`]).
187 #[inline]
188 pub fn id(&self) -> &str {
189 self.id.as_str()
190 }
191
192 /// Original payload as the engine received it. Immutable for the
193 /// lifetime of the message — the engine reads it through this Arc and
194 /// copies into `context` only as needed by handlers.
195 #[inline]
196 pub fn payload(&self) -> &OwnedDataValue {
197 &self.payload
198 }
199
200 /// The shared payload `Arc` itself. Useful when forwarding the same
201 /// payload to multiple messages without recloning the underlying
202 /// `OwnedDataValue` tree.
203 #[inline]
204 pub fn payload_arc(&self) -> &Arc<OwnedDataValue> {
205 &self.payload
206 }
207
208 /// Audit-trail entries recorded by the engine, one per task that ran
209 /// (skipped tasks are absent unless `Trace` mode is on).
210 #[inline]
211 pub fn audit_trail(&self) -> &[AuditTrail] {
212 &self.audit_trail
213 }
214
215 /// Errors collected while processing — both validation failures and
216 /// task errors that the workflow swallowed via `continue_on_error`.
217 #[inline]
218 pub fn errors(&self) -> &[ErrorInfo] {
219 &self.errors
220 }
221
222 /// Whether per-write `Change` capture is on. When `false`, audit-trail
223 /// entries are still emitted but their `changes` lists are empty —
224 /// the bulk-pipeline fast path.
225 #[inline]
226 pub fn capture_changes(&self) -> bool {
227 self.capture_changes
228 }
229
230 /// Get a reference to the `data` field in context. Returns
231 /// `&OwnedDataValue::Null` if missing (matches `serde_json::Value`'s
232 /// `Index` fallback semantics).
233 pub fn data(&self) -> &OwnedDataValue {
234 &self.context["data"]
235 }
236
237 /// Get a reference to the `metadata` field in context.
238 pub fn metadata(&self) -> &OwnedDataValue {
239 &self.context["metadata"]
240 }
241
242 /// Get a reference to the `temp_data` field in context.
243 pub fn temp_data(&self) -> &OwnedDataValue {
244 &self.context["temp_data"]
245 }
246}
247
248/// Builder for [`Message`]. Collapses the historical
249/// `new` / `with_id` / `from_value` / `without_change_capture` four-way
250/// constructor split into a single fluent shape.
251///
252/// ```
253/// use dataflow_rs::Message;
254/// use serde_json::json;
255///
256/// // Minimal: serde_json payload, default UUID id, capture on.
257/// let m = Message::builder()
258/// .payload_json(&json!({"order": {"total": 1500}}))
259/// .build();
260/// assert!(m.id().len() > 0);
261/// assert!(m.capture_changes());
262/// ```
263#[must_use = "MessageBuilder must be `.build()` to produce a Message"]
264#[derive(Default)]
265pub struct MessageBuilder {
266 id: Option<String>,
267 payload: Option<Arc<OwnedDataValue>>,
268 capture_changes: Option<bool>,
269}
270
271impl MessageBuilder {
272 /// Create an empty builder. Equivalent to [`MessageBuilder::default`].
273 pub fn new() -> Self {
274 Self::default()
275 }
276
277 /// Caller-supplied id (typically a correlation id from upstream).
278 /// Defaults to a freshly-generated UUID v7.
279 pub fn id(mut self, id: impl Into<String>) -> Self {
280 self.id = Some(id.into());
281 self
282 }
283
284 /// Already-owned payload `Arc` — zero serde_json walk, refcount-only
285 /// share. Mutually exclusive with [`Self::payload_json`]; whichever is
286 /// called last wins.
287 pub fn payload(mut self, payload: Arc<OwnedDataValue>) -> Self {
288 self.payload = Some(payload);
289 self
290 }
291
292 /// Construct the payload from a `serde_json::Value`. Goes through the
293 /// `OwnedDataValue::from(&Value)` bridge (one deep walk).
294 pub fn payload_json(mut self, payload: &JsonValue) -> Self {
295 self.payload = Some(Arc::new(OwnedDataValue::from(payload)));
296 self
297 }
298
299 /// When `false`, built-in functions skip per-write `Change` capture —
300 /// audit-trail entries are still recorded but their `changes` list is
301 /// empty. Defaults to `true`.
302 pub fn capture_changes(mut self, on: bool) -> Self {
303 self.capture_changes = Some(on);
304 self
305 }
306
307 /// Finalize. Defaults: id = UUID v7, payload = `OwnedDataValue::Null`,
308 /// capture_changes = `true`.
309 pub fn build(self) -> Message {
310 Message {
311 id: self
312 .id
313 .map(MessageId::Custom)
314 .unwrap_or_else(MessageId::new_uuid_v7),
315 payload: self
316 .payload
317 .unwrap_or_else(|| Arc::new(OwnedDataValue::Null)),
318 context: empty_context(),
319 audit_trail: vec![],
320 errors: vec![],
321 capture_changes: self.capture_changes.unwrap_or(true),
322 }
323 }
324}
325
326/// Build the canonical empty context shape used by `Message::new` and
327/// `MessageBuilder::build`.
328fn empty_context() -> OwnedDataValue {
329 OwnedDataValue::Object(vec![
330 ("data".to_string(), OwnedDataValue::Object(Vec::new())),
331 ("metadata".to_string(), OwnedDataValue::Object(Vec::new())),
332 ("temp_data".to_string(), OwnedDataValue::Object(Vec::new())),
333 ])
334}
335
336#[derive(Serialize, Deserialize, Debug, Clone)]
337pub struct AuditTrail {
338 pub workflow_id: Arc<str>,
339 pub task_id: Arc<str>,
340 pub timestamp: DateTime<Utc>,
341 pub changes: Vec<Change>,
342 pub status: usize,
343}
344
345/// A single recorded mutation in the audit trail.
346///
347/// `old_value` and `new_value` are owned `OwnedDataValue`s rather than
348/// `Arc<OwnedDataValue>` — eliminates one heap allocation per Change on the
349/// hot path. External consumers that need to share a `Change` across threads
350/// can wrap it themselves; in-process pipelines (audit-on map mappings) don't
351/// pay the Arc cost they were never going to use.
352#[derive(Serialize, Deserialize, Debug, Clone)]
353pub struct Change {
354 pub path: Arc<str>,
355 pub old_value: OwnedDataValue,
356 pub new_value: OwnedDataValue,
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362
363 #[test]
364 fn from_json_str_parses_valid_payload() {
365 let msg =
366 Message::from_json_str(r#"{"order": {"total": 42}}"#).expect("valid JSON should parse");
367 let payload_json = serde_json::to_value(msg.payload()).unwrap();
368 assert_eq!(payload_json, serde_json::json!({"order": {"total": 42}}));
369 }
370
371 #[test]
372 fn from_json_str_rejects_malformed_payload() {
373 let err = Message::from_json_str("{ not json").expect_err("malformed input should fail");
374 assert!(matches!(err, DataflowError::Deserialization(_)));
375 }
376}