From 60a45b895c4d450fe4df4d372cd6e79210a00158 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 5 Nov 2026 19:11:51 +0100 Subject: [PATCH 1/2] fix(feature_flags,sharing): keep durable handler names stable across the layout move Durable outbox rows are keyed by the handler's import path. The layout moves (#834, #836) changed it for feature_flags' and sharing's tenant and user cleanup handlers, so rows still waiting for delivery would retry as unknown handlers and go dead. Pin the old names as constants, freeze every durable event key and handler name in host/tests/test_durable_wire_names.py, and add the rule to the module-layout checklist. Neither move has been released. Claude-Session: https://claude.ai/code/session_01FXHA3ZicrtZ4C5QPEgxfgq --- docs/module-layout.md | 6 +++ host/tests/test_durable_wire_names.py | 52 +++++++++++++++++++ .../feature_flags/core/constants.py | 4 ++ modules/feature_flags/feature_flags/module.py | 3 +- modules/sharing/sharing/core/constants.py | 5 ++ modules/sharing/sharing/module.py | 4 +- 6 files changed, 71 insertions(+), 3 deletions(-) create mode 100644 host/tests/test_durable_wire_names.py diff --git a/docs/module-layout.md b/docs/module-layout.md index edb2672e2..b8eed1136 100644 --- a/docs/module-layout.md +++ b/docs/module-layout.md @@ -183,6 +183,12 @@ One module per PR; no logic changes in a move PR. `from import x`, `mock.patch` targets and `monkeypatch` dotted strings. Rewrite relative TS imports from their old location. Do not rewrite `app.state.` attributes: these are runtime containers, not imports. + Do not rewrite strings that only look like paths: i18n keys + (`.audit.token`), permission names and upgrade ids share the prefix. + Durable event keys and handler names are wire names derived from the + import path: a moved durable handler keeps its old name with + `subscribe_durable(..., name=)`, and a moved durable event class + needs a data migration. `host/tests/test_durable_wire_names.py` freezes both. 3. Move tests to matching areas/features, keep root `conftest.py`, remove test package initializers and prefix each basename with the module name. JS tests remain in `tests-js/`. diff --git a/host/tests/test_durable_wire_names.py b/host/tests/test_durable_wire_names.py new file mode 100644 index 000000000..df9c60e8c --- /dev/null +++ b/host/tests/test_durable_wire_names.py @@ -0,0 +1,52 @@ +"""Durable event keys and handler names are wire names: freeze them. + +Outbox rows are keyed by the event's ``module.qualname`` and the handler's +name (its import path unless ``name=`` is given). Moving either to another +module (docs/module-layout.md) strands every undelivered row: it retries as an +unknown handler, then goes ``dead``. A move must pin the old name with +``subscribe_durable(..., name=...)``; a changed entry here is a data migration, +not a refactor. +""" + +from __future__ import annotations + +from simple_module_core.discovery import discover_modules +from simple_module_core.events import EventBus + +_TENANT_DELETED = "simple_module_core.tenant_events.TenantDeleted" +_USER_DELETED = "simple_module_core.user_events.UserDeleted" + +EXPECTED: dict[str, frozenset[str]] = { + "file_storage.tenant_deletion.TenantStorageCleanupRequested": frozenset( + {"file_storage.tenant_deletion.purge_objects"} + ), + _TENANT_DELETED: frozenset( + { + "api_tokens.tenant_cleanup.purge_tenant_data", + "feature_flags.tenant_cleanup.purge_tenant_data", + "file_storage.tenant_deletion.purge_rows", + "sharing.tenant_cleanup.purge_tenant_data", + } + ), + _USER_DELETED: frozenset( + { + "api_tokens.user_cleanup.purge_user_tokens", + "permissions.user_cleanup.purge_user_grants", + "sharing.user_cleanup.purge_user_shares", + "tenants.user_cleanup.purge_user_memberships", + } + ), + "tenants.contracts.events.InvitationCreated": frozenset({"tenants.send_invitation_email"}), +} + + +def _registered() -> dict[str, frozenset[str]]: + bus = EventBus() + for module in discover_modules(): + # The worker registers durable handlers the same way, with no app. + module.register_event_handlers(bus, None) + return {key: frozenset(by_name) for key, by_name in bus.durable._subs.items()} + + +def test_durable_wire_names_are_unchanged() -> None: + assert _registered() == EXPECTED diff --git a/modules/feature_flags/feature_flags/core/constants.py b/modules/feature_flags/feature_flags/core/constants.py index 5826487dc..7c9e26008 100644 --- a/modules/feature_flags/feature_flags/core/constants.py +++ b/modules/feature_flags/feature_flags/core/constants.py @@ -48,3 +48,7 @@ # flags work perfectly well without an audit log installed. AUDIT_LOG_MODULE = "AuditLog" AUDIT_LOG_VIEW_URL = "/admin/audit-log/" + +# Durable handler names key undelivered outbox rows, so they keep the import +# path the handler had before the module-layout move (renaming strands rows). +TENANT_CLEANUP_HANDLER = "feature_flags.tenant_cleanup.purge_tenant_data" diff --git a/modules/feature_flags/feature_flags/module.py b/modules/feature_flags/feature_flags/module.py index 823323bb2..84b228115 100644 --- a/modules/feature_flags/feature_flags/module.py +++ b/modules/feature_flags/feature_flags/module.py @@ -25,6 +25,7 @@ PERM_FEATURE_FLAGS_VIEW, PERM_GROUP, QP_OVERRIDE, + TENANT_CLEANUP_HANDLER, VIEW_PREFIX, ) @@ -74,7 +75,7 @@ def register_event_handlers(self, bus: EventBus, app: FastAPI | None = None) -> from feature_flags.core.tenant_cleanup import purge_tenant_data - bus.subscribe_durable(TenantDeleted, purge_tenant_data) + bus.subscribe_durable(TenantDeleted, purge_tenant_data, name=TENANT_CLEANUP_HANDLER) if app is not None: async def forget_tenant(event: TenantDeleted) -> None: diff --git a/modules/sharing/sharing/core/constants.py b/modules/sharing/sharing/core/constants.py index 5a694731a..9ef4e977f 100644 --- a/modules/sharing/sharing/core/constants.py +++ b/modules/sharing/sharing/core/constants.py @@ -63,3 +63,8 @@ ERR_BAD_ROLE: Final = "This role cannot be granted on this resource type" ERR_EXPIRED: Final = "expires_at must be in the future" ERR_SELF: Final = "You cannot share a resource with yourself" + +# Durable handler names key undelivered outbox rows, so they keep the import +# path the handlers had before the module-layout move (renaming strands rows). +TENANT_CLEANUP_HANDLER: Final = "sharing.tenant_cleanup.purge_tenant_data" +USER_CLEANUP_HANDLER: Final = "sharing.user_cleanup.purge_user_shares" diff --git a/modules/sharing/sharing/module.py b/modules/sharing/sharing/module.py index db3af9ffe..05da6f339 100644 --- a/modules/sharing/sharing/module.py +++ b/modules/sharing/sharing/module.py @@ -33,13 +33,13 @@ def register_event_handlers(self, bus: EventBus, app: FastAPI | None = None) -> from sharing.core.tenant_cleanup import purge_tenant_data - bus.subscribe_durable(TenantDeleted, purge_tenant_data) + bus.subscribe_durable(TenantDeleted, purge_tenant_data, name=c.TENANT_CLEANUP_HANDLER) from simple_module_core.user_events import UserDeleted from sharing.core.user_cleanup import purge_user_shares - bus.subscribe_durable(UserDeleted, purge_user_shares) + bus.subscribe_durable(UserDeleted, purge_user_shares, name=c.USER_CLEANUP_HANDLER) def register_settings(self, app: FastAPI) -> None: from sharing.core.services import SharingServices From fd9aab05f3d9c8e28bb7c71ebeb23cb903ee724c Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 5 Nov 2026 19:13:41 +0100 Subject: [PATCH 2/2] test(host): freeze the framework's tenant settings cleanup handler name too Claude-Session: https://claude.ai/code/session_01FXHA3ZicrtZ4C5QPEgxfgq --- host/tests/test_durable_wire_names.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/host/tests/test_durable_wire_names.py b/host/tests/test_durable_wire_names.py index df9c60e8c..c48ed25ab 100644 --- a/host/tests/test_durable_wire_names.py +++ b/host/tests/test_durable_wire_names.py @@ -12,6 +12,7 @@ from simple_module_core.discovery import discover_modules from simple_module_core.events import EventBus +from simple_module_db.tenant_cleanup import register_tenant_cleanup _TENANT_DELETED = "simple_module_core.tenant_events.TenantDeleted" _USER_DELETED = "simple_module_core.user_events.UserDeleted" @@ -25,6 +26,7 @@ "api_tokens.tenant_cleanup.purge_tenant_data", "feature_flags.tenant_cleanup.purge_tenant_data", "file_storage.tenant_deletion.purge_rows", + "simple_module_db.tenant_cleanup.delete_tenant_settings", "sharing.tenant_cleanup.purge_tenant_data", } ), @@ -42,6 +44,7 @@ def _registered() -> dict[str, frozenset[str]]: bus = EventBus() + register_tenant_cleanup(bus) # framework-owned, registered by the host and workers for module in discover_modules(): # The worker registers durable handlers the same way, with no app. module.register_event_handlers(bus, None)