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}