Skip to main content

somatize_core/
action.rs

1//! Two-table cache model: action records + content-addressed blobs.
2//!
3//! Following Bazel's action-cache/CAS split and Nectar's
4//! data-computation duality:
5//!
6//! - An **action record** ([`ActionResult`]) is a small JSON document
7//!   keyed by the computation's provenance ([`crate::cache::CacheKey`]:
8//!   config + state + input hashes). It names the output by *content*
9//!   hash and carries the metadata GC needs (compute cost, size,
10//!   provenance, timestamps).
11//! - A **blob** is the output's bytes, stored once under its
12//!   [`ContentHash`] — identical outputs from different actions
13//!   deduplicate automatically.
14//!
15//! Because the record is tiny and the blob is regenerable (re-run the
16//! action), eviction can delete blobs while keeping records: a later
17//! run recomputes the value, re-fills the same content address, and
18//! every other record pointing at it becomes servable again. Eviction
19//! degrades performance, never correctness.
20
21use crate::cache::{CacheKey, Origin};
22use crate::error::Result;
23use chrono::{DateTime, Utc};
24use serde::{Deserialize, Serialize};
25use std::collections::BTreeMap;
26
27/// Hash algorithm of a [`ContentHash`], self-describing (multihash
28/// lesson from IPFS: bake the algorithm into the address so a future
29/// migration needs no flag day).
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
31#[non_exhaustive]
32pub enum HashAlgo {
33    /// BLAKE3 — default for payload hashing (parallel tree hashing,
34    /// several GB/s single-threaded).
35    Blake3,
36    /// SHA-256 — for interop where required.
37    Sha256,
38}
39
40impl HashAlgo {
41    /// Short directory-safe prefix used in store layouts.
42    pub fn prefix(&self) -> &'static str {
43        match self {
44            HashAlgo::Blake3 => "b3",
45            HashAlgo::Sha256 => "s2",
46        }
47    }
48}
49
50/// Address of a blob: the hash of its bytes.
51#[derive(Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
52pub struct ContentHash {
53    /// Algorithm the digest was computed with — part of the identity,
54    /// so the same bytes hashed by two algorithms are two addresses.
55    pub algo: HashAlgo,
56    /// The 32-byte digest.
57    pub digest: [u8; 32],
58}
59
60impl ContentHash {
61    /// Hash bytes with BLAKE3 (the default payload algorithm).
62    pub fn blake3(bytes: &[u8]) -> Self {
63        Self {
64            algo: HashAlgo::Blake3,
65            digest: *blake3::hash(bytes).as_bytes(),
66        }
67    }
68
69    /// Hash bytes with SHA-256.
70    pub fn sha256(bytes: &[u8]) -> Self {
71        use sha2::{Digest, Sha256};
72        let mut hasher = Sha256::new();
73        hasher.update(bytes);
74        Self {
75            algo: HashAlgo::Sha256,
76            digest: hasher.finalize().into(),
77        }
78    }
79
80    /// Verify `bytes` against this address (content addresses are
81    /// self-verifying — no signatures needed).
82    pub fn verify(&self, bytes: &[u8]) -> bool {
83        let recomputed = match self.algo {
84            HashAlgo::Blake3 => Self::blake3(bytes),
85            HashAlgo::Sha256 => Self::sha256(bytes),
86        };
87        recomputed.digest == self.digest
88    }
89
90    /// Hex representation of the digest (algorithm prefix not included;
91    /// see [`HashAlgo::prefix`] for the store-layout form).
92    pub fn to_hex(&self) -> String {
93        self.digest.iter().map(|b| format!("{b:02x}")).collect()
94    }
95}
96
97impl std::fmt::Debug for ContentHash {
98    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99        write!(
100            f,
101            "ContentHash({}:{}...)",
102            self.algo.prefix(),
103            &self.to_hex()[..12]
104        )
105    }
106}
107
108/// The record of one completed computation.
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct ActionResult {
111    /// Provenance key: `hash(config + state + input)` (or the state-key
112    /// form for `fit` results).
113    pub key: CacheKey,
114    /// Output name → content hash. The runtime uses a single `"output"`
115    /// entry today; the map form leaves room for multi-output actions.
116    pub outputs: BTreeMap<String, ContentHash>,
117    /// Total encoded size of the outputs in bytes.
118    pub output_bytes: u64,
119    /// Wall-clock cost of the computation, for cost-aware eviction
120    /// (a tiny value that took days must outlive a huge one that took
121    /// seconds).
122    pub compute_ms: u64,
123    /// Whether the producing filter declared its forward deterministic.
124    pub deterministic: bool,
125    /// Provenance of the computation (node, run, source).
126    pub origin: Origin,
127    /// When the action first ran.
128    pub created_at: DateTime<Utc>,
129    /// Last time this record served a hit — recency for eviction.
130    pub last_accessed: DateTime<Utc>,
131}
132
133/// Store of action records (the small table).
134pub trait ActionCache: Send + Sync {
135    /// Look up the record for a provenance key, `None` on a miss.
136    fn get_action(&self, key: &CacheKey) -> Result<Option<ActionResult>>;
137
138    /// Store a record under its key, replacing any existing one.
139    fn put_action(&self, result: &ActionResult) -> Result<()>;
140}
141
142/// Store of content-addressed blobs (the big table).
143pub trait BlobStore: Send + Sync {
144    /// Store bytes under their content hash. Idempotent: storing the
145    /// same bytes twice is a no-op.
146    fn put_bytes(&self, bytes: &[u8]) -> Result<ContentHash>;
147
148    /// Read the blob at `hash`, `None` if absent (possibly evicted).
149    fn get_bytes(&self, hash: &ContentHash) -> Result<Option<Vec<u8>>>;
150
151    /// Whether a blob exists at `hash`, without reading it.
152    fn contains(&self, hash: &ContentHash) -> Result<bool>;
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn blake3_content_hash_roundtrip() {
161        let h = ContentHash::blake3(b"hello world");
162        assert!(h.verify(b"hello world"));
163        assert!(!h.verify(b"hello worlds"));
164        assert_eq!(h, ContentHash::blake3(b"hello world"));
165        assert_ne!(h, ContentHash::blake3(b"other"));
166    }
167
168    #[test]
169    fn algo_is_part_of_identity() {
170        let b = ContentHash::blake3(b"data");
171        let s = ContentHash::sha256(b"data");
172        assert_ne!(b, s);
173        assert_eq!(b.algo.prefix(), "b3");
174        assert_eq!(s.algo.prefix(), "s2");
175    }
176
177    #[test]
178    fn action_result_serde_roundtrip() {
179        let mut outputs = BTreeMap::new();
180        outputs.insert("output".to_string(), ContentHash::blake3(b"payload"));
181        let record = ActionResult {
182            key: CacheKey::hash_data(b"action"),
183            outputs,
184            output_bytes: 7,
185            compute_ms: 123_456,
186            deterministic: true,
187            origin: Origin::Computed {
188                node_id: "n".into(),
189                run_id: "r".into(),
190            },
191            created_at: Utc::now(),
192            last_accessed: Utc::now(),
193        };
194        let json = serde_json::to_string(&record).unwrap();
195        let back: ActionResult = serde_json::from_str(&json).unwrap();
196        assert_eq!(back.key, record.key);
197        assert_eq!(back.outputs, record.outputs);
198        assert_eq!(back.compute_ms, 123_456);
199    }
200}