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..c48ed25ab --- /dev/null +++ b/host/tests/test_durable_wire_names.py @@ -0,0 +1,55 @@ +"""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 +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" + +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", + "simple_module_db.tenant_cleanup.delete_tenant_settings", + "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() + 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) + 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