Skip to main content

clawft_kernel/cog_ingest/
registry.rs

1//! Per-instance tokens and the placement binding behind them.
2//!
3//! At place time the node registers the instance's `COGNITUM_COG_TOKEN`
4//! together with the project that placed it. The registry keeps only the
5//! BLAKE3 hash of each token. At unload the instance is revoked.
6
7use std::collections::HashMap;
8use std::sync::{Arc, Mutex};
9use std::time::{Duration, Instant};
10
11use super::forward::Forwarder;
12use super::types::IngestError;
13use crate::workload_runtime::HostContract;
14
15/// What the bridge knows about a placed cog instance.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct InstanceBinding {
18    /// Instance id on this node.
19    pub instance_id: String,
20    /// Project that placed the cog (the placement record's project id).
21    /// `None`: the placing controller's store takes the vectors.
22    pub project_id: Option<String>,
23    /// Node id of the placing controller.
24    pub controller_node: String,
25}
26
27impl InstanceBinding {
28    /// Binding for a placement made by `controller_node` on behalf of
29    /// `project_id`.
30    pub fn new(
31        instance_id: impl Into<String>,
32        project_id: Option<String>,
33        controller_node: impl Into<String>,
34    ) -> Self {
35        Self {
36            instance_id: instance_id.into(),
37            project_id,
38            controller_node: controller_node.into(),
39        }
40    }
41
42    /// Binding for a [`PlacementRecord`](crate::workload_ctl::PlacementRecord).
43    pub fn from_record(
44        rec: &crate::workload_ctl::PlacementRecord,
45        project_id: Option<String>,
46        controller_node: &str,
47    ) -> Self {
48        Self::new(rec.instance_id.clone(), project_id, controller_node)
49    }
50}
51
52/// Registry of live instances, by token hash.
53#[derive(Default)]
54pub struct TokenRegistry {
55    inner: Mutex<RegInner>,
56}
57
58#[derive(Default)]
59struct RegInner {
60    by_hash: HashMap<[u8; 32], InstanceBinding>,
61    by_instance: HashMap<String, [u8; 32]>,
62}
63
64fn hash(token: &str) -> [u8; 32] {
65    *blake3::hash(token.as_bytes()).as_bytes()
66}
67
68impl TokenRegistry {
69    /// Empty registry.
70    pub fn new() -> Self {
71        Self::default()
72    }
73
74    /// Register an instance with the token of its host contract.
75    pub fn register(
76        &self,
77        binding: InstanceBinding,
78        contract: &HostContract,
79    ) -> Result<(), IngestError> {
80        contract
81            .validate()
82            .map_err(|e| IngestError::Malformed(e.to_string()))?;
83        let h = hash(contract.token.expose());
84        let mut g = self.inner.lock().map_err(|_| poisoned())?;
85        if g.by_instance.contains_key(&binding.instance_id) || g.by_hash.contains_key(&h) {
86            return Err(IngestError::Malformed(
87                "instance or token already registered".into(),
88            ));
89        }
90        g.by_instance.insert(binding.instance_id.clone(), h);
91        g.by_hash.insert(h, binding);
92        Ok(())
93    }
94
95    /// Revoke an instance's token. True if it was registered.
96    pub fn revoke(&self, instance_id: &str) -> bool {
97        let Ok(mut g) = self.inner.lock() else {
98            return false;
99        };
100        match g.by_instance.remove(instance_id) {
101            Some(h) => g.by_hash.remove(&h).is_some(),
102            None => false,
103        }
104    }
105
106    /// The binding a presented token belongs to.
107    pub fn lookup(&self, token: &str) -> Option<InstanceBinding> {
108        self.inner.lock().ok()?.by_hash.get(&hash(token)).cloned()
109    }
110
111    /// True if `instance_id` currently has a token.
112    pub fn contains(&self, instance_id: &str) -> bool {
113        self.inner
114            .lock()
115            .map(|g| g.by_instance.contains_key(instance_id))
116            .unwrap_or(false)
117    }
118
119    /// Live instances.
120    pub fn len(&self) -> usize {
121        self.inner.lock().map(|g| g.by_instance.len()).unwrap_or(0)
122    }
123
124    /// True when no instance is registered.
125    pub fn is_empty(&self) -> bool {
126        self.len() == 0
127    }
128}
129
130fn poisoned() -> IngestError {
131    IngestError::Unavailable("registry lock poisoned".into())
132}
133
134/// Per-instance request and vector budgets over a fixed window.
135pub struct RateBudget {
136    window: Duration,
137    max_requests: u32,
138    max_vectors: u32,
139    used: Mutex<HashMap<String, (Instant, u32, u32)>>,
140}
141
142impl RateBudget {
143    /// `max_requests` and `max_vectors` per instance per `window`.
144    pub fn new(window: Duration, max_requests: u32, max_vectors: u32) -> Self {
145        Self {
146            window,
147            max_requests,
148            max_vectors,
149            used: Mutex::new(HashMap::new()),
150        }
151    }
152
153    /// Charge one request (before the body is read).
154    pub fn charge_request(&self, instance: &str) -> bool {
155        self.charge(instance, 1, 0)
156    }
157
158    /// Charge `n` vectors (after the body is validated).
159    pub fn charge_vectors(&self, instance: &str, n: usize) -> bool {
160        self.charge(instance, 0, u32::try_from(n).unwrap_or(u32::MAX))
161    }
162
163    fn charge(&self, instance: &str, reqs: u32, vecs: u32) -> bool {
164        let Ok(mut m) = self.used.lock() else {
165            return false;
166        };
167        let now = Instant::now();
168        let e = m.entry(instance.to_string()).or_insert((now, 0, 0));
169        if now.duration_since(e.0) >= self.window {
170            *e = (now, 0, 0);
171        }
172        if e.1.saturating_add(reqs) > self.max_requests || e.2.saturating_add(vecs) > self.max_vectors
173        {
174            return false;
175        }
176        e.1 += reqs;
177        e.2 += vecs;
178        true
179    }
180
181    /// Drop an instance's counters (at unload).
182    pub fn forget(&self, instance: &str) {
183        if let Ok(mut m) = self.used.lock() {
184            m.remove(instance);
185        }
186    }
187}
188
189impl Default for RateBudget {
190    /// 20 requests and 2048 vectors per second per instance.
191    fn default() -> Self {
192        Self::new(Duration::from_secs(1), 20, 2048)
193    }
194}
195
196/// Decides which forwarder (store owner) takes an instance's batches.
197pub trait StoreRouter: Send + Sync {
198    /// Forwarder for `b`, or `None` when no store owner is known.
199    fn route(&self, b: &InstanceBinding) -> Option<Arc<dyn Forwarder>>;
200}
201
202/// Router over fixed maps. A binding with a project routes to that
203/// project's owner and to nothing else: when the project has no route the
204/// batch is refused, it is never redirected to the controller's store. A
205/// binding without a project routes to its placing controller's store.
206#[derive(Default)]
207pub struct StaticRouter {
208    projects: Mutex<HashMap<String, Arc<dyn Forwarder>>>,
209    controllers: Mutex<HashMap<String, Arc<dyn Forwarder>>>,
210}
211
212impl StaticRouter {
213    /// Empty router.
214    pub fn new() -> Self {
215        Self::default()
216    }
217
218    /// Route a project's batches.
219    pub fn with_project_route(self, project_id: &str, f: Arc<dyn Forwarder>) -> Self {
220        if let Ok(mut m) = self.projects.lock() {
221            m.insert(project_id.to_string(), f);
222        }
223        self
224    }
225
226    /// Route project-less batches placed by `controller_node`.
227    pub fn with_controller(self, controller_node: &str, f: Arc<dyn Forwarder>) -> Self {
228        if let Ok(mut m) = self.controllers.lock() {
229            m.insert(controller_node.to_string(), f);
230        }
231        self
232    }
233}
234
235impl StoreRouter for StaticRouter {
236    fn route(&self, b: &InstanceBinding) -> Option<Arc<dyn Forwarder>> {
237        match &b.project_id {
238            Some(p) => self.projects.lock().ok()?.get(p).cloned(),
239            None => self.controllers.lock().ok()?.get(&b.controller_node).cloned(),
240        }
241    }
242}