Aria 2.0.0
C++23 MVVM framework (C++20 minimum) — reactive, coroutine-first, ABI-layered
Loading...
Searching...
No Matches
async_validator.hpp
Go to the documentation of this file.
1// ============================================================================
2// aria/async/async_validator.hpp
3// ----------------------------------------------------------------------------
4// Async validation rules as a first-class citizen of `Validator<T>`.
5// Callers should not have to hand-roll cancellation,
6// de-duplication, or "latest-wins" arbitration when a validation rule
7// is asynchronous (e.g. "is this username taken?" hitting the network).
8//
9// Design contract (V-N IDs, cross-referenced from docs/error-model.md
10// E-21 and lifecycle.md L-37):
11//
12// V-1 (latest-wins). Each fresh source-property change cancels the
13// previous in-flight rule; the cancelled rule's result MUST be
14// dropped (it never reaches `Validator::end_pending`). Mirrors
15// AsyncResource R-1.
16//
17// V-2 (pending semantics). Between fire and settle the validator
18// sits in `ValidationState.pending == true`. UI consumes
19// `state().pending` for spinner / disable-submit.
20//
21// V-3 (cancellation never surfaces as Error). Per error-model.md
22// E-22, a rule that observed cancellation MUST NOT add any
23// Error to `state.errors`. The validator simply settles back
24// to its previous error set.
25//
26// V-4 (key + rule_id). Async errors live under
27// `ValidationKey{validator.field_path(), rule_id}`. This makes
28// them indistinguishable from sync rules at the form level.
29//
30// V-5 (de-duplication). Two consecutive identical source values
31// do NOT spawn two rules; the second is a no-op. Avoids
32// spamming a slow remote validator on Property echo / re-emit.
33//
34// V-6 (lifetime). Destroying the AsyncValidator cancels any
35// in-flight rule; detaching via the returned Subscription
36// does the same.
37// ============================================================================
38#pragma once
39
42#include "aria/async/task.hpp"
43#include "aria/error.hpp"
44#include "aria/property.hpp"
45#include "aria/subscription.hpp"
47#include "aria/validator.hpp"
48
49#include <atomic>
50#include <functional>
51#include <memory>
52#include <optional>
53#include <stdexcept>
54#include <string>
55#include <type_traits>
56#include <utility>
57#include <vector>
58
59namespace aria::async {
60
67 std::vector<::aria::Error> errors;
68
70 std::vector<::aria::Error> warnings;
71
72 [[nodiscard]] static AsyncRuleResult passed() { return {}; }
73
76 std::string message) {
78 r.errors.push_back(
79 ::aria::Error::validation(std::move(key), std::move(message)));
80 return r;
81 }
82};
83
84namespace detail {
85
86template<class T>
87struct AsyncValidatorState
88 : std::enable_shared_from_this<AsyncValidatorState<T>>
89{
90 using Factory = std::function<
92
93 IExecutor* ui;
94 IExecutor* worker;
95 Factory factory;
97
98 // Latest-wins generation counter. Each fire bumps it; only the
99 // run whose `my_gen` still equals `gen` on completion gets to
100 // settle the validator.
101 std::atomic<std::uint64_t> gen{0};
102
103 // V-5 de-dupe: stash the most recent source value and skip
104 // identical successors. Stored as optional so the "first ever
105 // fire" is never accidentally suppressed.
106 std::optional<T> last_value;
107
108 // Validator pointer is cleared when the Subscription detaches; a
109 // subsequent stale-rule completion sees nullptr and drops.
110 ::aria::Validator<T>* target{nullptr};
111 std::weak_ptr<void> target_lifetime;
112 ::aria::Subscription source_subscription;
113 std::uint64_t attachment{0};
114
115 // These fields are accessed only on the graph/UI thread. The worker
116 // observes only its captured token and the atomic generation counter.
117 [[nodiscard]] bool has_target() const noexcept {
118 return target && !target_lifetime.expired();
119 }
120
121 AsyncValidatorState(IExecutor& u, IExecutor& w, Factory f)
122 : ui(&u), worker(&w), factory(std::move(f)) {}
123};
124
125template<class T>
126Task<void> async_validator_run_one_(
127 std::shared_ptr<AsyncValidatorState<T>> self,
128 T value,
129 std::uint64_t my_gen,
130 CancellationToken tok)
131{
132 std::optional<AsyncRuleResult> outcome;
133 std::optional<::aria::Error> failure;
134 bool cancelled = false;
135 try {
136 co_await schedule_on(*self->worker);
137 tok.throw_if_cancelled();
138 outcome = co_await self->factory(std::move(value), tok);
139 tok.throw_if_cancelled();
140 } catch (const OperationCancelled&) {
141 // V-3: a current run must leave pending, preserving prior errors.
142 // Superseded runs are discarded by the same generation guard below.
143 cancelled = true;
144 } catch (...) {
145 // V-3 -- arbitrary throws map to AsyncFailure under the
146 // validator's source tag. The error message follows
147 // error-model.md E-13 (What/Where/How).
149 std::current_exception(), "AsyncValidator");
150 }
151
152 if (self->gen.load(std::memory_order_acquire) != my_gen) co_return;
153 co_await schedule_on(*self->ui);
154
155 // Stale-result guard (V-1). Mirrors AsyncResource R-1: a stale
156 // run does NOT touch the validator's pending state; the winner
157 // clears it.
158 if (self->gen.load(std::memory_order_acquire) != my_gen) {
159 co_return;
160 }
161 if (!self->has_target()) {
162 // Detached mid-flight; nothing to settle.
163 co_return;
164 }
165 if (cancelled || tok.is_cancelled()) {
166 self->target->cancel_pending();
167 co_return;
168 }
169
170 std::vector<::aria::Error> extras;
171 if (failure.has_value()) {
172 extras.push_back(std::move(*failure));
173 } else if (outcome.has_value()) {
174 for (auto& e : outcome->errors) extras.push_back(std::move(e));
175 for (auto& w : outcome->warnings) extras.push_back(std::move(w));
176 }
177 self->target->end_pending(std::move(extras));
178 co_return;
179}
180
181} // namespace detail
182
209template<class T>
211public:
212 using Factory = typename detail::AsyncValidatorState<T>::Factory;
213
215 : state_(std::make_shared<detail::AsyncValidatorState<T>>(
216 ui, worker, std::move(factory))) {}
217
219 auto retired = std::move(state_);
220 detach_(retired);
221 }
222
225 AsyncValidator(AsyncValidator&&) noexcept = default;
226 AsyncValidator& operator=(AsyncValidator&& other) noexcept {
227 if (this != &other) {
228 auto retired = std::exchange(state_, std::move(other.state_));
229 detach_(retired);
230 }
231 return *this;
232 }
233
238 ::aria::Property<T>& source) {
239 auto state = state_;
240 if (!state) throw std::logic_error("AsyncValidator: cannot attach a moved-from driver");
241 auto lifetime = v.lifetime_token_();
242 const auto attachment = detach_(state);
243 if (state->attachment != attachment) return {}; // Reattached during teardown.
244 state->target = &v;
245 state->target_lifetime = std::move(lifetime);
246 state->last_value.reset();
247
248 // The state owns the connection; its callback is weak to avoid a
249 // cycle. Install it before begin_pending can notify user observers.
250 std::weak_ptr<detail::AsyncValidatorState<T>> weak = state;
251 state->source_subscription = source.on_changed(
252 [weak, attachment](const T& value) {
253 if (auto current = weak.lock(); current && current->attachment == attachment) {
254 fire_(current, value);
255 }
256 });
257 ::aria::Subscription connection{std::function<void()>{[weak, attachment] {
258 if (auto current = weak.lock(); current && current->attachment == attachment) {
259 detach_(current);
260 }
261 }}};
262 // Initial fire on the current value, as for synchronous rules.
263 fire_(state, source.get());
264 return connection;
265 }
266
267private:
268 static std::uint64_t detach_(
269 const std::shared_ptr<detail::AsyncValidatorState<T>>& state) noexcept {
270 if (!state) return 0;
271 const auto attachment = ++state->attachment;
272 state->gen.fetch_add(1, std::memory_order_acq_rel);
273 auto* target = std::exchange(state->target, nullptr);
274 auto lifetime = std::move(state->target_lifetime);
275 auto cancellation = std::move(state->cancel);
276 state->source_subscription.release();
277 // Invalidate before invoking observers/cancellation callbacks. A
278 // reentrant attach then owns a fresh generation and cancellation source.
279 if (target && !lifetime.expired()) {
280 try { target->cancel_pending(); }
281 catch (...) {
282 ::aria::report_callback_failure("AsyncValidator.detach", std::current_exception());
283 }
284 }
285 try { cancellation.cancel(); }
286 catch (...) {
287 ::aria::report_callback_failure("AsyncValidator.cancel", std::current_exception());
288 }
289 return attachment;
290 }
291
292 static void fire_(const std::shared_ptr<detail::AsyncValidatorState<T>>& state,
293 const T& value)
294 {
295 if (!state->has_target()) return;
296 if (state->last_value.has_value() && *state->last_value == value) {
297 // V-5 -- identical to last fire, no-op.
298 return;
299 }
300 T snapshot = value;
301 state->last_value = snapshot;
302
303 CancellationSource next;
304 auto previous = std::move(state->cancel);
305 state->cancel = std::move(next);
306 auto token = state->cancel.token();
307 const auto my_gen =
308 state->gen.fetch_add(1, std::memory_order_acq_rel) + 1;
309 previous.cancel();
310 if (state->gen.load(std::memory_order_acquire) != my_gen || !state->has_target()) return;
311 state->target->begin_pending(); // May synchronously change the source or detach.
312 if (state->gen.load(std::memory_order_acquire) != my_gen || !state->has_target()) return;
313 detail::async_validator_run_one_<T>(state, std::move(snapshot), my_gen, std::move(token))
314 .start_detached();
315 }
316
317 std::shared_ptr<detail::AsyncValidatorState<T>> state_;
318};
319
320} // namespace aria::async
RAII handle to a single subscription.
Definition subscription.hpp:44
Definition validator.hpp:135
void cancel_pending()
Cancel pending work while preserving the previous validation result, including async errors and warni...
Definition validator.hpp:303
AsyncValidator(AsyncValidator &&) noexcept=default
AsyncValidator(const AsyncValidator &)=delete
typename detail::AsyncValidatorState< T >::Factory Factory
Definition async_validator.hpp:212
AsyncValidator & operator=(const AsyncValidator &)=delete
~AsyncValidator()
Definition async_validator.hpp:218
::aria::Subscription attach_to(::aria::Validator< T > &v, ::aria::Property< T > &source)
Attach to a Validator + its source Property.
Definition async_validator.hpp:237
AsyncValidator(IExecutor &ui, IExecutor &worker, Factory factory)
Definition async_validator.hpp:214
Definition cancellation.hpp:218
Definition cancellation.hpp:182
Abstract executor interface — schedules a callable to run "somewhere".
Definition executor.hpp:36
Task() noexcept=default
Definition property.hpp:103
T get() const
Auto-tracked read.
Definition property.hpp:138
::aria::Subscription on_changed(std::function< void(const T &)> fn)
Run fn(new_value) every time the value changes.
Definition property.hpp:200
Definition async_command.hpp:118
auto schedule_on(IExecutor &exec)
Schedule a coroutine to resume on the given executor.
Definition executor.hpp:396
void report_callback_failure(std::string_view category, std::exception_ptr exception, std::string_view message={}) noexcept
Report a callback failure.
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 validation(ValidationKey k, std::string msg)
Hard validation error.
Definition error.hpp:170
(field_path, rule_id) locator for a validation message.
Definition validation_key.hpp:52
Outcome of an async rule invocation.
Definition async_validator.hpp:65
std::vector<::aria::Error > warnings
Soft advisories.
Definition async_validator.hpp:70
static AsyncRuleResult passed()
Definition async_validator.hpp:72
std::vector<::aria::Error > errors
Hard failures. Empty iff the rule passed.
Definition async_validator.hpp:67
static AsyncRuleResult failed(::aria::ValidationKey key, std::string message)
Convenience: build a single-error failure under the given key.
Definition async_validator.hpp:75