Skip to main content

graphrefly_core/
topology.rs

1//! Core-level topology-change notification primitive.
2//!
3//! The topology sink receives events when nodes are registered,
4//! torn down, or have their deps mutated. This is the substrate
5//! for reactive `describe()` and reactive `observe()` at the
6//! graph layer.
7//!
8//! Topology sinks are NOT nodes — they sit outside the reactive
9//! graph to avoid circularity (registering an observer node
10//! would itself be a topology change).
11
12use std::rc::Rc;
13
14use crate::handle::NodeId;
15
16/// What changed in the topology.
17#[derive(Debug, Clone)]
18pub enum TopologyEvent {
19    /// A new node was registered (state, derived, or dynamic).
20    NodeRegistered(NodeId),
21    /// A node received TEARDOWN (terminal destruction).
22    NodeTornDown(NodeId),
23    /// A node's deps were atomically replaced via `set_deps`.
24    DepsChanged {
25        node: NodeId,
26        old_deps: Vec<NodeId>,
27        new_deps: Vec<NodeId>,
28    },
29}
30
31/// Callback for topology changes. D246/S2c/D248: single-owner ⇒ no
32/// `Send + Sync` (fires owner-side; the bound was shared-Core-era
33/// legacy). D272 (2026-05-21): switched the outer container from
34/// `Arc` to `Rc` to match the single-owner-thread shape and drop the
35/// `clippy::arc_with_non_send_sync` lints. The `assert_not_impl_any!`
36/// below locks D248 intent at the type system.
37pub type TopologySink = Rc<dyn Fn(&TopologyEvent)>;
38
39static_assertions::assert_not_impl_any!(TopologySink: Send, Sync);
40
41/// Identifier for a topology subscription (S2b / D225). Returned by
42/// [`super::node::Core::subscribe_topology`]; pass it to
43/// [`super::node::Core::unsubscribe_topology`] to deregister. The
44/// core-level RAII `TopologySubscription` is retired for the same
45/// reason as [`crate::node::SubscriptionId`] (D223: owned relocatable
46/// `Core`, no parameterless-`Drop` reach). Binding-layer RAII wraps
47/// `unsubscribe_topology` where the holder co-owns the `Core`.
48pub type TopologySubscriptionId = u64;
49
50/// Deregister topology sink `id`. Shared body (D225 S2a) for the
51/// synchronous owner-invoked [`super::node::Core::unsubscribe_topology`].
52/// Operates on `&C` directly — it never needed a `Core`.
53pub(crate) fn unsubscribe_topology_sink(core: &crate::node::Core, id: u64) {
54    // D246/S2c: `topology_sinks` lives in the `CoreShared` region
55    // (`St`'s `.shared` field).
56    let mut s = crate::node::St::new(core);
57    s.shared.topology_sinks.remove(&id);
58}
59
60impl super::node::Core {
61    /// Subscribe to topology changes. The sink fires synchronously
62    /// from the registration / teardown / `set_deps` call site, under
63    /// no Core lock (the state lock is dropped before firing). Sinks
64    /// MAY re-enter Core (`register_*`, `teardown`, `set_deps`, etc.)
65    /// — the lock-released discipline (Slice A close) makes this safe.
66    ///
67    /// Returns a [`TopologySubscriptionId`]; pass it to
68    /// [`Self::unsubscribe_topology`] to deregister (S2b / D225: core
69    /// RAII retired — binding-layer RAII wraps `unsubscribe_topology`).
70    ///
71    /// # Event semantics
72    ///
73    /// - `NodeRegistered(id)` fires from `register_state` /
74    ///   `register_computed`. The Core has finished installing the node
75    ///   record but a Graph-layer namespace name (if any) is NOT yet in
76    ///   place — the sink runs while the caller (`Graph::add`) is still
77    ///   between Core insert and namespace insert. Sinks calling
78    ///   `graph.name_of(id)` from this event will see `None`. Use the
79    ///   Graph-level [`crate::node::Core`]-paired namespace-change hook
80    ///   (graphrefly-graph) for namespace-aware reactivity.
81    /// - `NodeTornDown(id)` fires for the root teardown AND for every
82    ///   meta companion + downstream consumer that auto-cascades. One
83    ///   `Core::teardown(root)` call may produce many events.
84    /// - `DepsChanged { ... }` fires only when `set_deps` actually
85    ///   rewires deps. The idempotent fast-path (deps unchanged as a
86    ///   set) returns without firing.
87    pub fn subscribe_topology(&self, sink: TopologySink) -> TopologySubscriptionId {
88        let mut s = self.lock_state();
89        let id = s.shared.next_topology_id;
90        s.shared.next_topology_id += 1;
91        s.shared.topology_sinks.insert(id, sink);
92        id
93    }
94
95    /// Synchronous owner-invoked topology unsubscribe (D225 refined A2).
96    /// Symmetric with `subscribe_topology`; the binding-layer / embedder
97    /// RAII wrapper calls this on `Drop` (it holds the `Core` on its
98    /// affinity worker). Idempotent — removing an absent id is a no-op.
99    pub fn unsubscribe_topology(&self, id: TopologySubscriptionId) {
100        unsubscribe_topology_sink(self, id);
101    }
102
103    /// Fire topology event to all registered sinks. Called from
104    /// registration, teardown, and `set_deps` sites AFTER the state
105    /// lock is dropped.
106    pub(crate) fn fire_topology_event(&self, event: &TopologyEvent) {
107        // Step 2a (D220-EXEC): `topology_sinks` is pure-shared — take
108        // ONLY the `CoreShared` region (no shard lock needed).
109        let sinks: Vec<TopologySink> =
110            self.with_shared(|sh| sh.topology_sinks.values().cloned().collect());
111        for sink in sinks {
112            sink(event);
113        }
114    }
115}