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::sync::Arc;
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).
34pub type TopologySink = Arc<dyn Fn(&TopologyEvent)>;
35
36/// Identifier for a topology subscription (S2b / D225). Returned by
37/// [`super::node::Core::subscribe_topology`]; pass it to
38/// [`super::node::Core::unsubscribe_topology`] to deregister. The
39/// core-level RAII `TopologySubscription` is retired for the same
40/// reason as [`crate::node::SubscriptionId`] (D223: owned relocatable
41/// `Core`, no parameterless-`Drop` reach). Binding-layer RAII wraps
42/// `unsubscribe_topology` where the holder co-owns the `Core`.
43pub type TopologySubscriptionId = u64;
44
45/// Deregister topology sink `id`. Shared body (D225 S2a) for the
46/// synchronous owner-invoked [`super::node::Core::unsubscribe_topology`].
47/// Operates on `&C` directly — it never needed a `Core`.
48pub(crate) fn unsubscribe_topology_sink(core: &crate::node::Core, id: u64) {
49 // D246/S2c: `topology_sinks` lives in the `CoreShared` region
50 // (`St`'s `.shared` field).
51 let mut s = crate::node::St::new(core);
52 s.shared.topology_sinks.remove(&id);
53}
54
55impl super::node::Core {
56 /// Subscribe to topology changes. The sink fires synchronously
57 /// from the registration / teardown / `set_deps` call site, under
58 /// no Core lock (the state lock is dropped before firing). Sinks
59 /// MAY re-enter Core (`register_*`, `teardown`, `set_deps`, etc.)
60 /// — the lock-released discipline (Slice A close) makes this safe.
61 ///
62 /// Returns a [`TopologySubscriptionId`]; pass it to
63 /// [`Self::unsubscribe_topology`] to deregister (S2b / D225: core
64 /// RAII retired — binding-layer RAII wraps `unsubscribe_topology`).
65 ///
66 /// # Event semantics
67 ///
68 /// - `NodeRegistered(id)` fires from `register_state` /
69 /// `register_computed`. The Core has finished installing the node
70 /// record but a Graph-layer namespace name (if any) is NOT yet in
71 /// place — the sink runs while the caller (`Graph::add`) is still
72 /// between Core insert and namespace insert. Sinks calling
73 /// `graph.name_of(id)` from this event will see `None`. Use the
74 /// Graph-level [`crate::node::Core`]-paired namespace-change hook
75 /// (graphrefly-graph) for namespace-aware reactivity.
76 /// - `NodeTornDown(id)` fires for the root teardown AND for every
77 /// meta companion + downstream consumer that auto-cascades. One
78 /// `Core::teardown(root)` call may produce many events.
79 /// - `DepsChanged { ... }` fires only when `set_deps` actually
80 /// rewires deps. The idempotent fast-path (deps unchanged as a
81 /// set) returns without firing.
82 pub fn subscribe_topology(&self, sink: TopologySink) -> TopologySubscriptionId {
83 let mut s = self.lock_state();
84 let id = s.shared.next_topology_id;
85 s.shared.next_topology_id += 1;
86 s.shared.topology_sinks.insert(id, sink);
87 id
88 }
89
90 /// Synchronous owner-invoked topology unsubscribe (D225 refined A2).
91 /// Symmetric with `subscribe_topology`; the binding-layer / embedder
92 /// RAII wrapper calls this on `Drop` (it holds the `Core` on its
93 /// affinity worker). Idempotent — removing an absent id is a no-op.
94 pub fn unsubscribe_topology(&self, id: TopologySubscriptionId) {
95 unsubscribe_topology_sink(self, id);
96 }
97
98 /// Fire topology event to all registered sinks. Called from
99 /// registration, teardown, and `set_deps` sites AFTER the state
100 /// lock is dropped.
101 pub(crate) fn fire_topology_event(&self, event: &TopologyEvent) {
102 // Step 2a (D220-EXEC): `topology_sinks` is pure-shared — take
103 // ONLY the `CoreShared` region (no shard lock needed).
104 let sinks: Vec<TopologySink> =
105 self.with_shared(|sh| sh.topology_sinks.values().cloned().collect());
106 for sink in sinks {
107 sink(event);
108 }
109 }
110}