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 consulted by
113/// [`WorkerOutboxDispatch`](crate::worker::WorkerOutboxDispatch), which since
114/// #52 R4 selects for BOTH transports — so "prefer labelled worker, spill to
115/// any" cannot mean two things, because only one walk derives it.
116///
117/// The returned sequence is a list of node filters to try IN ORDER, stopping at
118/// the first that has a live worker:
119///
120/// - `Prefer{L}` → each label in `L` (deterministic [`BTreeSet`](std::collections::BTreeSet)
121/// order) as `Some(label)`, then a final `None` spill tier (any live worker).
122/// An empty `L` collapses to just the `None` spill.
123/// - `Unplaced` → a single `None` tier (any live worker).
124///
125/// `Pinned{L}` is NOT dispatched through this fn: [`worker_selection_for`] routes it
126/// to the NON-spilling [`WorkerSelection::Required`] decision (require an L-labelled
127/// worker, WAIT on absence, never spill — P2-I1, #164). The `Pinned` arm here is only
128/// the internal fall-through of the soft `Prefer` path and never satisfies a hard pin.
129///
130/// This is consulted ONLY for an unpinned row (`row.node == None`): an authored
131/// `Some(N)` pin is authoritative and never enters this path. The result is a
132/// pure worker-SELECTION input; it never mutates the recorded row's `node`
133/// (the determinism invariant, CP-Phase-2 §2.4).
134#[must_use]
135pub fn preferred_node_order(placement: &NamespacePlacement) -> Vec<Option<String>> {
136 match placement {
137 NamespacePlacement::Prefer { nodes } => {
138 // Tier 1..N: each preferred label in deterministic set order.
139 // Tier N+1: the `None` spill to any live worker.
140 let mut tiers: Vec<Option<String>> =
141 nodes.iter().map(|label| Some(label.clone())).collect();
142 tiers.push(None);
143 tiers
144 }
145 // Unplaced today, and Pinned (which does NOT spill): a single any-worker
146 // tier, byte-identical to the pre-Phase-2 unpinned dispatch. `Pinned` is
147 // routed to a NON-spilling `Required` decision by `worker_selection_for`
148 // BEFORE this fn is reached, so a `Pinned` value here is only the internal
149 // fall-through of the soft `Prefer` path and never satisfies a hard pin.
150 NamespacePlacement::Unplaced | NamespacePlacement::Pinned { .. } => vec![None],
151 }
152}
153
154/// How an UNPINNED outbox row (`row.node == None`) must be dispatched given its
155/// namespace's [`NamespacePlacement`] — the SINGLE decision consulted by
156/// [`WorkerOutboxDispatch`](crate::worker::WorkerOutboxDispatch), which since
157/// #52 R4 selects for BOTH transports, so `Prefer` (spill) versus `Pinned`
158/// (require and wait, NEVER spill) is decided once for every row, whatever the
159/// chosen worker is delivered over.
160///
161/// This is consulted ONLY for an unpinned row: an authored `Some(N)` pin is
162/// authoritative and never enters this path. The result is a pure
163/// worker-SELECTION input; it never mutates the recorded row's `node` (the
164/// determinism invariant, CP-Phase-2 §2.4).
165#[derive(Clone, Debug, Eq, PartialEq)]
166pub enum WorkerSelection {
167 /// `Unplaced`/`Prefer`: try the ordered tiers (each preferred label, then the
168 /// `None` spill), stopping at the first tier with a live worker. `Unplaced`
169 /// collapses to the single `None` any-worker tier.
170 PreferTiers(Vec<Option<String>>),
171 /// `Pinned{L}`: require a worker whose advertised node ∈ `L`, and WAIT when
172 /// none is live — NEVER a `None` spill to an any-node worker (CP-Phase-2 §2.5,
173 /// P2-I1). An empty required set can never be satisfied by any labelled worker,
174 /// so it stalls until the namespace's placement is relaxed — the correct
175 /// "isolation > availability" behaviour of a hard pin with no admissible node.
176 Required(BTreeSet<String>),
177}
178
179/// Resolve the [`WorkerSelection`] for an unpinned row from its namespace
180/// placement. The single seam that keeps `Prefer` (soft spill) and `Pinned`
181/// (hard require + wait) identical across the gRPC and liminal transports.
182#[must_use]
183pub fn worker_selection_for(placement: &NamespacePlacement) -> WorkerSelection {
184 match placement {
185 NamespacePlacement::Pinned { nodes } => WorkerSelection::Required(nodes.clone()),
186 other => WorkerSelection::PreferTiers(preferred_node_order(other)),
187 }
188}
189
190#[cfg(test)]
191mod tests {
192 #![allow(clippy::expect_used)]
193
194 use std::collections::BTreeSet;
195 use std::sync::Arc;
196 use std::time::Duration;
197
198 use aion_store::{InMemoryStore, NamespaceOrigin, NamespacePlacement, NamespaceStore};
199
200 use super::PlacementCache;
201
202 fn labels(values: &[&str]) -> BTreeSet<String> {
203 values.iter().map(|v| (*v).to_owned()).collect()
204 }
205
206 #[tokio::test]
207 async fn reads_placement_from_store_and_serves_a_fresh_hit()
208 -> Result<(), Box<dyn std::error::Error>> {
209 let store: Arc<dyn NamespaceStore> = Arc::new(InMemoryStore::default());
210 store
211 .register_namespace("orders", NamespaceOrigin::Explicit)
212 .await?;
213 store
214 .set_namespace_placement(
215 "orders",
216 NamespacePlacement::Prefer {
217 nodes: labels(&["n1"]),
218 },
219 )
220 .await?;
221 let cache = PlacementCache::new(Arc::clone(&store), Duration::from_secs(60));
222
223 let first = cache.placement("orders").await;
224 assert_eq!(
225 first,
226 NamespacePlacement::Prefer {
227 nodes: labels(&["n1"])
228 }
229 );
230
231 // Mutate the durable record AFTER the cache filled: a fresh hit still
232 // serves the cached value (proving the second read did not hit the store).
233 store
234 .set_namespace_placement("orders", NamespacePlacement::Unplaced)
235 .await?;
236 let cached = cache.placement("orders").await;
237 assert_eq!(
238 cached,
239 NamespacePlacement::Prefer {
240 nodes: labels(&["n1"])
241 },
242 "a fresh cache hit must not re-read the mutated durable record"
243 );
244 Ok(())
245 }
246
247 #[tokio::test]
248 async fn refreshes_after_ttl_expiry() -> Result<(), Box<dyn std::error::Error>> {
249 let store: Arc<dyn NamespaceStore> = Arc::new(InMemoryStore::default());
250 store
251 .register_namespace("orders", NamespaceOrigin::Explicit)
252 .await?;
253 store
254 .set_namespace_placement(
255 "orders",
256 NamespacePlacement::Prefer {
257 nodes: labels(&["n1"]),
258 },
259 )
260 .await?;
261 // A zero TTL forces every read to be a miss, so a mutation is observed.
262 let cache = PlacementCache::new(Arc::clone(&store), Duration::ZERO);
263
264 assert_eq!(
265 cache.placement("orders").await,
266 NamespacePlacement::Prefer {
267 nodes: labels(&["n1"])
268 }
269 );
270 store
271 .set_namespace_placement("orders", NamespacePlacement::Unplaced)
272 .await?;
273 assert_eq!(
274 cache.placement("orders").await,
275 NamespacePlacement::Unplaced,
276 "an expired entry must re-read the mutated durable record"
277 );
278 Ok(())
279 }
280
281 #[tokio::test]
282 async fn absent_namespace_defaults_to_unplaced() -> Result<(), Box<dyn std::error::Error>> {
283 let store: Arc<dyn NamespaceStore> = Arc::new(InMemoryStore::default());
284 let cache = PlacementCache::new(store, Duration::from_secs(60));
285 assert_eq!(
286 cache.placement("never-seen").await,
287 NamespacePlacement::Unplaced,
288 "an absent registry row defaults to Unplaced (any worker)"
289 );
290 Ok(())
291 }
292
293 // --- #163: the shared prefer-then-spill tier order (gRPC + liminal) --------
294
295 use super::preferred_node_order;
296
297 /// A `Prefer{L}` placement yields each label in deterministic set order as a
298 /// `Some` tier, then a final `None` spill tier — the single source both the
299 /// gRPC and liminal dispatch paths consult so they cannot diverge.
300 #[test]
301 fn prefer_order_is_each_label_then_the_none_spill() {
302 let order = preferred_node_order(&NamespacePlacement::Prefer {
303 nodes: labels(&["n2", "n1"]),
304 });
305 // BTreeSet order is sorted: n1 before n2, then the None spill.
306 assert_eq!(
307 order,
308 vec![Some("n1".to_owned()), Some("n2".to_owned()), None],
309 "each preferred label (sorted) precedes the None spill tier"
310 );
311 }
312
313 /// An empty `Prefer{}` set collapses to the immediate `None` spill.
314 #[test]
315 fn empty_prefer_set_is_just_the_spill() {
316 let order = preferred_node_order(&NamespacePlacement::Prefer {
317 nodes: BTreeSet::new(),
318 });
319 assert_eq!(order, vec![None], "an empty prefer set is the spill case");
320 }
321
322 /// `Unplaced` yields a single any-worker tier (the pre-Phase-2 unpinned
323 /// selection). `preferred_node_order` also yields a single `None` tier for
324 /// `Pinned`, but that is NOT the Pinned dispatch path any more: the hard pin is
325 /// resolved by [`worker_selection_for`] into a NON-spilling `Required` decision
326 /// (see below), so `preferred_node_order`'s `Pinned` arm is only the internal
327 /// fall-through of the soft `Prefer` path and never satisfies a hard pin.
328 #[test]
329 fn unplaced_is_a_single_any_worker_tier() {
330 assert_eq!(
331 preferred_node_order(&NamespacePlacement::Unplaced),
332 vec![None]
333 );
334 }
335
336 // --- #164 (P2-I1): the shared Prefer-vs-Pinned selection decision ----------
337
338 use super::{WorkerSelection, worker_selection_for};
339
340 /// `Prefer{L}` resolves to the ordered prefer-then-spill tiers (each label,
341 /// then the `None` spill) — the soft, high-availability path.
342 #[test]
343 fn prefer_selects_ordered_tiers_with_a_none_spill() {
344 assert_eq!(
345 worker_selection_for(&NamespacePlacement::Prefer {
346 nodes: labels(&["n2", "n1"]),
347 }),
348 WorkerSelection::PreferTiers(vec![Some("n1".to_owned()), Some("n2".to_owned()), None,]),
349 );
350 }
351
352 /// `Unplaced` resolves to the single `None` any-worker tier.
353 #[test]
354 fn unplaced_selects_the_single_any_worker_tier() {
355 assert_eq!(
356 worker_selection_for(&NamespacePlacement::Unplaced),
357 WorkerSelection::PreferTiers(vec![None]),
358 );
359 }
360
361 /// `Pinned{L}` resolves to a NON-spilling `Required` decision over exactly the
362 /// required labels — it must NEVER contain a `None` spill tier, the hard-pin
363 /// invariant both transports share (CP-Phase-2 §2.5, P2-I1).
364 #[test]
365 fn pinned_selects_required_labels_and_never_spills() {
366 let selection = worker_selection_for(&NamespacePlacement::Pinned {
367 nodes: labels(&["n1", "n2"]),
368 });
369 assert_eq!(
370 selection,
371 WorkerSelection::Required(labels(&["n1", "n2"])),
372 "Pinned must require its label set with NO None spill tier"
373 );
374 }
375}