Skip to main content

myko/server/
context.rs

1//! Server context for the cell-based server.
2//!
3//! Provides modules (like PeerRegistry) with the ability to:
4//! - Run reactive queries (like GetPeerServers)
5//! - Publish entities (Reduce → Relationships → Persist)
6//! - Access server identity (host_id)
7
8use std::{
9    any::Any,
10    collections::HashMap,
11    sync::{Arc, Mutex},
12    thread,
13    time::Duration,
14};
15
16use dashmap::DashMap;
17use hyphae::{
18    Cell, CellImmutable, CellMap, CellMutable, Gettable, IdFor, MaterializeDefinite, Mutable,
19    WeakCellMap,
20};
21use serde::de::DeserializeOwned;
22use uuid::Uuid;
23
24use super::{
25    HandlerRegistry, RelationshipManager,
26    persister::{PersistError, PersistHealth, PersisterRouter},
27};
28use crate::{
29    cache::CacheKey,
30    client::{ConnectionStatus, MykoClient},
31    common::{
32        to_value::ToValue,
33        with_id::{WithId, WithTypedId},
34    },
35    core::item::{
36        AnyItem, Eventable, IngestBufferPolicy, downcast_any_item_arc, typed_map_arc_from_any_item,
37        typed_map_from_any_item_with_typed_id,
38    },
39    query::{
40        FilteredCellMap, QueryContext, QueryFactory, QueryHandler, QueryParams, QueryRequest,
41        QueryTestCtx,
42    },
43    report::{ReportContext, ReportHandler, ReportId},
44    request::RequestContext,
45    search::SearchIndex,
46    store::StoreRegistry,
47    view::{FilteredViewCellMap, TypedViewCellMap, ViewFactory},
48    wire::{EventOptions, MEvent, MEventType},
49};
50
51type AnyItemArc = Arc<dyn AnyItem>;
52
53/// Where a mutation came from. This is the single policy point for the apply
54/// pipeline's loop-safety: it determines whether a mutation should run
55/// relationship cascades. (Both origins produce.)
56///
57/// It replaces the scattered per-call loop-guard flag checks; `from_options`
58/// bridges the legacy `EventOptions::prevent_relationship_updates` flag to an
59/// `Origin` for the deprecated `*_with_options` methods.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub(crate) enum Origin {
62    /// A command handler / server module emitting a new mutation here (also a
63    /// client event ingested over the WebSocket). Cascades and produces.
64    Local,
65    /// A relationship cascade product — a consequence of another mutation here.
66    Cascade,
67    /// An event replicated from a peer server: already durable and already
68    /// cascaded at its origin. Applied to the store + search index only — it must
69    /// not cascade (the origin already replicated its cascade products) and must
70    /// not produce (which would echo it back around the peer mesh).
71    ///
72    /// Reserved: nothing constructs this right now (peer-origin tracking has been
73    /// moved off the wire). The wiring is kept for when that mechanism returns.
74    #[allow(dead_code)]
75    Remote,
76}
77
78impl Origin {
79    /// Bridge the legacy `EventOptions::prevent_relationship_updates` flag to an
80    /// `Origin`: cascade products set it (→ `Cascade`); everything else `Local`.
81    pub(crate) fn from_options(options: &EventOptions) -> Origin {
82        if options.prevent_relationship_updates {
83            Origin::Cascade
84        } else {
85            Origin::Local
86        }
87    }
88
89    /// Whether this origin's mutations should run relationship cascades.
90    ///
91    /// - `Local` mutations always cascade.
92    /// - `Cascade` products are gated on the change type: a **DEL** product
93    ///   keeps cascading, so a deleted parent's children, grandchildren, … are
94    ///   all removed at runtime (not just one level, and not deferred to the
95    ///   boot-time orphan sweep). The owns_many array-fixup **SET** product must
96    ///   not descend structurally.
97    /// - `Remote` never cascades (the origin already replicated its products).
98    ///
99    /// Transitive DEL cascade terminates without a depth counter or visited set:
100    /// reduce runs before cascade, so each node is removed from the store before
101    /// its own cascade runs. The store is therefore a monotonically shrinking
102    /// visited-set — a cyclic schema (A→B→A) finds nothing the second time, and
103    /// a cascade-deleted child cannot resurrect its already-removed parent.
104    fn should_cascade(self, change: MEventType) -> bool {
105        match self {
106            Origin::Local => true,
107            Origin::Cascade => change == MEventType::DEL,
108            Origin::Remote => false,
109        }
110    }
111
112    /// Whether this origin's mutations should be produced to persisters/sink.
113    ///
114    /// `Remote` events are already durable and already cascaded at their origin,
115    /// so re-producing them would echo them back around the peer mesh. Everything
116    /// else produces; per-type durability is the persister router's job
117    /// (`BlackholePersister`), not a per-event flag.
118    fn should_produce(self) -> bool {
119        self != Origin::Remote
120    }
121}
122
123/// Weak-ref report cache entry. The cell stays alive as long as someone is
124/// subscribed to it. When all subscribers drop, the weak ref fails to upgrade
125/// and the next request recomputes.
126trait ReportCacheEntryDyn: Any + Send + Sync {
127    fn as_any(&self) -> &dyn Any;
128    fn is_alive(&self) -> bool;
129}
130
131struct ReportCacheEntry<T> {
132    weak: hyphae::cell::WeakCell<T, CellImmutable>,
133}
134
135impl<T> ReportCacheEntry<T>
136where
137    T: Clone + Send + Sync + 'static,
138{
139    fn new(cell: &Cell<T, CellImmutable>) -> Self {
140        Self {
141            weak: cell.downgrade(),
142        }
143    }
144
145    fn get(&self) -> Option<Cell<T, CellImmutable>> {
146        self.weak.upgrade()
147    }
148}
149
150impl<T> ReportCacheEntryDyn for ReportCacheEntry<T>
151where
152    T: Clone + Send + Sync + 'static,
153{
154    fn as_any(&self) -> &dyn Any {
155        self
156    }
157
158    fn is_alive(&self) -> bool {
159        self.weak.upgrade().is_some()
160    }
161}
162
163struct MapCacheEntry {
164    weak: WeakCellMap<Arc<str>, AnyItemArc>,
165    /// Lazily-created typed projections keyed by `TypeId` of the output
166    /// `CellMap<K, V>`. Each value is a type-erased weak cell map that can be
167    /// downcast back to the concrete `WeakCellMap<K, V>`.
168    typed: Mutex<HashMap<std::any::TypeId, Box<dyn Any + Send + Sync>>>,
169}
170
171#[derive(Default)]
172struct BufferedIngestState {
173    events: Vec<MEvent>,
174    flush_scheduled: bool,
175}
176
177struct BufferedIngestType {
178    state: Mutex<BufferedIngestState>,
179}
180
181impl BufferedIngestType {
182    fn new() -> Self {
183        Self {
184            state: Mutex::new(BufferedIngestState::default()),
185        }
186    }
187}
188
189impl MapCacheEntry {
190    fn new(map: &FilteredCellMap) -> Self {
191        Self {
192            weak: map.downgrade(),
193            typed: Mutex::new(HashMap::new()),
194        }
195    }
196
197    fn get(&self) -> Option<FilteredCellMap> {
198        self.weak.upgrade().map(|map| map.lock())
199    }
200
201    /// Get or create a typed projection of this untyped map.
202    ///
203    /// `F` is called at most once per projection type to create the typed map
204    /// from the untyped source. Subsequent calls return the cached projection.
205    fn get_or_create_typed<K, V, F>(&self, create: F) -> Option<CellMap<K, V, CellImmutable>>
206    where
207        K: std::hash::Hash + Eq + hyphae::traits::CellValue + 'static,
208        V: hyphae::traits::CellValue + 'static,
209        F: FnOnce(FilteredCellMap) -> CellMap<K, V, CellImmutable>,
210    {
211        let type_key = std::any::TypeId::of::<WeakCellMap<K, V>>();
212        let mut typed = self.typed.lock().unwrap();
213
214        // Try to upgrade an existing weak ref
215        if let Some(entry) = typed.get(&type_key) {
216            if let Some(weak) = entry.downcast_ref::<WeakCellMap<K, V>>()
217                && let Some(strong) = weak.upgrade()
218            {
219                return Some(strong.lock());
220            }
221            // Dead — remove stale entry
222            typed.remove(&type_key);
223        }
224
225        // Create from the untyped source
226        let source = self.weak.upgrade()?.lock();
227        let built = create(source);
228        typed.insert(type_key, Box::new(built.downgrade()));
229        Some(built)
230    }
231}
232
233/// Context providing capabilities to server modules.
234///
235/// This is the cell-based equivalent of `MykoServerCtx`, providing:
236/// - Entity store access (read-only, via queries)
237/// - Event publishing (Reduce → Relationships → Persist)
238/// - Server identity
239#[derive(Clone)]
240pub struct CellServerCtx {
241    /// Unique identifier for this server instance
242    pub host_id: Uuid,
243    /// Store registry for entity access
244    pub registry: Arc<StoreRegistry>,
245    /// Handler registry for item parsers
246    pub handler_registry: Arc<HandlerRegistry>,
247    /// Relationship manager - handles cascades
248    relationship_manager: Arc<RelationshipManager>,
249    /// Persister routing (default + per-entity overrides)
250    persisters: Arc<PersisterRouter>,
251    /// Full-text search index
252    search_index: Arc<SearchIndex>,
253    /// Live peer clients by peer server id (populated by peer registry).
254    peer_clients: Arc<DashMap<Arc<str>, Arc<MykoClient>>>,
255    /// Monotonic tick bumped on peer client register/unregister.
256    peer_clients_tick: Cell<u64, CellMutable>,
257    /// Optional event sink used to fan out applied events to saga runtimes.
258    event_sink: Option<flume::Sender<MEvent>>,
259    // AHash on the cache + dispatch maps below: every subscriber and every
260    // applied event goes through one or more of these. Bench: ~1.6× faster
261    // DashMap lookups vs default SipHash.
262    /// Top-level cache for reactive query maps.
263    query_cache: Arc<DashMap<String, MapCacheEntry, ahash::RandomState>>,
264    /// Top-level cache for reactive view maps.
265    view_cache: Arc<DashMap<String, MapCacheEntry, ahash::RandomState>>,
266    /// Top-level cache for reactive report cells with short-lived strong retention.
267    report_cache: Arc<DashMap<String, Arc<dyn ReportCacheEntryDyn>, ahash::RandomState>>,
268    /// Per-key coordination for concurrent report/query/view computation.
269    /// Prevents duplicate computation when multiple threads request the same key.
270    compute_gates: Arc<DashMap<String, Arc<std::sync::Mutex<()>>, ahash::RandomState>>,
271    /// Optional ingest buffers keyed by entity type for opt-in burst smoothing.
272    ingest_buffers: Arc<DashMap<Arc<str>, Arc<BufferedIngestType>, ahash::RandomState>>,
273    /// Optional history replay provider for point-in-time snapshots.
274    history_replay: Option<Arc<dyn crate::server::HistoryReplayProvider>>,
275}
276
277impl CellServerCtx {
278    /// Create a new server context.
279    #[allow(clippy::too_many_arguments)]
280    pub fn new(
281        host_id: Uuid,
282        registry: Arc<StoreRegistry>,
283        handler_registry: Arc<HandlerRegistry>,
284        relationship_manager: Arc<RelationshipManager>,
285        persisters: Arc<PersisterRouter>,
286        search_index: Arc<SearchIndex>,
287        peer_clients: Arc<DashMap<Arc<str>, Arc<MykoClient>>>,
288        event_sink: Option<flume::Sender<MEvent>>,
289        history_replay: Option<Arc<dyn crate::server::HistoryReplayProvider>>,
290    ) -> Self {
291        Self {
292            host_id,
293            registry,
294            handler_registry,
295            relationship_manager,
296            persisters,
297            search_index,
298            peer_clients,
299            peer_clients_tick: Cell::new(0).with_name("peer_clients_tick"),
300            event_sink,
301            query_cache: Arc::new(DashMap::with_hasher(ahash::RandomState::new())),
302            view_cache: Arc::new(DashMap::with_hasher(ahash::RandomState::new())),
303            report_cache: Arc::new(DashMap::with_hasher(ahash::RandomState::new())),
304            compute_gates: Arc::new(DashMap::with_hasher(ahash::RandomState::new())),
305            ingest_buffers: Arc::new(DashMap::with_hasher(ahash::RandomState::new())),
306            history_replay,
307        }
308    }
309
310    fn cache_key<T: CacheKey>(
311        &self,
312        kind: &str,
313        id: &str,
314        params: &T,
315        request: &RequestContext,
316    ) -> String {
317        let payload_hash = params.cache_key_hash();
318        format!("{}:{kind}:{id}:{payload_hash:016x}", request.host_id)
319    }
320
321    /// Get the search index.
322    pub fn search_index(&self) -> &Arc<SearchIndex> {
323        &self.search_index
324    }
325
326    /// Get the history replay provider, if configured.
327    pub fn history_replay(&self) -> Option<&Arc<dyn crate::server::HistoryReplayProvider>> {
328        self.history_replay.as_ref()
329    }
330
331    /// Register or replace a live peer client for a server id.
332    pub fn register_peer_client<S: AsRef<str>>(&self, peer_id: S, client: Arc<MykoClient>) {
333        self.peer_clients
334            .insert(Arc::<str>::from(peer_id.as_ref()), client);
335        let next = self.peer_clients_tick.get().saturating_add(1);
336        self.peer_clients_tick.set(next);
337    }
338
339    /// Remove a live peer client for a server id.
340    pub fn unregister_peer_client(&self, peer_id: &str) {
341        if self.peer_clients.remove(peer_id).is_some() {
342            let next = self.peer_clients_tick.get().saturating_add(1);
343            self.peer_clients_tick.set(next);
344        }
345    }
346
347    /// Get a live peer client by server id, if present.
348    pub fn peer_client(&self, peer_id: &str) -> Option<Arc<MykoClient>> {
349        self.peer_clients
350            .get(peer_id)
351            .map(|entry| entry.value().clone())
352    }
353
354    /// Get a peer's current connection status if the client is present.
355    pub fn peer_connection_status(&self, peer_id: &str) -> Option<ConnectionStatus> {
356        self.peer_client(peer_id)
357            .map(|client| client.get_connection_status_sync())
358    }
359
360    /// Reactive tick that updates whenever peer client membership changes.
361    pub fn peer_clients_tick(&self) -> Cell<u64, CellImmutable> {
362        self.peer_clients_tick.clone().lock()
363    }
364
365    /// Number of currently tracked peer clients.
366    pub fn peer_client_count(&self) -> usize {
367        self.peer_clients.len()
368    }
369
370    /// Get the live persist health counters from the default persister.
371    pub fn persist_health(&self) -> Arc<PersistHealth> {
372        self.persisters.default_health()
373    }
374
375    /// Number of entries in the query cache (includes dead weak refs).
376    pub fn query_cache_len(&self) -> usize {
377        self.query_cache.len()
378    }
379
380    /// Number of entries in the view cache (includes dead weak refs).
381    pub fn view_cache_len(&self) -> usize {
382        self.view_cache.len()
383    }
384
385    /// Number of entries in the report cache (includes dead weak refs).
386    pub fn report_cache_len(&self) -> usize {
387        self.report_cache.len()
388    }
389
390    /// Count live (upgradeable) entries in the report cache.
391    pub fn report_cache_live_count(&self) -> usize {
392        self.report_cache
393            .iter()
394            .filter(|entry| entry.value().is_alive())
395            .count()
396    }
397
398    /// Count live (upgradeable) entries in the query cache.
399    pub fn query_cache_live_count(&self) -> usize {
400        self.query_cache
401            .iter()
402            .filter(|entry| entry.value().weak.upgrade().is_some())
403            .count()
404    }
405
406    /// Count live (upgradeable) entries in the view cache.
407    pub fn view_cache_live_count(&self) -> usize {
408        self.view_cache
409            .iter()
410            .filter(|entry| entry.value().weak.upgrade().is_some())
411            .count()
412    }
413
414    /// Remove dead weak-ref entries from all caches, including belongs-to
415    /// source index buckets (process-global, not per-`CellServerCtx`, but
416    /// swept from here for hosting apps that already call this
417    /// periodically). Bucket entries are also reaped lazily on next access
418    /// regardless — this is a backstop for foreign ids that go dead and are
419    /// never looked up again.
420    pub fn sweep_dead_cache_entries(&self) {
421        self.query_cache
422            .retain(|_, entry| entry.weak.upgrade().is_some());
423        self.view_cache
424            .retain(|_, entry| entry.weak.upgrade().is_some());
425        self.report_cache.retain(|_, entry| entry.is_alive());
426        crate::query::sweep_all_belongs_to_source_indexes();
427    }
428
429    /// Parse JSON to a typed entity using the registered item parser.
430    ///
431    /// Takes the `Value` by ownership so we don't pay a deep-clone of the
432    /// nested-enum tree on every applied event. Bench `from_value_with_clone`
433    /// shows the clone is ~136 ns/event on a typical entity payload — small
434    /// per event, but multiplied by the apply_event_batch hot path it's the
435    /// cheapest non-breaking win on the ingest path.
436    ///
437    /// Returns None if the entity type is not registered or parsing fails.
438    pub fn parse_item(
439        &self,
440        entity_type: &str,
441        json: serde_json::Value,
442    ) -> Option<Arc<dyn AnyItem>> {
443        let parse = self.handler_registry.get_item_parser(entity_type)?;
444        parse(json).ok()
445    }
446
447    // ─────────────────────────────────────────────────────────────────────────
448    // Typed entity publishing (for server modules)
449    // ─────────────────────────────────────────────────────────────────────────
450
451    /// Publish an entity (SET) with default options.
452    ///
453    /// Default behavior: Reduce + Relationships + Persist
454    pub fn set<T>(&self, entity: &T) -> Result<(), PersistError>
455    where
456        T: Eventable + 'static,
457    {
458        self.set_with_origin(entity, Origin::Local)
459    }
460
461    /// Publish an entity (SET) with options.
462    ///
463    /// **Deprecated.** `EventOptions` are internal loop-guard plumbing (cascade
464    /// and peer-replication markers) and must not be set by callers — use
465    /// [`set`](Self::set) instead.
466    #[deprecated(note = "EventOptions is internal plumbing; use `set` instead")]
467    pub fn set_with_options<T>(
468        &self,
469        entity: &T,
470        options: Option<EventOptions>,
471    ) -> Result<(), PersistError>
472    where
473        T: Eventable + 'static,
474    {
475        self.set_with_origin(entity, Origin::from_options(&options.unwrap_or_default()))
476    }
477
478    /// Internal SET: typed reduce (direct `Arc` store insert) followed by the
479    /// shared `apply_effects` tail, gated by `origin`.
480    pub(crate) fn set_with_origin<T>(&self, entity: &T, origin: Origin) -> Result<(), PersistError>
481    where
482        T: Eventable + 'static,
483    {
484        let item: Arc<dyn AnyItem> = Arc::new(entity.clone());
485        self.reduce_one(&item, MEventType::SET);
486        self.apply_effects(std::slice::from_ref(&item), MEventType::SET, origin)
487    }
488
489    /// Delete an entity (DEL) with default options.
490    ///
491    /// Default behavior: Reduce + Relationships + Persist
492    pub fn del<T>(&self, entity: &T) -> Result<(), PersistError>
493    where
494        T: Eventable + Clone + 'static,
495    {
496        self.del_with_origin(entity, Origin::Local)
497    }
498
499    /// Delete an entity (DEL) with options.
500    ///
501    /// **Deprecated.** `EventOptions` are internal plumbing; use [`del`](Self::del).
502    #[deprecated(note = "EventOptions is internal plumbing; use `del` instead")]
503    pub fn del_with_options<T>(
504        &self,
505        entity: &T,
506        options: Option<EventOptions>,
507    ) -> Result<(), PersistError>
508    where
509        T: Eventable + Clone + 'static,
510    {
511        self.del_with_origin(entity, Origin::from_options(&options.unwrap_or_default()))
512    }
513
514    pub(crate) fn del_with_origin<T>(&self, entity: &T, origin: Origin) -> Result<(), PersistError>
515    where
516        T: Eventable + Clone + 'static,
517    {
518        let item: Arc<dyn AnyItem> = Arc::new(entity.clone());
519        self.reduce_one(&item, MEventType::DEL);
520        self.apply_effects(std::slice::from_ref(&item), MEventType::DEL, origin)
521    }
522
523    /// Publish a batch of entities (SET) with default options.
524    ///
525    /// Default behavior: Reduce + Relationships + Persist
526    pub fn batch_set<T>(&self, entities: &[T]) -> Result<(), PersistError>
527    where
528        T: Eventable + Clone + 'static,
529    {
530        self.batch_set_with_origin(entities, Origin::Local)
531    }
532
533    /// Publish a batch of entities (SET) with shared options.
534    ///
535    /// **Deprecated.** `EventOptions` are internal plumbing; use [`batch_set`](Self::batch_set).
536    #[deprecated(note = "EventOptions is internal plumbing; use `batch_set` instead")]
537    pub fn batch_set_with_options<T>(
538        &self,
539        entities: &[T],
540        options: Option<EventOptions>,
541    ) -> Result<(), PersistError>
542    where
543        T: Eventable + Clone + 'static,
544    {
545        self.batch_set_with_origin(entities, Origin::from_options(&options.unwrap_or_default()))
546    }
547
548    /// Publish a batch of entities (SET) with one grouped store insert.
549    pub(crate) fn batch_set_with_origin<T>(
550        &self,
551        entities: &[T],
552        origin: Origin,
553    ) -> Result<(), PersistError>
554    where
555        T: Eventable + Clone + 'static,
556    {
557        if entities.is_empty() {
558            return Ok(());
559        }
560        let items: Vec<Arc<dyn AnyItem>> = entities
561            .iter()
562            .map(|e| Arc::new(e.clone()) as Arc<dyn AnyItem>)
563            .collect();
564        self.emit_grouped(&items, MEventType::SET, origin)
565    }
566
567    /// Delete a batch of entities (DEL) with default options.
568    ///
569    /// Default behavior: Reduce + Relationships + Persist
570    pub fn batch_del<T>(&self, entities: &[T]) -> Result<(), PersistError>
571    where
572        T: Eventable + Clone + 'static,
573    {
574        self.batch_del_with_origin(entities, Origin::Local)
575    }
576
577    /// Delete a batch of entities (DEL) with shared options.
578    ///
579    /// **Deprecated.** `EventOptions` are internal plumbing; use [`batch_del`](Self::batch_del).
580    #[deprecated(note = "EventOptions is internal plumbing; use `batch_del` instead")]
581    pub fn batch_del_with_options<T>(
582        &self,
583        entities: &[T],
584        options: Option<EventOptions>,
585    ) -> Result<(), PersistError>
586    where
587        T: Eventable + Clone + 'static,
588    {
589        self.batch_del_with_origin(entities, Origin::from_options(&options.unwrap_or_default()))
590    }
591
592    /// Delete a batch of entities (DEL) with one grouped store remove.
593    pub(crate) fn batch_del_with_origin<T>(
594        &self,
595        entities: &[T],
596        origin: Origin,
597    ) -> Result<(), PersistError>
598    where
599        T: Eventable + Clone + 'static,
600    {
601        if entities.is_empty() {
602            return Ok(());
603        }
604        let items: Vec<Arc<dyn AnyItem>> = entities
605            .iter()
606            .map(|e| Arc::new(e.clone()) as Arc<dyn AnyItem>)
607            .collect();
608        self.emit_grouped(&items, MEventType::DEL, origin)
609    }
610
611    // ─────────────────────────────────────────────────────────────────────────
612    // Dynamic item publishing (for parsed JSON)
613    // ─────────────────────────────────────────────────────────────────────────
614
615    /// Publish a dynamic item (SET) with default options.
616    ///
617    /// Default behavior: Reduce + Relationships + Persist
618    pub fn set_dyn(&self, item: Arc<dyn AnyItem>) -> Result<(), PersistError> {
619        self.set_dyn_with_origin(item, Origin::Local)
620    }
621
622    /// Publish a dynamic item (SET) with options.
623    ///
624    /// **Deprecated.** `EventOptions` are internal plumbing; use [`set_dyn`](Self::set_dyn).
625    #[deprecated(note = "EventOptions is internal plumbing; use `set_dyn` instead")]
626    pub fn set_dyn_with_options(
627        &self,
628        item: Arc<dyn AnyItem>,
629        options: Option<EventOptions>,
630    ) -> Result<(), PersistError> {
631        self.set_dyn_with_origin(item, Origin::from_options(&options.unwrap_or_default()))
632    }
633
634    pub(crate) fn set_dyn_with_origin(
635        &self,
636        item: Arc<dyn AnyItem>,
637        origin: Origin,
638    ) -> Result<(), PersistError> {
639        self.reduce_one(&item, MEventType::SET);
640        self.apply_effects(std::slice::from_ref(&item), MEventType::SET, origin)
641    }
642
643    /// Publish a batch of dynamic items (SET).
644    pub fn batch_set_dyn(&self, items: &[Arc<dyn AnyItem>]) -> Result<(), PersistError> {
645        self.batch_set_dyn_with_origin(items, Origin::Local)
646    }
647
648    /// Publish a batch of dynamic items (SET) with shared options.
649    ///
650    /// **Deprecated.** `EventOptions` are internal plumbing; use [`batch_set_dyn`](Self::batch_set_dyn).
651    #[deprecated(note = "EventOptions is internal plumbing; use `batch_set_dyn` instead")]
652    pub fn batch_set_dyn_with_options(
653        &self,
654        items: &[Arc<dyn AnyItem>],
655        options: Option<EventOptions>,
656    ) -> Result<(), PersistError> {
657        self.batch_set_dyn_with_origin(items, Origin::from_options(&options.unwrap_or_default()))
658    }
659
660    pub(crate) fn batch_set_dyn_with_origin(
661        &self,
662        items: &[Arc<dyn AnyItem>],
663        origin: Origin,
664    ) -> Result<(), PersistError> {
665        self.emit_grouped(items, MEventType::SET, origin)
666    }
667
668    /// Delete a dynamic item (DEL) with default options.
669    ///
670    /// Default behavior: Reduce + Relationships + Persist
671    pub fn del_dyn(&self, item: Arc<dyn AnyItem>) -> Result<(), PersistError> {
672        self.del_dyn_with_origin(item, Origin::Local)
673    }
674
675    /// Delete a dynamic item (DEL) with options.
676    ///
677    /// **Deprecated.** `EventOptions` are internal plumbing; use [`del_dyn`](Self::del_dyn).
678    #[deprecated(note = "EventOptions is internal plumbing; use `del_dyn` instead")]
679    pub fn del_dyn_with_options(
680        &self,
681        item: Arc<dyn AnyItem>,
682        options: Option<EventOptions>,
683    ) -> Result<(), PersistError> {
684        self.del_dyn_with_origin(item, Origin::from_options(&options.unwrap_or_default()))
685    }
686
687    pub(crate) fn del_dyn_with_origin(
688        &self,
689        item: Arc<dyn AnyItem>,
690        origin: Origin,
691    ) -> Result<(), PersistError> {
692        self.reduce_one(&item, MEventType::DEL);
693        self.apply_effects(std::slice::from_ref(&item), MEventType::DEL, origin)
694    }
695
696    /// Publish a batch of dynamic items (DEL).
697    pub fn batch_del_dyn(&self, items: &[Arc<dyn AnyItem>]) -> Result<(), PersistError> {
698        self.batch_del_dyn_with_origin(items, Origin::Local)
699    }
700
701    /// Publish a batch of dynamic items (DEL) with shared options.
702    ///
703    /// **Deprecated.** `EventOptions` are internal plumbing; use [`batch_del_dyn`](Self::batch_del_dyn).
704    #[deprecated(note = "EventOptions is internal plumbing; use `batch_del_dyn` instead")]
705    pub fn batch_del_dyn_with_options(
706        &self,
707        items: &[Arc<dyn AnyItem>],
708        options: Option<EventOptions>,
709    ) -> Result<(), PersistError> {
710        self.batch_del_dyn_with_origin(items, Origin::from_options(&options.unwrap_or_default()))
711    }
712
713    pub(crate) fn batch_del_dyn_with_origin(
714        &self,
715        items: &[Arc<dyn AnyItem>],
716        origin: Origin,
717    ) -> Result<(), PersistError> {
718        self.emit_grouped(items, MEventType::DEL, origin)
719    }
720
721    /// Delete an entity by type/id and publish DEL even if the item is not present locally.
722    ///
723    /// This is useful for explicit tombstoning of entities (e.g. disconnected peers)
724    /// where we must ensure a DEL event is produced to durable backend.
725    ///
726    /// Note: relationship cascades require the full item and are therefore skipped here.
727    pub fn del_by_id(&self, entity_type: &str, id: &str) -> Result<(), PersistError> {
728        self.del_by_id_with_origin(entity_type, id, Origin::Local)
729    }
730
731    /// Delete an entity by type/id with options.
732    ///
733    /// **Deprecated.** `EventOptions` are internal plumbing; use [`del_by_id`](Self::del_by_id).
734    #[deprecated(note = "EventOptions is internal plumbing; use `del_by_id` instead")]
735    pub fn del_by_id_with_options(
736        &self,
737        entity_type: &str,
738        id: &str,
739        options: Option<EventOptions>,
740    ) -> Result<(), PersistError> {
741        self.del_by_id_with_origin(
742            entity_type,
743            id,
744            Origin::from_options(&options.unwrap_or_default()),
745        )
746    }
747
748    pub(crate) fn del_by_id_with_origin(
749        &self,
750        entity_type: &str,
751        id: &str,
752        origin: Origin,
753    ) -> Result<(), PersistError> {
754        let id_arc: Arc<str> = id.into();
755
756        let existing = self
757            .registry
758            .get(entity_type)
759            .and_then(|store| store.get(&id_arc).get());
760
761        crate::server::entity_set_stats::record_del(entity_type);
762
763        // Reduce: remove from store
764        self.registry.get_or_create(entity_type).remove(&id_arc);
765
766        // Search: remove from index
767        self.search_index.remove_entity(entity_type, id);
768
769        // Persist: produce unless this origin must not (e.g. a peer tombstone).
770        if origin.should_produce() {
771            if let Some(item) = existing {
772                self.produce_del_dyn(&item)?;
773            } else {
774                tracing::warn!(
775                    "del_by_id could not persist DEL without full entity: {}:{}",
776                    entity_type,
777                    id
778                );
779            }
780        }
781
782        tracing::trace!("Published DEL {}:{}", entity_type, id);
783        Ok(())
784    }
785
786    /// Apply a single wire event (parse -> reduce -> relationships -> persist).
787    ///
788    /// Returns `true` when the event was parsed and applied, `false` otherwise.
789    pub fn apply_event(&self, event: MEvent) -> Result<bool, PersistError> {
790        Ok(self.apply_event_batch(vec![event])? == 1)
791    }
792
793    /// Apply a batch of wire events with a single parse pass and grouped store updates.
794    ///
795    /// This reduces overhead versus calling `set_dyn`/`del_dyn` for each event individually.
796    /// Returns the number of successfully parsed/applied events.
797    pub fn apply_event_batch(&self, events: Vec<MEvent>) -> Result<usize, PersistError> {
798        if events.is_empty() {
799            return Ok(0);
800        }
801
802        let mut accepted = 0usize;
803        let mut immediate_events = Vec::new();
804        let mut buffered_by_type: HashMap<Arc<str>, (u64, Vec<MEvent>)> = HashMap::new();
805
806        for event in events {
807            match self
808                .handler_registry
809                .get_item_buffer_policy(&event.item_type)
810            {
811                IngestBufferPolicy::None => immediate_events.push(event),
812                IngestBufferPolicy::TimeWindow { window_ms } => {
813                    let entity_type: Arc<str> = event.item_type.clone().into();
814                    buffered_by_type
815                        .entry(entity_type)
816                        .or_insert_with(|| (window_ms, Vec::new()))
817                        .1
818                        .push(event);
819                }
820            }
821        }
822
823        if !immediate_events.is_empty() {
824            accepted += self.apply_event_batch_immediate(immediate_events)?;
825        }
826
827        for (entity_type, (window_ms, buffered_events)) in buffered_by_type {
828            accepted += buffered_events.len();
829            self.enqueue_buffered_events(entity_type, window_ms, buffered_events);
830        }
831
832        Ok(accepted)
833    }
834
835    fn apply_event_batch_immediate(&self, events: Vec<MEvent>) -> Result<usize, PersistError> {
836        if events.is_empty() {
837            return Ok(0);
838        }
839        let input_len = events.len();
840
841        let mut set_items: Vec<Arc<dyn AnyItem>> = Vec::new();
842        let mut del_items: Vec<Arc<dyn AnyItem>> = Vec::new();
843
844        for event in events {
845            let change = event.change_type;
846            let item_type = event.item_type;
847            let item_value = event.item;
848            let Some(item) = self.parse_item(&item_type, item_value) else {
849                tracing::warn!("Unknown entity type or parse error for ingest: {item_type}");
850                continue;
851            };
852            match change {
853                MEventType::SET => set_items.push(item),
854                MEventType::DEL => del_items.push(item),
855            }
856        }
857
858        let applied = set_items.len() + del_items.len();
859        if applied == 0 {
860            return Ok(0);
861        }
862
863        tracing::trace!(
864            target: "myko::server::context",
865            "apply_event_batch parsed: input_events={} sets={} dels={}",
866            input_len,
867            set_items.len(),
868            del_items.len()
869        );
870
871        // Ingested wire events are Local (cascade + produce); the shared batch
872        // path groups by type, reduces, then runs the cascade/produce tail.
873        // `emit_grouped` itself opens the `hyphae::batch` window (scoped to
874        // just its reduce loop — see the comment there for why).
875        let emit = || -> Result<(), PersistError> {
876            self.emit_grouped(&set_items, MEventType::SET, Origin::Local)?;
877            self.emit_grouped(&del_items, MEventType::DEL, Origin::Local)?;
878            Ok(())
879        };
880        emit()?;
881
882        Ok(applied)
883    }
884
885    fn ingest_buffer_for(&self, entity_type: Arc<str>) -> Arc<BufferedIngestType> {
886        self.ingest_buffers
887            .entry(entity_type)
888            .or_insert_with(|| Arc::new(BufferedIngestType::new()))
889            .clone()
890    }
891
892    fn enqueue_buffered_events(&self, entity_type: Arc<str>, window_ms: u64, events: Vec<MEvent>) {
893        let buffer = self.ingest_buffer_for(entity_type.clone());
894        let should_schedule = {
895            let Ok(mut state) = buffer.state.lock() else {
896                tracing::error!(
897                    "Could not acquire ingest buffer lock for entity_type={}",
898                    entity_type
899                );
900                if let Err(e) = self.apply_event_batch_immediate(events) {
901                    tracing::error!("Failed to apply buffered events for {}: {}", entity_type, e);
902                }
903                return;
904            };
905
906            state.events.extend(events);
907            if state.flush_scheduled {
908                false
909            } else {
910                state.flush_scheduled = true;
911                true
912            }
913        };
914
915        if !should_schedule {
916            return;
917        }
918
919        let ctx = self.clone();
920        thread::spawn(move || {
921            thread::sleep(Duration::from_millis(window_ms));
922            ctx.flush_buffered_events_for_type(&entity_type);
923        });
924    }
925
926    fn flush_buffered_events_for_type(&self, entity_type: &Arc<str>) -> usize {
927        let Some(buffer) = self
928            .ingest_buffers
929            .get(entity_type.as_ref())
930            .map(|entry| entry.clone())
931        else {
932            return 0;
933        };
934
935        let events = {
936            let Ok(mut state) = buffer.state.lock() else {
937                tracing::error!(
938                    "Could not acquire ingest buffer lock for flush entity_type={}",
939                    entity_type
940                );
941                return 0;
942            };
943
944            state.flush_scheduled = false;
945            if state.events.is_empty() {
946                return 0;
947            }
948
949            std::mem::take(&mut state.events)
950        };
951
952        tracing::trace!(
953            target: "myko::server::context",
954            "flush_buffered_events entity_type={} count={}",
955            entity_type,
956            events.len()
957        );
958
959        match self.apply_event_batch_immediate(events) {
960            Ok(count) => count,
961            Err(e) => {
962                tracing::error!("Failed to flush buffered events for {}: {}", entity_type, e);
963                0
964            }
965        }
966    }
967
968    #[cfg(test)]
969    fn flush_all_buffered_events(&self) -> usize {
970        let entity_types: Vec<Arc<str>> = self
971            .ingest_buffers
972            .iter()
973            .map(|entry| entry.key().clone())
974            .collect();
975
976        entity_types
977            .into_iter()
978            .map(|entity_type| self.flush_buffered_events_for_type(&entity_type))
979            .sum()
980    }
981
982    // ─────────────────────────────────────────────────────────────────────────
983    // shared emission pipeline (batch is first-class; single is a thin wrapper)
984    // ─────────────────────────────────────────────────────────────────────────
985
986    /// Single-item store reduce — **no allocation**. Records the stat and applies
987    /// the store insert/remove for one item. Paired with
988    /// `apply_effects(slice::from_ref(&item), …)` by the single-item entry points
989    /// so a single mutation never allocates a Vec or groups by type.
990    fn reduce_one(&self, item: &Arc<dyn AnyItem>, change: MEventType) {
991        let entity_type = item.entity_type();
992        // Every SET/DEL entry point (typed set/del, batch_*, apply_event,
993        // set_dyn/del_dyn) funnels through here — the true root of a fanout
994        // cascade. One span here, discriminated by entity_type (bounded
995        // cardinality — never per-instance id), lets a span-based profiler
996        // (e.g. a tracing-Tracy layer) show the whole downstream
997        // `hyphae.fanout` subtree nested under one legible typed zone
998        // instead of an anonymous root.
999        let _span = tracing::trace_span!("myko.reduce", ty = entity_type, op = ?change).entered();
1000        match change {
1001            MEventType::SET => {
1002                crate::server::entity_set_stats::record_set(entity_type);
1003                self.registry
1004                    .get_or_create(entity_type)
1005                    .insert(item.id(), item.clone());
1006            }
1007            MEventType::DEL => {
1008                crate::server::entity_set_stats::record_del(entity_type);
1009                self.registry.get_or_create(entity_type).remove(&item.id());
1010            }
1011        }
1012    }
1013
1014    /// The batch emission path (first-class). Groups `items` by entity type,
1015    /// applies one grouped store reduce per type (a single store diff each) for
1016    /// **all** groups before any cascade runs, then runs the shared
1017    /// `apply_effects` tail per (same-type) group.
1018    ///
1019    /// Every batch entry point and the wire-ingest path funnel through here. The
1020    /// single-item entry points deliberately do **not** — they call
1021    /// `reduce_one` + `apply_effects` directly to avoid the grouping/Vec cost.
1022    fn emit_grouped(
1023        &self,
1024        items: &[Arc<dyn AnyItem>],
1025        change: MEventType,
1026        origin: Origin,
1027    ) -> Result<(), PersistError> {
1028        if items.is_empty() {
1029            return Ok(());
1030        }
1031
1032        let mut by_type: std::collections::BTreeMap<&'static str, Vec<Arc<dyn AnyItem>>> =
1033            std::collections::BTreeMap::new();
1034        for item in items {
1035            by_type
1036                .entry(item.entity_type())
1037                .or_default()
1038                .push(item.clone());
1039        }
1040
1041        // Reduce: one store diff per type, across all groups, before any cascade
1042        // (so the store is fully settled — load-bearing for transitive cascade).
1043        //
1044        // Wrapped in `hyphae::batch` so N distinct types' stores settle in one
1045        // glitch-free drain instead of firing eagerly per type — but scoped to
1046        // *only* this loop, not the `apply_effects` tail below. `by_type`
1047        // guarantees each type's `diffs_cell` is set at most once in this loop,
1048        // which is the invariant `batch`'s last-write-wins coalescing needs
1049        // (`diffs_cell` carries diff *events*, not latest-value state, and
1050        // isn't `no_coalesce`-stamped — two sets to the same one in one window
1051        // silently drops the first). `apply_effects` runs after this batch has
1052        // already drained, specifically because cascades recurse back into
1053        // `emit_grouped` (e.g. `handle_belongs_to_cascade_batch` ->
1054        // `publish_del_cascade_batch` -> `batch_del_dyn_with_origin` ->
1055        // `emit_grouped`) and could touch a type already reduced in this same
1056        // window — running effects outside the batch means that recursive call
1057        // opens its own fresh window instead of joining (and colliding with)
1058        // this one.
1059        // Wrapped in `hyphae::batch` so N distinct types' stores settle in one
1060        // glitch-free drain instead of firing eagerly per type — but scoped to
1061        // *only* this loop, not the `apply_effects` tail below. `by_type`
1062        // guarantees each type's `diffs_cell` is set at most once in this loop,
1063        // which is the invariant `batch`'s last-write-wins coalescing needs
1064        // (`diffs_cell` carries diff *events*, not latest-value state, and
1065        // isn't `no_coalesce`-stamped — two sets to the same one in one window
1066        // silently drops the first). `apply_effects` runs after this batch has
1067        // already drained, specifically because cascades recurse back into
1068        // `emit_grouped` (e.g. `handle_belongs_to_cascade_batch` ->
1069        // `publish_del_cascade_batch` -> `batch_del_dyn_with_origin` ->
1070        // `emit_grouped`) and could touch a type already reduced in this same
1071        // window — running effects outside the batch means that recursive call
1072        // opens its own fresh window instead of joining (and colliding with)
1073        // this one.
1074        hyphae::batch(|| {
1075            for (entity_type, group) in &by_type {
1076                let store = self.registry.get_or_create(entity_type);
1077                match change {
1078                    MEventType::SET => {
1079                        let mut entries: Vec<(Arc<str>, Arc<dyn AnyItem>)> =
1080                            Vec::with_capacity(group.len());
1081                        for item in group {
1082                            crate::server::entity_set_stats::record_set(entity_type);
1083                            entries.push((item.id(), item.clone()));
1084                        }
1085                        store.insert_many(entries);
1086                    }
1087                    MEventType::DEL => {
1088                        let mut ids: Vec<Arc<str>> = Vec::with_capacity(group.len());
1089                        for item in group {
1090                            crate::server::entity_set_stats::record_del(entity_type);
1091                            ids.push(item.id());
1092                        }
1093                        store.remove_many(ids);
1094                    }
1095                }
1096            }
1097        });
1098
1099        // Effects: search + cascade + produce, per same-type group.
1100        for group in by_type.values() {
1101            self.apply_effects(group, change, origin)?;
1102        }
1103        Ok(())
1104    }
1105
1106    /// Shared post-reduce tail: search index, relationship cascade (gated by
1107    /// `origin`), and produce (gated by `origin`).
1108    ///
1109    /// Operates on a slice of items **of the same entity type** whose store
1110    /// reduce has already run. Single-item callers pass `slice::from_ref(&item)`
1111    /// (zero alloc); `emit_grouped` passes each type-group. The type-erased
1112    /// produce path is equivalent to the typed one (`MEvent::from_item` ≡
1113    /// `MEvent::set_from_value(item.to_value())`, modulo the fresh `created_at`/`tx`).
1114    fn apply_effects(
1115        &self,
1116        items: &[Arc<dyn AnyItem>],
1117        change: MEventType,
1118        origin: Origin,
1119    ) -> Result<(), PersistError> {
1120        // Separate from `myko.reduce` so the relationship-cascade/persist
1121        // tail is distinguishable from the direct state-cell write in a
1122        // profiler trace. `items` is always a single entity-type group by
1123        // the time it reaches here (see the doc comment above).
1124        let _span = tracing::trace_span!(
1125            "myko.apply_effects",
1126            ty = items.first().map(|i| i.entity_type()).unwrap_or("empty"),
1127            op = ?change,
1128        )
1129        .entered();
1130
1131        // Search: index searchable fields.
1132        match change {
1133            MEventType::SET => {
1134                for item in items {
1135                    self.search_index.index_item(item);
1136                }
1137            }
1138            MEventType::DEL => {
1139                for item in items {
1140                    self.search_index
1141                        .remove_entity(item.entity_type(), &item.id());
1142                }
1143            }
1144        }
1145
1146        // Relationships: run cascades unless this origin must not descend.
1147        if origin.should_cascade(change) {
1148            match change {
1149                MEventType::SET => {
1150                    for item in items {
1151                        self.relationship_manager.forward_set(item.clone(), self)?;
1152                    }
1153                }
1154                MEventType::DEL => self.relationship_manager.forward_del_batch(items, self)?,
1155            }
1156        }
1157
1158        // Persist: produce to persisters + sink unless this origin must not.
1159        if origin.should_produce() {
1160            match change {
1161                MEventType::SET => {
1162                    for item in items {
1163                        self.produce_set_dyn(item)?;
1164                    }
1165                }
1166                MEventType::DEL => {
1167                    for item in items {
1168                        self.produce_del_dyn(item)?;
1169                    }
1170                }
1171            }
1172        }
1173
1174        Ok(())
1175    }
1176
1177    // ─────────────────────────────────────────────────────────────────────────
1178    // durable backend production (private)
1179    // ─────────────────────────────────────────────────────────────────────────
1180
1181    fn produce_del_dyn(&self, item: &Arc<dyn AnyItem>) -> Result<(), PersistError> {
1182        if let Some(persister) = self.persisters.resolve(item.entity_type()) {
1183            let event = MEvent::del_from_any(item, &self.host_id.to_string());
1184            persister.persist(event)?;
1185        }
1186        if let Some(sink) = &self.event_sink {
1187            let event = MEvent::del_from_any(item, &self.host_id.to_string());
1188            let _ = sink.send(event);
1189        }
1190        Ok(())
1191    }
1192
1193    fn produce_set_dyn(&self, item: &Arc<dyn AnyItem>) -> Result<(), PersistError> {
1194        if let Some(persister) = self.persisters.resolve(item.entity_type()) {
1195            let event = MEvent::set_from_value(
1196                item.entity_type(),
1197                item.to_value(),
1198                &self.host_id.to_string(),
1199            );
1200            persister.persist(event)?;
1201        }
1202        if let Some(sink) = &self.event_sink {
1203            let event = MEvent::set_from_value(
1204                item.entity_type(),
1205                item.to_value(),
1206                &self.host_id.to_string(),
1207            );
1208            let _ = sink.send(event);
1209        }
1210        Ok(())
1211    }
1212
1213    // ─────────────────────────────────────────────────────────────────────────
1214    // Query methods
1215    // ─────────────────────────────────────────────────────────────────────────
1216
1217    /// Run a reactive query and return a typed map keyed by the item's typed id.
1218    ///
1219    /// The typed projection is cached — multiple callers with the same query
1220    /// share a single underlying map instead of each creating their own copy.
1221    pub fn query_map<Q>(
1222        &self,
1223        query: Q,
1224        request: Arc<RequestContext>,
1225    ) -> CellMap<<Q::Item as WithTypedId>::Id, Arc<Q::Item>, CellImmutable>
1226    where
1227        Q: QueryParams + 'static,
1228        Q::Item: Eventable
1229            + WithId
1230            + WithTypedId
1231            + DeserializeOwned
1232            + Clone
1233            + std::fmt::Debug
1234            + Send
1235            + Sync
1236            + 'static,
1237    {
1238        let key = self.cache_key("query", Q::query_id_static().as_ref(), &query, &request);
1239        // Hold the untyped map alive so the weak ref in the cache entry stays valid.
1240        let untyped = self.query_map_untyped(query, request);
1241        if let Some(entry) = self.query_cache.get(&key)
1242            && let Some(typed) = entry.value().get_or_create_typed(|source| {
1243                typed_map_from_any_item_with_typed_id(source, "CellServerCtx::query_map")
1244            })
1245        {
1246            return typed;
1247        }
1248        // Concurrent cache sweep may have evicted the entry — re-insert and retry
1249        self.query_cache
1250            .insert(key.clone(), MapCacheEntry::new(&untyped));
1251        let entry = self.query_cache.get(&key).expect("just re-inserted");
1252        entry
1253            .value()
1254            .get_or_create_typed(|source| {
1255                typed_map_from_any_item_with_typed_id(source, "CellServerCtx::query_map")
1256            })
1257            .expect("typed projection from freshly inserted entry")
1258    }
1259
1260    /// Run a reactive query and return a typed map keyed by canonical string ids.
1261    ///
1262    /// Prefer `query_map()` unless you specifically need string ids.
1263    pub fn query_map_by_str<Q>(
1264        &self,
1265        query: Q,
1266        request: Arc<RequestContext>,
1267    ) -> CellMap<Arc<str>, Arc<Q::Item>, CellImmutable>
1268    where
1269        Q: QueryParams + 'static,
1270        Q::Item:
1271            Eventable + WithId + DeserializeOwned + Clone + std::fmt::Debug + Send + Sync + 'static,
1272    {
1273        let key = self.cache_key("query", Q::query_id_static().as_ref(), &query, &request);
1274        let untyped = self.query_map_untyped(query, request);
1275        if let Some(entry) = self.query_cache.get(&key)
1276            && let Some(typed) = entry.value().get_or_create_typed(|source| {
1277                typed_map_arc_from_any_item(source, "CellServerCtx::query_map_by_str")
1278            })
1279        {
1280            return typed;
1281        }
1282        // Concurrent cache sweep may have evicted the entry — re-insert and retry
1283        self.query_cache
1284            .insert(key.clone(), MapCacheEntry::new(&untyped));
1285        let entry = self.query_cache.get(&key).expect("just re-inserted");
1286        entry
1287            .value()
1288            .get_or_create_typed(|source| {
1289                typed_map_arc_from_any_item(source, "CellServerCtx::query_map_by_str")
1290            })
1291            .expect("typed projection from freshly inserted entry")
1292    }
1293
1294    /// Run a reactive query.
1295    ///
1296    /// Returns a type-erased map that updates whenever the query results change.
1297    /// The query's `test_entity` is applied with proper server context.
1298    ///
1299    /// # Example
1300    ///
1301    /// ```rust,no_run
1302    /// use std::sync::Arc;
1303    /// use myko::entities::server::GetPeerServers;
1304    /// use myko::request::RequestContext;
1305    /// use myko::server::CellServerCtx;
1306    ///
1307    /// fn demo(ctx: &CellServerCtx, req: Arc<RequestContext>) {
1308    ///     let _peer_servers = ctx.query_map_untyped(GetPeerServers {}, req);
1309    ///     // _peer_servers is CellMap<Arc<str>, Arc<dyn AnyItem>, CellImmutable>
1310    /// }
1311    /// ```
1312    pub fn query_map_untyped<Q>(&self, query: Q, request: Arc<RequestContext>) -> FilteredCellMap
1313    where
1314        Q: QueryFactory + QueryHandler + QueryParams + Clone + Send + Sync + 'static,
1315        Q::Item: DeserializeOwned + Clone + std::fmt::Debug + Send + Sync + 'static,
1316    {
1317        let key = self.cache_key("query", Q::query_id_static().as_ref(), &query, &request);
1318
1319        // Fast path
1320        if let Some(cell) = self.try_get_cached_query(&key) {
1321            return cell;
1322        }
1323
1324        let gate = self
1325            .compute_gates
1326            .entry(key.clone())
1327            .or_insert_with(|| Arc::new(std::sync::Mutex::new(())))
1328            .clone();
1329        let _lock = gate.lock().unwrap();
1330
1331        // Re-check after gate
1332        if let Some(cell) = self.try_get_cached_query(&key) {
1333            return cell;
1334        }
1335
1336        let query_req = QueryRequest::with_tx(query, request.tx.clone());
1337        let any_query: Arc<dyn crate::query::AnyQuery> = Arc::new(query_req);
1338
1339        let built = Q::cell_factory(
1340            any_query,
1341            self.registry.clone(),
1342            request,
1343            Some(Arc::new(self.clone())),
1344        )
1345        .expect("query cell factory should not fail for typed query");
1346        self.query_cache
1347            .insert(key.clone(), MapCacheEntry::new(&built));
1348        // The gate's only job was deduping concurrent first-computation; once
1349        // the cache entry above is visible, any racing caller's re-check
1350        // (line ~1268 above) will hit it directly, gate or no gate. Removing
1351        // it here — rather than never, which is a compute_gates memory leak
1352        // that grows with every distinct query/param combination ever
1353        // computed — is safe regardless of ordering relative to `_lock`'s
1354        // drop, since a fresh gate + a cache hit on re-check behaves
1355        // identically to blocking on the old gate.
1356        self.compute_gates.remove(&key);
1357        built
1358    }
1359
1360    fn try_get_cached_query(&self, key: &str) -> Option<FilteredCellMap> {
1361        let existing = self.query_cache.get(key)?;
1362        if let Some(shared) = existing.value().get() {
1363            return Some(shared);
1364        }
1365        drop(existing);
1366        self.query_cache.remove(key);
1367        None
1368    }
1369
1370    /// Build a reactive view cell map (type-erased for framework internals).
1371    pub fn view_map_untyped<V>(&self, view: V, request: Arc<RequestContext>) -> FilteredViewCellMap
1372    where
1373        V: ViewFactory + Clone + Send + Sync + 'static,
1374        V::Item: DeserializeOwned + Clone + std::fmt::Debug + Send + Sync + 'static,
1375    {
1376        let key = self.cache_key("view", V::view_id_static().as_ref(), &view, &request);
1377
1378        // Fast path
1379        if let Some(cell) = self.try_get_cached_view(&key) {
1380            return cell;
1381        }
1382
1383        let gate = self
1384            .compute_gates
1385            .entry(key.clone())
1386            .or_insert_with(|| Arc::new(std::sync::Mutex::new(())))
1387            .clone();
1388        let _lock = gate.lock().unwrap();
1389
1390        // Re-check after gate
1391        if let Some(cell) = self.try_get_cached_view(&key) {
1392            return cell;
1393        }
1394
1395        let view_req = crate::view::ViewRequest::with_tx(view, request.tx.clone());
1396        let any_view: Arc<dyn crate::view::AnyView> = Arc::new(view_req);
1397
1398        let built = V::cell_factory(
1399            any_view,
1400            self.registry.clone(),
1401            request,
1402            Arc::new(self.clone()),
1403        )
1404        .expect("view cell factory should not fail for typed view");
1405        self.view_cache
1406            .insert(key.clone(), MapCacheEntry::new(&built));
1407        // See the matching comment in `query_map_untyped` — the gate is only
1408        // needed to dedupe concurrent first-computation, not after the cache
1409        // entry above is visible.
1410        self.compute_gates.remove(&key);
1411        built
1412    }
1413
1414    fn try_get_cached_view(&self, key: &str) -> Option<FilteredViewCellMap> {
1415        let existing = self.view_cache.get(key)?;
1416        if let Some(shared) = existing.value().get() {
1417            return Some(shared);
1418        }
1419        drop(existing);
1420        self.view_cache.remove(key);
1421        None
1422    }
1423
1424    /// Back-compat alias for type-erased view map.
1425    pub fn view_map<V>(&self, view: V, request: Arc<RequestContext>) -> FilteredViewCellMap
1426    where
1427        V: ViewFactory + Clone + Send + Sync + 'static,
1428        V::Item: DeserializeOwned + Clone + std::fmt::Debug + Send + Sync + 'static,
1429    {
1430        self.view_map_untyped(view, request)
1431    }
1432
1433    /// Build a typed reactive view cell map.
1434    pub fn view<V>(&self, view: V, request: Arc<RequestContext>) -> TypedViewCellMap<V::Item>
1435    where
1436        V: ViewFactory + Clone + Send + Sync + 'static,
1437        V::Item: DeserializeOwned + Clone + std::fmt::Debug + Send + Sync + 'static,
1438    {
1439        let key = self.cache_key("view", V::view_id_static().as_ref(), &view, &request);
1440        let _untyped = self.view_map_untyped(view, request);
1441        if let Some(entry) = self.view_cache.get(&key)
1442            && let Some(typed) = entry.value().get_or_create_typed(|source| {
1443                typed_map_arc_from_any_item(source, "CellServerCtx::view")
1444            })
1445        {
1446            return typed;
1447        }
1448        unreachable!("view_map_untyped just populated the cache")
1449    }
1450
1451    /// Get a one-shot typed entity snapshot by id.
1452    pub fn entity_snapshot<T>(&self, id: &<T as WithTypedId>::Id) -> Option<Arc<T>>
1453    where
1454        T: Eventable + WithTypedId + Send + Sync + 'static,
1455        <T as WithTypedId>::Id: hyphae::IdFor<T, MapKey = Arc<str>>,
1456    {
1457        let store = self.registry.get_or_create(T::entity_name_static());
1458        let map_key = id.map_key();
1459        let item = store.get_value(&map_key)?;
1460        Some(downcast_any_item_arc::<T>(
1461            &item,
1462            "CellServerCtx::entity_snapshot",
1463        ))
1464    }
1465
1466    /// Get one-shot typed entity snapshots for an item type.
1467    pub fn entity_snapshots<T>(&self) -> Vec<Arc<T>>
1468    where
1469        T: Eventable + WithTypedId + Send + Sync + 'static,
1470        <T as WithTypedId>::Id: hyphae::IdFor<T, MapKey = Arc<str>>,
1471    {
1472        let store = self.registry.get_or_create(T::entity_name_static());
1473        store
1474            .snapshot()
1475            .into_iter()
1476            .map(|(_, item)| downcast_any_item_arc::<T>(&item, "CellServerCtx::entity_snapshots"))
1477            .collect()
1478    }
1479
1480    /// Get one-shot typed entity snapshots for the provided ids.
1481    pub fn entity_snapshots_by_id<T>(
1482        &self,
1483        ids: impl IntoIterator<Item = <T as WithTypedId>::Id>,
1484    ) -> Vec<Arc<T>>
1485    where
1486        T: Eventable + WithTypedId + Send + Sync + 'static,
1487        <T as WithTypedId>::Id: hyphae::IdFor<T, MapKey = Arc<str>>,
1488    {
1489        ids.into_iter()
1490            .filter_map(|id| self.entity_snapshot::<T>(&id))
1491            .collect()
1492    }
1493
1494    /// Run a one-shot (non-reactive) query.
1495    ///
1496    /// Iterates the store directly and returns matching entities without creating
1497    /// any reactive cells or subscriptions. Use this for command handlers and other
1498    /// contexts where you need a point-in-time snapshot, not a live query.
1499    pub fn query_snapshot<Q>(&self, query: Q, request: Arc<RequestContext>) -> Vec<Arc<Q::Item>>
1500    where
1501        Q: QueryHandler + QueryParams + Clone + Send + Sync + 'static,
1502        Q::Item: DeserializeOwned + Clone + std::fmt::Debug + Send + Sync + 'static,
1503    {
1504        let query_item_type = Q::query_item_type_static();
1505        let store = self.registry.get_or_create(&query_item_type);
1506
1507        let query_context = Arc::new(QueryContext {
1508            req: request.clone(),
1509        });
1510        let query = Arc::new(query);
1511
1512        store
1513            .snapshot()
1514            .into_iter()
1515            .filter_map(|(_, item)| {
1516                let typed_item =
1517                    downcast_any_item_arc::<Q::Item>(&item, "CellServerCtx::query_snapshot");
1518                let ctx = QueryTestCtx {
1519                    item: typed_item.clone(),
1520                    query: query.clone(),
1521                    query_context: query_context.clone(),
1522                };
1523                if Q::test_entity(ctx) {
1524                    Some(typed_item)
1525                } else {
1526                    None
1527                }
1528            })
1529            .collect()
1530    }
1531
1532    pub fn report<R>(
1533        &self,
1534        report: R,
1535        request: Arc<RequestContext>,
1536    ) -> Cell<Arc<R::Output>, CellImmutable>
1537    where
1538        R: ReportHandler + ReportId + CacheKey + Clone + serde::Serialize + 'static,
1539    {
1540        let key = self.cache_key("report", report.report_id().as_ref(), &report, &request);
1541        let report_id = report.report_id();
1542
1543        // Fast path: cache hit with live cell.
1544        if let Some(cell) = self.try_get_cached_report::<R>(&key) {
1545            crate::server::report_cache_stats::record_hit(&report_id);
1546            tracing::trace!(
1547                target: "myko::server::context::report_cache",
1548                "report_cache HIT report_id={} key={}",
1549                report_id,
1550                key,
1551            );
1552            return cell;
1553        }
1554
1555        // NOTE(ts): Per-key gate prevents duplicate computation when multiple threads
1556        // request the same report concurrently. First thread computes, others wait.
1557        let gate = self
1558            .compute_gates
1559            .entry(key.clone())
1560            .or_insert_with(|| Arc::new(std::sync::Mutex::new(())))
1561            .clone();
1562        let _lock = gate.lock().unwrap();
1563
1564        // Re-check after acquiring the gate — another thread may have computed while we waited.
1565        if let Some(cell) = self.try_get_cached_report::<R>(&key) {
1566            crate::server::report_cache_stats::record_hit_after_gate(&report_id);
1567            tracing::trace!(
1568                target: "myko::server::context::report_cache",
1569                "report_cache HIT_AFTER_GATE report_id={} key={}",
1570                report_id,
1571                key,
1572            );
1573            return cell;
1574        }
1575
1576        // Emit MISS_COMPUTE *before* compute() so the analyze pass can correlate
1577        // the miss with the work that follows even if compute panics or hangs.
1578        // Payload is only serialized when the trace target is enabled.
1579        if tracing::enabled!(target: "myko::server::context::report_cache", tracing::Level::TRACE) {
1580            let payload = serde_json::to_string(&report)
1581                .unwrap_or_else(|e| format!("<serialize error: {e}>"));
1582            tracing::trace!(
1583                target: "myko::server::context::report_cache",
1584                "report_cache MISS_COMPUTE report_id={} key={} payload={}",
1585                report_id,
1586                key,
1587                payload,
1588            );
1589        }
1590
1591        // Bounded cardinality (one name per report *type*, not per invocation),
1592        // matching the `myko.reduce`/`myko.command` spans — this is the one-time
1593        // cache-miss materialization, not a per-subscriber-update hot path.
1594        let _span = tracing::trace_span!("myko.report", report = report_id.as_ref()).entered();
1595        crate::server::dispatch_metrics::record_report(report_id.as_ref(), request.origin());
1596        let nested_ctx = ReportContext::new(request, Arc::new(self.clone()));
1597        // The trait returns `impl Pipeline<...>`; materialize once here so the
1598        // cache and downstream consumers get a concrete `Cell`. This is the only
1599        // materialization per report, regardless of how deep the inner chain is.
1600        let built = report.compute(nested_ctx).materialize();
1601        // Named by report id (bounded cardinality — one name per report
1602        // *type*, not per invocation) so hyphae's `hyphae.fanout` span
1603        // (under the `profiling` feature) surfaces `cell.name` instead of
1604        // being anonymous. `Cell<T, CellImmutable>::with_name` is available
1605        // post-materialize (unlike `CellMap`, which only exposes it pre-lock
1606        // — query/view result maps can't be named at this seam the same way).
1607        #[cfg(feature = "profiling")]
1608        let built = built.with_name(report_id.as_ref());
1609        self.report_cache
1610            .insert(key.clone(), Arc::new(ReportCacheEntry::new(&built)));
1611        // See the matching comment in `query_map_untyped` — the gate is only
1612        // needed to dedupe concurrent first-computation, not after the cache
1613        // entry above is visible.
1614        self.compute_gates.remove(&key);
1615
1616        crate::server::report_cache_stats::record_miss(&report_id);
1617
1618        built
1619    }
1620
1621    /// Try to get a cached report cell. Returns None if missing or dead.
1622    fn try_get_cached_report<R>(&self, key: &str) -> Option<Cell<Arc<R::Output>, CellImmutable>>
1623    where
1624        R: ReportHandler + 'static,
1625    {
1626        let existing = self.report_cache.get(key)?;
1627        if let Some(entry) = existing
1628            .value()
1629            .as_any()
1630            .downcast_ref::<ReportCacheEntry<Arc<R::Output>>>()
1631            && let Some(shared) = entry.get()
1632        {
1633            return Some(shared);
1634        }
1635        // Dead entry — drop the ref before removing to avoid DashMap deadlock
1636        drop(existing);
1637        self.report_cache.remove(key);
1638        None
1639    }
1640
1641    pub fn new_server_transaction(&self) -> Arc<RequestContext> {
1642        Arc::new(RequestContext {
1643            tx: Arc::<str>::from(Uuid::new_v4().to_string()),
1644            client_id: None,
1645            lineage: vec![],
1646            host_id: self.host_id,
1647            created_at: chrono::Utc::now().to_string(),
1648            windback: None,
1649        })
1650    }
1651}
1652
1653impl std::fmt::Debug for CellServerCtx {
1654    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1655        f.debug_struct("CellServerCtx").finish()
1656    }
1657}
1658
1659#[cfg(test)]
1660mod tests {
1661    use std::sync::Arc;
1662
1663    use serde::{Deserialize, Serialize};
1664    use serde_json::json;
1665    use uuid::Uuid;
1666
1667    use super::CellServerCtx;
1668    use crate::{
1669        common::with_id::WithId,
1670        core::item::{
1671            AnyItem, Eventable, IngestBufferPolicy, IngestBufferRegistration, ItemRegistration,
1672        },
1673        hyphae::Gettable,
1674        search::SearchIndex,
1675        server::{HandlerRegistry, RelationshipManager, persister::PersisterRouter},
1676        store::StoreRegistry,
1677        test_util::scheduler_test_serial,
1678        wire::{MEvent, MEventType},
1679    };
1680
1681    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1682    struct BufferedTestItem {
1683        id: Arc<str>,
1684        value: i32,
1685    }
1686
1687    impl WithId for BufferedTestItem {
1688        fn id(&self) -> Arc<str> {
1689            self.id.clone()
1690        }
1691    }
1692
1693    impl AnyItem for BufferedTestItem {
1694        fn as_any(&self) -> &dyn std::any::Any {
1695            self
1696        }
1697
1698        fn entity_type(&self) -> &'static str {
1699            "BufferedTestItem"
1700        }
1701
1702        fn equals(&self, other: &dyn AnyItem) -> bool {
1703            other
1704                .as_any()
1705                .downcast_ref::<Self>()
1706                .map(|typed| self == typed)
1707                .unwrap_or(false)
1708        }
1709    }
1710
1711    impl Eventable for BufferedTestItem {
1712        const ENTITY_NAME_STATIC: &'static str = "BufferedTestItem";
1713    }
1714
1715    inventory::submit! {
1716        ItemRegistration {
1717            entity_type: "BufferedTestItem",
1718            crate_name: env!("CARGO_PKG_NAME"),
1719            parse: BufferedTestItem::parse,
1720            parse_bytes: BufferedTestItem::parse_bytes,
1721            serialize_json: |any| {
1722                let typed = any.as_any().downcast_ref::<BufferedTestItem>().unwrap();
1723                ::serde_json::value::to_raw_value(typed)
1724            },
1725        }
1726    }
1727
1728    inventory::submit! {
1729        IngestBufferRegistration {
1730            entity_type: "BufferedTestItem",
1731            policy: IngestBufferPolicy::TimeWindow { window_ms: 60_000 },
1732        }
1733    }
1734
1735    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1736    struct ImmediateTestItem {
1737        id: Arc<str>,
1738        value: i32,
1739    }
1740
1741    impl WithId for ImmediateTestItem {
1742        fn id(&self) -> Arc<str> {
1743            self.id.clone()
1744        }
1745    }
1746
1747    impl AnyItem for ImmediateTestItem {
1748        fn as_any(&self) -> &dyn std::any::Any {
1749            self
1750        }
1751
1752        fn entity_type(&self) -> &'static str {
1753            "ImmediateTestItem"
1754        }
1755
1756        fn equals(&self, other: &dyn AnyItem) -> bool {
1757            other
1758                .as_any()
1759                .downcast_ref::<Self>()
1760                .map(|typed| self == typed)
1761                .unwrap_or(false)
1762        }
1763    }
1764
1765    impl Eventable for ImmediateTestItem {
1766        const ENTITY_NAME_STATIC: &'static str = "ImmediateTestItem";
1767    }
1768
1769    inventory::submit! {
1770        ItemRegistration {
1771            entity_type: "ImmediateTestItem",
1772            crate_name: env!("CARGO_PKG_NAME"),
1773            parse: ImmediateTestItem::parse,
1774            parse_bytes: ImmediateTestItem::parse_bytes,
1775            serialize_json: |any| {
1776                let typed = any.as_any().downcast_ref::<ImmediateTestItem>().unwrap();
1777                ::serde_json::value::to_raw_value(typed)
1778            },
1779        }
1780    }
1781
1782    fn make_ctx() -> CellServerCtx {
1783        CellServerCtx::new(
1784            Uuid::new_v4(),
1785            Arc::new(StoreRegistry::new()),
1786            Arc::new(HandlerRegistry::new()),
1787            Arc::new(RelationshipManager::new()),
1788            Arc::new(PersisterRouter::default()),
1789            Arc::new(SearchIndex::new()),
1790            Arc::new(dashmap::DashMap::new()),
1791            None,
1792            None,
1793        )
1794    }
1795
1796    #[test]
1797    fn apply_event_batch_keeps_default_entities_immediate() {
1798        let _serial = scheduler_test_serial();
1799        let ctx = make_ctx();
1800        let applied = ctx
1801            .apply_event_batch(vec![MEvent {
1802                item: json!({
1803                    "id": "immediate-1",
1804                    "value": 7,
1805                }),
1806                change_type: MEventType::SET,
1807                item_type: "ImmediateTestItem".to_string(),
1808                created_at: "2026-03-12T00:00:00Z".to_string(),
1809                tx: "tx-immediate".to_string(),
1810                source_id: Some("test".to_string()),
1811            }])
1812            .expect("apply_event_batch should succeed");
1813
1814        assert_eq!(applied, 1);
1815        let store = ctx.registry.get_or_create("ImmediateTestItem");
1816        assert!(store.get(&Arc::<str>::from("immediate-1")).get().is_some());
1817    }
1818
1819    #[test]
1820    fn apply_event_batch_buffers_opted_in_entities() {
1821        let _serial = scheduler_test_serial();
1822        let ctx = make_ctx();
1823        let applied = ctx
1824            .apply_event_batch(vec![MEvent {
1825                item: json!({
1826                    "id": "buffered-1",
1827                    "value": 42,
1828                }),
1829                change_type: MEventType::SET,
1830                item_type: "BufferedTestItem".to_string(),
1831                created_at: "2026-03-12T00:00:00Z".to_string(),
1832                tx: "tx-buffered".to_string(),
1833                source_id: Some("test".to_string()),
1834            }])
1835            .expect("apply_event_batch should succeed");
1836
1837        assert_eq!(applied, 1);
1838        let store = ctx.registry.get_or_create("BufferedTestItem");
1839        assert!(store.get(&Arc::<str>::from("buffered-1")).get().is_none());
1840
1841        let flushed = ctx.flush_all_buffered_events();
1842        assert_eq!(flushed, 1);
1843        assert!(store.get(&Arc::<str>::from("buffered-1")).get().is_some());
1844    }
1845
1846    #[test]
1847    fn apply_event_batch_delivers_both_diffs_for_mixed_set_and_del_same_type() {
1848        // Regression test for the hazard documented on `emit` in
1849        // `apply_event_batch_immediate`: CellMap's `diffs_cell` coalesces
1850        // last-write-wins like any other cell under `hyphae::batch`, so a
1851        // single shared batch window across the SET and DEL groups would
1852        // silently drop whichever of the two diffs isn't last. Each
1853        // `emit_grouped` call now opens its own batch window instead, so a
1854        // wire batch mixing a SET and a DEL of the *same* entity type must
1855        // still deliver both diffs to subscribers.
1856        let _serial = scheduler_test_serial();
1857        let ctx = make_ctx();
1858
1859        ctx.apply_event_batch(vec![MEvent {
1860            item: json!({ "id": "old-1", "value": 1 }),
1861            change_type: MEventType::SET,
1862            item_type: "ImmediateTestItem".to_string(),
1863            created_at: "2026-03-12T00:00:00Z".to_string(),
1864            tx: "tx-seed".to_string(),
1865            source_id: Some("test".to_string()),
1866        }])
1867        .expect("seed apply_event_batch should succeed");
1868
1869        let store = ctx.registry.get_or_create("ImmediateTestItem");
1870        let diffs_seen = Arc::new(std::sync::Mutex::new(Vec::new()));
1871        let diffs_seen_for_closure = diffs_seen.clone();
1872        let _guard = store.subscribe_diffs(move |diff| {
1873            diffs_seen_for_closure
1874                .lock()
1875                .unwrap()
1876                .push(format!("{diff:?}"));
1877        });
1878        // subscribe_diffs replays the current snapshot synchronously on
1879        // subscribe -- drop that so only diffs from the batch below count.
1880        diffs_seen.lock().unwrap().clear();
1881
1882        let applied = ctx
1883            .apply_event_batch(vec![
1884                MEvent {
1885                    item: json!({ "id": "new-1", "value": 2 }),
1886                    change_type: MEventType::SET,
1887                    item_type: "ImmediateTestItem".to_string(),
1888                    created_at: "2026-03-12T00:00:01Z".to_string(),
1889                    tx: "tx-mixed".to_string(),
1890                    source_id: Some("test".to_string()),
1891                },
1892                MEvent {
1893                    item: json!({ "id": "old-1", "value": 1 }),
1894                    change_type: MEventType::DEL,
1895                    item_type: "ImmediateTestItem".to_string(),
1896                    created_at: "2026-03-12T00:00:01Z".to_string(),
1897                    tx: "tx-mixed".to_string(),
1898                    source_id: Some("test".to_string()),
1899                },
1900            ])
1901            .expect("mixed apply_event_batch should succeed");
1902
1903        assert_eq!(applied, 2);
1904        assert!(store.get(&Arc::<str>::from("new-1")).get().is_some());
1905        assert!(store.get(&Arc::<str>::from("old-1")).get().is_none());
1906
1907        let seen = diffs_seen.lock().unwrap();
1908        assert_eq!(
1909            seen.len(),
1910            2,
1911            "both the SET and DEL diffs must reach subscribers, not just the last one: {:?}",
1912            *seen
1913        );
1914    }
1915
1916    #[test]
1917    fn compute_gates_does_not_leak_after_cache_populates() {
1918        // compute_gates only exists to dedupe concurrent first-computation
1919        // (see the comment on the removal call in query_map_untyped); once
1920        // the corresponding cache entry lands, the gate must not linger —
1921        // otherwise every distinct (kind, id, param-hash) ever computed
1922        // leaves a permanent entry, unbounded over the process lifetime.
1923        use crate::{entities::server::GetPeerServers, request::RequestContext};
1924
1925        let _serial = scheduler_test_serial();
1926        let ctx = make_ctx();
1927        let request = Arc::new(RequestContext::internal(
1928            Arc::<str>::from(Uuid::new_v4().to_string()),
1929            ctx.host_id,
1930            "test",
1931        ));
1932
1933        // First call: cache miss — populates compute_gates transiently, then
1934        // must remove it once query_cache is populated.
1935        let _ = ctx.query_map_untyped(GetPeerServers {}, request.clone());
1936        assert!(
1937            ctx.compute_gates.is_empty(),
1938            "compute_gates must be empty once the query cache is populated, got {:?}",
1939            ctx.compute_gates
1940        );
1941
1942        // Second call: cache hit on the fast path — must not touch
1943        // compute_gates at all.
1944        let _ = ctx.query_map_untyped(GetPeerServers {}, request);
1945        assert!(ctx.compute_gates.is_empty());
1946    }
1947
1948    #[test]
1949    fn compute_gates_does_not_leak_after_report_cache_populates() {
1950        // Same invariant as compute_gates_does_not_leak_after_cache_populates,
1951        // exercised through the report() call site's independent gate-removal
1952        // (a separate line, since it inserts into report_cache instead of
1953        // query_cache — verified both were fixed, not just the query one).
1954        use crate::{
1955            entities::client::{ClientId, ClientStatus},
1956            request::RequestContext,
1957        };
1958
1959        let _serial = scheduler_test_serial();
1960        let ctx = make_ctx();
1961        let request = Arc::new(RequestContext::internal(
1962            Arc::<str>::from(Uuid::new_v4().to_string()),
1963            ctx.host_id,
1964            "test",
1965        ));
1966
1967        let report = ClientStatus {
1968            client_id: ClientId::from(Arc::<str>::from("test-client")),
1969        };
1970        let _ = ctx.report(report.clone(), request.clone());
1971        assert!(
1972            ctx.compute_gates.is_empty(),
1973            "compute_gates must be empty once the report cache is populated, got {:?}",
1974            ctx.compute_gates
1975        );
1976
1977        let _ = ctx.report(report, request);
1978        assert!(ctx.compute_gates.is_empty());
1979    }
1980}