src/genro_asgi_multiworker_spa/register_registry.py¶
Source from this local checkout, regenerated when the reader rebuilds.
Line links use #L<number>; a GitHub line range opens its first line.
1 # Copyright 2025 Softwell S.r.l.2 #3 # Licensed under the Apache License, Version 2.0 (the "License");4 # you may not use this file except in compliance with the License.5 # You may obtain a copy of the License at6 #7 # https://www.apache.org/licenses/LICENSE-2.08 #9 # Unless required by applicable law or agreed to in writing, software10 # distributed under the License is distributed on an "AS IS" BASIS,11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.12 # See the License for the specific language governing permissions and13 # limitations under the License.14 15 """RegisterRegistry: the register of registers.16 17 One host holding named :class:`Register` instances. The core creates the three18 primary registers of the worker world (legacy origin, the rich-content ones)19 and nothing else: ``user_items`` (keyed by user, no secondary index),20 ``connection_items`` (keyed by the connection id, no secondary index) and21 ``page_items`` (keyed by page_id, indexed by ``connection_id`` and22 ``root_page_id`` — query dimensions of the page forest, not edges of the23 ownership tree). The ``_items`` suffix is the ratified24 naming rule: the name says the map and the level — naked ``users``/``pages``25 are banned26 because the commander world will host its own thin routing registers27 (``user_worker_map``, ``worker_roster``, ...) and the word alone must tell28 them apart.29 30 **Semantic minimum vs passthrough.** The core assigns meaning to exactly the31 fields it indexes and cascades on; every other field a caller passes is stored32 verbatim and never interpreted. A downstream layer can therefore enrich a page33 row with its own data (``connection_id``, a socket handle, whatever it owns)34 without the core learning about it.35 36 **The ownership chain — the tree LIVES IN THE ITEMS.** A page belongs to a37 CONNECTION, and a connection belongs to a user: page → connection → user.38 Downwards the edges are sets carried by the items themselves — a user entry39 carries ``connections``, a connection row carries ``pages``; upwards they are40 the parent key each child already holds — a page row's ``connection_id``, a41 connection row's ``user``. Every lifecycle mutator writes both directions in42 the same gesture, so the two can never disagree. A page row stores NO ``user``43 label: the owner is DERIVED by walking up (``page_user``), and what is44 derived cannot diverge. The connection row is born GUEST — its ``user`` is45 ``GUEST_PREFIX`` + the connection id, the name itself carrying the guest rule —46 and the login is a label mutation on that live row, never a re-key.47 48 **The extension seam.** ``new_register(name, index_attrs=())`` creates, hosts49 and *returns* the register: the caller keeps the reference — the same graft50 pattern as the site grammar, no magic attribute access, no by-name getter.51 Only ``user_items``, ``connection_items`` and ``page_items`` are exposed as52 properties, because only they are stable core API. A consumer that needs an53 index the core did not declare calls ``add_index(register_name, attr)`` and the54 register rebuilds.55 56 **Lifecycle vocabulary.** ``new_user``/``new_connection``/``new_page``/57 ``update_page``/``change_connection_user``/``drop_page``/``drop_connection``/58 ``drop_user`` are the only supported way to move the three generic registers59 through their life: they hold the root conventions of a page forest and the60 cascades along the chain. The cascades live in code, never in a declarative61 table.62 63 **The login is a mutation.** ``change_connection_user`` re-labels a live64 connection onto the logged-in user and moves its id between the two users'65 ``connections`` sets. The pages need no re-labelling — their owner was never written66 down. Nothing is re-keyed and nothing is re-born: keys and live stores67 survive the login. The old user is dropped only once its68 ``connections`` set is empty. The one exception is the anonymous entry claiming69 its first real identity: there the user item itself is TRANSFERRED onto the new70 key, store included — see ``change_connection_user`` for the rule and its two71 boundaries.72 73 **The cascade discipline, legacy verbatim.** Bringing into being climbs the74 chain: ``new_page`` creates the connection when unseen and the user when75 unseen. Demolition climbs it only ONCE, from the originating drop —76 ``drop_page`` takes the connection with the last page of it, ``drop_connection``77 takes the user with its last connection — and every descending drop passes78 ``cascade=False``, so the demolition never climbs back up the branch it is79 already tearing down. Both "last one" checks read the emptied set on the parent80 item; the descending walks iterate a COPY of it, since each drop discards from81 the very set being walked.82 83 **The live stores.** A user row and a page row each carry ``store``, a live84 Bag born of ``new_store`` — the seam a consumer overrides with its own type.85 What a page captures of its own store or of its user's, and how, is the86 consumer's: its row class adds the fields, its registry subclass attaches the87 capture (``new_page`` calls ``subscribe_page_store`` after the birth, and the88 lifecycle calls ``detach_page`` before a row leaves or its store is copied —89 both empty here).90 91 **The row's own lock.** Every row of the three registers — user, connection92 and page — is born with ``item_lock``, an exclusive re-entrant lock. Every93 access to the row and to its Bag, read or write, takes it: one access at a94 time per item, items in parallel. The daemon had no such need on the row95 itself — it served one call at a time on one thread — and added a cooperative96 per-item ``lock_item`` only for the site's ``with`` blocks, with97 ``LOCK_EXPIRY_SECONDS = 10`` because a WSGI process could die inside one.98 Here the block is in-process and always exits, so the lock has no expiry.99 100 Impossible cases are explicit errors: a duplicate register name raises101 ValueError, an unknown register name raises KeyError.102 """103 104 from __future__ import annotations105 106 import time107 from typing import Any108 109 from genro_bag import Bag110 111 from genro_asgi.session.session import Session112 from .register import Register113 from .register_row import ConnectionRow, PageRow, UserRow114 115 __all__ = ["GUEST_PREFIX", "RegisterRegistry"]116 117 #: The reserved prefix that names an anonymous user — the daemon's own118 #: convention (siteregister.py:716-717), restored so the NAME carries the119 #: guest rule and a consumer minting its own ``guest_<id>`` needs no120 #: translation layer. ``change_connection_user`` refuses a target carrying it:121 #: nobody can log in as a guest.122 GUEST_PREFIX = "guest_"123 124 125 class RegisterRegistry:126 """A host of named registers, with the two generic ones built in."""127 128 #: The row classes the three registers build their items as: a consumer129 #: pairing its own fields with the chain subclasses the row and names it here.130 user_row_class: type[UserRow] = UserRow131 connection_row_class: type[ConnectionRow] = ConnectionRow132 page_row_class: type[PageRow] = PageRow133 134 def __init__(self) -> None:135 """Create the host with the three primary registers of the chain."""136 self._registers: dict[str, Register] = {137 "user_items": Register("user_items", row_class=self.user_row_class),138 "connection_items": Register(139 "connection_items", row_class=self.connection_row_class140 ),141 "page_items": Register(142 "page_items",143 index_attrs=("connection_id", "root_page_id"),144 row_class=self.page_row_class,145 ),146 }147 148 @property149 def user_items(self) -> Register:150 """The primary register of users, keyed by user."""151 return self._registers["user_items"]152 153 @property154 def connection_items(self) -> Register:155 """The primary register of connections, keyed by the connection id."""156 return self._registers["connection_items"]157 158 @property159 def page_items(self) -> Register:160 """The primary register of pages, keyed by page_id."""161 return self._registers["page_items"]162 163 def new_register(self, name: str, index_attrs: tuple[str, ...] = ()) -> Register:164 """Create a register named ``name``, host it and return it.165 166 The returned reference is how the caller reaches its own register:167 there is no by-name getter. Raises ``ValueError`` if ``name`` is168 already hosted.169 """170 if name in self._registers:171 raise ValueError(f"register already exists: {name!r}")172 register = Register(name, index_attrs=index_attrs)173 self._registers[name] = register174 return register175 176 def add_index(self, register_name: str, attr: str) -> None:177 """Add a secondary index on ``attr`` to a hosted register.178 179 Delegates to the register's own ``add_index`` (idempotent, rebuilds180 from the existing rows). Raises ``KeyError`` if ``register_name`` is181 not hosted.182 """183 self._registers[register_name].add_index(attr)184 185 def new_store(self) -> Any:186 """The store factory: the birth of every row's live store.187 188 A consumer whose rows hold its own store type overrides this alone —189 nothing else in the machinery names the concrete class, and a store190 travels the move pickled, whole.191 """192 return Bag()193 194 def subscribe_page_store(self, page: dict[str, Any]) -> None:195 """Attach to a page's store whatever must capture its writes: nothing here.196 197 Args:198 page: the page row just born, its ``store`` on it.199 200 Called by ``new_page`` after the birth. The seam a consumer overrides201 to pair its capture with its row class.202 """203 204 def new_user(self, user: str, **fields: Any) -> dict[str, Any]:205 """Create the entry of ``user`` in the ``user_items`` register.206 207 The entry is born with a live ``store`` Bag unless the caller supplies208 one — a moved user arrives with its own, already hydrated — and with an209 empty ``connections`` set, the downward edge of the tree, which callers210 never supply: it is filled by ``new_connection``.211 212 It is born STAMPED: ``last_refresh_ts`` carries the server's own clock213 from birth, so the expiry sweep needs no fallback to a start time. A214 supplied value is honoured — a moved row keeps the stamp it travelled215 with.216 217 Raises ``ValueError`` if the user already has an entry — page218 creation calls this only for a user it has not seen.219 """220 if "store" not in fields:221 fields["store"] = self.new_store()222 fields.setdefault("last_refresh_ts", time.time())223 return self.user_items.create(224 user, connections=set(), **fields225 )226 227 def new_connection(228 self, connection_id: str, user: str | None = None, **fields: Any229 ) -> dict[str, Any]:230 """Create the connection row of ``connection_id``, born guest by default.231 232 ``user is None`` means the anonymous reception: the row takes233 ``GUEST_PREFIX`` + the connection id as its user — the daemon's own234 naming, so the name itself says guest — and the guest user entry is235 brought into being with it, a user entry like any other, with its own236 live store. A consumer that mints its own ``guest_<id>`` passes it237 explicitly and falls under the same rule with no translation.238 239 The row is born with a live ``store`` Bag unless the caller supplies one240 — a moved connection arrives with its own, already hydrated — like every241 other row of the tree. That store is SERVER-SIDE ONLY: no view, no242 collector, nothing of it is ever replicated with the browser.243 244 The row is born with an empty ``pages`` set and its id joins the owner245 entry's ``connections``: both directions of the edge in one gesture. It246 is born STAMPED with the server's clock, like every row of the chain.247 248 Raises ``ValueError`` if the connection already has a row.249 """250 if user is None:251 user = GUEST_PREFIX + connection_id252 if "store" not in fields:253 fields["store"] = self.new_store()254 fields.setdefault("last_refresh_ts", time.time())255 if user not in self.user_items:256 self.new_user(user)257 connection = self.connection_items.create(258 connection_id, user=user, pages=set(), **fields259 )260 self.user_items.get(user)["connections"].add(connection_id)261 return connection262 263 def new_page(264 self,265 page_id: str,266 *,267 user: str,268 connection_id: str,269 root_page_id: str | None = None,270 parent_page_id: str | None = None,271 avatar_key: str = Session.ROOT_AVATAR_KEY,272 data: Any = None,273 **fields: Any,274 ) -> dict[str, Any]:275 """Create a page row, defaulting the root conventions of its tree.276 277 ``parent_page_id is None`` means the page is a root, and a root's278 ``root_page_id`` defaults to its own ``page_id`` — so279 ``keys_by("root_page_id", X)`` returns the whole tree, root included.280 A child (``parent_page_id`` set) without a ``root_page_id`` is an281 impossible case and raises ``ValueError``.282 283 ``connection_id`` names the connection the page hangs from — the284 daemon's own word for it. The chain is brought into being from the285 bottom up: the connection row when unseen, and with it the user286 entry when unseen. ``user`` drives that cascade ONLY — it is never287 stored on the page row, whose owner is derived by ``page_user``.288 The new page id joins its connection row's ``pages``.289 290 Rows are born with a live ``store`` Bag, then handed to291 ``subscribe_page_store``, and with whatever ``page_row_class`` seeds as292 its defaults — a passed field winning over a default, so a woken page293 arrives with what its parcel carried and keeps it. The row is born294 STAMPED with the server's clock, like the connection and the user above295 it. Every other keyword passes through verbatim (schemaless).296 """297 if parent_page_id is not None and root_page_id is None:298 raise ValueError(f"page {page_id!r} has a parent but no root_page_id")299 if root_page_id is None:300 root_page_id = page_id301 if user not in self.user_items:302 self.new_user(user)303 if connection_id not in self.connection_items:304 self.new_connection(connection_id, user=user)305 store = fields.pop("store", None)306 if store is None:307 store = self.new_store()308 fields.setdefault("last_refresh_ts", time.time())309 page = self.page_items.create(310 page_id,311 connection_id=connection_id,312 root_page_id=root_page_id,313 parent_page_id=parent_page_id,314 avatar_key=avatar_key,315 data=data,316 store=store,317 **fields,318 )319 self.connection_items.get(connection_id)["pages"].add(page_id)320 self.subscribe_page_store(page)321 return page322 323 def page_user(self, page_id: str) -> str:324 """The user a page belongs to, derived by walking up its chain.325 326 Page row → ``connection_id`` → connection row → ``user``. Nothing is327 stored twice, so nothing can go stale. Raises ``KeyError`` if the page328 is not registered.329 """330 page = self.page_items.get(page_id)331 if page is None:332 raise KeyError(f"page_user: unknown page {page_id!r}")333 return self.connection_items.get(page["connection_id"])["user"]334 335 def change_connection_user(336 self, connection_id: str, user: str, **fields: Any337 ) -> dict[str, Any]:338 """Move a live connection from its current user to ``user`` — a mutation.339 340 The login re-labels; it never re-keys. The connection row and every page341 row of it keep their keys and their live objects: only the ``user``342 label changes, on the connection alone — the pages have none to change,343 their owner being derived through this very row.344 345 **The guest item follows its first real identity.** This is a346 declared divergence from the daemon, which built the new user fresh347 and let the guest die with its data. When the connection is still348 anonymous (its user name carries ``GUEST_PREFIX``, the born-guest rule)349 and the target user has no350 entry yet, the entry is TRANSFERRED: only the key changes, the values —351 the live store above all — are conserved, and the ``connections`` set352 travels inside the entry already naming this connection. Two boundaries hold it in place: a RESIDENT wins353 — a login onto a user that already exists leaves that entry and its354 store the truth, and the orphaned guest still dies with its data — and355 only an ANONYMOUS item transfers: a real user's entry never changes key.356 357 On the non-transfer paths the connection id moves between the two users'358 ``connections`` sets in the same gesture, and the destination user entry359 is brought into being when unseen with its own live store. The previous360 user leaves only when its ``connections`` set came out empty: this was its last connection, and its store dies361 with it.362 363 ``GUEST_PREFIX`` is RESERVED: a login target carrying it raises364 ``ValueError`` — nobody can log in as a guest. The ban lives here and365 not at ``new_connection``, which legitimately receives an explicit366 ``guest_<id>`` from a consumer declaring its own anonymous connection.367 368 Returns the mutated connection row; raises ``KeyError`` if369 ``connection_id`` is not registered.370 """371 if user.startswith(GUEST_PREFIX):372 raise ValueError(373 f"change_connection_user: {user!r} — "374 f"{GUEST_PREFIX!r} is reserved, nobody logs in as a guest"375 )376 connection = self.connection_items.get(connection_id)377 if connection is None:378 raise KeyError(f"change_connection_user: unknown connection {connection_id!r}")379 previous_user = connection["user"]380 if user not in self.user_items and previous_user.startswith(GUEST_PREFIX):381 entry = self.user_items.drop(previous_user)382 del entry["register_item_id"]383 self.user_items.create(user, **{**entry, **fields})384 return self.connection_items.update(connection_id, user=user, **fields)385 if user not in self.user_items:386 self.new_user(user, **fields)387 connection = self.connection_items.update(connection_id, user=user, **fields)388 self.user_items.get(previous_user)["connections"].discard(connection_id)389 self.user_items.get(user)["connections"].add(connection_id)390 if not self.user_items.get(previous_user)["connections"]:391 self.user_items.drop(previous_user)392 return connection393 394 def update_page(self, page_id: str, **fields: Any) -> dict[str, Any]:395 """Merge ``fields`` into a page row; ``KeyError`` if it is not there."""396 return self.page_items.update(page_id, **fields)397 398 def detach_page(self, page: dict[str, Any]) -> None:399 """Stop whatever captures into a page row: nothing here.400 401 Args:402 page: the page row leaving the register, or a copy of it about to403 be pickled.404 405 Called before a row is dropped and before its store is copied for a406 parcel. The seam a consumer overrides together with407 ``subscribe_page_store``.408 """409 410 def drop_page(self, page_id: str, cascade: bool = True) -> dict[str, Any]:411 """Drop a page row, taking its connection with it if it was the last one.412 413 ``detach_page`` runs first: the row leaves the register with nothing414 still capturing into it, and its id leaves its connection's ``pages`` — the edge dies with the row. ``cascade=False``415 is how a descending demolition drops a page without climbing back up416 the branch it is already tearing down.417 418 Returns the dropped page row; raises ``KeyError`` if ``page_id`` is419 not registered.420 """421 page = self.page_items.drop(page_id)422 self.detach_page(page)423 connection = self.connection_items.get(page["connection_id"])424 connection["pages"].discard(page_id)425 if cascade and not connection["pages"]:426 self.drop_connection(connection["register_item_id"])427 return page428 429 def drop_connection(self, connection_id: str, cascade: bool = True) -> dict[str, Any]:430 """Drop a connection row and every page of it, up to the user.431 432 The pages go down first — a copy of ``pages`` is walked, since each drop433 discards from that very set — each with ``cascade=False``: they must not434 try to take this connection away a second time. The connection then435 leaves its user's ``connections`` and, with ``cascade``, takes the user436 along when that set comes out empty.437 438 Returns the dropped connection row; raises ``KeyError`` if439 ``connection_id`` is not registered.440 """441 connection = self.connection_items.get(connection_id)442 if connection is None:443 raise KeyError(f"drop_connection: unknown connection {connection_id!r}")444 for page_id in list(connection["pages"]):445 self.drop_page(page_id, cascade=False)446 self.connection_items.drop(connection_id)447 user_entry = self.user_items.get(connection["user"])448 user_entry["connections"].discard(connection_id)449 if cascade and not user_entry["connections"]:450 self.user_items.drop(connection["user"])451 return connection452 453 def drop_user(self, user: str) -> dict[str, Any]:454 """Drop a user entry and every connection of that user, pages included.455 456 A copy of ``connections`` is walked — each drop discards from that very457 set — and every connection goes down with ``cascade=False``: this user458 is already being demolished and must not be dropped twice. Every page459 goes through ``detach_page`` on its way out.460 461 Returns the dropped user entry; raises ``KeyError`` if ``user`` has462 no entry.463 """464 entry = self.user_items.get(user)465 if entry is None:466 raise KeyError(f"drop_user: unknown user {user!r}")467 for connection_id in list(entry["connections"]):468 self.drop_connection(connection_id, cascade=False)469 return self.user_items.drop(user)