morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
watch_table.hpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4#include <array>
5#include <cstddef>
6#include <cstdint>
7#include <functional>
8#include <map>
9#include <memory>
10#include <optional>
11#include <string>
12#include <string_view>
13#include <utility>
14#include <vector>
15
16#include "../../session/session.hpp"
18#include "../wire.hpp"
19
20/// @file
21/// @brief The topics each connection watches, as `RemoteServer` keeps them.
22///
23/// Holds only what the server decided: admission, which needs the record and
24/// action registries and the authorizer, happens in the server, which hands
25/// this table the admitted topics.
26
27namespace morph::backend::detail {
28
29/// @brief What one watched topic was admitted on.
30///
31/// A connection has no principal of its own, so each watch keeps the session
32/// of the `watch` envelope that created it and the principal verified from
33/// that session. Fan-out re-authorises the watch on them at send time, by the
34/// rule that admitted it.
35struct Watch {
36 /// @brief The `watch` envelope's session, its principal already the verified one.
37 ::morph::session::Context context;
38 /// @brief The principal the server verified for that envelope; empty when none was.
39 std::string principal;
40};
41
42/// @brief What an admission answer in a `WatchTable` is about.
43enum class AdmissionTopic : std::uint8_t {
44 /// @brief A record type, by wire name.
45 record,
46 /// @brief The instances of a model type, by type name.
47 modelType,
48};
49
50/// @brief Each connection's watched record types and keyed instances. On the
51/// server strand: not synchronised.
52///
53/// A record topic is stored by record name. An instance topic is stored by
54/// `(modelType, primary)` and never resolved to an instance id, because the
55/// watch is sent before the fetch that may create the instance. Watching a
56/// topic the connection already watches keeps the newer `Watch`. One
57/// connection watches at most `wire::kMaxWatchTopics` topics, records and
58/// instances together, which bounds what a peer can make the server hold.
59///
60/// The table also caches admission answers per verified principal and token:
61/// two tokens of one principal may carry different claims, so they are judged
62/// apart. Each `(principal, token)` counts the topics watched with it, and its
63/// answers are forgotten when that count returns to zero, so a session that
64/// watches again is judged afresh.
65class WatchTable {
66public:
67 /// @brief Adds @p body's topics to @p cid's watch set, or, when
68 /// `body.replace` is set, makes the set exactly @p body's topics.
69 ///
70 /// A topic not watched yet is refused while the connection already
71 /// watches `wire::kMaxWatchTopics` topics.
72 /// @param cid Connection the topics are watched on.
73 /// @param body The admitted topics, without duplicates; `instance` ids are ignored.
74 /// @param watch What the topics were admitted on, shared by all of them.
75 /// @return The topics refused because the connection was at its cap.
76 ::morph::wire::WatchReply add(ConnectionId cid, ::morph::wire::WatchBody const& body, Watch watch) {
77 if (body.replace) {
78 dropConnection(cid);
79 }
80 auto const shared = std::make_shared<Watch const>(std::move(watch));
81 SessionKey const key = keyOf(*shared);
82 ::morph::wire::WatchReply overCap;
83 Connection& connection = _connections[cid];
84 for (auto const& record : body.records) {
85 if (!place(connection, connection.records, record, shared, key)) {
86 overCap.refusedRecords.push_back(record);
87 }
88 }
89 for (auto const& topic : body.instances) {
90 if (!place(connection, connection.instances, InstanceKey{topic.modelType, topic.primary}, shared, key)) {
91 overCap.refusedInstances.push_back(topic);
92 }
93 }
94 if (connection.size() == 0) {
95 _connections.erase(cid);
96 }
97 forgetIfIdle(key);
98 return overCap;
99 }
100
101 /// @brief Removes @p body's topics from @p cid's watch set. A topic not
102 /// watched there is ignored.
103 /// @param cid Connection the topics were watched on.
104 /// @param body The topics; `replace` and `instance` ids are ignored.
105 void remove(ConnectionId cid, ::morph::wire::WatchBody const& body) {
106 auto found = _connections.find(cid);
107 if (found == _connections.end()) {
108 return;
109 }
110 for (auto const& record : body.records) {
111 erase(found->second.records, record);
112 }
113 for (auto const& topic : body.instances) {
114 erase(found->second.instances, InstanceKey{topic.modelType, topic.primary});
115 }
116 if (found->second.size() == 0) {
117 _connections.erase(found);
118 }
119 }
120
121 /// @brief Drops every watch @p cid holds.
122 /// @param cid Connection that closed, failed a write, or changed principal.
123 void dropConnection(ConnectionId cid) {
124 auto found = _connections.find(cid);
125 if (found == _connections.end()) {
126 return;
127 }
128 for (auto const& [record, watch] : found->second.records) {
129 release(keyOf(*watch));
130 }
131 for (auto const& [topic, watch] : found->second.instances) {
132 release(keyOf(*watch));
133 }
134 _connections.erase(found);
135 }
136
137 /// @brief The connections watching record type @p record.
138 /// @param record A record's wire name.
139 /// @return The connection ids, ascending.
140 [[nodiscard]] std::vector<ConnectionId> watchersOf(std::string_view record) const {
141 std::vector<ConnectionId> watchers;
142 for (auto const& [cid, connection] : _connections) {
143 if (connection.records.contains(record)) {
144 watchers.push_back(cid);
145 }
146 }
147 return watchers;
148 }
149
150 /// @brief The connections watching the keyed instance `(modelType, primary)`.
151 /// @param modelType The instance's registered model type.
152 /// @param primary The instance's directory key text.
153 /// @return The connection ids, ascending.
154 [[nodiscard]] std::vector<ConnectionId> watchersOf(std::string_view modelType, std::string_view primary) const {
155 InstanceKey const key{std::string{modelType}, std::string{primary}};
156 std::vector<ConnectionId> watchers;
157 for (auto const& [cid, connection] : _connections) {
158 if (connection.instances.contains(key)) {
159 watchers.push_back(cid);
160 }
161 }
162 return watchers;
163 }
164
165 /// @brief What @p cid's watch on record type @p record was admitted on.
166 /// @param cid Connection.
167 /// @param record A record's wire name.
168 /// @return The watch, valid until the table next changes, or null when
169 /// @p cid does not watch @p record.
170 [[nodiscard]] Watch const* watchOf(ConnectionId cid, std::string_view record) const {
171 auto const connection = _connections.find(cid);
172 if (connection == _connections.end()) {
173 return nullptr;
174 }
175 auto const found = connection->second.records.find(record);
176 return found == connection->second.records.end() ? nullptr : found->second.get();
177 }
178
179 /// @brief What @p cid's watch on the keyed instance `(modelType, primary)`
180 /// was admitted on.
181 /// @param cid Connection.
182 /// @param modelType The instance's registered model type.
183 /// @param primary The instance's directory key text.
184 /// @return The watch, valid until the table next changes, or null when
185 /// @p cid does not watch that instance.
186 [[nodiscard]] Watch const* watchOf(ConnectionId cid, std::string_view modelType, std::string_view primary) const {
187 auto const connection = _connections.find(cid);
188 if (connection == _connections.end()) {
189 return nullptr;
190 }
191 auto const found =
192 connection->second.instances.find(InstanceKey{std::string{modelType}, std::string{primary}});
193 return found == connection->second.instances.end() ? nullptr : found->second.get();
194 }
195
196 /// @brief Drops every watch @p cid holds that was admitted with
197 /// @p principal and @p token, and keeps the rest.
198 ///
199 /// What a watch whose token no longer authenticates takes with it: every
200 /// topic the connection watched under that same session.
201 /// @param cid Connection.
202 /// @param principal The verified principal the watches were admitted for.
203 /// @param token The token they were admitted with.
204 void dropSession(ConnectionId cid, std::string const& principal, std::string const& token) {
205 auto found = _connections.find(cid);
206 if (found == _connections.end()) {
207 return;
208 }
209 SessionKey const key{principal, token};
210 auto const dropMatching = [this, &key](auto& topics) {
211 for (auto it = topics.begin(); it != topics.end();) {
212 if (keyOf(*it->second) == key) {
213 release(key);
214 it = topics.erase(it);
215 } else {
216 ++it;
217 }
218 }
219 };
220 dropMatching(found->second.records);
221 dropMatching(found->second.instances);
222 if (found->second.size() == 0) {
223 _connections.erase(found);
224 }
225 }
226
227 /// @brief How many topics @p cid watches, records and instances together.
228 /// @param cid Connection.
229 /// @return The count; 0 for a connection with no watches.
230 [[nodiscard]] std::size_t topicCount(ConnectionId cid) const {
231 auto const found = _connections.find(cid);
232 return found == _connections.end() ? 0 : found->second.size();
233 }
234
235 /// @brief The cached answer to whether @p session may watch @p name.
236 /// @param session A session whose principal is the verified one.
237 /// @param kind What @p name names.
238 /// @param name A record's wire name or a model type name.
239 /// @return The answer, or `nullopt` when none is cached.
240 [[nodiscard]] std::optional<bool> admission(::morph::session::Context const& session, AdmissionTopic kind,
241 std::string_view name) const {
242 auto const bySession = _sessions.find(SessionKey{session.principal, session.token});
243 if (bySession == _sessions.end()) {
244 return std::nullopt;
245 }
246 auto const& answers = bySession->second.answers.at(static_cast<std::size_t>(kind));
247 auto const found = answers.find(name);
248 if (found == answers.end()) {
249 return std::nullopt;
250 }
251 return found->second;
252 }
253
254 /// @brief Caches whether @p session may watch @p name.
255 ///
256 /// Kept while a topic watched with the same principal and token remains;
257 /// forgotten at the end of the next `add` otherwise.
258 /// @param session A session whose principal is the verified one.
259 /// @param kind What @p name names.
260 /// @param name A registered record's wire name or model type name.
261 /// @param admitted The answer.
262 void noteAdmission(::morph::session::Context const& session, AdmissionTopic kind, std::string_view name,
263 bool admitted) {
264 auto& answers = _sessions[SessionKey{session.principal, session.token}].answers;
265 answers.at(static_cast<std::size_t>(kind)).insert_or_assign(std::string{name}, admitted);
266 }
267
268private:
269 using InstanceKey = std::pair<std::string, std::string>;
270 // (verified principal, token): what an admission answer was decided on.
271 using SessionKey = std::pair<std::string, std::string>;
272 using WatchPtr = std::shared_ptr<Watch const>;
273 using Answers = std::map<std::string, bool, std::less<>>;
274
275 /// One connection's watches, by topic.
276 struct Connection {
277 std::map<std::string, WatchPtr, std::less<>> records;
278 std::map<InstanceKey, WatchPtr> instances;
279
280 [[nodiscard]] std::size_t size() const { return records.size() + instances.size(); }
281 };
282
283 /// How many watched topics carry one session, and its cached answers by `AdmissionTopic`.
284 struct Session {
285 std::size_t topics = 0;
286 std::array<Answers, 2> answers;
287 };
288
289 [[nodiscard]] static SessionKey keyOf(Watch const& watch) { return {watch.principal, watch.context.token}; }
290
291 /// Stores @p watch for @p topic, unless that would take @p connection past the cap.
292 template <class Topics, class Topic>
293 bool place(Connection const& connection, Topics& topics, Topic const& topic, WatchPtr const& watch,
294 SessionKey const& key) {
295 auto const found = topics.find(topic);
296 if (found != topics.end()) {
297 // Counted before the old session is released, so a re-watch by the
298 // same session never drops that session's answers.
299 ++_sessions[key].topics;
300 release(keyOf(*found->second));
301 found->second = watch;
302 return true;
303 }
304 if (connection.size() >= ::morph::wire::kMaxWatchTopics) {
305 return false;
306 }
307 topics.emplace(topic, watch);
308 ++_sessions[key].topics;
309 return true;
310 }
311
312 /// Removes @p topic, if watched, and releases its session.
313 template <class Topics, class Topic>
314 void erase(Topics& topics, Topic const& topic) {
315 auto const found = topics.find(topic);
316 if (found == topics.end()) {
317 return;
318 }
319 release(keyOf(*found->second));
320 topics.erase(found);
321 }
322
323 /// One topic watched with @p key is gone; the session goes with its last.
324 void release(SessionKey const& key) {
325 auto const found = _sessions.find(key);
326 if (found != _sessions.end() && --found->second.topics == 0) {
327 _sessions.erase(found);
328 }
329 }
330
331 /// Forgets @p key's answers when no watched topic carries it.
332 void forgetIfIdle(SessionKey const& key) {
333 auto const found = _sessions.find(key);
334 if (found != _sessions.end() && found->second.topics == 0) {
335 _sessions.erase(found);
336 }
337 }
338
339 // Ordered by connection id, so `watchersOf` answers in ascending order.
340 std::map<ConnectionId, Connection> _connections;
341 std::map<SessionKey, Session> _sessions;
342};
343
344} // namespace morph::backend::detail
AnyHandle session(std::string const &name)
A session attribute, {"session": name}.
Definition author.hpp:1214
What a transport hands RemoteServer to write to one connection, and the backlog bounds both sides app...
std::vector< InstanceTopic > instances
Instance topics to watch (or stop watching).
Definition wire.hpp:685
bool replace
For watch: replace the connection's whole watch set instead of adding to it.
Definition wire.hpp:687
std::vector< std::string > records
Record wire names to watch (or stop watching).
Definition wire.hpp:683
std::vector< InstanceTopic > refusedInstances
Instance topics the server refused.
Definition wire.hpp:695
std::vector< std::string > refusedRecords
Record names the server does not publish or the caller may not read.
Definition wire.hpp:693