Aria 3.2.6
C++23 MVVM framework — 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 const auto state = state_;
192 cancel();
193 state->has_key = false;
194 state->dirty.store(false, std::memory_order_release);
195 state->is_loading = false;
196 state->error = std::nullopt;
197 state->error_message = "";
198 state->data = std::optional<T>{};
199 state->recompute_loadable_();
200 }
201
220 void cancel() {
221 const auto state = state_;
222 // 1. Flip the cooperative-cancel tokens every in-flight coroutine
223 // is holding; they unwind at their next `throw_if_cancelled`
224 // probe (after the ui/worker hops). The detached-task path
225 // swallows the resulting OperationCancelled.
226 state->cancel.cancel();
227 // 2. Re-arm with a fresh source so future fetches are cancellable
228 // again (move-assign drops our handle to the now-cancelled state;
229 // in-flight coroutines keep their own token alive via shared_ptr).
230 state->cancel = CancellationSource{};
231 // 3. Bump the generation so any run that lands after this point is
232 // dropped by the stale-gen guard in `run_one_`, and release the
233 // in-flight flag (R-1: we are now the flag's owner).
234 state->gen.fetch_add(1, std::memory_order_acq_rel);
235 state->in_flight.store(false, std::memory_order_release);
236 // 4. Surface a non-loading state (SWR: last `data` is kept).
237 state->is_loading = false;
238 state->recompute_loadable_();
241 ::aria::trace::Async{"AsyncResource", "cancel",
242 state->gen.load(std::memory_order_relaxed)});
243 }
244 }
245
246 [[nodiscard]] bool has_data() const { return data.get().has_value(); }
247
248 // ── Public observable handles ────────────────────────────────────
249 //
250 // `in_flight` invariant (R-1): scoped to the **latest** fetch
251 // generation only. When a newer `fetch(...)` bumps `gen`, any
252 // earlier in-flight run that lands afterwards is a *stale* run --
253 // it observes `gen != my_gen`, drops its result, and MUST NOT
254 // clear `in_flight`. The newer run is now the sole owner of the
255 // flag and clears it on its own completion path. This guarantees
256 // observers see `in_flight = true` continuously across rapid
257 // key changes, not a brief false flicker caused by a stale
258 // run's clean-up.
263
269
270private:
271 void do_fetch_(Key key) {
272 // Property observers run synchronously and may destroy this resource.
273 // Own everything needed by the operation before the first publication.
274 const auto state = state_;
275 auto fetcher = fetcher_;
276 const auto tok = state->cancel.token();
277 state->in_flight.store(true, std::memory_order_release);
278 state->dirty.store(false, std::memory_order_release);
279 const auto my_gen = state->gen.fetch_add(1, std::memory_order_acq_rel) + 1;
280 auto current = [&] {
281 return !tok.is_cancelled() && state->gen.load(std::memory_order_acquire) == my_gen;
282 };
283 // Synchronously surface the in-flight state on the public
284 // Properties so observers see Loading / Refreshing the moment
285 // `fetch()` returns -- not after the worker hop.
286 state->is_loading = true;
287 if (!current()) { return; }
288 state->error = std::nullopt;
289 if (!current()) { return; }
290 state->error_message = "";
291 if (!current()) { return; }
292 state->recompute_loadable_();
293 if (!current()) { return; }
296 ::aria::trace::Async{"AsyncResource", "fetch_start", my_gen});
297 }
298 auto runner = run_one_(state, std::move(fetcher), std::move(key), my_gen, tok);
299 std::move(runner).start_detached();
300 }
301
302 static Task<void> run_one_(std::shared_ptr<State> state, Fetcher fetcher,
303 Key key, std::uint64_t my_gen, CancellationToken tok) {
304 co_await schedule_on(*state->ui);
305 tok.throw_if_cancelled();
306 if (state->gen.load(std::memory_order_acquire) != my_gen) { co_return; }
307
308 std::exception_ptr ex;
309 std::optional<T> result;
310 try {
311 co_await schedule_on(*state->worker);
312 tok.throw_if_cancelled();
313 T v = co_await fetcher(key);
314 result.emplace(std::move(v));
315 } catch (...) {
316 ex = std::current_exception();
317 }
318
319 co_await schedule_on(*state->ui);
320 tok.throw_if_cancelled();
321
322 // Stale-result guard: only the latest fetch wins. Per the
323 // resource's `in_flight` invariant (R-1, see
324 // `docs/error-model.md` companion notes / and below): a stale
325 // run MUST NOT touch `in_flight` -- the newer run that
326 // bumped `gen` has already taken ownership of the flag. The
327 // newer run's own completion path will clear it once it is
328 // truly the last one in flight.
329 if (state->gen.load(std::memory_order_acquire) != my_gen) {
332 ::aria::trace::Async{"AsyncResource", "stale_drop", my_gen});
333 }
334 co_return;
335 }
336
337 if (ex) {
338 try { std::rethrow_exception(ex); }
339 catch (const OperationCancelled&) {
340 // Silent: cancellation is not surfaced as an
341 // observable error; keep `error` as nullopt.
343 auto err = ::aria::Error::cancellation("AsyncResource");
345 ::aria::trace::Async{"AsyncResource", "cancelled", my_gen},
346 std::move(err));
347 }
348 }
349 catch (const TimeoutError& e) {
350 auto err = ::aria::Error::timeout("AsyncResource");
351 err.message = e.what();
354 ::aria::trace::Async{"AsyncResource", "timeout", my_gen},
355 err);
356 }
357 state->error_message = err.message;
358 state->error = std::move(err);
359 }
360 catch (...) {
361 auto err = ::aria::Error::from_exception(ex, "AsyncResource");
364 ::aria::trace::Async{"AsyncResource", "failure", my_gen},
365 err);
366 }
367 state->error_message = err.message;
368 state->error = std::move(err);
369 }
370 } else {
371 state->data = result;
374 ::aria::trace::Async{"AsyncResource", "fetch_finish", my_gen});
375 }
376 }
377 finish_fetch(state, my_gen, tok);
378 }
379
380 static void finish_fetch(const std::shared_ptr<State>& state,
381 std::uint64_t my_gen, const CancellationToken& tok) {
382 // A result/error observer may synchronously start a newer fetch.
383 if (tok.is_cancelled() || state->gen.load(std::memory_order_acquire) != my_gen) {
384 return;
385 }
386 state->in_flight.store(false, std::memory_order_release);
387 state->is_loading = false;
388 state->recompute_loadable_();
389 }
390};
391
392} // 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:261
Property< std::optional< T > > & data
Definition async_resource.hpp:262
AsyncResource & operator=(const AsyncResource &)=delete
Property<::aria::Loadable< T > > & loadable
Five-state loadable view-model – Idle / Loading / Refreshing / Success / Error.
Definition async_resource.hpp:268
void fetch(Key key)
Fetch for key.
Definition async_resource.hpp:152
Property< bool > & is_loading
Definition async_resource.hpp:259
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:220
bool has_data() const
Definition async_resource.hpp:246
~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:260
Definition cancellation.hpp:221
Definition cancellation.hpp:182
void throw_if_cancelled() const
Throw OperationCancelled if cancelled. Call at safe await points.
Definition cancellation.hpp:193
Abstract executor interface — schedules a callable to run "somewhere".
Definition executor.hpp:37
Definition task.hpp:79
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:472
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