Skip to content

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)