morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
instance_directory.hpp
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4#include <atomic>
5#include <cstddef>
6#include <cstdint>
7#include <memory>
8#include <optional>
9#include <string>
10#include <unordered_map>
11#include <unordered_set>
12#include <utility>
13#include <vector>
14
15#include "../model.hpp"
16#include "../profiler.hpp"
17
18namespace morph::backend::detail {
19
20/// @brief How far a freshly created shared instance has got through its first action.
21///
22/// A shared instance is created *before* the action that hydrates it runs, so
23/// there is a window in which the directory holds an instance nobody yet knows
24/// to be usable. `docs/spec/core/shared_instances.md`'s Failure modes section
25/// says what must happen when that first action fails: the instance must not be
26/// handed to a *new* attacher, and is evicted from the directory the next time
27/// anyone attaches to its key.
28enum class Hydration : std::uint8_t {
29 /// @brief The first action has not settled yet.
30 pending,
31 /// @brief The first action succeeded; the instance is shareable.
32 healthy,
33 /// @brief The first action failed; the instance must not be handed out again.
34 poisoned,
35};
36
37/// @brief The hydration phase of one shared instance, as a single atomic cell.
38///
39/// One cell rather than a `firstActionPending`/`poisoned` pair, because the pair
40/// cannot be moved between its states atomically: a reader landing between
41/// "first action is no longer pending" and "…and it failed" observes a
42/// not-poisoned instance whose first action has already failed, and is handed
43/// it — the exact case the spec forbids.
44///
45/// Owned through a `shared_ptr` and captured that way — never via a raw pointer
46/// to the owning backend — into the strand task that settles it: that task may
47/// still be running after the backend itself is destroyed. See
48/// `LocalBackend::execute`'s capture-by-`shared_ptr` rationale.
49struct HydrationState {
50 /// @brief Current phase. Only ever moves away from `pending`, exactly once.
51 std::atomic<Hydration> phase{Hydration::pending};
52
53 /// @brief Records the first action's outcome, if it has not been recorded already.
54 ///
55 /// A single compare-exchange, so the transition out of `pending` is
56 /// indivisible: a concurrent `poisoned()` sees either the whole transition
57 /// or none of it. Later actions' outcomes are ignored — only the *first*
58 /// action decides hydration.
59 /// @param succeeded `true` if the first action succeeded.
60 void settle(bool succeeded) noexcept {
61 Hydration expected = Hydration::pending;
62 phase.compare_exchange_strong(expected, succeeded ? Hydration::healthy : Hydration::poisoned);
63 }
64
65 /// @brief Whether the first action has settled as a failure.
66 /// @return `true` only in the `poisoned` phase; `false` while still `pending`.
67 [[nodiscard]] bool poisoned() const noexcept { return phase.load() == Hydration::poisoned; }
68};
69
70/// @brief A shared instance's directory key: `(typeId, primary)`.
71using DirectoryKey = std::pair<std::string, std::string>;
72
73/// @brief Hash functor for `DirectoryKey`.
74///
75/// Structurally identical to `registry.hpp`'s `::morph::model::detail::PairKeyHash`,
76/// but defined here rather than reused so this header depends on nothing beyond
77/// `model.hpp`: `registry.hpp` pulls in glaze and the forms/schema stack, and the
78/// instance directory is part of the async core that must stay usable without
79/// it.
80struct DirectoryKeyHash {
81 /// @brief Combines the hashes of the type id and the primary key.
82 /// @param key The directory key to hash.
83 /// @return The combined hash value.
84 [[nodiscard]] std::size_t operator()(const DirectoryKey& key) const noexcept {
85 std::size_t seed = std::hash<std::string>{}(key.first);
86 // NOLINTNEXTLINE(readability-magic-numbers, cppcoreguidelines-avoid-magic-numbers)
87 seed ^= std::hash<std::string>{}(key.second) + 0x9e3779b9 + (seed << 6) + (seed >> 2);
88 return seed;
89 }
90};
91
92/// @brief Everything a backend knows about one live model instance.
93///
94/// One record per instance, rather than one entry per instance in each of
95/// several `ModelId`-keyed maps kept in lockstep by convention. Every field is
96/// reached through a single hash lookup, and a half-updated instance is not
97/// representable.
98struct Instance {
99 /// @brief The model itself. Never null for a record that is in the directory.
100 std::shared_ptr<::morph::model::detail::IModelHolder> holder;
101
102 /// @brief Recorded owner principal, for `IAuthorizer::authorizeInstance`.
103 ///
104 /// Empty for an ownerless instance — which is every shared instance by
105 /// design (see `RemoteServer::acquireSharedInstance`) and every instance in
106 /// a `LocalBackend`, which has no authorizer.
107 std::string owner;
108
109 /// @brief Number of outstanding attachments, or `0` for a private instance.
110 ///
111 /// A private instance is not reference-counted at all: it has no directory
112 /// key, cannot be attached to, and is destroyed by the first `release()`.
113 /// A shared instance starts at `1` and is destroyed when it returns to `0`.
114 std::size_t attachCount = 0;
115
116 /// @brief The key this instance is filed under, or empty if it is private.
117 std::optional<DirectoryKey> sharedKey;
118
119 /// @brief First-action tracking, or null if this instance has none.
120 ///
121 /// Only an instance *created* by a shared register-or-attach carries one. A
122 /// private instance has nothing to hydrate, and an instance promoted into
123 /// the directory by `promote()` has already run the action that produced its
124 /// key, so neither can be poisoned.
125 ///
126 /// It therefore doubles as the permanent mark of "this instance was created
127 /// for a directory key", which outlives `sharedKey` — `attach()` clears that
128 /// on eviction, `hydration` survives — and is what makes an evicted instance
129 /// ineligible for `promote()`. See `promote()` for why that matters.
130 std::shared_ptr<HydrationState> hydration;
131};
132
133/// @brief The live model instances of one backend, plus the shared-instance directory.
134///
135/// One type, shared by `LocalBackend` and `RemoteServer`, in place of the six
136/// and eight parallel `ModelId`-keyed containers each would otherwise carry and
137/// two separate copies of the register-or-attach logic. The invariants it
138/// maintains are stated in `docs/spec/core/shared_instances.md`; this class is
139/// where they are enforced rather than asserted in comments.
140///
141/// @par Locking
142/// **Caller-locked, deliberately.** Every operation below assumes the owning
143/// backend already holds the mutex that guards it. That mutex covers more than
144/// this class — `RemoteServer` mutates connection scopes and checks
145/// `LimitPolicy::maxLiveModels` in the same critical section — so a lock of its
146/// own would either be redundant or, worse, let those neighbouring decisions
147/// straddle a directory change. The one piece of state that *is* internally
148/// synchronised is `HydrationState`, because it is settled from a model strand
149/// that holds no backend lock at all.
150class InstanceDirectory {
151public:
152 /// @brief Outcome of `release()`.
153 enum class Release : std::uint8_t {
154 /// @brief Another attachment remains; the instance is still live.
155 retained,
156 /// @brief The last reference went away and the instance was destroyed.
157 destroyed,
158 };
159
160 /// @brief Looks up one instance, read-only.
161 ///
162 /// Read-only because every field a backend reads — holder, owner, hydration
163 /// state — is set when the instance is filed and changed only by the
164 /// mutators below. Nothing outside this class has business writing one.
165 /// @param mid Instance id.
166 /// @return Pointer to the record, or `nullptr` if @p mid is not live.
167 [[nodiscard]] const Instance* find(::morph::exec::detail::ModelId mid) const {
168 auto iter = _instances.find(mid);
169 return iter == _instances.end() ? nullptr : &iter->second;
170 }
171
172 /// @brief Number of live instances, shared and private alike.
173 /// @return The instance count, which is what `LimitPolicy::maxLiveModels` bounds.
174 [[nodiscard]] std::size_t size() const noexcept { return _instances.size(); }
175
176 /// @brief Records a private instance: no key, no sharing, no hydration tracking.
177 /// @param mid Freshly allocated id.
178 /// @param holder The constructed model.
179 /// @param owner Owner principal for `authorizeInstance`; empty if none.
180 void insertPrivate(::morph::exec::detail::ModelId mid,
181 std::shared_ptr<::morph::model::detail::IModelHolder> holder, std::string owner = {}) {
182 _instances.insert_or_assign(mid, Instance{.holder = std::move(holder), .owner = std::move(owner)});
183 MORPH_PLOT("RemoteServer::liveModels", _instances.size());
184 }
185
186 /// @brief Records a freshly created shared instance and files it under @p key.
187 ///
188 /// The instance starts with one attachment and with hydration `pending`, so
189 /// the outcome of its first action decides whether it may be handed to a
190 /// second attacher.
191 /// @param mid Freshly allocated id.
192 /// @param holder The constructed model.
193 /// @param key Directory key to file it under; must not already be taken.
194 /// @param owner Owner principal for `authorizeInstance`; empty if none.
195 void insertShared(::morph::exec::detail::ModelId mid, std::shared_ptr<::morph::model::detail::IModelHolder> holder,
196 DirectoryKey key, std::string owner = {}) {
197 indexKey(key, mid);
198 _instances.insert_or_assign(mid, Instance{.holder = std::move(holder),
199 .owner = std::move(owner),
200 .attachCount = 1,
201 .sharedKey = std::move(key),
202 .hydration = std::make_shared<HydrationState>()});
203 MORPH_PLOT("RemoteServer::liveModels", _instances.size());
204 }
205
206 /// @brief Takes one more attachment to the instance filed under @p key, if any.
207 ///
208 /// A poisoned instance reports as a *miss*: it is evicted from the directory
209 /// here — lazily, at the next attach, exactly as the spec's Failure modes
210 /// section specifies — and the caller goes on to create a fresh instance.
211 /// The evicted instance itself is untouched and still live; whoever created
212 /// it releases it normally.
213 /// @param key Directory key being acquired.
214 /// @return The attached instance's id, or `std::nullopt` on a miss or an eviction.
215 [[nodiscard]] std::optional<::morph::exec::detail::ModelId> attach(const DirectoryKey& key) {
216 auto keyIter = _byKey.find(key);
217 if (keyIter == _byKey.end()) {
218 return std::nullopt;
219 }
220 auto const mid = keyIter->second;
221 Instance& inst = _instances.at(mid);
222 if (inst.hydration && inst.hydration->poisoned()) {
223 unindexKey(key);
224 inst.sharedKey.reset();
225 return std::nullopt;
226 }
227 inst.attachCount += 1;
228 return mid;
229 }
230
231 /// @brief Files an already-live, still-anonymous instance under @p key.
232 ///
233 /// The promotion half of keyed instances: an action that creates its own
234 /// entity runs on an instance that has no key yet, and the key only exists
235 /// once the result comes back. The existing holder of a key always wins, and
236 /// an instance that already holds a real key never changes it — both are
237 /// silent no-ops, because instances never mutate their own identity.
238 ///
239 /// Only an instance that has **never** held a key can be promoted, and that
240 /// is a stronger test than "has no key right now". `attach()` clears the
241 /// `sharedKey` of an instance it evicts as poisoned, so an evicted instance
242 /// looks anonymous from the directory's side — but it was created *for* its
243 /// original key and told so once, permanently: `RemoteServer` builds its
244 /// holder through `ModelRegistryFactory::create(typeId, primary)`, which
245 /// calls `IModelHolder::attachIdentity(primary)`, and attaches the action
246 /// log under that same key. Nothing updates either afterwards, so re-filing
247 /// such an instance under a second key would hand every later attacher a
248 /// model that still identifies itself as the first one — the re-keying
249 /// `docs/spec/core/shared_instances.md` ("Re-pointing, not re-keying")
250 /// forbids outright. A non-null `hydration` is exactly the mark of
251 /// "created for a directory key", so it is what disqualifies the record.
252 ///
253 /// Its own Failure modes section already says what happens instead: the
254 /// handler that hit the failed first action "does not self-heal… it keeps
255 /// its broken instance until it releases and re-attaches from scratch".
256 /// The promotion is a silent no-op like the other three, the instance stays
257 /// anonymous and unshareable, and the key the host asked for is left free
258 /// for the next attacher to create a healthy instance under.
259 ///
260 /// A promoted instance therefore never carries a `HydrationState`: the
261 /// action that produced its key has already succeeded, so there is nothing
262 /// left to poison.
263 /// @param mid Live instance to promote.
264 /// @param key Directory key to file it under.
265 /// @return `true` if the promotion happened.
266 bool promote(::morph::exec::detail::ModelId mid, DirectoryKey key) {
267 auto instIter = _instances.find(mid);
268 if (instIter == _instances.end() || _byKey.contains(key)) {
269 return false;
270 }
271 Instance& inst = instIter->second;
272 if (inst.sharedKey.has_value() || inst.hydration) {
273 return false;
274 }
275 indexKey(key, mid);
276 inst.sharedKey = std::move(key);
277 // The instance was private until now — `insertPrivate` is the only way
278 // in for a record with no `hydration`, and it files one with no
279 // attachments at all. The directory now references it, so it gets the
280 // one attachment that reference stands for.
281 inst.attachCount = 1;
282 return true;
283 }
284
285 /// @brief Releases one attachment, destroying the instance at zero.
286 ///
287 /// A private instance (`attachCount == 0`) is destroyed outright. A shared
288 /// instance is unfiled from the directory in the same step that destroys it,
289 /// so directory membership can never outlive the instance it names.
290 /// @param mid Instance to release.
291 /// @return Whether this call destroyed the instance.
292 Release release(::morph::exec::detail::ModelId mid) {
293 auto instIter = _instances.find(mid);
294 if (instIter == _instances.end()) {
295 return Release::retained;
296 }
297 Instance& inst = instIter->second;
298 if (inst.attachCount > 0) {
299 inst.attachCount -= 1;
300 if (inst.attachCount > 0) {
301 return Release::retained;
302 }
303 if (inst.sharedKey) {
304 unindexKey(*inst.sharedKey);
305 }
306 }
307 _instances.erase(instIter);
308 MORPH_PLOT("RemoteServer::liveModels", _instances.size());
309 return Release::destroyed;
310 }
311
312 /// @brief Primary keys of the live shared instances of @p typeId.
313 ///
314 /// Served from a per-type index rather than by scanning the whole directory,
315 /// so enumerating one model type does not cost the size of every other type
316 /// put together.
317 /// @param typeId String type-id to enumerate.
318 /// @return The canonical key strings, in unspecified order.
319 [[nodiscard]] std::vector<std::string> keysOfType(const std::string& typeId) const {
320 auto typeIter = _byType.find(typeId);
321 if (typeIter == _byType.end()) {
322 return {};
323 }
324 return {typeIter->second.begin(), typeIter->second.end()};
325 }
326
327private:
328 /// @brief Adds @p key to both directory indexes. The single place either is grown.
329 /// @param key Directory key being filed.
330 /// @param mid Instance it names.
331 void indexKey(const DirectoryKey& key, ::morph::exec::detail::ModelId mid) {
332 _byKey.emplace(key, mid);
333 _byType[key.first].insert(key.second);
334 }
335
336 /// @brief Removes @p key from both directory indexes. The single place either shrinks.
337 /// @param key Directory key being unfiled.
338 void unindexKey(const DirectoryKey& key) {
339 _byKey.erase(key);
340 // A guarded `find`, not `at`: the two indexes are only ever grown and
341 // shrunk together, here and in `indexKey`, so a missing type bucket is a
342 // broken invariant -- but this runs inside `release()`, which
343 // `RemoteServer::closeConnection` calls, which transports in turn call
344 // from a (noexcept) scope-guard destructor (`net/socket_server.hpp`). A
345 // `std::out_of_range` escaping there aborts the process rather than
346 // reporting anything, so the invariant is left to the tests to police.
347 if (auto typeIter = _byType.find(key.first); typeIter != _byType.end()) {
348 typeIter->second.erase(key.second);
349 if (typeIter->second.empty()) {
350 _byType.erase(typeIter);
351 }
352 }
353 }
354
355 /// @brief Every live instance, private and shared alike. The directory's only owner of state.
356 std::unordered_map<::morph::exec::detail::ModelId, Instance, ::morph::exec::detail::ModelIdHash> _instances;
357
358 /// @brief `(typeId, primary)` -> instance. An index over `_instances`, never an owner.
359 std::unordered_map<DirectoryKey, ::morph::exec::detail::ModelId, DirectoryKeyHash> _byKey;
360
361 /// @brief `typeId` -> that type's live primaries. An index over `_byKey`, for `keysOfType`.
362 std::unordered_map<std::string, std::unordered_set<std::string>> _byType;
363};
364
365} // namespace morph::backend::detail
Instance
How a model alias binds when no key expression is given (spec 5 §5).
Definition author.hpp:1546
Compile-time profiler instrumentation: Tracy zones, plots, messages and thread names,...
#define MORPH_PLOT(name, value)
Records value on the plot named name.
Definition profiler.hpp:132