Skip to main content

a3s_flow/
runtime_build.rs

1use serde::de::Error as _;
2use serde::{Deserialize, Deserializer, Serialize};
3use std::collections::BTreeSet;
4use std::fmt;
5use std::str::FromStr;
6
7use crate::error::{FlowError, Result};
8
9const MAX_RUNTIME_BUILD_ID_BYTES: usize = 128;
10
11/// Immutable identity of a deployed workflow runtime build.
12///
13/// Runs pin this identity in their [`WorkflowSpec`](crate::WorkflowSpec). A
14/// worker admits the run only when its explicit compatibility set contains the
15/// same identity. The value is bounded and path-independent so it is safe to
16/// persist in event history and queue-routing metadata.
17#[derive(Debug, Clone, Serialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
18#[serde(transparent)]
19pub struct RuntimeBuildId(String);
20
21impl RuntimeBuildId {
22    /// Validate and create an opaque deployed-runtime identity.
23    pub fn new(value: impl Into<String>) -> Result<Self> {
24        let value = value.into();
25        validate_runtime_build_id(&value)?;
26        Ok(Self(value))
27    }
28
29    /// Return the validated identity text.
30    pub fn as_str(&self) -> &str {
31        &self.0
32    }
33}
34
35impl fmt::Display for RuntimeBuildId {
36    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
37        formatter.write_str(&self.0)
38    }
39}
40
41impl AsRef<str> for RuntimeBuildId {
42    fn as_ref(&self) -> &str {
43        self.as_str()
44    }
45}
46
47impl FromStr for RuntimeBuildId {
48    type Err = FlowError;
49
50    fn from_str(value: &str) -> Result<Self> {
51        Self::new(value)
52    }
53}
54
55impl<'de> Deserialize<'de> for RuntimeBuildId {
56    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
57    where
58        D: Deserializer<'de>,
59    {
60        let value = String::deserialize(deserializer)?;
61        Self::new(value).map_err(D::Error::custom)
62    }
63}
64
65/// Runtime builds one engine instance can execute deterministically.
66///
67/// The current build is always admitted. Older compatible builds must be
68/// registered explicitly. Unpinned histories are rejected by default once a
69/// worker opts into build fencing; hosts can enable them during a bounded
70/// migration with [`Self::accept_unpinned`].
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct RuntimeBuildCompatibility {
73    current_build_id: RuntimeBuildId,
74    compatible_build_ids: BTreeSet<RuntimeBuildId>,
75    accepts_unpinned: bool,
76}
77
78impl RuntimeBuildCompatibility {
79    /// Create a strict compatibility set containing the current build.
80    pub fn new(current_build_id: RuntimeBuildId) -> Self {
81        let mut compatible_build_ids = BTreeSet::new();
82        compatible_build_ids.insert(current_build_id.clone());
83        Self {
84            current_build_id,
85            compatible_build_ids,
86            accepts_unpinned: false,
87        }
88    }
89
90    /// Declare an older build that this worker can still replay exactly.
91    pub fn with_compatible_build(mut self, build_id: RuntimeBuildId) -> Self {
92        self.compatible_build_ids.insert(build_id);
93        self
94    }
95
96    /// Temporarily admit histories created before build pinning was enabled.
97    pub fn accept_unpinned(mut self) -> Self {
98        self.accepts_unpinned = true;
99        self
100    }
101
102    /// Return the identity advertised as the current deployed build.
103    pub fn current_build_id(&self) -> &RuntimeBuildId {
104        &self.current_build_id
105    }
106
107    /// Iterate over the current and explicitly compatible build identities.
108    pub fn compatible_build_ids(&self) -> impl Iterator<Item = &RuntimeBuildId> {
109        self.compatible_build_ids.iter()
110    }
111
112    /// Return whether this worker admits legacy unpinned histories.
113    pub fn accepts_unpinned(&self) -> bool {
114        self.accepts_unpinned
115    }
116
117    /// Return whether this compatibility set admits a persisted requirement.
118    pub fn supports(&self, required_build_id: Option<&RuntimeBuildId>) -> bool {
119        match required_build_id {
120            Some(build_id) => self.compatible_build_ids.contains(build_id),
121            None => self.accepts_unpinned,
122        }
123    }
124}
125
126fn validate_runtime_build_id(value: &str) -> Result<()> {
127    if value.is_empty() {
128        return Err(FlowError::InvalidRuntimeBuildId(
129            "runtime build id must not be empty".to_string(),
130        ));
131    }
132    if value.len() > MAX_RUNTIME_BUILD_ID_BYTES {
133        return Err(FlowError::InvalidRuntimeBuildId(format!(
134            "runtime build id must not exceed {MAX_RUNTIME_BUILD_ID_BYTES} bytes"
135        )));
136    }
137    if !value.is_ascii() {
138        return Err(FlowError::InvalidRuntimeBuildId(
139            "runtime build id must contain only ASCII characters".to_string(),
140        ));
141    }
142    if !value
143        .as_bytes()
144        .first()
145        .is_some_and(u8::is_ascii_alphanumeric)
146        || !value
147            .as_bytes()
148            .last()
149            .is_some_and(u8::is_ascii_alphanumeric)
150    {
151        return Err(FlowError::InvalidRuntimeBuildId(
152            "runtime build id must start and end with an ASCII alphanumeric character".to_string(),
153        ));
154    }
155    if !value
156        .bytes()
157        .all(|byte| byte.is_ascii_alphanumeric() || b"-_.:+/@".contains(&byte))
158    {
159        return Err(FlowError::InvalidRuntimeBuildId(
160            "runtime build id contains an unsupported character".to_string(),
161        ));
162    }
163    Ok(())
164}