Skip to main content

hyphae/
cell.rs

1#[cfg(feature = "metrics")]
2use std::time::Duration;
3use std::{
4    fmt::Debug,
5    marker::PhantomData,
6    panic::Location,
7    sync::{
8        Arc, Mutex, Weak,
9        atomic::{AtomicBool, Ordering},
10    },
11};
12
13#[cfg(feature = "metrics")]
14use arc_swap::ArcSwap;
15use dashmap::DashMap;
16use rustc_hash::FxHashMap;
17use uuid::Uuid;
18
19#[cfg(feature = "metrics")]
20use crate::metrics::CellMetrics;
21use crate::{
22    signal::Signal,
23    subscription::SubscriptionGuard,
24    traits::{CellValue, DepNode, Gettable, Mutable, Watchable, WatchableResult},
25};
26
27/// Information about a slow subscriber callback.
28#[cfg(feature = "metrics")]
29#[derive(Debug, Clone)]
30pub struct SlowSubscriberAlert {
31    /// The subscriber ID.
32    pub subscriber_id: Uuid,
33    /// How long the subscriber took (nanoseconds).
34    pub duration_ns: u64,
35    /// The configured threshold (nanoseconds).
36    pub threshold_ns: u64,
37}
38
39#[cfg(feature = "metrics")]
40type SlowSubscriberCallback = Arc<dyn Fn(SlowSubscriberAlert) + Send + Sync>;
41
42#[derive(Debug, Clone)]
43pub struct CellMutable;
44
45#[derive(Debug, Clone)]
46pub struct CellImmutable;
47
48/// The inner data of a Cell, wrapped in Arc for shared ownership.
49pub(crate) struct CellInner<T> {
50    pub(crate) id: Uuid,
51    /// Infallible subscriber registry. See [`SubscriberRegistry`]: an id-keyed
52    /// index (O(1) subscribe/unsubscribe) fronting a lazily-rebuilt `Arc<Vec>`
53    /// snapshot that `notify` clones and iterates lock-free, so user callbacks
54    /// never run with an internal cell mutex held.
55    pub(crate) subscribers: parking_lot::Mutex<SubscriberRegistry<Subscriber<T>>>,
56    /// Fallible subscribers. Invoked after `subscribers` on each notify;
57    /// `Err` values are logged via `log::error!` and do not propagate.
58    pub(crate) result_subscribers: parking_lot::Mutex<SubscriberRegistry<ResultSubscriber<T>>>,
59    /// The cell's current value. Stored as `Mutex<Arc<T>>` rather than
60    /// `ArcSwap<T>` so writes don't pay arc_swap's reader-debt-slot scan.
61    /// Reads `lock + clone (Arc bump) + unlock`. Writes
62    /// `lock + assign (drops old Arc inline) + unlock`. Old values reclaim
63    /// via `Arc` refcounting — readers holding clones keep the value alive
64    /// until they drop.
65    pub(crate) value: Mutex<Arc<T>>,
66    /// Optional human-readable name for tracing/debugging. Cold path — set
67    /// rarely via `with_name`, read from `DepNode::name`. Mutex avoids the
68    /// per-cell ArcSwap drop cost paid on every cell teardown.
69    pub(crate) name: Mutex<Option<Arc<str>>>,
70    /// Subscription guards owned by this cell (dropped when cell drops, provides dependency tracking).
71    pub(crate) owned: DashMap<Uuid, SubscriptionGuard>,
72    /// Whether this cell has completed (no more values will be emitted).
73    pub(crate) completed: AtomicBool,
74    /// Whether this cell has errored.
75    pub(crate) errored: AtomicBool,
76    /// The error, if any. Cold path — only written when the cell errors,
77    /// read by error/subscribe paths.
78    pub(crate) error: Mutex<Option<Arc<anyhow::Error>>>,
79    /// Scheduler height cache: packed `(epoch << 32) | height`. The scheduler
80    /// computes a cell's propagation height (`1 + max(dep.height)`) once per
81    /// topology epoch and caches it here, so a steady-state batch reads height
82    /// as a single atomic load instead of walking `deps()` every notify. `0`
83    /// means "never computed" (epoch 0 is never current). Invalidated lazily by
84    /// bumping the global topology epoch on any edge change.
85    #[cfg(feature = "scheduler")]
86    pub(crate) height_cache: std::sync::atomic::AtomicU64,
87    /// Scheduler coalescing policy. When `true`, the scheduler enqueues every
88    /// notify from this cell as a distinct height-ordered op instead of
89    /// last-write-wins coalescing them — preserving the event semantics
90    /// (scan/pairwise/merge, or a hand-rolled stateful `map`) that a dropped
91    /// intermediate would corrupt. Stamped at birth inside a
92    /// [`scheduler::no_coalesce`](crate::scheduler::no_coalesce) scope, or after
93    /// the fact via [`Cell::no_coalesce`]. Default `false` (coalesce), so the
94    /// behavior-cell majority gets the glitch-free win.
95    #[cfg(feature = "scheduler")]
96    pub(crate) no_coalesce: AtomicBool,
97    /// Optional metrics for observability.
98    #[cfg(feature = "metrics")]
99    pub(crate) metrics: Option<Arc<CellMetrics>>,
100    /// Slow subscriber threshold (nanoseconds). None = disabled.
101    #[cfg(feature = "metrics")]
102    pub(crate) slow_subscriber_threshold_ns: ArcSwap<Option<u64>>,
103    /// Callback for slow subscriber alerts.
104    #[cfg(feature = "metrics")]
105    pub(crate) slow_subscriber_callback: ArcSwap<Option<SlowSubscriberCallback>>,
106    /// Source location where this cell was created (via #[track_caller]).
107    #[allow(dead_code)]
108    pub(crate) caller: &'static Location<'static>,
109}
110
111/// A reactive cell that holds a value and notifies subscribers on change.
112pub struct Cell<T, M> {
113    pub(crate) inner: Arc<CellInner<T>>,
114    pub(crate) _marker: PhantomData<M>,
115}
116
117/// A weak reference to a Cell that doesn't prevent it from being dropped.
118pub struct WeakCell<T, M> {
119    inner: Weak<CellInner<T>>,
120    _marker: PhantomData<M>,
121}
122
123impl<T, M> WeakCell<T, M> {
124    /// Try to upgrade to a strong Cell reference.
125    /// Returns None if the Cell has been dropped.
126    pub fn upgrade(&self) -> Option<Cell<T, M>> {
127        self.inner.upgrade().map(|inner| Cell {
128            inner,
129            _marker: PhantomData,
130        })
131    }
132
133    /// Whether the referenced Cell is still alive (has live strong references).
134    ///
135    /// Cheaper than `upgrade().is_some()` — it only reads the strong count and
136    /// never materializes (or transiently reference-counts) a `Cell`, so it is
137    /// safe to call in a hot sweep over many weaks.
138    pub fn is_alive(&self) -> bool {
139        self.inner.strong_count() > 0
140    }
141}
142
143impl<T, M> Clone for WeakCell<T, M> {
144    fn clone(&self) -> Self {
145        WeakCell {
146            inner: self.inner.clone(),
147            _marker: PhantomData,
148        }
149    }
150}
151
152/// Indexed subscriber registry: O(1) subscribe/unsubscribe by subscription id,
153/// with a lazily-rebuilt [`SubSnapshot`] for lock-free notify iteration.
154///
155/// `index` is authoritative. `snapshot` is a cached view of it that `notify`
156/// clones (an `Arc` bump, or nothing for the 0/1-subscriber cases) and iterates
157/// *without* the lock held. Mutations touch
158/// only `index` and set `dirty`; they never rebuild the snapshot, so subscribe
159/// and unsubscribe are O(1) instead of the old copy-on-write O(n) `Vec` rebuild
160/// (the `eq<Uuid>` linear scan that dominated the profile). The snapshot is
161/// rebuilt once, lazily, on the next `notify` after any mutation — amortizing
162/// the O(n) rebuild across every change since the previous notify.
163///
164/// Displaced `Arc`s (a removed subscriber, or the replaced snapshot) are
165/// *returned* from the mutating methods rather than dropped inline: the caller
166/// must drop them **after** releasing the mutex, because a subscriber's drop
167/// can cascade into upstream `CellInner` drops that acquire other cell mutexes,
168/// and running that under our lock can deadlock two concurrently-dropping cells.
169/// A notify snapshot, sized to the subscriber count so the common cases don't
170/// pay for the general one. Profiling rship's HRLV playback showed the *vast
171/// majority* of cells carry exactly one subscriber, yet churn-heavy sources
172/// (switch_map rewiring its input every fire) re-`dirty` the registry each
173/// notify — so the old always-`Arc<Vec>` snapshot heap-allocated a `Vec` *and*
174/// an `Arc` per fire just to hold a single element.
175///
176/// - `Zero` — no subscribers; an empty slice, no allocation.
177/// - `One` — the single subscriber inline; cloning is one `Arc` bump, no heap.
178/// - `Many` — today's path: an `Arc<Vec>` cloned by ref-count bump.
179///
180/// [`as_slice`](SubSnapshot::as_slice) unifies the three for the consumer
181/// (sequential fanout and `par_for_each` alike): `One` yields a length-1 slice
182/// via [`std::slice::from_ref`] over its inline tuple, so no variant needs a
183/// backing `Vec`.
184pub(crate) enum SubSnapshot<S> {
185    Zero,
186    One((Uuid, Arc<S>)),
187    Many(Arc<Vec<(Uuid, Arc<S>)>>),
188}
189
190// Manual `Clone` (not derived) so the bound is `Arc<S>: Clone` — always true —
191// rather than `S: Clone`, which the subscriber payloads don't satisfy.
192impl<S> Clone for SubSnapshot<S> {
193    fn clone(&self) -> Self {
194        match self {
195            SubSnapshot::Zero => SubSnapshot::Zero,
196            SubSnapshot::One(pair) => SubSnapshot::One(pair.clone()),
197            SubSnapshot::Many(subs) => SubSnapshot::Many(subs.clone()),
198        }
199    }
200}
201
202impl<S> SubSnapshot<S> {
203    /// View the snapshot as a slice for iteration — the same shape for all three
204    /// variants, so callers fan out identically whether there are zero, one, or
205    /// many subscribers. Borrows from `self`; the caller keeps `self` alive (and
206    /// drops it outside the lock) for the duration of the fanout.
207    pub(crate) fn as_slice(&self) -> &[(Uuid, Arc<S>)] {
208        match self {
209            SubSnapshot::Zero => &[],
210            SubSnapshot::One(pair) => std::slice::from_ref(pair),
211            SubSnapshot::Many(subs) => subs.as_slice(),
212        }
213    }
214}
215
216/// The authoritative subscriber store, sized to the subscriber count so the
217/// 0/1-subscriber majority never allocates a hash table. Most cells in a large
218/// reactive graph carry at most one subscriber for their whole life (a `map`
219/// feeding one downstream, a leaf sink); for those, `FxHashMap`'s first-insert
220/// bucket allocation was pure per-cell overhead — paid once per cell, but
221/// across millions of cells.
222///
223/// - `Zero` / `One` — inline, no heap.
224/// - `Many` — the `FxHashMap` path, entered on the 1 → 2 transition.
225///
226/// **No demotion.** Once a registry reaches `Many` it stays there even if it
227/// shrinks back to one subscriber. Demoting would thrash the hash table's
228/// allocation for cells that oscillate across the 1/2 boundary (switch_map
229/// re-knitting subscribe-before-unsubscribe transiently holds two); keeping the
230/// map matches the previous always-`FxHashMap` behaviour for exactly those
231/// cells, while cells that never exceed one subscriber pay nothing. All
232/// operations stay O(1); iteration order is unspecified (it always was).
233enum SubIndex<S> {
234    Zero,
235    One(Uuid, Arc<S>),
236    Many(FxHashMap<Uuid, Arc<S>>),
237}
238
239impl<S> SubIndex<S> {
240    fn len(&self) -> usize {
241        match self {
242            SubIndex::Zero => 0,
243            SubIndex::One(..) => 1,
244            SubIndex::Many(map) => map.len(),
245        }
246    }
247
248    /// Insert a subscriber, returning any Arc displaced by a same-id overwrite
249    /// (normally `None`, since ids are fresh) for the caller to drop outside the
250    /// lock.
251    fn insert(&mut self, id: Uuid, sub: Arc<S>) -> Option<Arc<S>> {
252        match self {
253            SubIndex::Zero => {
254                *self = SubIndex::One(id, sub);
255                return None;
256            }
257            SubIndex::One(existing_id, existing_sub) => {
258                if *existing_id == id {
259                    return Some(std::mem::replace(existing_sub, sub));
260                }
261                // Different id: fall out of the match to promote (the borrow of
262                // `existing_sub` must end before we reassign `*self`).
263            }
264            SubIndex::Many(map) => {
265                return map.insert(id, sub);
266            }
267        }
268
269        // Reached only from `One` with a different id: promote to `Many`,
270        // carrying the existing single subscriber plus the new one.
271        let (old_id, old_sub) = match std::mem::replace(self, SubIndex::Zero) {
272            SubIndex::One(old_id, old_sub) => (old_id, old_sub),
273            _ => unreachable!("promotion is entered only from the One arm"),
274        };
275        let mut map = FxHashMap::default();
276        map.insert(old_id, old_sub);
277        map.insert(id, sub);
278        *self = SubIndex::Many(map);
279        None
280    }
281
282    /// Remove a subscriber by id, returning the removed Arc (if present) for the
283    /// caller to drop outside the lock. Never demotes `Many` (see the type doc).
284    fn remove(&mut self, id: &Uuid) -> Option<Arc<S>> {
285        match self {
286            SubIndex::Zero => None,
287            SubIndex::One(existing_id, _) => {
288                if *existing_id != *id {
289                    return None;
290                }
291                match std::mem::replace(self, SubIndex::Zero) {
292                    SubIndex::One(_, sub) => Some(sub),
293                    _ => unreachable!("just matched One"),
294                }
295            }
296            SubIndex::Many(map) => map.remove(id),
297        }
298    }
299}
300
301pub(crate) struct SubscriberRegistry<S> {
302    index: SubIndex<S>,
303    snapshot: SubSnapshot<S>,
304    dirty: bool,
305}
306
307impl<S> SubscriberRegistry<S> {
308    fn new() -> Self {
309        Self {
310            index: SubIndex::Zero,
311            snapshot: SubSnapshot::Zero,
312            dirty: false,
313        }
314    }
315
316    /// Insert a subscriber. O(1). Returns any Arc it displaced (normally `None`,
317    /// since ids are fresh) for the caller to drop outside the lock.
318    #[must_use = "displaced subscriber must be dropped outside the lock"]
319    fn insert(&mut self, id: Uuid, sub: Arc<S>) -> Option<Arc<S>> {
320        self.dirty = true;
321        self.index.insert(id, sub)
322    }
323
324    /// Remove a subscriber by id. O(1). Returns the removed Arc (if present) for
325    /// the caller to drop outside the lock.
326    #[must_use = "removed subscriber must be dropped outside the lock"]
327    fn remove(&mut self, id: &Uuid) -> Option<Arc<S>> {
328        let removed = self.index.remove(id);
329        if removed.is_some() {
330            self.dirty = true;
331        }
332        removed
333    }
334
335    pub(crate) fn len(&self) -> usize {
336        self.index.len()
337    }
338
339    /// Current notify snapshot, rebuilt from `index` if the index changed since
340    /// the last call. Returns `(snapshot_to_iterate, displaced_old_snapshot)`;
341    /// the caller must drop the displaced snapshot outside the lock — it may
342    /// hold the last ref to an unsubscribed subscriber whose drop cascades.
343    #[must_use = "displaced snapshot must be dropped outside the lock"]
344    pub(crate) fn snapshot(&mut self) -> (SubSnapshot<S>, Option<SubSnapshot<S>>) {
345        if self.dirty {
346            // Size the rebuilt snapshot to the subscriber count, mirroring the
347            // index's own shape: the 0/1 cases (1 being the overwhelming
348            // majority) avoid the `Vec` + `Arc` heap allocation the general
349            // path pays.
350            let next = match &self.index {
351                SubIndex::Zero => SubSnapshot::Zero,
352                SubIndex::One(id, sub) => SubSnapshot::One((*id, sub.clone())),
353                SubIndex::Many(map) => SubSnapshot::Many(Arc::new(
354                    map.iter().map(|(id, sub)| (*id, sub.clone())).collect(),
355                )),
356            };
357            let old = std::mem::replace(&mut self.snapshot, next);
358            self.dirty = false;
359            (self.snapshot.clone(), Some(old))
360        } else {
361            (self.snapshot.clone(), None)
362        }
363    }
364}
365
366/// Type alias for subscriber callback functions.
367pub(crate) type SubscriberCallback<T> = Arc<dyn Fn(&Signal<T>) + Send + Sync>;
368
369pub(crate) struct Subscriber<T> {
370    pub(crate) callback: SubscriberCallback<T>,
371}
372
373impl<T> Subscriber<T> {
374    pub(crate) fn new(callback: impl Fn(&Signal<T>) + Send + Sync + 'static) -> Self {
375        Self {
376            callback: Arc::new(callback),
377        }
378    }
379}
380
381/// Type alias for fallible subscriber callbacks. See [`WatchableResult::subscribe_result`].
382pub(crate) type ResultSubscriberCallback<T> =
383    Arc<dyn Fn(&Signal<T>) -> Result<(), String> + Send + Sync>;
384
385pub(crate) struct ResultSubscriber<T> {
386    pub(crate) callback: ResultSubscriberCallback<T>,
387}
388
389impl<T> ResultSubscriber<T> {
390    pub(crate) fn new(
391        callback: impl Fn(&Signal<T>) -> Result<(), String> + Send + Sync + 'static,
392    ) -> Self {
393        Self {
394            callback: Arc::new(callback),
395        }
396    }
397}
398
399impl<T: CellValue> Cell<T, CellMutable> {
400    #[track_caller]
401    pub fn new(initial_value: T) -> Self {
402        let inner = Arc::new(CellInner {
403            id: Uuid::new_v4(),
404            subscribers: parking_lot::Mutex::new(SubscriberRegistry::new()),
405            result_subscribers: parking_lot::Mutex::new(SubscriberRegistry::new()),
406            value: Mutex::new(Arc::new(initial_value)),
407            name: Mutex::new(None),
408            owned: DashMap::new(),
409            completed: AtomicBool::new(false),
410            errored: AtomicBool::new(false),
411            error: Mutex::new(None),
412            #[cfg(feature = "scheduler")]
413            height_cache: std::sync::atomic::AtomicU64::new(0),
414            #[cfg(feature = "scheduler")]
415            no_coalesce: AtomicBool::new(crate::scheduler::birth_no_coalesce()),
416            #[cfg(feature = "metrics")]
417            metrics: default_metrics(),
418            #[cfg(feature = "metrics")]
419            slow_subscriber_threshold_ns: ArcSwap::from_pointee(None),
420            #[cfg(feature = "metrics")]
421            slow_subscriber_callback: ArcSwap::from_pointee(None),
422            caller: Location::caller(),
423        });
424        #[cfg(feature = "inspector")]
425        crate::registry::registry().register(inner.id, Arc::downgrade(&inner) as Weak<dyn DepNode>);
426        #[cfg(feature = "trace")]
427        crate::tracing::register_cell(inner.id, Some(Location::caller().to_string()));
428        Self {
429            inner,
430            _marker: PhantomData,
431        }
432    }
433
434    /// Create a new mutable cell with metrics collection enabled.
435    #[cfg(feature = "metrics")]
436    #[track_caller]
437    pub fn with_metrics(initial_value: T) -> Self {
438        let inner = Arc::new(CellInner {
439            id: Uuid::new_v4(),
440            subscribers: parking_lot::Mutex::new(SubscriberRegistry::new()),
441            result_subscribers: parking_lot::Mutex::new(SubscriberRegistry::new()),
442            value: Mutex::new(Arc::new(initial_value)),
443            name: Mutex::new(None),
444            owned: DashMap::new(),
445            completed: AtomicBool::new(false),
446            errored: AtomicBool::new(false),
447            error: Mutex::new(None),
448            #[cfg(feature = "scheduler")]
449            height_cache: std::sync::atomic::AtomicU64::new(0),
450            #[cfg(feature = "scheduler")]
451            no_coalesce: AtomicBool::new(crate::scheduler::birth_no_coalesce()),
452            metrics: Some(Arc::new(CellMetrics::new())),
453            slow_subscriber_threshold_ns: ArcSwap::from_pointee(None),
454            slow_subscriber_callback: ArcSwap::from_pointee(None),
455            caller: Location::caller(),
456        });
457        #[cfg(feature = "inspector")]
458        crate::registry::registry().register(inner.id, Arc::downgrade(&inner) as Weak<dyn DepNode>);
459        #[cfg(feature = "trace")]
460        crate::tracing::register_cell(inner.id, Some(Location::caller().to_string()));
461        Self {
462            inner,
463            _marker: PhantomData,
464        }
465    }
466    /// Configure slow subscriber detection.
467    ///
468    /// When any subscriber callback takes longer than `threshold`, the `callback`
469    /// is invoked with details about the slow subscriber.
470    ///
471    /// Note: This requires metrics to be enabled. If metrics are not enabled,
472    /// subscriber timing is not tracked and slow subscriber detection will not work.
473    ///
474    /// # Example
475    ///
476    /// ```
477    /// use hyphae::{Cell, Mutable};
478    /// use std::time::Duration;
479    ///
480    /// let cell = Cell::with_metrics(0);
481    /// cell.on_slow_subscriber(Duration::from_millis(10), |alert| {
482    ///     eprintln!("Slow subscriber {:?} took {}ms",
483    ///         alert.subscriber_id,
484    ///         alert.duration_ns / 1_000_000);
485    /// });
486    /// ```
487    #[cfg(feature = "metrics")]
488    pub fn on_slow_subscriber<F>(&self, threshold: Duration, callback: F)
489    where
490        F: Fn(SlowSubscriberAlert) + Send + Sync + 'static,
491    {
492        self.inner
493            .slow_subscriber_threshold_ns
494            .store(Arc::new(Some(threshold.as_nanos() as u64)));
495        self.inner
496            .slow_subscriber_callback
497            .store(Arc::new(Some(Arc::new(callback))));
498    }
499
500    /// Lock this mutable cell, converting it to an immutable cell.
501    /// The underlying data is shared; only the type changes.
502    pub fn lock(self) -> Cell<T, CellImmutable> {
503        Cell {
504            inner: self.inner,
505            _marker: PhantomData,
506        }
507    }
508
509    pub fn with_name(self, name: impl Into<Arc<str>>) -> Self {
510        let name = name.into();
511        *self.inner.name.lock().expect("cell name poisoned") = Some(name.clone());
512        #[cfg(feature = "trace")]
513        crate::tracing::update_name(self.inner.id, name.to_string());
514        self
515    }
516
517    /// Check if the cell appears backed up based on last notify time.
518    ///
519    /// Returns true if metrics are enabled and the last notify took longer
520    /// than 1ms (the default threshold). Use `is_backed_up_threshold()` for
521    /// a custom threshold.
522    ///
523    /// Returns false if metrics are not enabled.
524    #[cfg(feature = "metrics")]
525    pub fn is_backed_up(&self) -> bool {
526        self.is_backed_up_threshold(std::time::Duration::from_millis(1))
527    }
528
529    /// Check if the cell is backed up with a custom threshold.
530    ///
531    /// Returns true if metrics are enabled and the last notify duration
532    /// exceeded the given threshold.
533    #[cfg(feature = "metrics")]
534    pub fn is_backed_up_threshold(&self, threshold: std::time::Duration) -> bool {
535        self.inner
536            .metrics
537            .as_ref()
538            .map(|m| m.last_notify_time_ns() > threshold.as_nanos() as u64)
539            .unwrap_or(false)
540    }
541
542    /// Try to set a value, rejecting if the cell appears backed up.
543    ///
544    /// Uses the default 1ms threshold. Returns `Err(value)` if the cell
545    /// is backed up (last notify took > 1ms), allowing the caller to
546    /// handle backpressure.
547    #[cfg(feature = "metrics")]
548    pub fn try_set(&self, value: T) -> Result<(), T> {
549        if self.is_backed_up() {
550            Err(value)
551        } else {
552            self.set(value);
553            Ok(())
554        }
555    }
556
557    /// Try to set a value with a custom backpressure threshold.
558    ///
559    /// Returns `Err(value)` if the last notify duration exceeded the threshold.
560    #[cfg(feature = "metrics")]
561    pub fn try_set_threshold(&self, value: T, threshold: std::time::Duration) -> Result<(), T> {
562        if self.is_backed_up_threshold(threshold) {
563            Err(value)
564        } else {
565            self.set(value);
566            Ok(())
567        }
568    }
569}
570
571impl<T, M> Clone for Cell<T, M> {
572    fn clone(&self) -> Self {
573        Cell {
574            inner: Arc::clone(&self.inner),
575            _marker: PhantomData,
576        }
577    }
578}
579
580impl<T, M> Cell<T, M> {
581    /// Create a weak reference to this cell.
582    /// The weak reference doesn't prevent the cell from being dropped.
583    pub fn downgrade(&self) -> WeakCell<T, M> {
584        WeakCell {
585            inner: Arc::downgrade(&self.inner),
586            _marker: PhantomData,
587        }
588    }
589
590    /// Get metrics if enabled for this cell.
591    ///
592    /// Returns `None` if the cell was created without metrics.
593    /// Use `Cell::with_metrics()` to create a cell with metrics enabled.
594    #[cfg(feature = "metrics")]
595    pub fn metrics(&self) -> Option<&CellMetrics> {
596        self.inner.metrics.as_ref().map(|m| m.as_ref())
597    }
598
599    /// Take ownership of a subscription guard, dropping it when this cell is dropped.
600    pub fn own(&self, guard: SubscriptionGuard) {
601        #[cfg(feature = "inspector")]
602        crate::registry::registry().mark_owned(guard.source().id(), self.inner.id);
603        self.inner.owned.insert(Uuid::new_v4(), guard);
604        // An added dependency edge invalidates cached scheduler heights.
605        #[cfg(feature = "scheduler")]
606        crate::scheduler::bump_topology_epoch();
607        #[cfg(feature = "trace")]
608        crate::tracing::update_owned_count(self.inner.id, self.inner.owned.len());
609    }
610
611    /// Take ownership of a subscription guard with a stable key.
612    ///
613    /// If a guard with the same key already exists, it is replaced (and dropped).
614    /// This is used by `switch_map` to ensure the old inner subscription is cleaned up
615    /// when switching to a new inner cell.
616    pub fn own_keyed(&self, key: Uuid, guard: SubscriptionGuard) {
617        #[cfg(feature = "inspector")]
618        {
619            // Unmark old owned cell if being replaced
620            if let Some((_, old_guard)) = self.inner.owned.remove(&key) {
621                crate::registry::registry().unmark_owned(old_guard.source().id());
622            }
623            crate::registry::registry().mark_owned(guard.source().id(), self.inner.id);
624        }
625        self.inner.owned.insert(key, guard);
626        // switch_map rewiring changes edges (and heights); invalidate the cache.
627        #[cfg(feature = "scheduler")]
628        crate::scheduler::bump_topology_epoch();
629        #[cfg(feature = "trace")]
630        crate::tracing::update_owned_count(self.inner.id, self.inner.owned.len());
631    }
632}
633
634// ============================================================================
635// DepNode implementation for Cell - enables type-erased dependency traversal
636// ============================================================================
637
638#[cfg(feature = "scheduler")]
639impl<T, M> Cell<T, M> {
640    /// Opt this cell out of the scheduler's last-write-wins coalescing.
641    ///
642    /// Under [`batch`](crate::batch), a coalescing cell keeps only its final
643    /// value per tick — correct for behavior operators (map/filter/join/
644    /// switch_map), but it silently drops intermediates for event operators
645    /// (scan/pairwise/merge/buffer/zip) and hand-rolled stateful maps, whose
646    /// result depends on seeing every emission. Marking such a cell
647    /// `no_coalesce` makes the scheduler enqueue each of its notifies as a
648    /// distinct height-ordered op — every intermediate preserved, still drained
649    /// in height order (so it reads settled inputs; deferral is glitch-free, only
650    /// the last-write-wins *drop* is unsafe for these).
651    ///
652    /// For an event-semantic *subgraph*, prefer
653    /// [`scheduler::no_coalesce`](crate::scheduler::no_coalesce), which stamps
654    /// every cell born inside it — including the sources upstream of the
655    /// operator, where coalescing would otherwise starve it before its inputs
656    /// ever reach it. This builder is the single-cell escape hatch for sites
657    /// where wrapping construction is awkward.
658    pub fn no_coalesce(self) -> Self {
659        self.inner.no_coalesce.store(true, Ordering::Relaxed);
660        self
661    }
662}
663
664impl<T: Send + Sync, M: Send + Sync> DepNode for Cell<T, M> {
665    fn id(&self) -> Uuid {
666        self.inner.id
667    }
668
669    fn name(&self) -> Option<String> {
670        self.inner
671            .name
672            .lock()
673            .expect("cell name poisoned")
674            .as_ref()
675            .map(|s| s.to_string())
676    }
677
678    fn deps(&self) -> Vec<Arc<dyn DepNode>> {
679        // Collect unique dependencies from owned subscription guards
680        let mut seen = std::collections::HashSet::new();
681        self.inner
682            .owned
683            .iter()
684            .filter_map(|entry| {
685                let source = entry.value().source();
686                let id = source.id();
687                if seen.insert(id) {
688                    Some(Arc::clone(source))
689                } else {
690                    None
691                }
692            })
693            .collect()
694    }
695
696    #[cfg(feature = "scheduler")]
697    fn height_cache(&self) -> Option<&std::sync::atomic::AtomicU64> {
698        Some(&self.inner.height_cache)
699    }
700
701    #[cfg(feature = "scheduler")]
702    fn no_coalesce(&self) -> bool {
703        self.inner.no_coalesce.load(Ordering::Relaxed)
704    }
705
706    fn subscriber_count(&self) -> usize {
707        self.inner.subscribers.lock().len() + self.inner.result_subscribers.lock().len()
708    }
709
710    fn owned_count(&self) -> usize {
711        self.inner.owned.len()
712    }
713}
714
715impl<T: CellValue> Cell<T, CellImmutable> {
716    pub fn with_name(self, name: impl Into<Arc<str>>) -> Self {
717        let name = name.into();
718        *self.inner.name.lock().expect("cell name poisoned") = Some(name.clone());
719        #[cfg(feature = "trace")]
720        crate::tracing::update_name(self.inner.id, name.to_string());
721        self
722    }
723}
724
725impl<T: CellValue, M: Send + Sync + 'static> Cell<T, M> {
726    /// Emit a signal to all subscribers.
727    ///
728    /// This is the unified notification mechanism for values, completion, and errors.
729    ///
730    /// Under the `profiling` feature the propagation boundaries
731    /// ([`notify`](Self::notify)/[`write_value`](Self::write_value)/[`fanout`](Self::fanout))
732    /// are `#[inline(never)]` so sampling profilers resolve them as distinct
733    /// frames instead of folding the whole cascade into one `eq`/`notify`
734    /// symbol. This costs a call on the hot path, so it is opt-in.
735    #[doc(hidden)]
736    #[cfg_attr(feature = "profiling", inline(never))]
737    pub fn notify(&self, signal: Signal<T>) {
738        // Don't emit anything after completion or error
739        if self.inner.completed.load(Ordering::SeqCst) || self.inner.errored.load(Ordering::SeqCst)
740        {
741            return;
742        }
743
744        // Opt-in scheduler interception. Inside a `batch` (never in the
745        // default build, never on the synchronous path) this defers the
746        // value-settle + fanout into the height-ordered tick queue and returns;
747        // the drain runs them in order at the batch boundary. One thread-local
748        // bool load when the feature is on but no batch is open.
749        #[cfg(feature = "scheduler")]
750        if crate::scheduler::tick_active() {
751            let cell = self.clone();
752            let signal = signal.clone();
753            crate::scheduler::enqueue(
754                self.inner.id,
755                self as &dyn crate::traits::DepNode,
756                move || {
757                    cell.write_value(&signal);
758                    cell.fanout(&signal);
759                },
760            );
761            return;
762        }
763
764        // Two phases, split so the (opt-in) scheduler can settle a cell's value
765        // in height order *before* running its fanout — glitch-free coalescing —
766        // and so sampling profilers resolve the value-write and the fanout as
767        // distinct symbols instead of one folded `notify`. Outside a scheduler
768        // batch (the default, and always on wasm) they run back-to-back: the
769        // exact synchronous eager-push path, with no behavioral change.
770        self.write_value(&signal);
771        self.fanout(&signal);
772    }
773
774    /// Settle this cell's current value — or its terminal completed/errored
775    /// state — from `signal`. Brief mutex work only; runs no subscriber fanout.
776    #[cfg_attr(feature = "profiling", inline(never))]
777    fn write_value(&self, signal: &Signal<T>) {
778        match signal {
779            Signal::Value(arc_value) => {
780                // `Mutex<Arc<T>>` write: brief lock, swap the Arc, drop lock.
781                // The previous Arc drops inline at the end of this scope —
782                // `Arc::drop` is just a refcount decrement (and dealloc when
783                // it hits zero), no `arc_swap::Debt::pay_all` reader-slot
784                // scan. Readers that grabbed an earlier Arc keep it alive
785                // via their own clone until they're done.
786                *self.inner.value.lock().expect("cell value poisoned") = arc_value.clone();
787            }
788            Signal::Complete => {
789                self.inner.completed.store(true, Ordering::SeqCst);
790            }
791            Signal::Error(err) => {
792                self.inner.errored.store(true, Ordering::SeqCst);
793                *self.inner.error.lock().expect("cell error poisoned") = Some(err.clone());
794            }
795        }
796    }
797
798    /// Fan `signal` out to this cell's subscribers. The value is assumed already
799    /// settled by [`write_value`]; callbacks run with no internal lock held.
800    #[cfg_attr(feature = "profiling", inline(never))]
801    fn fanout(&self, signal: &Signal<T>) {
802        // Tally this emit against the active measurement pass (if any). One per
803        // fanout: synchronously this counts every re-fire; under `batch` the
804        // coalesced cell fanouts once, so the same counter shows the collapse.
805        // Pure measurement — compiles to nothing without `profiling`.
806        #[cfg(feature = "profiling")]
807        crate::profiling::record_fire(self.inner.id);
808
809        // A `tracing` span per fanout so span-based profilers (`tracing-flame`,
810        // `tracing-tracy`) get one entry per cell emit, tagged with the cell's
811        // id and (if set) its name. The consumer attaches the subscriber; when
812        // `profiling` is off this compiles to nothing. Later phases nest this
813        // under a per-frame span.
814        #[cfg(feature = "profiling")]
815        let _fanout_span = {
816            let name = self.inner.name.lock().expect("cell name poisoned").clone();
817            ::tracing::trace_span!(
818                "hyphae.fanout",
819                cell.id = %self.inner.id,
820                cell.name = name.as_deref().unwrap_or(""),
821            )
822            .entered()
823        };
824
825        // Start timing if metrics enabled
826        #[cfg(feature = "metrics")]
827        let notify_start = self
828            .inner
829            .metrics
830            .as_ref()
831            .map(|_| crate::platform::Instant::now());
832
833        // Hot path: take the subscribers mutex briefly to grab the notify
834        // snapshot (rebuilt from the id-index only if it changed since the last
835        // notify), drop the lock, then iterate with no internal lock held.
836        // Subscriber callbacks run lock-free; subscribers added during this
837        // iteration land in the next notify's snapshot (they're inserted into
838        // the index and mark it dirty; this in-flight notify iterates its
839        // already-cloned snapshot). The displaced old snapshot drops *outside*
840        // the lock — it may hold the last ref to an unsubscribed subscriber
841        // whose drop cascades into upstream cell drops.
842        let subs = {
843            let (subs, old_snapshot) = self.inner.subscribers.lock().snapshot();
844            drop(old_snapshot);
845            subs
846        };
847
848        // Slow-subscriber config is only consulted when metrics are enabled
849        // and configured. Defer the ArcSwap loads until then so the steady
850        // state pays nothing.
851        #[cfg(feature = "metrics")]
852        let metrics = &self.inner.metrics;
853        #[cfg(feature = "metrics")]
854        let (slow_threshold, slow_callback) = if metrics.is_some() {
855            (
856                **self.inner.slow_subscriber_threshold_ns.load(),
857                (**self.inner.slow_subscriber_callback.load()).clone(),
858            )
859        } else {
860            (None, None)
861        };
862
863        // Subscriber callbacks must not panic — see `Watchable::subscribe` docs.
864        // A panic here propagates out of the caller's `set`/`send` and halts the
865        // rest of this fanout, which is a bug in the subscriber that should surface
866        // loudly rather than be silently swallowed.
867        for (_subscriber_id, sub) in subs.as_slice() {
868            #[cfg(feature = "metrics")]
869            let sub_start = metrics.as_ref().map(|_| crate::platform::Instant::now());
870
871            (sub.callback)(signal);
872
873            #[cfg(feature = "metrics")]
874            if let (Some(m), Some(start)) = (metrics, sub_start) {
875                let elapsed = start.elapsed().as_nanos() as u64;
876                m.update_slowest_subscriber(elapsed);
877
878                if let (Some(threshold), Some(cb)) = (&slow_threshold, &slow_callback)
879                    && elapsed > *threshold
880                {
881                    let alert = SlowSubscriberAlert {
882                        subscriber_id: *_subscriber_id,
883                        duration_ns: elapsed,
884                        threshold_ns: *threshold,
885                    };
886                    cb(alert);
887                }
888            }
889        }
890
891        // Fallible subscribers run after the infallible chain. Errors are logged
892        // and dropped — they do not interrupt the fanout, and the panic contract
893        // above still applies (a panic in a result-subscriber halts the rest of
894        // this loop). Use `subscribe_result` when you want a structured error
895        // channel instead of `panic!`.
896        // Same snapshot pattern as `subscribers` above.
897        let result_subs = {
898            let (result_subs, old_snapshot) = self.inner.result_subscribers.lock().snapshot();
899            drop(old_snapshot);
900            result_subs
901        };
902
903        for (subscriber_id, sub) in result_subs.as_slice() {
904            #[cfg(feature = "metrics")]
905            let sub_start = metrics.as_ref().map(|_| crate::platform::Instant::now());
906
907            if let Err(err) = (sub.callback)(signal) {
908                log::error!(
909                    "hyphae: fallible subscriber {} on cell {} returned error: {}",
910                    subscriber_id,
911                    self.inner.id,
912                    err
913                );
914            }
915
916            #[cfg(feature = "metrics")]
917            if let (Some(m), Some(start)) = (metrics, sub_start) {
918                let elapsed = start.elapsed().as_nanos() as u64;
919                m.update_slowest_subscriber(elapsed);
920
921                if let (Some(threshold), Some(cb)) = (&slow_threshold, &slow_callback)
922                    && elapsed > *threshold
923                {
924                    let alert = SlowSubscriberAlert {
925                        subscriber_id: *subscriber_id,
926                        duration_ns: elapsed,
927                        threshold_ns: *threshold,
928                    };
929                    cb(alert);
930                }
931            }
932        }
933
934        // Record overall notify timing
935        #[cfg(feature = "metrics")]
936        if let (Some(metrics), Some(start)) = (&self.inner.metrics, notify_start) {
937            let duration_ns = start.elapsed().as_nanos() as u64;
938            metrics.record_notify(duration_ns);
939            #[cfg(feature = "trace")]
940            crate::tracing::record_notify(
941                self.inner.id,
942                duration_ns,
943                subs.as_slice().len() + result_subs.as_slice().len(),
944                self.inner.owned.len(),
945                metrics.slowest_subscriber_ns(),
946            );
947        }
948    }
949}
950
951impl<T: CellValue, U: Send + Sync + 'static> Gettable<T> for Cell<T, U> {
952    fn get(&self) -> T {
953        // Brief lock to clone the Arc (refcount bump), release, then deref
954        // and clone T outside the lock. Keeps the critical section small.
955        let arc = self
956            .inner
957            .value
958            .lock()
959            .expect("cell value poisoned")
960            .clone();
961        (*arc).clone()
962    }
963}
964
965impl<T: CellValue, U: Send + Sync + 'static> Watchable<T> for Cell<T, U> {
966    fn subscribe(
967        &self,
968        callback: impl Fn(&Signal<T>) + Send + Sync + 'static,
969    ) -> SubscriptionGuard {
970        let id = Uuid::new_v4();
971        let sub = Arc::new(Subscriber::new(callback));
972
973        // Insert BEFORE seeding. The prior order (fire the seed with the current
974        // value, THEN insert) left a window in which a concurrent `notify` on
975        // another thread could take its subscriber snapshot between the seed and
976        // the insert: that notify iterated a snapshot WITHOUT this subscriber,
977        // so the new subscriber missed the emit and latched the stale seed value
978        // with no way to recover until some later emit reached it. Against a
979        // source that had already moved on, the subscription stranded
980        // permanently — correct on a fresh `get()` (reads live value) but stuck
981        // on the live subscription. That is the root of the intermittent
982        // "value stuck, UI/fresh-read correct, clears on restart" class.
983        //
984        // Inserting first guarantees this subscriber is in the index for every
985        // subsequent notify, so it can never miss the source moving on. The seed
986        // below then only needs to backfill the current value.
987        //
988        // Any displaced Arc (none, for a fresh id) drops *outside* the lock: a
989        // subscriber's drop can cascade into upstream cell drops that touch
990        // other cell mutexes, and running that under this lock allowed two
991        // concurrently-dropping cells to deadlock.
992        let displaced = self.inner.subscribers.lock().insert(id, sub.clone());
993        drop(displaced);
994
995        // Seed the current value AFTER the insert (backfilling the freshest
996        // stored value) and fire OUTSIDE the subscribers lock — subscriber
997        // callbacks must never run with an internal cell mutex held, since they
998        // can cascade into other cells' locks/drops and deadlock. A notify that
999        // raced the insert above already delivers to this now-indexed
1000        // subscriber; a duplicate value delivery is benign, and any one-emit
1001        // ordering skew self-heals on the next notify.
1002        let current = self
1003            .inner
1004            .value
1005            .lock()
1006            .expect("cell value poisoned")
1007            .clone();
1008        (sub.callback)(&Signal::Value(current));
1009
1010        // If already complete or errored, send that signal too
1011        if self.is_complete() {
1012            (sub.callback)(&Signal::Complete);
1013        } else if self.is_error()
1014            && let Some(err) = self.error()
1015        {
1016            (sub.callback)(&Signal::Error(err));
1017        }
1018
1019        // Record subscriber added if metrics enabled
1020        #[cfg(feature = "metrics")]
1021        if let Some(metrics) = &self.inner.metrics {
1022            metrics.record_subscriber_added();
1023        }
1024        #[cfg(feature = "trace")]
1025        {
1026            let subs_len = self.inner.subscribers.lock().len();
1027            let result_len = self.inner.result_subscribers.lock().len();
1028            crate::tracing::update_subscriber_count(self.inner.id, subs_len + result_len);
1029        }
1030
1031        let source: Arc<dyn DepNode> = Arc::new(self.clone());
1032        let cell = self.clone();
1033        #[cfg(feature = "metrics")]
1034        let metrics = self.inner.metrics.clone();
1035        SubscriptionGuard::new(id, source, move || {
1036            // O(1) indexed remove; the removed subscriber drops outside the lock
1037            // (see insert above).
1038            let removed_sub = cell.inner.subscribers.lock().remove(&id);
1039            let removed = removed_sub.is_some();
1040            drop(removed_sub);
1041            #[cfg(feature = "metrics")]
1042            if removed && let Some(m) = &metrics {
1043                m.record_subscriber_removed();
1044            }
1045            #[cfg(not(feature = "metrics"))]
1046            let _ = removed;
1047            #[cfg(feature = "trace")]
1048            {
1049                let subs_len = cell.inner.subscribers.lock().len();
1050                let result_len = cell.inner.result_subscribers.lock().len();
1051                crate::tracing::update_subscriber_count(cell.inner.id, subs_len + result_len);
1052            }
1053        })
1054    }
1055
1056    fn unsubscribe(&self, id: Uuid) {
1057        // O(1) indexed removes. The removed `Arc`s drop AFTER each lock guard is
1058        // released so cascading Subscriber/Cell Drops never run with an internal
1059        // cell mutex held (two concurrently-dropping cells could otherwise
1060        // acquire each other's mutex and deadlock).
1061        let removed_sub = self.inner.subscribers.lock().remove(&id);
1062        let removed_from_subs = removed_sub.is_some();
1063        drop(removed_sub);
1064        let removed_from_result = if removed_from_subs {
1065            false
1066        } else {
1067            let removed = self.inner.result_subscribers.lock().remove(&id);
1068            let did = removed.is_some();
1069            drop(removed);
1070            did
1071        };
1072        if removed_from_subs || removed_from_result {
1073            // Record subscriber removed if metrics enabled
1074            #[cfg(feature = "metrics")]
1075            if let Some(metrics) = &self.inner.metrics {
1076                metrics.record_subscriber_removed();
1077            }
1078            #[cfg(feature = "trace")]
1079            {
1080                let subs_len = self.inner.subscribers.lock().len();
1081                let result_len = self.inner.result_subscribers.lock().len();
1082                crate::tracing::update_subscriber_count(self.inner.id, subs_len + result_len);
1083            }
1084        }
1085    }
1086
1087    fn is_complete(&self) -> bool {
1088        self.inner.completed.load(Ordering::SeqCst)
1089    }
1090
1091    fn is_error(&self) -> bool {
1092        self.inner.errored.load(Ordering::SeqCst)
1093    }
1094
1095    fn error(&self) -> Option<Arc<anyhow::Error>> {
1096        self.inner
1097            .error
1098            .lock()
1099            .expect("cell error poisoned")
1100            .clone()
1101    }
1102}
1103
1104impl<T: CellValue, U: Send + Sync + 'static> WatchableResult<T> for Cell<T, U> {
1105    fn subscribe_result(
1106        &self,
1107        callback: impl Fn(&Signal<T>) -> Result<(), String> + Send + Sync + 'static,
1108    ) -> SubscriptionGuard {
1109        let cell_id = self.inner.id;
1110        let log_err = |id: &Uuid, err: &str| {
1111            log::error!(
1112                "hyphae: fallible subscriber {} on cell {} returned error: {}",
1113                id,
1114                cell_id,
1115                err
1116            );
1117        };
1118
1119        let id = Uuid::new_v4();
1120
1121        // Send current value immediately (Arc clone, no deep copy).
1122        let current = self
1123            .inner
1124            .value
1125            .lock()
1126            .expect("cell value poisoned")
1127            .clone();
1128        if let Err(err) = callback(&Signal::Value(current)) {
1129            log_err(&id, &err);
1130        }
1131
1132        // Replay any prior terminal signal.
1133        if self.inner.completed.load(Ordering::SeqCst) {
1134            if let Err(err) = callback(&Signal::Complete) {
1135                log_err(&id, &err);
1136            }
1137        } else if self.inner.errored.load(Ordering::SeqCst)
1138            && let Some(e) = self
1139                .inner
1140                .error
1141                .lock()
1142                .expect("cell error poisoned")
1143                .clone()
1144            && let Err(err) = callback(&Signal::Error(e))
1145        {
1146            log_err(&id, &err);
1147        }
1148
1149        let sub = Arc::new(ResultSubscriber::new(callback));
1150        // O(1) indexed insert; displaced Arc drops outside the lock. See
1151        // Watchable::subscribe above.
1152        let displaced = self.inner.result_subscribers.lock().insert(id, sub);
1153        drop(displaced);
1154
1155        #[cfg(feature = "metrics")]
1156        if let Some(metrics) = &self.inner.metrics {
1157            metrics.record_subscriber_added();
1158        }
1159        #[cfg(feature = "trace")]
1160        {
1161            let subs_len = self.inner.subscribers.lock().len();
1162            let result_len = self.inner.result_subscribers.lock().len();
1163            crate::tracing::update_subscriber_count(self.inner.id, subs_len + result_len);
1164        }
1165
1166        let source: Arc<dyn DepNode> = Arc::new(self.clone());
1167        let cell = self.clone();
1168        #[cfg(feature = "metrics")]
1169        let metrics = self.inner.metrics.clone();
1170        SubscriptionGuard::new(id, source, move || {
1171            // O(1) indexed remove; removed subscriber drops outside the lock.
1172            // See Watchable::subscribe above.
1173            let removed_sub = cell.inner.result_subscribers.lock().remove(&id);
1174            let removed = removed_sub.is_some();
1175            drop(removed_sub);
1176            #[cfg(feature = "metrics")]
1177            if removed && let Some(m) = &metrics {
1178                m.record_subscriber_removed();
1179            }
1180            #[cfg(not(feature = "metrics"))]
1181            let _ = removed;
1182            #[cfg(feature = "trace")]
1183            {
1184                let subs_len = cell.inner.subscribers.lock().len();
1185                let result_len = cell.inner.result_subscribers.lock().len();
1186                crate::tracing::update_subscriber_count(cell.inner.id, subs_len + result_len);
1187            }
1188        })
1189    }
1190}
1191
1192impl<T: CellValue> Mutable<T> for Cell<T, CellMutable> {
1193    fn set(&self, value: T) {
1194        self.notify(Signal::value(value)); // Wraps in Arc
1195    }
1196
1197    fn complete(&self) {
1198        self.notify(Signal::Complete);
1199    }
1200
1201    fn fail(&self, error: impl Into<anyhow::Error>) {
1202        self.notify(Signal::error(error));
1203    }
1204}
1205
1206// ============================================================================
1207// Inspector feature: DepNode for CellInner + Drop to deregister
1208// ============================================================================
1209
1210#[cfg(feature = "inspector")]
1211impl<T: CellValue> DepNode for CellInner<T> {
1212    fn id(&self) -> Uuid {
1213        self.id
1214    }
1215
1216    fn name(&self) -> Option<String> {
1217        self.name
1218            .lock()
1219            .expect("cell name poisoned")
1220            .as_ref()
1221            .map(|s| s.to_string())
1222    }
1223
1224    fn deps(&self) -> Vec<Arc<dyn DepNode>> {
1225        let mut seen = std::collections::HashSet::new();
1226        self.owned
1227            .iter()
1228            .filter_map(|entry| {
1229                let source = entry.value().source();
1230                let id = source.id();
1231                if seen.insert(id) {
1232                    Some(Arc::clone(source))
1233                } else {
1234                    None
1235                }
1236            })
1237            .collect()
1238    }
1239
1240    fn subscriber_count(&self) -> usize {
1241        self.subscribers.lock().len() + self.result_subscribers.lock().len()
1242    }
1243
1244    fn owned_count(&self) -> usize {
1245        self.owned.len()
1246    }
1247
1248    fn value_debug(&self) -> Option<String> {
1249        let arc = self.value.lock().expect("cell value poisoned").clone();
1250        Some(format!("{:?}", &*arc))
1251    }
1252
1253    fn caller(&self) -> Option<&'static Location<'static>> {
1254        Some(self.caller)
1255    }
1256}
1257
1258impl<T> Drop for CellInner<T> {
1259    fn drop(&mut self) {
1260        #[cfg(feature = "trace")]
1261        crate::tracing::deregister_cell(&self.id);
1262        #[cfg(feature = "inspector")]
1263        crate::registry::registry().deregister(&self.id);
1264    }
1265}
1266
1267#[cfg(all(feature = "metrics", feature = "trace"))]
1268fn default_metrics() -> Option<Arc<CellMetrics>> {
1269    Some(Arc::new(CellMetrics::new()))
1270}
1271
1272#[cfg(all(feature = "metrics", not(feature = "trace")))]
1273fn default_metrics() -> Option<Arc<CellMetrics>> {
1274    None
1275}
1276
1277#[cfg(test)]
1278mod sub_index_tests {
1279    use std::sync::Arc;
1280
1281    use uuid::Uuid;
1282
1283    use super::{SubIndex, SubSnapshot};
1284
1285    // The `Arc<S>` payload stands in for a real subscriber; only identity and
1286    // ref-count matter here, so `i32` is enough.
1287    fn sub(v: i32) -> Arc<i32> {
1288        Arc::new(v)
1289    }
1290
1291    #[test]
1292    fn zero_and_one_stay_inline() {
1293        let mut idx: SubIndex<i32> = SubIndex::Zero;
1294        assert!(matches!(idx, SubIndex::Zero));
1295        assert_eq!(idx.len(), 0);
1296
1297        // First insert → One, no hash table.
1298        assert!(idx.insert(Uuid::new_v4(), sub(1)).is_none());
1299        assert!(matches!(idx, SubIndex::One(..)));
1300        assert_eq!(idx.len(), 1);
1301    }
1302
1303    #[test]
1304    fn same_id_insert_overwrites_and_returns_old() {
1305        let id = Uuid::new_v4();
1306        let mut idx: SubIndex<i32> = SubIndex::Zero;
1307        let first = sub(1);
1308        assert!(idx.insert(id, first.clone()).is_none());
1309
1310        // Re-inserting the same id swaps the Arc and returns the displaced one,
1311        // without promoting to Many.
1312        let displaced = idx.insert(id, sub(2)).expect("old sub returned");
1313        assert!(Arc::ptr_eq(&displaced, &first));
1314        assert!(matches!(idx, SubIndex::One(..)));
1315        assert_eq!(idx.len(), 1);
1316    }
1317
1318    #[test]
1319    fn second_distinct_id_promotes_to_many_keeping_both() {
1320        let (a, b) = (Uuid::new_v4(), Uuid::new_v4());
1321        let mut idx: SubIndex<i32> = SubIndex::Zero;
1322        assert!(idx.insert(a, sub(1)).is_none());
1323        // 1 → 2 promotes; no displacement.
1324        assert!(idx.insert(b, sub(2)).is_none());
1325        assert!(matches!(idx, SubIndex::Many(_)));
1326        assert_eq!(idx.len(), 2);
1327
1328        // Both survive the promotion.
1329        let snap = build_snapshot(&idx);
1330        let ids: Vec<Uuid> = snap.as_slice().iter().map(|(id, _)| *id).collect();
1331        assert!(ids.contains(&a) && ids.contains(&b));
1332    }
1333
1334    #[test]
1335    fn remove_from_one_returns_to_zero() {
1336        let id = Uuid::new_v4();
1337        let mut idx: SubIndex<i32> = SubIndex::Zero;
1338        let s = sub(7);
1339        let _ = idx.insert(id, s.clone());
1340
1341        let removed = idx.remove(&id).expect("present");
1342        assert!(Arc::ptr_eq(&removed, &s));
1343        assert!(matches!(idx, SubIndex::Zero));
1344        assert_eq!(idx.len(), 0);
1345
1346        // Removing a missing id from Zero is a no-op.
1347        assert!(idx.remove(&Uuid::new_v4()).is_none());
1348    }
1349
1350    #[test]
1351    fn remove_wrong_id_from_one_is_noop() {
1352        let mut idx: SubIndex<i32> = SubIndex::Zero;
1353        let _ = idx.insert(Uuid::new_v4(), sub(1));
1354        assert!(idx.remove(&Uuid::new_v4()).is_none());
1355        assert!(matches!(idx, SubIndex::One(..)));
1356        assert_eq!(idx.len(), 1);
1357    }
1358
1359    #[test]
1360    fn many_does_not_demote_when_shrinking() {
1361        let (a, b) = (Uuid::new_v4(), Uuid::new_v4());
1362        let mut idx: SubIndex<i32> = SubIndex::Zero;
1363        let _ = idx.insert(a, sub(1));
1364        let _ = idx.insert(b, sub(2));
1365        assert!(matches!(idx, SubIndex::Many(_)));
1366
1367        // Shrinking back to one subscriber keeps the hash table (no demotion),
1368        // so cells oscillating across the 1/2 boundary don't thrash the alloc.
1369        let _ = idx.remove(&a);
1370        assert_eq!(idx.len(), 1);
1371        assert!(matches!(idx, SubIndex::Many(_)));
1372    }
1373
1374    // Mirror `SubscriberRegistry::snapshot`'s index→snapshot mapping so tests can
1375    // read the contents back out without a full registry.
1376    fn build_snapshot(idx: &SubIndex<i32>) -> SubSnapshot<i32> {
1377        match idx {
1378            SubIndex::Zero => SubSnapshot::Zero,
1379            SubIndex::One(id, s) => SubSnapshot::One((*id, s.clone())),
1380            SubIndex::Many(map) => SubSnapshot::Many(Arc::new(
1381                map.iter().map(|(id, s)| (*id, s.clone())).collect(),
1382            )),
1383        }
1384    }
1385}