Aria 2.0.0
C++23 MVVM framework (C++20 minimum) — reactive, coroutine-first, ABI-layered
Loading...
Searching...
No Matches
async_resource.hpp
Go to the documentation of this file.
1#pragma once
2
3// AsyncResource<T> -- SWR / TanStack-Query style data caching for aria.
4//
5// Task<UserProfile> fetch_profile(Api& api, int id) { co_return co_await api.fetch_user(id); }
6// AsyncResource<UserProfile> profile{
7// ui_executor, net_pool,
8// [&](int id) { return fetch_profile(api, id); }
9// };
10//
11// // View binds the four observable fields:
12// auto sub_loading = profile.is_loading.bind([](bool b){ spinner.visible = b; });
13// auto sub_error = profile.error.on_changed([](auto& e){ if (e) toast(e->message); });
14// auto sub_data = profile.data.on_changed([](auto& opt){ if (opt) render(*opt); });
15//
16// profile.fetch(42); // first call -- kicks off the fetch
17// profile.fetch(42); // de-duped -- same key, no extra request
18// profile.fetch(99); // different key -- new fetch
19// profile.invalidate(); // mark dirty; next fetch() will refetch even with same key
20// profile.refresh(); // force refetch with current key
21//
22// Observable surface (per docs/error-model.md):
23//
24// - is_loading : Property<bool>
25// - error : Property<std::optional<aria::Error>> (nullopt when fine)
26// - error_message : Property<std::string> ("" when fine)
27// - data : Property<std::optional<T>>
28//
29// SWR niceties (unchanged):
30// - last successful result is kept in `data` while a refresh is in flight
31// (the "stale-while-revalidate" pattern), so the UI doesn't flash empty.
32// - in-flight requests with the same key are deduped (only one network hit).
33// - by default keys are compared via `==`; pass a custom `KeyEq` for
34// fancier semantics.
35
38#include "aria/async/task.hpp"
39#include "aria/async/timeout.hpp" // TimeoutError detection in error mapping
40#include "aria/diagnostics.hpp"
41#include "aria/error.hpp"
42#include "aria/loadable.hpp"
43#include "aria/property.hpp"
44
45#include <atomic>
46#include <cstdint>
47#include <functional>
48#include <memory>
49#include <optional>
50#include <string>
51#include <utility>
52
53namespace aria::async {
54
55namespace detail {
56
57template<typename T, typename Key>
58struct AsyncResourceState {
59 IExecutor* ui;
60 IExecutor* worker;
61 CancellationSource cancel;
62
63 // Public observable state (lives here so coroutines can mutate it
64 // even after the outer AsyncResource has been destroyed).
65 Property<bool> is_loading{false};
66 Property<std::optional<::aria::Error>> error{std::nullopt};
67 Property<std::string> error_message{""};
68 Property<std::optional<T>> data{std::optional<T>{}};
69 // Five-state loadable view-model. Updated whenever any of
70 // (is_loading / data / error) is written. See `loadable.hpp`
71 // for the LO-N protocol. Always kept consistent with the
72 // four primitive Properties above; observers may bind to either
73 // surface depending on whether they want raw fields or the
74 // collapsed sum-type.
75 Property<::aria::Loadable<T>> loadable{::aria::Loadable<T>::idle()};
76
77 // Book-keeping
78 Key current_key{};
79 bool has_key{false};
80 std::atomic<bool> dirty{false};
81 std::atomic<bool> in_flight{false};
82 std::atomic<std::uint64_t> gen{0};
83
84 AsyncResourceState(IExecutor& u, IExecutor& w) : ui(&u), worker(&w) {}
85
91 void recompute_loadable_() {
92 const bool loading_now = is_loading.peek();
93 const std::optional<T> d = data.peek();
94 const std::optional<::aria::Error> e = error.peek();
95
96 if (e.has_value()) {
97 // Error state -- preserve last-good value (SWR) if any.
98 if (d.has_value()) {
99 loadable.set(::aria::Loadable<T>::error(*e, *d));
100 } else {
101 loadable.set(::aria::Loadable<T>::error(*e));
102 }
103 return;
104 }
105 if (loading_now) {
106 if (d.has_value()) {
107 loadable.set(::aria::Loadable<T>::refreshing(*d));
108 } else {
109 loadable.set(::aria::Loadable<T>::loading());
110 }
111 return;
112 }
113 if (d.has_value()) {
114 loadable.set(::aria::Loadable<T>::success(*d));
115 return;
116 }
117 loadable.set(::aria::Loadable<T>::idle());
118 }
119};
120
121} // namespace detail
122
123template<typename T, typename Key = int>
125 using State = detail::AsyncResourceState<T, Key>;
126 std::shared_ptr<State> state_;
127
128public:
129 using Fetcher = std::function<Task<T>(Key)>;
130
131private:
132 Fetcher fetcher_;
133
134public:
136 : state_(std::make_shared<detail::AsyncResourceState<T, Key>>(ui, worker)),
137 fetcher_(std::move(fetcher)),
138 is_loading (state_->is_loading),
139 error (state_->error),
141 data (state_->data),
142 loadable (state_->loadable) {}
143
144 ~AsyncResource() { state_->cancel.cancel(); }
145
146 AsyncResource(const AsyncResource&) = delete;
148
152 void fetch(Key key) {
153 bool same_key = state_->has_key
154 && state_->current_key == key;
155
156 if (same_key && !state_->dirty.load(std::memory_order_acquire)
157 && !is_loading.get()
158 && data.get().has_value()) {
161 ::aria::trace::Async{"AsyncResource", "cache_hit",
162 state_->gen.load(std::memory_order_relaxed)});
163 }
164 return; // cache hit
165 }
166 if (same_key && state_->in_flight.load(std::memory_order_acquire)) {
169 ::aria::trace::Async{"AsyncResource", "dedupe",
170 state_->gen.load(std::memory_order_relaxed)});
171 }
172 return; // de-dupe in-flight
173 }
174
175 state_->current_key = key;
176 state_->has_key = true;
177 do_fetch_(key);
178 }
179
180 void refresh() {
181 if (!state_->has_key) return;
182 invalidate();
183 do_fetch_(state_->current_key);
184 }
185
186 void invalidate() noexcept {
187 state_->dirty.store(true, std::memory_order_release);
188 }
189
190 void clear() {
191 cancel();
192 state_->has_key = false;
193 state_->dirty.store(false, std::memory_order_release);
194 state_->is_loading = false;
195 state_->error = std::nullopt;
196 state_->error_message = "";
197 state_->data = std::optional<T>{};
198 state_->recompute_loadable_();
199 }
200
219 void cancel() {
220 // 1. Flip the cooperative-cancel tokens every in-flight coroutine
221 // is holding; they unwind at their next `throw_if_cancelled`
222 // probe (after the ui/worker hops). The detached-task path
223 // swallows the resulting OperationCancelled.
224 state_->cancel.cancel();
225 // 2. Re-arm with a fresh source so future fetches are cancellable
226 // again (move-assign drops our handle to the now-cancelled state;
227 // in-flight coroutines keep their own token alive via shared_ptr).
228 state_->cancel = CancellationSource{};
229 // 3. Bump the generation so any run that lands after this point is
230 // dropped by the stale-gen guard in `run_one_`, and release the
231 // in-flight flag (R-1: we are now the flag's owner).
232 state_->gen.fetch_add(1, std::memory_order_acq_rel);
233 state_->in_flight.store(false, std::memory_order_release);
234 // 4. Surface a non-loading state (SWR: last `data` is kept).
235 state_->is_loading = false;
236 state_->recompute_loadable_();
239 ::aria::trace::Async{"AsyncResource", "cancel",
240 state_->gen.load(std::memory_order_relaxed)});
241 }
242 }
243
244 [[nodiscard]] bool has_data() const { return data.get().has_value(); }
245
246 // ── Public observable handles ────────────────────────────────────
247 //
248 // `in_flight` invariant (R-1): scoped to the **latest** fetch
249 // generation only. When a newer `fetch(...)` bumps `gen`, any
250 // earlier in-flight run that lands afterwards is a *stale* run --
251 // it observes `gen != my_gen`, drops its result, and MUST NOT
252 // clear `in_flight`. The newer run is now the sole owner of the
253 // flag and clears it on its own completion path. This guarantees
254 // observers see `in_flight = true` continuously across rapid
255 // key changes, not a brief false flicker caused by a stale
256 // run's clean-up.
261
267
268private:
269 void do_fetch_(Key key) {
270 state_->in_flight.store(true, std::memory_order_release);
271 state_->dirty.store(false, std::memory_order_release);
272 auto my_gen = state_->gen.fetch_add(1, std::memory_order_acq_rel) + 1;
273 // Synchronously surface the in-flight state on the public
274 // Properties so observers see Loading / Refreshing the moment
275 // `fetch()` returns -- not after the worker hop. The same
276 // assignments happen again inside `run_one_` after the UI
277 // hop; equality-gated `set()` (E-11 / L-21) drops the second
278 // write as a no-op.
279 state_->is_loading = true;
280 state_->error = std::nullopt;
281 state_->error_message = "";
282 state_->recompute_loadable_();
285 ::aria::trace::Async{"AsyncResource", "fetch_start", my_gen});
286 }
287 auto runner = run_one_(key, my_gen);
288 std::move(runner).start_detached();
289 }
290
291 Task<void> run_one_(Key key, std::uint64_t my_gen) {
292 auto state = state_;
293 auto fetcher = fetcher_;
294 auto tok = state->cancel.token();
295
296 co_await schedule_on(*state->ui);
297 tok.throw_if_cancelled();
298 state->is_loading = true;
299 state->error = std::nullopt;
300 state->error_message = "";
301 state->recompute_loadable_();
302
303 std::exception_ptr ex;
304 std::optional<T> result;
305 try {
306 co_await schedule_on(*state->worker);
307 tok.throw_if_cancelled();
308 T v = co_await fetcher(key);
309 result.emplace(std::move(v));
310 } catch (...) {
311 ex = std::current_exception();
312 }
313
314 co_await schedule_on(*state->ui);
315 tok.throw_if_cancelled();
316
317 // Stale-result guard: only the latest fetch wins. Per the
318 // resource's `in_flight` invariant (R-1, see
319 // `docs/error-model.md` companion notes / and below): a stale
320 // run MUST NOT touch `in_flight` -- the newer run that
321 // bumped `gen` has already taken ownership of the flag. The
322 // newer run's own completion path will clear it once it is
323 // truly the last one in flight.
324 if (state->gen.load(std::memory_order_acquire) != my_gen) {
327 ::aria::trace::Async{"AsyncResource", "stale_drop", my_gen});
328 }
329 co_return;
330 }
331
332 if (ex) {
333 try { std::rethrow_exception(ex); }
334 catch (const OperationCancelled&) {
335 // Silent: cancellation is not surfaced as an
336 // observable error; keep `error` as nullopt.
338 auto err = ::aria::Error::cancellation("AsyncResource");
340 ::aria::trace::Async{"AsyncResource", "cancelled", my_gen},
341 std::move(err));
342 }
343 }
344 catch (const TimeoutError& e) {
345 auto err = ::aria::Error::timeout("AsyncResource");
346 err.message = e.what();
349 ::aria::trace::Async{"AsyncResource", "timeout", my_gen},
350 err);
351 }
352 state->error_message = err.message;
353 state->error = std::move(err);
354 }
355 catch (...) {
356 auto err = ::aria::Error::from_exception(ex, "AsyncResource");
359 ::aria::trace::Async{"AsyncResource", "failure", my_gen},
360 err);
361 }
362 state->error_message = err.message;
363 state->error = std::move(err);
364 }
365 } else {
366 state->data = result;
369 ::aria::trace::Async{"AsyncResource", "fetch_finish", my_gen});
370 }
371 }
372 state->is_loading = false;
373 state->in_flight.store(false, std::memory_order_release);
374 state->recompute_loadable_();
375 }
376};
377
378} // namespace aria::async
static Loadable loading()
Definition loadable.hpp:81
static Loadable refreshing(T prior)
Build a Refreshing state from an existing Success payload.
Definition loadable.hpp:89
const Error * error() const noexcept
Returns a pointer to the error if has_error(), else nullptr.
Definition loadable.hpp:160
static Loadable success(T v)
Definition loadable.hpp:96
static Loadable idle() noexcept
Definition loadable.hpp:79
void clear()
Definition async_resource.hpp:190
AsyncResource(const AsyncResource &)=delete
Property< std::string > & error_message
Definition async_resource.hpp:259
Property< std::optional< T > > & data
Definition async_resource.hpp:260
AsyncResource & operator=(const AsyncResource &)=delete
Property<::aria::Loadable< T > > & loadable
Five-state loadable view-model – Idle / Loading / Refreshing / Success / Error.
Definition async_resource.hpp:266
void fetch(Key key)
Fetch for key.
Definition async_resource.hpp:152
Property< bool > & is_loading
Definition async_resource.hpp:257
AsyncResource(IExecutor &ui, IExecutor &worker, Fetcher fetcher)
Definition async_resource.hpp:135
void cancel()
Cancel any in-flight fetch and drop its pending write-back, WITHOUT destroying the resource.
Definition async_resource.hpp:219
bool has_data() const
Definition async_resource.hpp:244
~AsyncResource()
Definition async_resource.hpp:144
void invalidate() noexcept
Definition async_resource.hpp:186
void refresh()
Definition async_resource.hpp:180
std::function< Task< T >(Key)> Fetcher
Definition async_resource.hpp:129
Property< std::optional<::aria::Error > > & error
Definition async_resource.hpp:258
Definition cancellation.hpp:218
Abstract executor interface — schedules a callable to run "somewhere".
Definition executor.hpp:36
Definition task.hpp:78
Definition property.hpp:103
Definition async_command.hpp:118
auto schedule_on(IExecutor &exec)
Schedule a coroutine to resume on the given executor.
Definition executor.hpp:396
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
@ Async
AsyncCommand / AsyncResource lifecycle.
Definition diagnostics.hpp:68
Definition validation_key.hpp:110
static Error from_exception(std::exception_ptr ex, std::string source_tag)
Catch-all converter from a thrown exception_ptr to a typed Error.
Definition error.hpp:237
static Error cancellation(std::string source_tag="AsyncCommand")
Cancellation.
Definition error.hpp:192
static Error timeout(std::string source_tag="AsyncCommand")
with_timeout deadline expired.
Definition error.hpp:198
Async lifecycle.
Definition diagnostics.hpp:128