Skip to main content

agent_effects_store/
id.rs

1//! Effect identity: record ids, logical keys and remote idempotency keys.
2
3use std::fmt;
4
5use serde::{Deserialize, Serialize};
6use uuid::Uuid;
7
8/// Maximum length, in bytes, of an [`EffectName`].
9pub const MAX_NAME_LEN: usize = 200;
10
11/// Maximum length, in bytes, of a [`LogicalKey`].
12pub const MAX_KEY_LEN: usize = 512;
13
14/// Namespace for deriving remote idempotency keys.
15///
16/// Part of the stability contract: changing it changes every derived key, so
17/// a retry issued by a new version would no longer deduplicate against an
18/// attempt issued by an old one.
19const IDEMPOTENCY_NAMESPACE: Uuid = Uuid::from_u128(0x5b2f_9c1e_7a4d_4e0b_9f3a_6c8d_1e2f_4a70);
20
21/// Durable identity of one effect record.
22///
23/// A version 7 UUID, so ids sort by creation time.
24#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
25#[serde(transparent)]
26pub struct EffectId(Uuid);
27
28impl EffectId {
29    /// Generates a new time-ordered id.
30    pub fn new() -> Self {
31        Self(Uuid::now_v7())
32    }
33
34    /// Wraps an existing UUID, e.g. one read back from a store.
35    pub const fn from_uuid(uuid: Uuid) -> Self {
36        Self(uuid)
37    }
38
39    /// The underlying UUID.
40    pub const fn as_uuid(&self) -> &Uuid {
41        &self.0
42    }
43}
44
45impl Default for EffectId {
46    fn default() -> Self {
47        Self::new()
48    }
49}
50
51impl fmt::Display for EffectId {
52    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53        self.0.fmt(f)
54    }
55}
56
57/// Why an [`EffectName`] or [`LogicalKey`] was rejected.
58#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
59pub enum IdentityError {
60    /// The value was empty.
61    #[error("{0} must not be empty")]
62    Empty(&'static str),
63    /// The value exceeded its maximum length.
64    #[error("{what} is {len} bytes, maximum is {max}")]
65    TooLong {
66        /// Which value was too long.
67        what: &'static str,
68        /// Its length in bytes.
69        len: usize,
70        /// The allowed maximum.
71        max: usize,
72    },
73    /// The value contained a control character.
74    #[error("{0} must not contain control characters")]
75    ControlCharacter(&'static str),
76}
77
78fn validate(what: &'static str, value: &str, max: usize) -> Result<(), IdentityError> {
79    if value.is_empty() {
80        return Err(IdentityError::Empty(what));
81    }
82    if value.len() > max {
83        return Err(IdentityError::TooLong {
84            what,
85            len: value.len(),
86            max,
87        });
88    }
89    if value.chars().any(char::is_control) {
90        return Err(IdentityError::ControlCharacter(what));
91    }
92    Ok(())
93}
94
95/// The type of an effect, e.g. `payment.charge`.
96#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
97#[serde(try_from = "String", into = "String")]
98pub struct EffectName(String);
99
100impl EffectName {
101    /// Validates and wraps a name.
102    ///
103    /// # Errors
104    ///
105    /// Rejects empty names, names over [`MAX_NAME_LEN`] bytes and names
106    /// containing control characters.
107    pub fn new(name: impl Into<String>) -> Result<Self, IdentityError> {
108        let name = name.into();
109        validate("effect name", &name, MAX_NAME_LEN)?;
110        Ok(Self(name))
111    }
112
113    /// The name as a string slice.
114    pub fn as_str(&self) -> &str {
115        &self.0
116    }
117}
118
119impl TryFrom<String> for EffectName {
120    type Error = IdentityError;
121
122    fn try_from(value: String) -> Result<Self, Self::Error> {
123        Self::new(value)
124    }
125}
126
127impl From<EffectName> for String {
128    fn from(value: EffectName) -> Self {
129        value.0
130    }
131}
132
133impl fmt::Display for EffectName {
134    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135        f.write_str(&self.0)
136    }
137}
138
139/// The application's identifier for one logical occurrence of an effect,
140/// e.g. the order id for `payment.charge`.
141#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
142#[serde(try_from = "String", into = "String")]
143pub struct LogicalKey(String);
144
145impl LogicalKey {
146    /// Validates and wraps a key.
147    ///
148    /// # Errors
149    ///
150    /// Rejects empty keys, keys over [`MAX_KEY_LEN`] bytes and keys containing
151    /// control characters.
152    pub fn new(key: impl Into<String>) -> Result<Self, IdentityError> {
153        let key = key.into();
154        validate("logical key", &key, MAX_KEY_LEN)?;
155        Ok(Self(key))
156    }
157
158    /// The key as a string slice.
159    pub fn as_str(&self) -> &str {
160        &self.0
161    }
162}
163
164impl TryFrom<String> for LogicalKey {
165    type Error = IdentityError;
166
167    fn try_from(value: String) -> Result<Self, Self::Error> {
168        Self::new(value)
169    }
170}
171
172impl From<LogicalKey> for String {
173    fn from(value: LogicalKey) -> Self {
174        value.0
175    }
176}
177
178impl fmt::Display for LogicalKey {
179    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
180        f.write_str(&self.0)
181    }
182}
183
184/// The unique identity of a logical effect: `(name, logical key)`.
185///
186/// Stores enforce that at most one record exists per `EffectKey`.
187#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
188pub struct EffectKey {
189    /// The effect type.
190    pub name: EffectName,
191    /// The logical occurrence.
192    pub key: LogicalKey,
193}
194
195impl EffectKey {
196    /// Pairs a name with a logical key.
197    pub const fn new(name: EffectName, key: LogicalKey) -> Self {
198        Self { name, key }
199    }
200
201    /// The idempotency key to forward to remote systems.
202    ///
203    /// Derived deterministically (a version 5 UUID) from the name and logical key, so
204    /// every attempt of the same logical effect sends the same value, even if
205    /// its record is lost and recreated.
206    pub fn idempotency_key(&self) -> IdempotencyKey {
207        // Length-prefixing the name keeps ("a:b", "c") and ("a", "b:c") apart.
208        let material = format!("{}:{}:{}", self.name.0.len(), self.name.0, self.key.0);
209        IdempotencyKey(Uuid::new_v5(&IDEMPOTENCY_NAMESPACE, material.as_bytes()))
210    }
211
212    /// The idempotency key for undoing the effect, distinct from
213    /// [`Self::idempotency_key`] so a remote system never confuses the undo
214    /// with a replay of the original request. Stable across compensation
215    /// attempts, which is what makes re-running a compensation safe.
216    pub fn compensation_idempotency_key(&self) -> IdempotencyKey {
217        let material = format!(
218            "compensate:{}:{}:{}",
219            self.name.0.len(),
220            self.name.0,
221            self.key.0
222        );
223        IdempotencyKey(Uuid::new_v5(&IDEMPOTENCY_NAMESPACE, material.as_bytes()))
224    }
225}
226
227impl fmt::Display for EffectKey {
228    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
229        write!(f, "{}:{}", self.name, self.key)
230    }
231}
232
233/// A key a remote system can use to deduplicate requests, such as an HTTP
234/// `Idempotency-Key` header.
235#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
236#[serde(transparent)]
237pub struct IdempotencyKey(Uuid);
238
239impl IdempotencyKey {
240    /// The underlying UUID.
241    pub const fn as_uuid(&self) -> &Uuid {
242        &self.0
243    }
244}
245
246impl fmt::Display for IdempotencyKey {
247    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
248        self.0.fmt(f)
249    }
250}
251
252/// Identifies a runtime instance that holds execution leases.
253#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
254#[serde(transparent)]
255pub struct WorkerId(String);
256
257impl WorkerId {
258    /// Wraps a caller-chosen worker id, e.g. a hostname plus process id.
259    pub fn new(id: impl Into<String>) -> Self {
260        Self(id.into())
261    }
262
263    /// Generates a random worker id.
264    pub fn random() -> Self {
265        Self(Uuid::now_v7().to_string())
266    }
267
268    /// The id as a string slice.
269    pub fn as_str(&self) -> &str {
270        &self.0
271    }
272}
273
274impl fmt::Display for WorkerId {
275    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
276        f.write_str(&self.0)
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    fn key(name: &str, key: &str) -> EffectKey {
285        EffectKey::new(
286            EffectName::new(name).unwrap(),
287            LogicalKey::new(key).unwrap(),
288        )
289    }
290
291    #[test]
292    fn idempotency_key_is_stable() {
293        // Pinned value: if this changes, deployed retries stop deduplicating.
294        assert_eq!(
295            key("payment.charge", "order_5824")
296                .idempotency_key()
297                .to_string(),
298            "ab211c89-566b-5012-bd33-3a7f81351a08"
299        );
300    }
301
302    #[test]
303    fn compensation_key_is_stable_and_distinct() {
304        let effect = key("payment.charge", "order_5824");
305        // Pinned, like the effect key.
306        assert_eq!(
307            effect.compensation_idempotency_key().to_string(),
308            "46877ef3-8997-5e94-a1ac-4be0bd2782b4"
309        );
310        assert_ne!(
311            effect.compensation_idempotency_key(),
312            effect.idempotency_key()
313        );
314    }
315
316    #[test]
317    fn idempotency_key_separates_ambiguous_splits() {
318        assert_ne!(
319            key("a:b", "c").idempotency_key(),
320            key("a", "b:c").idempotency_key()
321        );
322    }
323
324    #[test]
325    fn rejects_invalid_identity() {
326        assert_eq!(
327            EffectName::new(""),
328            Err(IdentityError::Empty("effect name"))
329        );
330        assert!(matches!(
331            LogicalKey::new("x".repeat(MAX_KEY_LEN + 1)),
332            Err(IdentityError::TooLong { .. })
333        ));
334        assert_eq!(
335            LogicalKey::new("a\nb"),
336            Err(IdentityError::ControlCharacter("logical key"))
337        );
338    }
339
340    #[test]
341    fn deserialization_validates() {
342        assert!(serde_json::from_str::<EffectName>("\"\"").is_err());
343        let name: EffectName = serde_json::from_str("\"payment.charge\"").unwrap();
344        assert_eq!(name.as_str(), "payment.charge");
345    }
346
347    #[test]
348    fn effect_ids_are_time_ordered() {
349        let a = EffectId::new();
350        let b = EffectId::new();
351        assert!(a < b);
352    }
353}