Skip to main content

dora_message/
common.rs

1use core::fmt;
2use std::{borrow::Cow, collections::BTreeMap};
3
4use aligned_vec::{AVec, ConstAlign};
5use chrono::{DateTime, Utc};
6use eyre::Context as _;
7use serde::Deserialize;
8use uuid::Uuid;
9
10use crate::{BuildId, DataflowId, daemon_to_daemon::InterDaemonEvent, id::NodeId};
11
12pub use log::Level as LogLevel;
13
14#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
15#[must_use]
16pub struct LogMessage {
17    pub build_id: Option<BuildId>,
18    pub dataflow_id: Option<DataflowId>,
19    pub node_id: Option<NodeId>,
20    pub daemon_id: Option<DaemonId>,
21    pub level: LogLevelOrStdout,
22    pub target: Option<String>,
23    pub module_path: Option<String>,
24    pub file: Option<String>,
25    pub line: Option<u32>,
26    pub message: String,
27    pub timestamp: DateTime<Utc>,
28    pub fields: Option<BTreeMap<String, String>>,
29}
30
31#[derive(Deserialize)]
32pub struct LogMessageHelper {
33    build_id: Option<BuildId>,
34    dataflow_id: Option<DataflowId>,
35    node_id: Option<NodeId>,
36    daemon_id: Option<DaemonId>,
37    level: LogLevelOrStdout,
38    target: Option<String>,
39    module_path: Option<String>,
40    file: Option<String>,
41    line: Option<u32>,
42    message: Option<String>,
43    timestamp: DateTime<Utc>,
44    fields: Option<BTreeMap<String, String>>,
45}
46
47impl From<LogMessageHelper> for LogMessage {
48    fn from(helper: LogMessageHelper) -> Self {
49        let fields = helper.fields.as_ref();
50        LogMessage {
51            build_id: helper.build_id.or(fields
52                .and_then(|f| f.get("build_id").cloned())
53                .and_then(|id| Uuid::parse_str(&id).ok().map(BuildId))),
54            dataflow_id: helper.dataflow_id.or(fields
55                .and_then(|f| f.get("dataflow_id").cloned())
56                .and_then(|id| Uuid::parse_str(&id).ok())),
57            node_id: helper
58                .node_id
59                .or(fields.and_then(|f| f.get("node_id").cloned()).map(NodeId)),
60            daemon_id: helper.daemon_id.or(fields
61                .and_then(|f| f.get("daemon_id").cloned())
62                .and_then(|id| DaemonId::from_display_str(&id))),
63            level: helper.level,
64            target: helper
65                .target
66                .or(fields.and_then(|f| f.get("target").cloned())),
67            module_path: helper
68                .module_path
69                .or(fields.and_then(|f| f.get("module_path").cloned())),
70            file: helper.file.or(fields.and_then(|f| f.get("file").cloned())),
71            line: helper.line.or(fields
72                .and_then(|f| f.get("line").cloned())
73                .and_then(|s| s.parse().ok())),
74            message: helper
75                .message
76                .or(fields.and_then(|f| f.get("message").cloned()))
77                .unwrap_or_default(),
78            fields: helper.fields,
79            timestamp: helper.timestamp,
80        }
81    }
82}
83
84#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, PartialOrd, Ord)]
85#[serde(rename_all = "UPPERCASE")]
86pub enum LogLevelOrStdout {
87    #[serde(rename = "stdout")]
88    Stdout,
89    #[serde(untagged)]
90    LogLevel(LogLevel),
91}
92
93impl LogLevelOrStdout {
94    /// Returns true if a message at this level passes the given minimum level filter.
95    ///
96    /// Ordering: Stdout < Error < Warn < Info < Debug < Trace.
97    /// A message passes if its level is "at or above" (i.e. <=) the minimum.
98    pub fn passes(&self, min: &LogLevelOrStdout) -> bool {
99        match (self, min) {
100            (LogLevelOrStdout::Stdout, LogLevelOrStdout::Stdout) => true,
101            (LogLevelOrStdout::Stdout, _) => false,
102            (LogLevelOrStdout::LogLevel(_), LogLevelOrStdout::Stdout) => true,
103            (LogLevelOrStdout::LogLevel(msg), LogLevelOrStdout::LogLevel(max)) => msg <= max,
104        }
105    }
106}
107
108impl From<LogLevel> for LogLevelOrStdout {
109    fn from(level: LogLevel) -> Self {
110        Self::LogLevel(level)
111    }
112}
113
114#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
115pub struct NodeError {
116    pub timestamp: uhlc::Timestamp,
117    pub cause: NodeErrorCause,
118    pub exit_status: NodeExitStatus,
119}
120
121impl std::fmt::Display for NodeError {
122    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123        if let NodeErrorCause::FailedToSpawn(err) = &self.cause {
124            return write!(f, "failed to spawn node: {err}");
125        }
126        match &self.exit_status {
127            NodeExitStatus::Success => write!(f, "<success>"),
128            NodeExitStatus::IoError(err) => write!(f, "I/O error while reading exit status: {err}"),
129            NodeExitStatus::ExitCode(code) => write!(f, "exited with code {code}"),
130            NodeExitStatus::Signal(signal) => {
131                let signal_str: Cow<_> = match signal {
132                    1 => "SIGHUP".into(),
133                    2 => "SIGINT".into(),
134                    3 => "SIGQUIT".into(),
135                    4 => "SIGILL".into(),
136                    6 => "SIGABRT".into(),
137                    8 => "SIGFPE".into(),
138                    9 => "SIGKILL".into(),
139                    11 => "SIGSEGV".into(),
140                    13 => "SIGPIPE".into(),
141                    14 => "SIGALRM".into(),
142                    15 => "SIGTERM".into(),
143                    22 => "SIGABRT".into(),
144                    23 => "NSIG".into(),
145                    other => other.to_string().into(),
146                };
147                if matches!(self.cause, NodeErrorCause::GraceDuration) {
148                    write!(
149                        f,
150                        "node was killed by dora because it didn't react to a stop message in time ({signal_str})"
151                    )
152                } else {
153                    write!(f, "exited because of signal {signal_str}")
154                }
155            }
156            NodeExitStatus::Unknown => write!(f, "unknown exit status"),
157        }?;
158
159        match &self.cause {
160            NodeErrorCause::GraceDuration => {} // handled above
161            NodeErrorCause::Cascading { caused_by_node } => write!(
162                f,
163                ". This error occurred because node `{caused_by_node}` exited before connecting to dora."
164            )?,
165            NodeErrorCause::FailedToSpawn(_) => unreachable!(), // handled above
166            NodeErrorCause::Other { stderr } if stderr.is_empty() => {}
167            NodeErrorCause::Other { stderr } => {
168                let line: &str = "---------------------------------------------------------------------------------\n";
169                let stderr = stderr.trim_end();
170                write!(f, " with stderr output:\n{line}{stderr}\n{line}")?
171            }
172        }
173
174        Ok(())
175    }
176}
177
178#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
179pub enum NodeErrorCause {
180    /// Node was killed because it didn't react to a stop message in time.
181    GraceDuration,
182    /// Node failed because another node failed before,
183    Cascading {
184        caused_by_node: NodeId,
185    },
186    FailedToSpawn(String),
187    Other {
188        stderr: String,
189    },
190}
191
192#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
193pub enum NodeExitStatus {
194    Success,
195    IoError(String),
196    ExitCode(i32),
197    Signal(i32),
198    Unknown,
199}
200
201impl NodeExitStatus {
202    pub fn is_success(&self) -> bool {
203        matches!(self, NodeExitStatus::Success)
204    }
205}
206
207impl From<Result<std::process::ExitStatus, std::io::Error>> for NodeExitStatus {
208    fn from(result: Result<std::process::ExitStatus, std::io::Error>) -> Self {
209        match result {
210            Ok(status) => {
211                if status.success() {
212                    NodeExitStatus::Success
213                } else if let Some(code) = status.code() {
214                    Self::ExitCode(code)
215                } else {
216                    #[cfg(unix)]
217                    {
218                        use std::os::unix::process::ExitStatusExt;
219                        if let Some(signal) = status.signal() {
220                            return Self::Signal(signal);
221                        }
222                    }
223                    Self::Unknown
224                }
225            }
226            Err(err) => Self::IoError(err.to_string()),
227        }
228    }
229}
230
231#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
232pub struct Timestamped<T> {
233    pub inner: T,
234    pub timestamp: uhlc::Timestamp,
235}
236
237impl<T> Timestamped<T>
238where
239    T: serde::Serialize,
240{
241    pub fn serialize(&self) -> eyre::Result<Vec<u8>> {
242        bincode::serialize(self).wrap_err("failed to serialize timestamped message")
243    }
244}
245
246impl Timestamped<InterDaemonEvent> {
247    pub fn deserialize_inter_daemon_event(bytes: &[u8]) -> eyre::Result<Self> {
248        bincode::deserialize(bytes).wrap_err("failed to deserialize InterDaemonEvent")
249    }
250}
251
252pub type SharedMemoryId = String;
253
254#[derive(serde::Serialize, serde::Deserialize, Clone)]
255pub enum DataMessage {
256    Vec(AVec<u8, ConstAlign<128>>),
257}
258
259impl fmt::Debug for DataMessage {
260    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
261        match self {
262            Self::Vec(v) => f
263                .debug_struct("Vec")
264                .field("len", &v.len())
265                .finish_non_exhaustive(),
266        }
267    }
268}
269
270#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
271pub struct DaemonId {
272    machine_id: Option<String>,
273    uuid: Uuid,
274}
275
276impl DaemonId {
277    pub fn new(machine_id: Option<String>) -> Self {
278        DaemonId {
279            machine_id,
280            uuid: Uuid::now_v7(),
281        }
282    }
283
284    pub fn matches_machine_id(&self, machine_id: &str) -> bool {
285        self.machine_id
286            .as_ref()
287            .map(|id| id == machine_id)
288            .unwrap_or_default()
289    }
290
291    pub fn machine_id(&self) -> Option<&str> {
292        self.machine_id.as_deref()
293    }
294
295    /// Reverse of [`Display`](std::fmt::Display): parse `"{machine_id}-{uuid}"`, or a bare
296    /// `"{uuid}"` when there is no machine id.
297    ///
298    /// Both the machine id (hostnames) and the canonical UUID contain `-`, so
299    /// splitting on a hyphen drops or corrupts a hyphenated machine id. Split
300    /// off the fixed-width 36-char canonical UUID suffix instead
301    /// (dora-rs/dora#2027).
302    ///
303    /// Expects exact `Display` output (no surrounding whitespace). The
304    /// machine-id path requires the canonical 36-char UUID suffix that
305    /// `Display` emits; the bare path accepts any form `Uuid::parse_str`
306    /// recognizes (canonical / simple / urn / braced).
307    pub fn from_display_str(s: &str) -> Option<Self> {
308        // No machine id: the whole string is the UUID.
309        if let Ok(uuid) = Uuid::parse_str(s) {
310            return Some(DaemonId {
311                machine_id: None,
312                uuid,
313            });
314        }
315        // `Display` writes the UUID via `{}` (canonical 36-char hyphenated
316        // form), preceded by `"{machine_id}-"`.
317        const UUID_LEN: usize = 36;
318        let split = s.len().checked_sub(UUID_LEN)?;
319        let uuid = Uuid::parse_str(s.get(split..)?).ok()?;
320        let machine_id = s.get(..split)?.strip_suffix('-')?;
321        Some(DaemonId {
322            machine_id: Some(machine_id.to_string()),
323            uuid,
324        })
325    }
326}
327
328impl std::fmt::Display for DaemonId {
329    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
330        if let Some(id) = &self.machine_id {
331            write!(f, "{id}-")?;
332        }
333        write!(f, "{}", self.uuid)
334    }
335}
336
337#[derive(Debug, serde::Deserialize, serde::Serialize, Clone, PartialEq, Eq)]
338pub struct GitSource {
339    pub repo: String,
340    pub commit_hash: String,
341    /// Subdirectory of the repository the node lives in (monorepo support).
342    /// Build, env preparation, and spawn are rooted at `<clone>/<subdir>`.
343    #[serde(default, skip_serializing_if = "Option::is_none")]
344    pub subdir: Option<String>,
345    /// Hub provenance marker. Set when this git source was desugared from a
346    /// `hub:` reference — it tells the daemon to use confined path
347    /// resolution (no ambient `$PATH` fallback) for the node, and feeds the
348    /// lockfile and `dora hub` commands.
349    #[serde(default, skip_serializing_if = "Option::is_none")]
350    pub hub: Option<HubProvenance>,
351}
352
353/// Identity of the hub package a git source was resolved from.
354#[derive(Debug, serde::Deserialize, serde::Serialize, Clone, PartialEq, Eq)]
355pub struct HubProvenance {
356    /// Index key (`namespace/name`).
357    pub name: String,
358    /// Resolved version.
359    pub version: String,
360    /// Digest of the index entry's manifest at lock time. The commit hash pins
361    /// the *source tree*, but the entrypoint, build command, and typed contract
362    /// (inputs/outputs/types) all live in the (mutable) index entry — so a
363    /// rewritten entry could change any of them for an already-pinned version.
364    /// `--locked` hard-errors if this digest no longer matches. `Option` for
365    /// back-compat with lockfiles written before this field existed.
366    #[serde(default, skip_serializing_if = "Option::is_none")]
367    pub manifest_digest: Option<String>,
368}
369
370/// Lockfile pin for a `hub:` node resolved to a prebuilt binary artifact
371/// (spec §8.2). Mirrors [`GitSource`] for the binary source form: the
372/// `url`+`sha256` pin the bytes (re-verified on download), and `hub` records
373/// the package/version/manifest provenance for `--locked` tamper detection.
374#[derive(Debug, serde::Deserialize, serde::Serialize, Clone, PartialEq, Eq)]
375pub struct BinaryPin {
376    /// Platform the artifact was selected for at lock time (`<os>-<arch>`).
377    pub platform: String,
378    /// Download URL of the prebuilt artifact.
379    pub url: String,
380    /// SHA-256 the download must match.
381    pub sha256: String,
382    /// Hub provenance (index key, version, manifest digest).
383    pub hub: HubProvenance,
384}
385
386// Test roundtrip serialization of LogMessage
387#[cfg(test)]
388mod tests {
389    use super::*;
390    #[test]
391    fn test_log_message_serialization() {
392        let log_message = LogMessage {
393            build_id: Some(BuildId(Uuid::new_v4())),
394            dataflow_id: Some(DataflowId::from(Uuid::new_v4())),
395            node_id: Some(NodeId("node-1".to_string())),
396            daemon_id: Some(DaemonId::new(Some("machine-1".to_string()))),
397            level: LogLevelOrStdout::LogLevel(LogLevel::Info),
398            target: Some("target".to_string()),
399            module_path: Some("module::path".to_string()),
400            file: Some("file.rs".to_string()),
401            line: Some(42),
402            message: "This is a log message".to_string(),
403            timestamp: Utc::now(),
404            fields: Some(BTreeMap::from([("key".to_string(), "value".to_string())])),
405        };
406        let serialized = serde_yaml::to_string(&log_message).unwrap();
407        let deserialized: LogMessageHelper = serde_yaml::from_str(&serialized).unwrap();
408        assert_eq!(log_message, LogMessage::from(deserialized));
409    }
410
411    #[test]
412    fn stdout_passes_stdout_filter() {
413        let stdout = LogLevelOrStdout::Stdout;
414        assert!(stdout.passes(&LogLevelOrStdout::Stdout));
415    }
416
417    /// #2027: `DaemonId` must survive a `Display` -> `from_display_str` round
418    /// trip even when the machine id contains `-` (hostnames do). The old
419    /// `splitn(2, '-')` parse split on the first hyphen, which corrupted the
420    /// UUID (itself hyphenated) and silently dropped the daemon id.
421    #[test]
422    fn daemon_id_display_roundtrips_through_parse() {
423        let uuid = Uuid::new_v4();
424        for machine in [None, Some("host"), Some("my-host"), Some("a-b-c-d")] {
425            let id = DaemonId {
426                machine_id: machine.map(str::to_string),
427                uuid,
428            };
429            let parsed = DaemonId::from_display_str(&id.to_string())
430                .unwrap_or_else(|| panic!("failed to parse {id}"));
431            assert_eq!(parsed, id, "round-trip failed for machine_id={machine:?}");
432        }
433    }
434
435    /// A bare (machine-id-less) daemon id round-trips, and garbage does not
436    /// parse to a bogus id.
437    #[test]
438    fn daemon_id_parse_edge_cases() {
439        let uuid = Uuid::new_v4();
440        let bare = DaemonId {
441            machine_id: None,
442            uuid,
443        };
444        assert_eq!(DaemonId::from_display_str(&bare.to_string()), Some(bare));
445        assert_eq!(DaemonId::from_display_str("not-a-daemon-id"), None);
446        assert_eq!(DaemonId::from_display_str(""), None);
447    }
448
449    #[test]
450    fn stdout_fails_non_stdout_filters() {
451        let stdout = LogLevelOrStdout::Stdout;
452        assert!(!stdout.passes(&LogLevelOrStdout::LogLevel(LogLevel::Info)));
453        assert!(!stdout.passes(&LogLevelOrStdout::LogLevel(LogLevel::Warn)));
454        assert!(!stdout.passes(&LogLevelOrStdout::LogLevel(LogLevel::Error)));
455    }
456
457    #[test]
458    fn any_log_level_passes_stdout_filter() {
459        let stdout_filter = LogLevelOrStdout::Stdout;
460        for level in [
461            LogLevel::Error,
462            LogLevel::Warn,
463            LogLevel::Info,
464            LogLevel::Debug,
465            LogLevel::Trace,
466        ] {
467            assert!(
468                LogLevelOrStdout::LogLevel(level).passes(&stdout_filter),
469                "{level:?} should pass stdout filter"
470            );
471        }
472    }
473
474    #[test]
475    fn error_passes_less_verbose_filters() {
476        let error = LogLevelOrStdout::LogLevel(LogLevel::Error);
477        assert!(error.passes(&LogLevelOrStdout::LogLevel(LogLevel::Error)));
478        assert!(error.passes(&LogLevelOrStdout::LogLevel(LogLevel::Warn)));
479        assert!(error.passes(&LogLevelOrStdout::LogLevel(LogLevel::Info)));
480    }
481
482    #[test]
483    fn debug_fails_info_filter() {
484        let debug = LogLevelOrStdout::LogLevel(LogLevel::Debug);
485        assert!(!debug.passes(&LogLevelOrStdout::LogLevel(LogLevel::Info)));
486    }
487
488    #[test]
489    fn same_level_passes_itself() {
490        for level in [
491            LogLevel::Error,
492            LogLevel::Warn,
493            LogLevel::Info,
494            LogLevel::Debug,
495            LogLevel::Trace,
496        ] {
497            let l = LogLevelOrStdout::LogLevel(level);
498            assert!(l.passes(&l), "{level:?} should pass itself");
499        }
500    }
501
502    #[test]
503    fn trace_passes_trace_fails_debug() {
504        let trace = LogLevelOrStdout::LogLevel(LogLevel::Trace);
505        assert!(trace.passes(&LogLevelOrStdout::LogLevel(LogLevel::Trace)));
506        assert!(!trace.passes(&LogLevelOrStdout::LogLevel(LogLevel::Debug)));
507    }
508
509    #[test]
510    fn daemon_id_uses_v7_uuid() {
511        let id = DaemonId::new(None);
512        // v7 UUIDs have version nibble = 7
513        assert_eq!(id.uuid.get_version_num(), 7);
514    }
515}