morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
reconnect_coordinator.hpp
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4#include <array>
5#include <chrono>
6#include <cstdint>
7#include <functional>
8#include <memory>
9#include <string>
10#include <string_view>
11#include <utility>
12
13#include "../attributes.hpp"
14#include "../core/completion.hpp"
16#include "../core/executor.hpp"
17#include "../core/logger.hpp"
18#include "../core/observability.hpp"
20
21namespace morph::offline {
22
23/// @brief Outcome of a single `onOnline()` attempt sequence.
24enum class ReconnectOutcome : std::uint8_t {
25 Reconnected, ///< Backend reopened, made active, context bound. Replay is
26 ///< invoked only if `shouldContinue()` still holds at that
27 ///< point — see `onOnline()`; `Reconnected` can be returned
28 ///< without replaying.
29 GaveUp, ///< Exhausted maxAttempts without a successful reconnect; stayed offline.
30 Aborted, ///< shouldContinue() returned false before any reconnect (e.g. went offline again).
31};
32
33namespace detail {
34/// @brief Tag value for the `reconnectOutcome` metric's "outcome" dimension.
35constexpr std::string_view reconnectOutcomeName(ReconnectOutcome outcome) noexcept {
36 switch (outcome) {
37 case ReconnectOutcome::Reconnected:
38 return "Reconnected";
39 case ReconnectOutcome::GaveUp:
40 return "GaveUp";
41 case ReconnectOutcome::Aborted:
42 return "Aborted";
43 default:
44 // Unreachable through any real code path: ReconnectOutcome is a
45 // closed enum and every enumerator is already handled explicitly
46 // above. This arm only exists to satisfy the compiler that the
47 // function returns on every enum value, including one
48 // manufactured by an out-of-range `static_cast` -- mirrors
49 // ruleKindName's identical default: arm in forms.hpp.
50 return "?";
51 }
52}
53} // namespace detail
54
55/// @brief Tuning parameters for `ReconnectCoordinator`.
56///
57/// Declared outside the class so its default member initialisers are fully
58/// parsed before any constructor default argument names `Config{}`. (A nested
59/// incomplete type breaks constructor default arguments on clang/GCC — see the
60/// same note on `NetworkMonitorConfig`.)
62 /// @brief Max reconnect attempts per `onOnline()` call before giving up.
63 int maxAttempts = 10;
64
65 /// @brief Delay between failed reconnect attempts.
66 std::chrono::milliseconds retryDelay = std::chrono::seconds{2};
67};
68
69/// @brief Sequences reconnect → activate → bind → replay when connectivity returns.
70///
71/// All side effects are injected via `Deps`. The coordinator contains only the
72/// retry loop, the ordering guarantees, and the abort checks. It performs no
73/// I/O and owns no thread: it owns a strand over the executor it is given, the
74/// *offline strand*, and runs `onOnline()`'s and `onOffline()`'s bodies there.
75///
76/// @par Ordering guarantee (the reason this class exists)
77/// Within a successful `onOnline()`, the steps run in this strict order:
78/// 1. `tryReconnect()` returns true (backend usable)
79/// 2. `activatePrimary()` (make primary the active backend)
80/// 3. `bindContext()` (rebind per-connection / per-session state)
81/// 4. `replay()` (drain + replay the offline queue)
82/// Step 4 MUST NOT run before step 3 completes, and step 3 MUST NOT run before
83/// step 2. Implementations and tests should treat this as an invariant.
84///
85/// @par One owner
86/// `onOnline()` posts its whole sequence, retry sleeps included, as one task
87/// on the offline strand; `onOffline()` posts its two steps as another. A
88/// strand runs one task at a time, in post order, so the two never overlap and
89/// an `onOffline()` can never land between `bindContext()` and `replay()`.
90/// Both are callable from any thread and return at once. The executor should
91/// be a worker pool, **not** the I/O loop that runs `NetworkMonitor`'s
92/// callbacks: the retry sleeps block the thread the task runs on.
93///
94/// A `SyncWorker` given `strand()` as its owner drains inside the `replay`
95/// step when the step calls its `run()`, rather than after the task.
96// NOLINTNEXTLINE(cppcoreguidelines-special-member-functions)
98public:
99 /// @brief Alias for the configuration struct.
101
102 /// @brief Injected, host-supplied side effects. None may be null. Each is
103 /// called on the offline strand.
104 struct Deps {
105 /// @brief Attempt to (re)open the primary backend.
106 /// @return true once the backend is genuinely usable (not merely TCP-open).
107 /// Must not throw; throwing is treated as a failed attempt.
108 std::function<bool()> tryReconnect;
109
110 /// @brief Make the freshly-reconnected primary the active backend.
111 /// Called exactly once per successful `onOnline()`, after `tryReconnect()`
112 /// succeeds and before `bindContext()`.
113 std::function<void()> activatePrimary;
114
115 /// @brief Switch the active backend to the local/offline one.
116 /// Called by `onOffline()`. Followed by `bindContext()` so context tracks
117 /// the now-active backend.
118 std::function<void()> activateLocal;
119
120 /// @brief Rebind per-connection / per-session context to the active backend.
121 /// Called after every activate* step. Must not throw.
122 std::function<void()> bindContext;
123
124 /// @brief Replay the offline queue against the now-active primary.
125 /// Typically wraps `SyncWorker::run(replyExec)` on a worker owned by
126 /// `strand()`.
127 /// Called last in `onOnline()`.
128 std::function<void()> replay;
129
130 /// @brief Returns false to abort the current `onOnline()` sequence early
131 /// (e.g. the monitor reports the backend went offline again mid-retry).
132 /// Polled before each reconnect attempt and once more before replay.
133 std::function<bool()> shouldContinue;
134
135 /// @brief Sleep for the given duration between failed attempts.
136 /// Injected so tests can substitute a no-op / counter. Hosts wire this to
137 /// `std::this_thread::sleep_for`.
138 std::function<void(std::chrono::milliseconds)> sleep;
139 };
140
141 /// @brief Constructs a coordinator with injected dependencies and tuning,
142 /// running its sequences on a strand over @p executor.
143 /// @param deps Side-effect callbacks (all required, none null).
144 /// @param executor Where the offline strand's tasks run: a worker pool.
145 /// Borrowed: it must outlive this coordinator and keep
146 /// running tasks until the coordinator is destroyed.
147 /// @param cfg Retry tuning.
148 ///
149 /// A null `Deps` member is logged via `morph::log::logError` in all builds;
150 /// construction still succeeds. Invoking the coordinator with any null member
151 /// is undefined behaviour.
152 ReconnectCoordinator(Deps deps, ::morph::exec::IExecutor& executor MORPH_LIFETIMEBOUND, Config cfg = Config{})
153 : _state{std::make_shared<State>(std::move(deps), cfg)}, _strand{executor} {
154 assertDepsNonNull(_state->deps);
155 }
156
157 /// @brief Closes the offline strand: a sequence not yet started is dropped,
158 /// one running on another thread is waited for.
159 ~ReconnectCoordinator() { _strand.close(); }
160
162 ReconnectCoordinator& operator=(const ReconnectCoordinator&) = delete;
164 ReconnectCoordinator& operator=(ReconnectCoordinator&&) = delete;
165
166 /// @brief Runs the reconnect → activate → bind → replay sequence on the
167 /// offline strand.
168 ///
169 /// Posted as one task, after any `onOnline()`/`onOffline()` posted before
170 /// it. Callable from any thread; returns at once.
171 ///
172 /// @param replyExec Executor the outcome is delivered on and the caller
173 /// attaches its callbacks on: the caller's own, or `strand()` for a
174 /// caller that does not wait for it. Borrowed: it must outlive the
175 /// returned `Completion`.
176 /// @return A `Completion` settled on the offline strand with how the
177 /// sequence ended (see `ReconnectOutcome`), delivered on
178 /// @p replyExec.
181 _strand.postTask([state = _state, strand = &_strand, promise = std::move(settleable.second)]() mutable {
182 ::morph::exec::detail::noteOwner("ReconnectCoordinator::onOnline", strand->coreExecutor(),
183 strand->runningHere());
184 promise.resolve(state->runOnline());
185 });
186 return std::move(settleable.first);
187 }
188
189 /// @brief Switches to the local backend and rebinds context, on the
190 /// offline strand.
191 ///
192 /// Idempotent at the policy level: safe to call when already local. Posted
193 /// after any `onOnline()`/`onOffline()` posted before it. Callable from any
194 /// thread; returns at once.
195 void onOffline() {
196 _strand.postTask([state = _state, strand = &_strand] {
197 ::morph::exec::detail::noteOwner("ReconnectCoordinator::onOffline", strand->coreExecutor(),
198 strand->runningHere());
199 state->deps.activateLocal();
200 state->deps.bindContext();
201 });
202 }
203
204 /// @brief The offline strand, as an executor: what a `SyncWorker` that
205 /// replays for this coordinator is given as its owner.
206 /// @return The strand every `onOnline()`/`onOffline()` body runs on.
207 [[nodiscard]] ::morph::exec::IExecutor& strand() noexcept { return _strand; }
208
209private:
210 /// @brief Logs each null `Deps` member at error level (in all builds; does
211 /// not throw — construction still succeeds).
212 static void assertDepsNonNull(const Deps& deps) {
213 auto check = [](const char* name, bool present) {
214 if (!present) {
215 ::morph::log::logError(std::string{"[reconnect_coordinator] null Deps member: "} + name);
216 }
217 };
218 check("tryReconnect", static_cast<bool>(deps.tryReconnect));
219 check("activatePrimary", static_cast<bool>(deps.activatePrimary));
220 check("activateLocal", static_cast<bool>(deps.activateLocal));
221 check("bindContext", static_cast<bool>(deps.bindContext));
222 check("replay", static_cast<bool>(deps.replay));
223 check("shouldContinue", static_cast<bool>(deps.shouldContinue));
224 check("sleep", static_cast<bool>(deps.sleep));
225 }
226
227 /// What the offline strand's tasks run on: the deps and the retry loop.
228 /// Shared with each posted task, so a task never reaches through the
229 /// coordinator.
230 struct State {
231 State(Deps injected, Config config) : deps{std::move(injected)}, cfg{config} {}
232
233 Deps deps;
234 Config cfg;
235
236 /// @brief The whole `onOnline()` sequence. On the offline strand.
237 /// @return How it ended.
238 [[nodiscard]] ReconnectOutcome runOnline() const {
239 for (int attempt = 1; attempt <= cfg.maxAttempts; ++attempt) {
240 if (!callShouldContinue()) {
241 return emitOutcome(ReconnectOutcome::Aborted);
242 }
243 ::morph::observe::detail::emitMetric(::morph::observe::Metric::reconnectAttempts, 1.0);
244 if (callTryReconnect()) {
245 deps.activatePrimary();
246 deps.bindContext();
247 // Re-check before replay so we never replay into a backend
248 // that just went away. We are still Reconnected either way;
249 // this only controls whether replay runs.
250 if (callShouldContinue()) {
251 deps.replay();
252 }
253 return emitOutcome(ReconnectOutcome::Reconnected);
254 }
255 // No sleep after the final attempt — it would just waste
256 // retryDelay before giving up.
257 if (attempt < cfg.maxAttempts) {
258 deps.sleep(cfg.retryDelay);
259 }
260 }
261 ::morph::log::logWarn("[reconnect_coordinator] gave up after " + std::to_string(cfg.maxAttempts) +
262 " attempts, staying offline");
263 return emitOutcome(ReconnectOutcome::GaveUp);
264 }
265
266 /// @brief Calls `tryReconnect`, treating a thrown exception as a failed attempt.
267 [[nodiscard]] bool callTryReconnect() const noexcept {
268 try {
269 return deps.tryReconnect();
270 } catch (...) {
271 return false;
272 }
273 }
274
275 /// @brief Calls `shouldContinue`, treating a throw as "do not continue".
276 [[nodiscard]] bool callShouldContinue() const noexcept {
277 try {
278 return deps.shouldContinue();
279 } catch (...) {
280 return false;
281 }
282 }
283 };
284
285 /// @brief Emits the `reconnectOutcome` counter tagged by @p outcome, then
286 /// returns it — lets each `onOnline()` return site emit-and-return
287 /// in one expression.
288 static ReconnectOutcome emitOutcome(ReconnectOutcome outcome) noexcept {
289 std::array<std::pair<std::string_view, std::string_view>, 1> const tags{
290 {{"outcome", detail::reconnectOutcomeName(outcome)}}};
291 ::morph::observe::detail::emitMetric(::morph::observe::Metric::reconnectOutcome, 1.0, tags);
292 return outcome;
293 }
294
295 std::shared_ptr<State> _state;
296 /// The offline strand. Declared last, so it is closed before the state
297 /// the destructor's close waits on a running task to finish with.
298 ::morph::exec::OwnerStrand _strand;
299};
300
301} // namespace morph::offline
Move-only handle representing the eventual result of an asynchronous operation.
static std::pair< Completion< T >, Promise > makeSettleable(::morph::exec::IExecutor *execPtr)
Constructs a Completion<T>/Promise<T> pair sharing one settleable state.
Sequences reconnect → activate → bind → replay when connectivity returns.
::morph::async::Completion< ReconnectOutcome > onOnline(::morph::exec::IExecutor &replyExec)
Runs the reconnect → activate → bind → replay sequence on the offline strand.
void onOffline()
Switches to the local backend and rebinds context, on the offline strand.
~ReconnectCoordinator()
Closes the offline strand: a sequence not yet started is dropped, one running on another thread is wa...
ReconnectCoordinator(Deps deps, ::morph::exec::IExecutor &executor, Config cfg=Config{})
Constructs a coordinator with injected dependencies and tuning, running its sequences on a strand ove...
ReconnectCoordinatorConfig Config
Alias for the configuration struct.
::morph::exec::IExecutor & strand() noexcept
The offline strand, as an executor: what a SyncWorker that replays for this coordinator is given as i...
The check every loop-owned body makes on entry: "am I on my owner?".
OwnerStrand: one core-cpp strand over a morph executor, which is itself a morph executor — the owner ...
Abstract executor interface.
Definition executor.hpp:70
Tuning parameters for ReconnectCoordinator.
std::chrono::milliseconds retryDelay
Delay between failed reconnect attempts.
int maxAttempts
Max reconnect attempts per onOnline() call before giving up.
Injected, host-supplied side effects. None may be null. Each is called on the offline strand.
std::function< bool()> shouldContinue
Returns false to abort the current onOnline() sequence early (e.g. the monitor reports the backend we...
std::function< void(std::chrono::milliseconds)> sleep
Sleep for the given duration between failed attempts. Injected so tests can substitute a no-op / coun...
std::function< void()> bindContext
Rebind per-connection / per-session context to the active backend. Called after every activate* step....
std::function< void()> activatePrimary
Make the freshly-reconnected primary the active backend. Called exactly once per successful onOnline(...
std::function< void()> activateLocal
Switch the active backend to the local/offline one. Called by onOffline(). Followed by bindContext() ...
std::function< void()> replay
Replay the offline queue against the now-active primary. Typically wraps SyncWorker::run(replyExec) o...
std::function< bool()> tryReconnect
Attempt to (re)open the primary backend.