Skip to main content

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}