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 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}