Skip to main content

boundgraph_core/
id.rs

1//! Identifiers.
2//!
3//! A [`StepId`] and a [`RunId`] mean different things and are deliberately not
4//! interchangeable. A step is identified by *what it is* — the digest of its
5//! specification, so the same step in two runs has the same id. A run is
6//! identified by *when it happened* — a ULID, which sorts chronologically.
7//!
8//! Mixing them up would silently corrupt the ledger, so the type system stops
9//! it: neither converts to the other, and neither exposes the inner value in a
10//! way that invites a cast.
11
12use std::fmt;
13use std::str::FromStr;
14
15use serde::{Deserialize, Serialize};
16use ulid::Ulid;
17
18use crate::digest::{Digest, DigestParseError};
19
20/// Identifies a step by the digest of its specification.
21///
22/// Content-addressed, so it is stable across runs: re-running the same step
23/// yields the same `StepId`, which is what makes caching and replay matching
24/// possible.
25#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
26#[serde(transparent)]
27pub struct StepId(Digest);
28
29impl StepId {
30    /// Wrap the digest of a step specification.
31    pub const fn new(digest: Digest) -> Self {
32        Self(digest)
33    }
34
35    /// The underlying digest.
36    pub const fn digest(&self) -> &Digest {
37        &self.0
38    }
39}
40
41impl fmt::Display for StepId {
42    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43        write!(f, "step:{}", self.0)
44    }
45}
46
47impl fmt::Debug for StepId {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        write!(f, "StepId({}…)", self.0.short(12))
50    }
51}
52
53impl FromStr for StepId {
54    type Err = DigestParseError;
55
56    fn from_str(s: &str) -> Result<Self, Self::Err> {
57        Ok(Self(Digest::from_str(
58            s.strip_prefix("step:").unwrap_or(s),
59        )?))
60    }
61}
62
63/// Identifies one execution of a flow.
64///
65/// A ULID rather than a digest: two runs of the same flow are different events
66/// and must be distinguishable, and lexicographic order matches time order,
67/// which makes ledger scans cheap.
68#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
69#[serde(transparent)]
70pub struct RunId(Ulid);
71
72impl RunId {
73    /// Mint an id for a run starting now.
74    pub fn new() -> Self {
75        Self(Ulid::new())
76    }
77
78    /// Wrap an existing ULID, for replaying or importing a recorded run.
79    pub const fn from_ulid(ulid: Ulid) -> Self {
80        Self(ulid)
81    }
82
83    /// The underlying ULID.
84    pub const fn ulid(&self) -> Ulid {
85        self.0
86    }
87}
88
89impl Default for RunId {
90    fn default() -> Self {
91        Self::new()
92    }
93}
94
95impl fmt::Display for RunId {
96    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97        write!(f, "run:{}", self.0)
98    }
99}
100
101impl fmt::Debug for RunId {
102    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103        write!(f, "RunId({})", self.0)
104    }
105}
106
107impl FromStr for RunId {
108    type Err = ulid::DecodeError;
109
110    fn from_str(s: &str) -> Result<Self, Self::Err> {
111        Ok(Self(Ulid::from_string(
112            s.strip_prefix("run:").unwrap_or(s),
113        )?))
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use crate::canonical::Canonical;
121
122    #[test]
123    fn step_id_is_content_addressed_and_stable() {
124        let spec = ("reasoner-a", vec!["tool-x"], "checker-y");
125        let a = StepId::new(spec.digest().unwrap());
126        let b = StepId::new(spec.digest().unwrap());
127        assert_eq!(a, b, "the same step specification must yield the same id");
128    }
129
130    #[test]
131    fn different_specs_give_different_step_ids() {
132        let a = StepId::new(("reasoner-a",).digest().unwrap());
133        let b = StepId::new(("reasoner-b",).digest().unwrap());
134        assert_ne!(a, b);
135    }
136
137    #[test]
138    fn run_ids_are_unique() {
139        let first = RunId::new();
140        let second = RunId::new();
141        assert_ne!(first, second);
142    }
143
144    #[test]
145    fn run_ids_sort_chronologically() {
146        use std::time::{Duration, SystemTime};
147
148        let t0 = SystemTime::now();
149        let earlier = RunId::from_ulid(Ulid::from_datetime(t0));
150        let later = RunId::from_ulid(Ulid::from_datetime(t0 + Duration::from_millis(1)));
151
152        assert!(
153            earlier < later,
154            "a later run must sort after an earlier one"
155        );
156    }
157
158    #[test]
159    fn run_ids_never_go_backwards_in_time() {
160        let first = RunId::new();
161        let second = RunId::new();
162        assert!(first.ulid().timestamp_ms() <= second.ulid().timestamp_ms());
163    }
164
165    // Deliberately not asserted: the ordering of two ULIDs minted within the
166    // *same* millisecond. They share a timestamp and their remaining 80 bits
167    // are independently random, so their relative order is a coin flip. Only
168    // ordering across distinct milliseconds is a real guarantee, and that is
169    // what the tests above check. Asserting the within-millisecond case would
170    // be a test that fails roughly half the time.
171
172    #[test]
173    fn ids_round_trip_through_strings() {
174        let step = StepId::new(Digest::of(b"x"));
175        assert_eq!(StepId::from_str(&step.to_string()).unwrap(), step);
176
177        let run = RunId::new();
178        assert_eq!(RunId::from_str(&run.to_string()).unwrap(), run);
179    }
180
181    #[test]
182    fn ids_are_prefixed_so_they_cannot_be_confused_in_logs() {
183        assert!(
184            StepId::new(Digest::of(b"x"))
185                .to_string()
186                .starts_with("step:")
187        );
188        assert!(RunId::new().to_string().starts_with("run:"));
189    }
190}