aion_server/worker/placement_cache.rs
1//! Short-TTL in-process cache of per-namespace placement directives, read by the
2//! non-replayed outbox dispatcher (Control-Plane Phase 2, P2-P3).
3//!
4//! The dispatcher consults a namespace's [`NamespacePlacement`] on every claimed
5//! row to decide preferred-vs-spill worker selection. Reading it straight from
6//! the durable [`NamespaceStore`] each sweep would be a per-row quorum read on the
7//! hot claim loop. This cache front-runs `get_namespace`, holding each
8//! namespace's placement for a short TTL so the steady-state path is a lock +
9//! map-lookup, never a store round-trip.
10//!
11//! Staleness is benign for the `Prefer` soft-spill this slice ships: a stale entry
12//! only mis-*prefers* a worker for at most one TTL window — it self-corrects on
13//! the next refresh and never affects correctness or replay (placement is a
14//! dispatch-time selection input, never written to the recorded row). The TTL is
15//! deliberately short so an operator's `PUT /placement` takes effect promptly.
16
17use std::collections::{BTreeSet, HashMap};
18use std::sync::{Arc, Mutex};
19use std::time::{Duration, Instant};
20
21use aion_store::{NamespacePlacement, NamespaceStore};
22
23/// One cached placement entry plus the instant it was read, for TTL expiry.
24#[derive(Clone)]
25struct CachedPlacement {
26 placement: NamespacePlacement,
27 fetched_at: Instant,
28}
29
30/// A short-TTL cache over [`NamespaceStore::get_namespace`]'s placement field.
31///
32/// Cheap to clone (shares the inner store handle + map). A miss / expired entry
33/// reads the durable store once and re-caches; a backend error degrades to
34/// [`NamespacePlacement::Unplaced`] (the safe default = today's any-worker
35/// behaviour) rather than failing the dispatch, since placement is a soft
36/// optimization and a row must still dispatch when the registry read hiccups.
37#[derive(Clone)]
38pub struct PlacementCache {
39 store: Arc<dyn NamespaceStore>,
40 ttl: Duration,
41 entries: Arc<Mutex<HashMap<String, CachedPlacement>>>,
42}
43
44impl std::fmt::Debug for PlacementCache {
45 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46 f.debug_struct("PlacementCache")
47 .field("ttl", &self.ttl)
48 .finish_non_exhaustive()
49 }
50}
51
52impl PlacementCache {
53 /// Build a cache over the durable namespace store with the given entry TTL.
54 #[must_use]
55 pub fn new(store: Arc<dyn NamespaceStore>, ttl: Duration) -> Self {
56 Self {
57 store,
58 ttl,
59 entries: Arc::new(Mutex::new(HashMap::new())),
60 }
61 }
62
63 /// Return the namespace's placement, serving a fresh cache hit without a store
64 /// read and refreshing on a miss / expiry.
65 ///
66 /// A poisoned cache lock or a store-read failure falls back to
67 /// [`NamespacePlacement::Unplaced`] — the dispatch then behaves exactly as the
68 /// pre-Phase-2 any-worker path, so a registry hiccup never blocks a dispatch.
69 pub async fn placement(&self, namespace: &str) -> NamespacePlacement {
70 if let Some(hit) = self.fresh_hit(namespace) {
71 return hit;
72 }
73 let placement = match self.store.get_namespace(namespace).await {
74 Ok(Some(record)) => record.placement,
75 // An absent registry row (or a backend error) means no placement
76 // directive applies: default to Unplaced (any worker).
77 Ok(None) | Err(_) => NamespacePlacement::Unplaced,
78 };
79 self.store_entry(namespace, &placement);
80 placement
81 }
82
83 /// Return a still-fresh cached placement, or `None` on a miss / expiry / a
84 /// poisoned lock (which is treated as a miss so the caller re-reads).
85 fn fresh_hit(&self, namespace: &str) -> Option<NamespacePlacement> {
86 let entries = self.entries.lock().ok()?;
87 let entry = entries.get(namespace)?;
88 if entry.fetched_at.elapsed() < self.ttl {
89 Some(entry.placement.clone())
90 } else {
91 None
92 }
93 }
94
95 /// Record `placement` for `namespace` with a fresh fetch instant. A poisoned
96 /// lock is a silent no-op: the next read simply re-fetches.
97 fn store_entry(&self, namespace: &str, placement: &NamespacePlacement) {
98 if let Ok(mut entries) = self.entries.lock() {
99 entries.insert(
100 namespace.to_owned(),
101 CachedPlacement {
102 placement: placement.clone(),
103 fetched_at: Instant::now(),
104 },
105 );
106 }
107 }
108}
109
110/// The ordered node-filter tiers an UNPINNED row consults for the `Prefer{L}`
111/// two-tier spill (Control-Plane Phase 2, P2-P3) — the SINGLE source of the
112/// prefer-then-spill sequence shared by BOTH the gRPC
113/// ([`WorkerOutboxDispatch`](crate::worker::WorkerOutboxDispatch)) and liminal
114/// ([`RegistryLiminalDispatch`](crate::worker::RegistryLiminalDispatch)) dispatch
115/// paths, so the two transports can never diverge on what "prefer labelled worker,
116/// spill to any" means.
117///
118/// The returned sequence is a list of node filters to try IN ORDER, stopping at
119/// the first that has a live worker:
120///
121/// - `Prefer{L}` → each label in `L` (deterministic [`BTreeSet`](std::collections::BTreeSet)
122/// order) as `Some(label)`, then a final `None` spill tier (any live worker).
123/// An empty `L` collapses to just the `None` spill.
124/// - `Unplaced` → a single `None` tier (any live worker).
125///
126/// `Pinned{L}` is NOT dispatched through this fn: [`worker_selection_for`] routes it
127/// to the NON-spilling [`WorkerSelection::Required`] decision (require an L-labelled
128/// worker, WAIT on absence, never spill — P2-I1, #164). The `Pinned` arm here is only
129/// the internal fall-through of the soft `Prefer` path and never satisfies a hard pin.
130///
131/// This is consulted ONLY for an unpinned row (`row.node == None`): an authored
132/// `Some(N)` pin is authoritative and never enters this path. The result is a
133/// pure worker-SELECTION input; it never mutates the recorded row's `node`
134/// (the determinism invariant, CP-Phase-2 §2.4).
135#[must_use]
136pub fn preferred_node_order(placement: &NamespacePlacement) -> Vec<Option<String>> {
137 match placement {
138 NamespacePlacement::Prefer { nodes } => {
139 // Tier 1..N: each preferred label in deterministic set order.
140 // Tier N+1: the `None` spill to any live worker.
141 let mut tiers: Vec<Option<String>> =
142 nodes.iter().map(|label| Some(label.clone())).collect();
143 tiers.push(None);
144 tiers
145 }
146 // Unplaced today, and Pinned (which does NOT spill): a single any-worker
147 // tier, byte-identical to the pre-Phase-2 unpinned dispatch. `Pinned` is
148 // routed to a NON-spilling `Required` decision by `worker_selection_for`
149 // BEFORE this fn is reached, so a `Pinned` value here is only the internal
150 // fall-through of the soft `Prefer` path and never satisfies a hard pin.
151 NamespacePlacement::Unplaced | NamespacePlacement::Pinned { .. } => vec![None],
152 }
153}
154
155/// How an UNPINNED outbox row (`row.node == None`) must be dispatched given its
156/// namespace's [`NamespacePlacement`] — the SINGLE decision shared by BOTH the
157/// gRPC ([`WorkerOutboxDispatch`](crate::worker::WorkerOutboxDispatch)) and liminal
158/// ([`RegistryLiminalDispatch`](crate::worker::RegistryLiminalDispatch)) dispatch
159/// paths, so the two transports can never diverge on `Prefer` (spill) vs `Pinned`
160/// (require + wait, NEVER spill) semantics.
161///
162/// This is consulted ONLY for an unpinned row: an authored `Some(N)` pin is
163/// authoritative and never enters this path. The result is a pure
164/// worker-SELECTION input; it never mutates the recorded row's `node` (the
165/// determinism invariant, CP-Phase-2 §2.4).
166#[derive(Clone, Debug, Eq, PartialEq)]
167pub enum WorkerSelection {
168 /// `Unplaced`/`Prefer`: try the ordered tiers (each preferred label, then the
169 /// `None` spill), stopping at the first tier with a live worker. `Unplaced`
170 /// collapses to the single `None` any-worker tier.
171 PreferTiers(Vec<Option<String>>),
172 /// `Pinned{L}`: require a worker whose advertised node ∈ `L`, and WAIT when
173 /// none is live — NEVER a `None` spill to an any-node worker (CP-Phase-2 §2.5,
174 /// P2-I1). An empty required set can never be satisfied by any labelled worker,
175 /// so it stalls until the namespace's placement is relaxed — the correct
176 /// "isolation > availability" behaviour of a hard pin with no admissible node.
177 Required(BTreeSet<String>),
178}
179
180/// Resolve the [`WorkerSelection`] for an unpinned row from its namespace
181/// placement. The single seam that keeps `Prefer` (soft spill) and `Pinned`
182/// (hard require + wait) identical across the gRPC and liminal transports.
183#[must_use]
184pub fn worker_selection_for(placement: &NamespacePlacement) -> WorkerSelection {
185 match placement {
186 NamespacePlacement::Pinned { nodes } => WorkerSelection::Required(nodes.clone()),
187 other => WorkerSelection::PreferTiers(preferred_node_order(other)),
188 }
189}
190
191#[cfg(test)]
192mod tests {
193 #![allow(clippy::expect_used)]
194
195 use std::collections::BTreeSet;
196 use std::sync::Arc;
197 use std::time::Duration;
198
199 use aion_store::{InMemoryStore, NamespaceOrigin, NamespacePlacement, NamespaceStore};
200
201 use super::PlacementCache;
202
203 fn labels(values: &[&str]) -> BTreeSet<String> {
204 values.iter().map(|v| (*v).to_owned()).collect()
205 }
206
207 #[tokio::test]
208 async fn reads_placement_from_store_and_serves_a_fresh_hit()
209 -> Result<(), Box<dyn std::error::Error>> {
210 let store: Arc<dyn NamespaceStore> = Arc::new(InMemoryStore::default());
211 store
212 .register_namespace("orders", NamespaceOrigin::Explicit)
213 .await?;
214 store
215 .set_namespace_placement(
216 "orders",
217 NamespacePlacement::Prefer {
218 nodes: labels(&["n1"]),
219 },
220 )
221 .await?;
222 let cache = PlacementCache::new(Arc::clone(&store), Duration::from_secs(60));
223
224 let first = cache.placement("orders").await;
225 assert_eq!(
226 first,
227 NamespacePlacement::Prefer {
228 nodes: labels(&["n1"])
229 }
230 );
231
232 // Mutate the durable record AFTER the cache filled: a fresh hit still
233 // serves the cached value (proving the second read did not hit the store).
234 store
235 .set_namespace_placement("orders", NamespacePlacement::Unplaced)
236 .await?;
237 let cached = cache.placement("orders").await;
238 assert_eq!(
239 cached,
240 NamespacePlacement::Prefer {
241 nodes: labels(&["n1"])
242 },
243 "a fresh cache hit must not re-read the mutated durable record"
244 );
245 Ok(())
246 }
247
248 #[tokio::test]
249 async fn refreshes_after_ttl_expiry() -> Result<(), Box<dyn std::error::Error>> {
250 let store: Arc<dyn NamespaceStore> = Arc::new(InMemoryStore::default());
251 store
252 .register_namespace("orders", NamespaceOrigin::Explicit)
253 .await?;
254 store
255 .set_namespace_placement(
256 "orders",
257 NamespacePlacement::Prefer {
258 nodes: labels(&["n1"]),
259 },
260 )
261 .await?;
262 // A zero TTL forces every read to be a miss, so a mutation is observed.
263 let cache = PlacementCache::new(Arc::clone(&store), Duration::ZERO);
264
265 assert_eq!(
266 cache.placement("orders").await,
267 NamespacePlacement::Prefer {
268 nodes: labels(&["n1"])
269 }
270 );
271 store
272 .set_namespace_placement("orders", NamespacePlacement::Unplaced)
273 .await?;
274 assert_eq!(
275 cache.placement("orders").await,
276 NamespacePlacement::Unplaced,
277 "an expired entry must re-read the mutated durable record"
278 );
279 Ok(())
280 }
281
282 #[tokio::test]
283 async fn absent_namespace_defaults_to_unplaced() -> Result<(), Box<dyn std::error::Error>> {
284 let store: Arc<dyn NamespaceStore> = Arc::new(InMemoryStore::default());
285 let cache = PlacementCache::new(store, Duration::from_secs(60));
286 assert_eq!(
287 cache.placement("never-seen").await,
288 NamespacePlacement::Unplaced,
289 "an absent registry row defaults to Unplaced (any worker)"
290 );
291 Ok(())
292 }
293
294 // --- #163: the shared prefer-then-spill tier order (gRPC + liminal) --------
295
296 use super::preferred_node_order;
297
298 /// A `Prefer{L}` placement yields each label in deterministic set order as a
299 /// `Some` tier, then a final `None` spill tier — the single source both the
300 /// gRPC and liminal dispatch paths consult so they cannot diverge.
301 #[test]
302 fn prefer_order_is_each_label_then_the_none_spill() {
303 let order = preferred_node_order(&NamespacePlacement::Prefer {
304 nodes: labels(&["n2", "n1"]),
305 });
306 // BTreeSet order is sorted: n1 before n2, then the None spill.
307 assert_eq!(
308 order,
309 vec![Some("n1".to_owned()), Some("n2".to_owned()), None],
310 "each preferred label (sorted) precedes the None spill tier"
311 );
312 }
313
314 /// An empty `Prefer{}` set collapses to the immediate `None` spill.
315 #[test]
316 fn empty_prefer_set_is_just_the_spill() {
317 let order = preferred_node_order(&NamespacePlacement::Prefer {
318 nodes: BTreeSet::new(),
319 });
320 assert_eq!(order, vec![None], "an empty prefer set is the spill case");
321 }
322
323 /// `Unplaced` yields a single any-worker tier (the pre-Phase-2 unpinned
324 /// selection). `preferred_node_order` also yields a single `None` tier for
325 /// `Pinned`, but that is NOT the Pinned dispatch path any more: the hard pin is
326 /// resolved by [`worker_selection_for`] into a NON-spilling `Required` decision
327 /// (see below), so `preferred_node_order`'s `Pinned` arm is only the internal
328 /// fall-through of the soft `Prefer` path and never satisfies a hard pin.
329 #[test]
330 fn unplaced_is_a_single_any_worker_tier() {
331 assert_eq!(
332 preferred_node_order(&NamespacePlacement::Unplaced),
333 vec![None]
334 );
335 }
336
337 // --- #164 (P2-I1): the shared Prefer-vs-Pinned selection decision ----------
338
339 use super::{WorkerSelection, worker_selection_for};
340
341 /// `Prefer{L}` resolves to the ordered prefer-then-spill tiers (each label,
342 /// then the `None` spill) — the soft, high-availability path.
343 #[test]
344 fn prefer_selects_ordered_tiers_with_a_none_spill() {
345 assert_eq!(
346 worker_selection_for(&NamespacePlacement::Prefer {
347 nodes: labels(&["n2", "n1"]),
348 }),
349 WorkerSelection::PreferTiers(vec![Some("n1".to_owned()), Some("n2".to_owned()), None,]),
350 );
351 }
352
353 /// `Unplaced` resolves to the single `None` any-worker tier.
354 #[test]
355 fn unplaced_selects_the_single_any_worker_tier() {
356 assert_eq!(
357 worker_selection_for(&NamespacePlacement::Unplaced),
358 WorkerSelection::PreferTiers(vec![None]),
359 );
360 }
361
362 /// `Pinned{L}` resolves to a NON-spilling `Required` decision over exactly the
363 /// required labels — it must NEVER contain a `None` spill tier, the hard-pin
364 /// invariant both transports share (CP-Phase-2 §2.5, P2-I1).
365 #[test]
366 fn pinned_selects_required_labels_and_never_spills() {
367 let selection = worker_selection_for(&NamespacePlacement::Pinned {
368 nodes: labels(&["n1", "n2"]),
369 });
370 assert_eq!(
371 selection,
372 WorkerSelection::Required(labels(&["n1", "n2"])),
373 "Pinned must require its label set with NO None spill tier"
374 );
375 }
376}