Aria 2.0.0
C++23 MVVM framework (C++20 minimum) — reactive, coroutine-first, ABI-layered
Loading...
Searching...
No Matches
graph.inl
Go to the documentation of this file.
1#pragma once
2
3// ============================================================================
4// reactive/graph.inl
5// ----------------------------------------------------------------------------
6// Inline definitions of `Graph` and `Node` methods that depend on each
7// other's complete types.
8//
9// `core` is a header-only INTERFACE library, so these implementations live
10// in a .inl included from a single public header. Every function is marked
11// `inline` to avoid ODR violations across translation units.
12// ============================================================================
13
16
17#include "aria/diagnostics.hpp"
18
19#include <algorithm>
20#include <cassert>
21
22namespace aria::reactive {
23
24// ---------------------------------------------------------------------------
25// The graph storage lives in aria_abi, so every shared module observes the
26// same tracker and flush state. Template operations remain inline here.
27// ---------------------------------------------------------------------------
29
30inline Graph& Node::graph() noexcept { return graph_instance(); }
31
32inline Graph::Graph() noexcept = default;
33
34// ---------------------------------------------------------------------------
35// Node destructor: must detach from every incident edge, otherwise we
36// leave dangling Edge records pointing at freed memory -> UAF.
37// ---------------------------------------------------------------------------
38inline Node::~Node() noexcept {
39 retire_();
40}
41
42inline void Node::retire_() noexcept {
43 // Clear borrowed references before any member/capture destructor can
44 // re-enter the graph. No allocation and no graph-wide queue scan.
45 while (handles_head_) {
46 auto* handle = handles_head_;
47 handles_head_ = handle->next_;
48 handle->node_ = nullptr;
49 handle->previous_ = handle->next_ = nullptr;
50 }
51 // Detach downstream observers (their Edge::source would dangle).
52 while (observers_head_ != nullptr) {
53 Edge* e = observers_head_;
54 // detach_edge also removes `e` from source->observers_head_ below.
55 e->observer->detach_edge(*e);
56 }
57 // Detach our own subscriptions to upstream nodes.
59}
60
61// ---------------------------------------------------------------------------
62// Node: intrusive linked-list manipulation of upstream / downstream edges.
63// ---------------------------------------------------------------------------
64inline void Node::attach_as_observer_of(Node& source, Edge& edge) noexcept {
65 edge.source = &source;
66 edge.observer = this;
67 edge.observed_version = source.version_;
68
69 // Insert at the head of source's observers list.
70 edge.prev_observer = nullptr;
71 edge.next_observer = source.observers_head_;
72 if (source.observers_head_) source.observers_head_->prev_observer = &edge;
73 source.observers_head_ = &edge;
74
75 // Insert at the head of our own sources list.
76 edge.prev_source = nullptr;
77 edge.next_source = sources_head_;
78 if (sources_head_) sources_head_->prev_source = &edge;
79 sources_head_ = &edge;
80
81 // Maintain topological depth (observer depth >= source depth + 1).
82 // Used by flush to sort pending nodes ascending.
83 if (source.depth_ + 1 > depth_) {
84 depth_ = source.depth_ + 1;
85 }
86}
87
88inline void Node::detach_edge(Edge& edge) noexcept {
89 // Unlink from source->observers list.
90 Node* src = edge.source;
91 if (src != nullptr) {
92 if (edge.prev_observer) edge.prev_observer->next_observer = edge.next_observer;
93 else src->observers_head_ = edge.next_observer;
94 if (edge.next_observer) edge.next_observer->prev_observer = edge.prev_observer;
95 }
96 // Unlink from our sources list.
97 if (edge.prev_source) edge.prev_source->next_source = edge.next_source;
98 else sources_head_ = edge.next_source;
99 if (edge.next_source) edge.next_source->prev_source = edge.prev_source;
100
101 edge.source = nullptr;
102 edge.observer = nullptr;
103 edge.next_observer = edge.prev_observer = nullptr;
104 edge.next_source = edge.prev_source = nullptr;
105}
106
107inline void Node::clear_sources() noexcept {
108 while (sources_head_ != nullptr) {
109 detach_edge(*sources_head_);
110 }
111}
112
113// ---------------------------------------------------------------------------
114// Node: a Source signals a value change. Bumps version, colors downstream,
115// and hands off to the Graph to decide whether to flush immediately.
116// ---------------------------------------------------------------------------
117inline void Node::notify_changed() {
118 Graph& g = graph();
120 ++version_;
121 // Sources are authoritative, so downstream starts as MaybeDirty and
122 // will be promoted to Dirty by Graph::pull after comparing versions.
124 g.on_source_changed(*this);
125}
126
128 // Iterative coloring using an explicit stack to avoid blowing the
129 // C++ call stack on very deep DAGs.
130 //
131 // The stack lives on `Graph` so we don't allocate a fresh vector on
132 // every Source commit. Re-entry (e.g. a Reaction whose body itself
133 // commits another Source while a coloring walk is mid-flight) falls
134 // back to a local buffer to keep the outer walk's state intact.
135 Graph& g = graph();
136 std::vector<Node*> local_storage;
137 std::vector<Node*>* stack = nullptr;
138 bool owns_global = false;
139 if (!g.color_in_use_) {
140 g.color_in_use_ = true;
141 owns_global = true;
142 stack = &g.color_stack_;
143 stack->clear();
144 } else {
145 stack = &local_storage;
146 local_storage.reserve(8);
147 }
148 // RAII to release the global slot even if a downstream observer-list
149 // walk somehow throws (it shouldn't — only state writes happen).
150 struct Release {
151 Graph* g; bool owned;
152 ~Release() { if (owned) g->color_in_use_ = false; }
153 } release{&g, owns_global};
154
155 stack->push_back(this);
156 while (!stack->empty()) {
157 Node* cur = stack->back();
158 stack->pop_back();
159
160 for (Edge* e = cur->observers_head_; e != nullptr; e = e->next_observer) {
161 Node* obs = e->observer;
162 if (obs->state_ == NodeState::Clean) {
163 obs->state_ = NodeState::MaybeDirty;
164 stack->push_back(obs);
165 }
166 }
167 }
168}
169
170// ---------------------------------------------------------------------------
171// Graph: source-change event. Enqueue direct downstream nodes; deeper
172// descendants will surface naturally when their parents report a real
173// value change during flush.
174// ---------------------------------------------------------------------------
176 for (Edge* e = src.observers_head_; e != nullptr; e = e->next_observer) {
177 enqueue_dirty(*e->observer);
178 }
179 if (batch_depth_ == 0 && !flushing_) {
180 flush();
181 }
182}
183
184// ---------------------------------------------------------------------------
185// Graph::flush -- topologically-ordered pull, the core glitch-free loop.
186//
187// Algorithm (the Pull half of Push-Pull):
188// 1. Snapshot `pending_`; sort by ascending depth (stable).
189// 2. Pull each node in that order. If the recompute produces a changed
190// value, its downstream is re-colored and enqueued for the next round.
191// 3. Repeat until `pending_` is empty.
192// 4. If the round count exceeds `kMaxFlushRounds`, declare a cycle.
193// ---------------------------------------------------------------------------
194inline void Graph::flush() {
196 if (flushing_) return; // no nested flush; recursive set() rolls over
197 flushing_ = true;
198
199 // Install before copying or calling diagnostics: either can throw.
200 struct Guard {
201 Graph* g;
202 ~Guard() {
203 g->round_.clear();
204 g->flushing_ = false;
205 }
206 } guard{this};
207
208 int rounds = 0;
209
210 // Flush-tracing hook (installed by `GraphInspector`). The empty
211 // `std::function` check is a single null-compare, so this line costs
212 // essentially nothing when diagnostics are not enabled.
213 const auto trace = flush_trace_hook_();
214 const auto trace_phase = [&](int phase, const Node* node, int round, bool changed) {
215 if (!trace) return;
216 try {
217 (*trace)(phase, node, round, changed);
218 } catch (...) {
219 ::aria::report_callback_failure("reactive.flush_tracer", std::current_exception());
220 }
221 };
222
223 // Bridge to the unified diagnostic sink. Same zero-cost
224 // contract: when no sink is installed, `has_trace_sink()` is one
225 // atomic load + null check.
226 auto publish_phase = [](::aria::trace::ReactivePhase phase,
227 const Node* node, int round, bool changed) {
228 if (!::aria::has_trace_sink()) return;
230 phase,
231 node ? node->effective_debug_name() : std::string{},
232 round,
233 changed,
234 };
236 std::move(payload));
237 };
238
239 trace_phase(0 /*FlushBegin*/, nullptr, 0, false);
240 publish_phase(::aria::trace::ReactivePhase::FlushBegin, nullptr, 0, false);
241
242 // A diagnostic callback may retire its node. Re-read the borrowed
243 // handle after each callback, including the unified trace sink.
244 auto trace_node = [&](int phase, ::aria::trace::ReactivePhase category,
245 const detail::NodeHandle& node, bool changed) {
246 if (!node) return;
247 trace_phase(phase, node.get(), rounds, changed);
248 if (node) publish_phase(category, node.get(), rounds, changed);
249 };
250
251 while (!pending_.empty()) {
252 if (++rounds > kMaxFlushRounds) {
253 // Capture a representative cycle path: the remaining pending
254 // nodes sorted by depth form a superset of any ongoing cycle.
255 // We list their debug names (or addresses as a fallback) so
256 // the caller can pinpoint the loop with no inspector needed.
257 std::string detail = "reactive::Graph::flush exceeded "
258 + std::to_string(kMaxFlushRounds)
259 + " rounds -- likely a circular dependency. "
260 + "Pending nodes still dirty:";
261 int printed = 0;
262 for (const auto& n : pending_) {
263 if (!n) continue;
264 if (printed++ >= 16) { detail += " ..."; break; }
265 detail += "\n - ";
266 detail += n->effective_debug_name();
267 }
268 for (const auto& n : pending_) if (n) n->queued_ = false;
269 pending_.clear();
270 throw CircularDependencyError(detail);
271 }
272
273 // Snapshot the current round; new entries during pull land in the
274 // fresh empty `pending_` and will be processed in the next round.
275 round_.swap(pending_);
276 for (const auto& n : round_) if (n) n->queued_ = false;
277
278 // Sort by depth so parents resolve before children (the essence
279 // of glitch-free evaluation), then de-duplicate.
280 std::sort(round_.begin(), round_.end(),
281 [](const auto& a, const auto& b) {
282 if (!a || !b) return !a && static_cast<bool>(b);
283 if (a->depth() != b->depth()) return a->depth() < b->depth();
284 return a->node_id_ < b->node_id_;
285 });
286
287 trace_phase(1 /*RoundBegin*/, nullptr, rounds, false);
288 publish_phase(::aria::trace::ReactivePhase::RoundBegin, nullptr, rounds, false);
289
290 for (const auto& n : round_) {
291 if (!n) continue;
292 // A node may have been pulled by an earlier entry in this
293 // same round (via the upstream recursion inside pull()),
294 // reaching Clean state. Skip those.
295 if (n->state() == NodeState::Clean) {
296 trace_node(3, ::aria::trace::ReactivePhase::SkipClean, n, false);
297 continue;
298 }
299 trace_node(2, ::aria::trace::ReactivePhase::Pull, n, false);
300 if (!n) continue;
301 const bool changed = pull(*n);
302 trace_node(4, ::aria::trace::ReactivePhase::Recomputed, n, changed);
303 }
304
305 trace_phase(5 /*RoundEnd*/, nullptr, rounds, false);
306 publish_phase(::aria::trace::ReactivePhase::RoundEnd, nullptr, rounds, false);
307 round_.clear();
308 }
309
310 trace_phase(6 /*FlushEnd*/, nullptr, rounds, false);
311 publish_phase(::aria::trace::ReactivePhase::FlushEnd, nullptr, rounds, false);
312}
313
314// ---------------------------------------------------------------------------
315// Graph::pull -- evaluate a single node on demand; decide whether to
316// propagate downstream based on whether the value actually changed.
317//
318// Algorithm:
319// 1. If Clean -> nothing to do.
320// 2. If MaybeDirty -> for each upstream, compare versions.
321// - No upstream actually moved -> mark Clean, return false.
322// - At least one moved -> promote to Dirty.
323// 3. If Dirty -> invoke recompute(). A true return indicates the
324// cached value changed, which:
325// a) enqueues direct downstream nodes for the next round;
326// b) leaves `version_` already advanced by recompute().
327// 4. State returns to Clean; `changed` propagates back to the caller.
328//
329// Stack discipline:
330// The MaybeDirty fast-path used to recursively call `pull(*upstream)`
331// on each non-Clean source. On very deep DAGs (thousands of chained
332// Computeds) that recursion could blow the C++ call stack — only the
333// push side used an explicit worklist. We now replicate the same
334// iterative pattern on the pull side: walk MaybeDirty ancestors with
335// an explicit stack, settling them bottom-up, then come back to `n`
336// knowing every upstream is Clean. The recursion-only piece left in
337// the loop is the call to `recompute()`, which itself runs user code
338// that may legitimately read other Computeds (those calls re-enter
339// `pull` once per chain link, but at the height of a *user* read
340// chain, not the height of the whole DAG).
341// ---------------------------------------------------------------------------
342inline bool Graph::pull(Node& n) {
343 if (n.state() == NodeState::Clean) return false;
344 if (n.state() == NodeState::Computing) return pull_settle_(n);
345
346 bool has_unsettled_parent = false;
347 n.for_each_source([&](const Edge& e) {
348 has_unsettled_parent |= e.source->state() == NodeState::MaybeDirty;
349 });
350 if (!has_unsettled_parent) return pull_settle_(n);
351
352 // Only a pull through unresolved ancestors needs a worklist. Each
353 // entered frame marks the active DFS path, so MaybeDirty cycles are
354 // diagnosed before the worklist can grow without bound.
355 struct Frame {
356 explicit Frame(Node* target) noexcept : node(target) {}
357
358 detail::NodeHandle node;
359 bool entered = false;
360 };
361 std::vector<Frame> work;
362 struct VisitGuard {
363 std::vector<Frame>& work;
364 ~VisitGuard() {
365 for (const auto& frame : work) {
366 if (frame.node && frame.entered) frame.node->resolving_ = false;
367 }
368 }
369 } visit_guard{work};
370 work.emplace_back(&n);
371 while (!work.empty()) {
372 if (!work.back().node) { work.pop_back(); continue; }
373 auto& frame = work.back();
374 Node& current = *frame.node;
375 if (!frame.entered) {
376 if (current.resolving_) {
377 std::string path;
378 for (const auto& entry : work) {
379 if (!entry.node) continue;
380 if (!path.empty()) path += " -> ";
381 path += entry.node->effective_debug_name();
382 }
384 "reactive::Graph::pull detected an unresolved dependency cycle: " + path);
385 }
386 current.resolving_ = true;
387 frame.entered = true;
388 }
389
390 Node* unresolved = nullptr;
391 current.for_each_source([&](const Edge& e) {
392 if (!unresolved && e.source->state() == NodeState::MaybeDirty) {
393 unresolved = e.source;
394 }
395 });
396 if (unresolved) {
397 work.emplace_back(unresolved);
398 continue;
399 }
400
401 auto ready = std::move(frame.node);
402 current.resolving_ = false;
403 work.pop_back();
404 const bool changed = pull_settle_(*ready);
405 if (work.empty()) return changed;
406 }
407 return false; // The requested node was retired by an upstream callback.
408}
409
410// ---------------------------------------------------------------------------
411// Graph::pull_settle_ -- the recompute leg of pull(). Assumes every
412// MaybeDirty upstream has already been settled (Clean or Dirty with
413// a definitive version), so the version-comparison fast-path is valid.
414// ---------------------------------------------------------------------------
415inline bool Graph::pull_settle_(Node& n) {
416 if (n.state() == NodeState::Clean) return false;
417
418 // Cycle detection: re-entering an already-Computing node = cycle.
419 if (n.state() == NodeState::Computing) {
420 std::string path;
421 std::size_t cycle_start = pulling_stack_.size();
422 for (std::size_t i = 0; i < pulling_stack_.size(); ++i) {
423 if (pulling_stack_[i].get() == &n) { cycle_start = i; break; }
424 }
425 for (std::size_t i = cycle_start; i < pulling_stack_.size(); ++i) {
426 if (!pulling_stack_[i]) continue;
427 path += pulling_stack_[i]->effective_debug_name();
428 path += " -> ";
429 }
430 path += n.effective_debug_name();
431 throw CircularDependencyError(
432 "reactive::Graph::pull detected re-entrant computation; cycle path: "
433 + path);
434 }
435
436 // MaybeDirty with all parents settled: cheap version-compare path.
437 if (n.state() == NodeState::MaybeDirty) {
438 bool any_upstream_changed = false;
439 n.for_each_source([&](const Edge& e) {
440 if (e.source->state() == NodeState::Computing ||
441 e.source->version() != e.observed_version) {
442 any_upstream_changed = true;
443 }
444 });
445 if (!any_upstream_changed) {
447 return false;
448 }
449 n.mark_dirty();
450 }
451
452 // Dirty: perform the actual recomputation. Keep pulling_stack_
453 // consistent across exceptions via a small RAII guard.
454 // Keep self-cancelling reactions and their captures alive through all
455 // post-callback state writes, exception handling and propagation.
456 auto keep_alive = n.retain_for_recompute();
457 detail::NodeHandle target{&n};
458 pulling_stack_.emplace_back(&n);
459 struct StackGuard {
460 std::vector<detail::NodeHandle>* stack;
461 ~StackGuard() { if (stack) stack->pop_back(); }
462 } guard{&pulling_stack_};
463
465 bool changed = false;
466 try {
467 changed = n.recompute();
468 } catch (...) {
469 // Leave the node in a recoverable state before unwinding.
470 if (target) target->set_state_(NodeState::Clean);
471 throw;
472 }
473 if (!target) return false;
475
476 if (changed) {
477 // Value actually moved -> seed the next round with our downstream.
479 n.for_each_observer([&](Edge& e) {
480 this->enqueue_dirty(*e.observer);
481 });
482 }
483 return changed;
484}
485
486} // namespace aria::reactive
Thrown when flush detects a dependency cycle.
Definition graph.hpp:63
Process-wide singleton reactive graph (accessed via Node::graph()).
Definition graph.hpp:139
void on_source_changed(Node &src)
Called after a Source has committed a new value: bumps its version and colors the downstream MaybeDir...
Definition graph.inl:175
void enqueue_dirty(Node &n)
Enqueue a node into the current flush round's "pending" set.
Definition graph.hpp:203
friend class Node
Definition graph.hpp:145
bool pull(Node &n)
Force-evaluate a single node if it is Dirty / MaybeDirty.
Definition graph.inl:342
void assert_on_graph_thread() const noexcept
Definition graph.hpp:155
void flush()
Evaluate every dirty node in topological order.
Definition graph.inl:194
Common base for every node participating in the reactive graph.
Definition node.hpp:136
const std::string & effective_debug_name() const
A non-empty debug label for diagnostic output.
Definition node.hpp:172
void mark_dirty() noexcept
Escalate state to Dirty (used by Graph::pull after confirming an upstream has truly moved).
Definition node.hpp:204
static Graph & graph() noexcept
Returns the process-wide singleton Graph this node belongs to.
Definition graph.inl:30
virtual std::shared_ptr< Node > retain_for_recompute() noexcept
Reactions are shared-owned and may cancel themselves while running.
Definition node.hpp:220
void notify_changed()
Called by a Source after its value has actually changed: bumps the version and colors all downstream ...
Definition graph.inl:117
Node(NodeKind kind) noexcept
Definition node.hpp:144
void detach_edge(Edge &edge) noexcept
Definition graph.inl:88
void for_each_source(F &&f) const
Iterate the source list (used by a Derivation in pull() to compare each upstream's current version ag...
Definition node.hpp:243
void for_each_observer(F &&f)
Iterate the observer list (used by Graph to color downstream nodes).
Definition node.hpp:234
void retire_() noexcept
Retire before derived members (including user captures) are destroyed.
Definition graph.inl:42
void mark_downstream_maybe_dirty()
Mark self and all reachable descendants as MaybeDirty.
Definition graph.inl:127
void attach_as_observer_of(Node &source, Edge &edge) noexcept
Definition graph.inl:64
virtual bool recompute()
Recompute hook for Derivation / Reaction nodes.
Definition node.hpp:216
NodeState state() const noexcept
Definition node.hpp:154
void set_state_(NodeState s) noexcept
Definition node.hpp:259
void clear_sources() noexcept
Drop every upstream edge.
Definition graph.inl:107
friend class Graph
Definition node.hpp:140
#define ARIA_ABI_API
Definition export.hpp:21
Definition computed.hpp:60
@ Computing
Definition node.hpp:106
@ Clean
Definition node.hpp:103
@ MaybeDirty
Definition node.hpp:104
Graph & graph_instance() noexcept
std::shared_ptr< FlushTraceFn > & flush_trace_hook_() noexcept
Definition diagnostics.hpp:95
ReactivePhase
Reactive flush phases.
Definition diagnostics.hpp:101
@ SkipClean
Definition diagnostics.hpp:105
@ RoundBegin
Definition diagnostics.hpp:103
@ FlushBegin
Definition diagnostics.hpp:102
@ Recomputed
Definition diagnostics.hpp:106
@ Pull
Definition diagnostics.hpp:104
@ RoundEnd
Definition diagnostics.hpp:107
@ FlushEnd
Definition diagnostics.hpp:108
void publish_trace_unchecked(const TraceEvent &event) noexcept
Publish an already-built event using one owning sink snapshot.
Definition diagnostics.hpp:293
bool has_trace_sink() noexcept
True iff a sink is currently installed.
Definition diagnostics.hpp:286
@ Reactive
Graph flush, push-color, pull-evaluate.
Definition diagnostics.hpp:67
void report_callback_failure(std::string_view category, std::exception_ptr exception, std::string_view message={}) noexcept
Report a callback failure.
A single dependency edge: (upstream source) -> (downstream observer).
Definition node.hpp:113
Node * observer
The downstream node that depends on it.
Definition node.hpp:115
Edge * prev_observer
Definition node.hpp:120
Edge * next_observer
Thread in the source's "observers" list (from the source's point of view: "these are the nodes watchi...
Definition node.hpp:119
Node * source
The upstream node being observed.
Definition node.hpp:114
Definition diagnostics.hpp:111