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}