Aria 2.0.0
C++23 MVVM framework (C++20 minimum) — reactive, coroutine-first, ABI-layered
Loading...
Searching...
No Matches
graph.hpp
Go to the documentation of this file.
1#pragma once
2
3// ============================================================================
4// reactive/graph.hpp
5// ----------------------------------------------------------------------------
6// `Graph` is the process-wide coordinator of the reactive subsystem
7// (one instance per process, reached via `Node::graph()`).
8//
9// The Graph provides four things:
10// 1. Transactions (batches). Multiple Property writes coalesce into a
11// single flush, notifying the UI exactly once -- eliminating the
12// "3 setters -> 3 repaints" problem entirely.
13// 2. Push phase (coloring). A source change propagates MaybeDirty down
14// the DAG without triggering any computation.
15// 3. Pull phase (evaluation). At flush time the affected nodes are
16// evaluated in topological order (ascending depth); nodes whose
17// upstreams did not actually move are skipped, which is what makes
18// the graph glitch-free.
19// 4. Tracking context. While a Derivation computes, every reactive read
20// it performs is recorded automatically: `Property::get()` and
21// `Computed::get()` call into the active TrackingContext themselves,
22// so a Computed never declares its dependency list. The Graph turns
23// the recorded reads into Edges and reconciles the edge set after each
24// compute, which is what lets a dependency set change shape between
25// evaluations.
26//
27// `dep(x)` exists as an explicit escape hatch for the case where a
28// value must be depended upon without its `get()` appearing in the
29// compute body; `reactive::untracked` is the inverse, suppressing
30// recording for reads inside its scope.
31//
32// Threading model
33// ---------------
34// Single-threaded (UI thread). Every public Graph entry point asserts the
35// calling thread in Debug builds. Cross-thread updates must be posted via
36// the Dispatcher to the graph thread before invoking any API here.
37//
38// Cycles
39// ------
40// If flush encounters a dependency cycle B -> ... -> B, it raises
41// `CircularDependencyError` instead of spinning or overflowing the stack.
42// ============================================================================
43
46
47#include <algorithm>
48#include <cassert>
49#include <exception>
50#include <functional>
51#include <stdexcept>
52#include <string>
53#include <string_view>
54#include <thread>
55#include <unordered_set>
56#include <utility>
57#include <vector>
58
59namespace aria::reactive {
60
63class CircularDependencyError : public std::runtime_error {
64public:
65 using std::runtime_error::runtime_error;
66};
67
68// ---------------------------------------------------------------------------
69// Flush tracing hook (used by `aria::GraphInspector`).
70// We keep the type minimal here so `graph.hpp` does not have to know
71// anything about the inspector; `inspector.hpp` packages these raw
72// parameters into a nicer `FlushEvent` struct for user callbacks.
73//
74// Phase codes are stable integers so the ABI of the hook never breaks
75// across versions:
76// 0 = FlushBegin (node = nullptr)
77// 1 = RoundBegin (node = nullptr, round = 1..N)
78// 2 = Pull (node = n, round = 1..N)
79// 3 = SkipClean (node = n, round = 1..N)
80// 4 = Recomputed (node = n, round, changed)
81// 5 = RoundEnd (node = nullptr, round)
82// 6 = FlushEnd (node = nullptr)
83// ---------------------------------------------------------------------------
84using FlushTraceFn = std::function<void(int phase,
85 const Node* node,
86 int round,
87 bool changed)>;
88
89// Registration and use are confined to the graph thread. Shared ownership
90// preserves a running tracer and its state across replacement/reentrant clear.
91ARIA_ABI_API std::shared_ptr<FlushTraceFn>& flush_trace_hook_() noexcept;
92
98struct ReadRecord {
100};
101
104public:
105 using Buffer = std::vector<detail::NodeHandle>;
106
107 TrackingContext() = default;
108 explicit TrackingContext(Buffer& reusable) noexcept : reusable_(&reusable) {
109 reads_.swap(reusable);
110 }
112 if (reusable_) {
113 reads_.clear();
114 reads_.swap(*reusable_);
115 }
116 }
119
122 void record_read(Node& src) {
123 // Small read sets -- linear de-dup is more than fast enough.
124 for (const auto& n : reads_) {
125 if (n.get() == &src) return;
126 }
127 reads_.emplace_back(&src);
128 }
129
130 [[nodiscard]] const std::vector<detail::NodeHandle>& reads() const noexcept { return reads_; }
131 void clear() noexcept { reads_.clear(); }
132
133private:
134 Buffer reads_;
135 Buffer* reusable_ = nullptr;
136};
137
139class Graph {
140 // `Node::mark_downstream_maybe_dirty()` reaches into `color_stack_`
141 // and `color_in_use_` to amortise the coloring buffer across
142 // notifications. Symmetric to `Node`'s existing `friend class
143 // Graph;` declaration — together they keep the intrusive coupling
144 // between the two header-only types contained.
145 friend class Node;
146
147public:
148 Graph() noexcept;
149 ~Graph() noexcept = default;
150
151 Graph(const Graph&) = delete;
152 Graph& operator=(const Graph&) = delete;
153
154 // ---- Debug thread assertion (no-op in Release) -----------------------
155 void assert_on_graph_thread() const noexcept {
156#ifndef NDEBUG
157 // First call records the thread; later calls must match.
158 if (owner_thread_ == std::thread::id{}) {
159 const_cast<Graph*>(this)->owner_thread_ = std::this_thread::get_id();
160 return;
161 }
162 assert(owner_thread_ == std::this_thread::get_id()
163 && "reactive::Graph accessed from a non-UI thread. "
164 "Use Dispatcher::post to marshal updates back to the graph thread.");
165#endif
166 }
167
168 // ------------------------------------------------------------------
169 // Transactions (batches)
170 // ------------------------------------------------------------------
171
173 void begin_batch() noexcept {
175 ++batch_depth_;
176 }
177
180 void end_batch() {
182 assert(batch_depth_ > 0 && "end_batch without matching begin_batch");
183 if (--batch_depth_ == 0 && !flushing_) {
184 flush();
185 }
186 }
187
188 [[nodiscard]] bool in_batch() const noexcept { return batch_depth_ > 0; }
189
190 // ------------------------------------------------------------------
191 // Push phase -- invoked when a Source's value changes.
192 // ------------------------------------------------------------------
193
198 void on_source_changed(Node& src);
199
204 if (n.queued_) return;
205 pending_.emplace_back(&n);
206 n.queued_ = true;
207 }
208
209 // ------------------------------------------------------------------
210 // Pull phase -- flush and evaluation
211 // ------------------------------------------------------------------
212
217 void flush();
218
221 bool pull(Node& n);
222
223 // ------------------------------------------------------------------
224 // Tracking
225 // ------------------------------------------------------------------
226
227 [[nodiscard]] TrackingContext* current_tracker() noexcept {
228 return tracker_stack_.empty() ? nullptr : tracker_stack_.back();
229 }
230
232 void push_tracker(TrackingContext* ctx) { tracker_stack_.push_back(ctx); }
234 assert(!tracker_stack_.empty() && tracker_stack_.back() == ctx);
235 (void)ctx;
236 tracker_stack_.pop_back();
237 }
238
242 void enter_untracked() { tracker_stack_.push_back(nullptr); }
244 assert(!tracker_stack_.empty() && tracker_stack_.back() == nullptr);
245 tracker_stack_.pop_back();
246 }
247
248private:
249 // Inner helper used by `pull`: assumes every MaybeDirty upstream of
250 // `n` has already been settled, and runs the version-compare /
251 // recompute leg without recursing into upstream resolution. The
252 // public `pull` first walks the graph iteratively to reach this
253 // precondition, then calls `pull_settle_` for each node bottom-up.
254 bool pull_settle_(Node& n);
255
256 // Reusable scratch buffer for `Node::mark_downstream_maybe_dirty()`.
257 // The coloring walk used to allocate a fresh `std::vector<Node*>`
258 // every time a Source committed a value — which is millions of
259 // allocations per second on hot UI workloads. Hoisting it into the
260 // (single-threaded) graph lets us amortise the buffer across every
261 // notify, capacity grows monotonically to the deepest fan-out
262 // observed and is reused thereafter. Exposed only to `Node` via the
263 // existing `friend class Graph` declaration.
264 std::vector<Node*> color_stack_;
265 bool color_in_use_ = false; // re-entrancy guard
266
267 // Guard against nested flush calls. Recursive set() re-enqueues into
268 // the next round instead of starting a new flush.
269 bool flushing_ = false;
270
271 int batch_depth_ = 0;
272
273 // A node has at most one entry in pending_. Snapshotting the next
274 // round resets queued_ before user code runs, permitting a new pulse.
275 std::vector<detail::NodeHandle> pending_;
276 std::vector<detail::NodeHandle> round_;
277
278 // Active recursive `pull()` stack. When a cycle trips the
279 // `Computing` re-entry check we use this to format a full
280 // A → B → C → A path in the error message — vastly more useful
281 // than just "node X is re-entering itself".
282 std::vector<detail::NodeHandle> pulling_stack_;
283
284 // Tracker stack. `nullptr` entries denote untracked scopes.
285 std::vector<TrackingContext*> tracker_stack_;
286
287 std::thread::id owner_thread_{};
288
289 // Safety fuse against runaway propagation (which should only happen
290 // when there is a bug in a user-supplied recompute function).
291 static constexpr int kMaxFlushRounds = 100;
292};
293
294// ------------------------------------------------------------------
295// Convenience API -- user-facing entry points
296// ------------------------------------------------------------------
297
300[[nodiscard]] inline TrackingContext* current_tracker() noexcept {
301 return Node::graph().current_tracker();
302}
303
306public:
307 explicit TrackerScope(TrackingContext& ctx) : ctx_(&ctx) {
309 }
311 TrackerScope(const TrackerScope&) = delete;
313private:
314 TrackingContext* ctx_;
315};
316
325
328template<class Fn>
329auto untracked(Fn&& fn) -> decltype(fn()) {
330 UntrackedScope guard;
331 if constexpr (std::is_void_v<decltype(fn())>) {
332 std::forward<Fn>(fn)();
333 } else {
334 return std::forward<Fn>(fn)();
335 }
336}
337
347public:
349 ~BatchScope() noexcept {
350 try {
352 } catch (...) {
353 // Avoid std::terminate during unwinding, but do not make
354 // the failure invisible: route it through the lightweight
355 // callback-failure sink (stderr fallback until the host
356 // installs its own sink). Keep this dependency in core so
357 // the header remains portable across GCC/Clang/MSVC.
359 std::string_view{"reactive.batch_scope.end_batch"},
360 std::current_exception());
361 }
362 }
363 BatchScope(const BatchScope&) = delete;
364 BatchScope& operator=(const BatchScope&) = delete;
365};
366
368template<class Fn>
369auto batch(Fn&& fn) -> decltype(fn()) {
370 BatchScope guard;
371 if constexpr (std::is_void_v<decltype(fn())>) {
372 std::forward<Fn>(fn)();
373 } else {
374 return std::forward<Fn>(fn)();
375 }
376}
377
378} // namespace aria::reactive
BatchScope()
Definition graph.hpp:348
RAII batch guard: { BatchScope b; ...; } or use batch([&]{...}).
Definition graph.hpp:346
BatchScope(const BatchScope &)=delete
BatchScope & operator=(const BatchScope &)=delete
BatchScope()
Definition graph.hpp:348
~BatchScope() noexcept
Definition graph.hpp:349
Thrown when flush detects a dependency cycle.
Definition graph.hpp:63
TrackingContext * current_tracker() noexcept
Definition graph.hpp:227
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
void pop_tracker(TrackingContext *ctx)
Definition graph.hpp:233
friend class Node
Definition graph.hpp:145
bool in_batch() const noexcept
Definition graph.hpp:188
void push_tracker(TrackingContext *ctx)
Push / pop a tracker (used by RAII TrackerScope).
Definition graph.hpp:232
void begin_batch() noexcept
Open a new batch. Nesting is legal; the outermost close flushes.
Definition graph.hpp:173
void enter_untracked()
Enter / leave an untracked scope.
Definition graph.hpp:242
bool pull(Node &n)
Force-evaluate a single node if it is Dirty / MaybeDirty.
Definition graph.inl:342
void end_batch()
Close the current batch; triggers a flush once the outermost batch closes (unless we are already insi...
Definition graph.hpp:180
void leave_untracked()
Definition graph.hpp:243
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
static Graph & graph() noexcept
Returns the process-wide singleton Graph this node belongs to.
Definition graph.inl:30
~TrackerScope()
Definition graph.hpp:310
TrackerScope & operator=(const TrackerScope &)=delete
TrackerScope(TrackingContext &ctx)
Definition graph.hpp:307
TrackerScope(const TrackerScope &)=delete
Per-recompute tracking context for a single Derivation evaluation.
Definition graph.hpp:103
std::vector< detail::NodeHandle > Buffer
Definition graph.hpp:105
void clear() noexcept
Definition graph.hpp:131
~TrackingContext()
Definition graph.hpp:111
TrackingContext(Buffer &reusable) noexcept
Definition graph.hpp:108
TrackingContext & operator=(const TrackingContext &)=delete
void record_read(Node &src)
Records one upstream read.
Definition graph.hpp:122
TrackingContext(const TrackingContext &)=delete
const std::vector< detail::NodeHandle > & reads() const noexcept
Definition graph.hpp:130
RAII: within the scope, every dep() behaves like a plain get().
Definition graph.hpp:318
UntrackedScope & operator=(const UntrackedScope &)=delete
~UntrackedScope()
Definition graph.hpp:321
UntrackedScope()
Definition graph.hpp:320
UntrackedScope(const UntrackedScope &)=delete
#define ARIA_ABI_API
Definition export.hpp:21
Definition computed.hpp:60
auto batch(Fn &&fn) -> decltype(fn())
Sugar: batch([&]{ firstName = "..."; lastName = "..."; }).
Definition graph.hpp:369
auto untracked(Fn &&fn) -> decltype(fn())
Sugar: untracked([&]{ ... }).
Definition graph.hpp:329
std::function< void(int phase, const Node *node, int round, bool changed)> FlushTraceFn
Definition graph.hpp:84
std::shared_ptr< FlushTraceFn > & flush_trace_hook_() noexcept
TrackingContext * current_tracker() noexcept
Returns the current tracker of the global graph.
Definition graph.hpp:300
void report_callback_failure(std::string_view category, std::exception_ptr exception, std::string_view message={}) noexcept
Report a callback failure.
One "upstream read" record.
Definition graph.hpp:98
Node * source
The upstream node that was read.
Definition graph.hpp:99