simple-module-core 0.0.34__tar.gz → 0.0.35__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/PKG-INFO +1 -1
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/pyproject.toml +1 -1
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/__init__.py +8 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/diagnostics/_module.py +4 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/diagnostics/_pages.py +15 -1
- simple_module_core-0.0.35/simple_module_core/diagnostics/_tenancy.py +108 -0
- simple_module_core-0.0.35/simple_module_core/invalidation.py +280 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/module.py +18 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/services.py +8 -1
- simple_module_core-0.0.35/tests/test_diagnostics_tenancy.py +115 -0
- simple_module_core-0.0.35/tests/test_invalidation.py +261 -0
- simple_module_core-0.0.35/tests/test_invalidation_handlers.py +62 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_sm003_page_render.py +18 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/.gitignore +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/LICENSE +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/README.md +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/__main__.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/audit_links.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/csp.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/design_packs.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/diagnostics/__init__.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/diagnostics/_coupling.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/diagnostics/_css.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/diagnostics/_i18n.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/diagnostics/_inertia_api.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/diagnostics/_js_workspace.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/diagnostics/_migration.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/diagnostics/_runner.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/diagnostics/_types.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/discovery.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/dotenv.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/environments.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/events.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/exceptions.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/feature_flags.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/health.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/i18n.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/menu.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/module_meta.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/permissions.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/public_routes.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/py.typed +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/redirect_safety.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/settings_base.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/setup_steps.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/versioning.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_audit_links.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_auth_provider_selection.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_csp_registry.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_css_diagnostics.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_css_scanner.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_design_packs.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_diagnostics.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_discovery.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_dotenv.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_environments.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_events.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_feature_flags.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_feature_flags_decorator.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_health_registry.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_i18n.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_i18n_diagnostics.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_menu.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_module_base.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_module_diagnostics.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_permissions.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_public_routes.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_redirect_safety.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_services.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_settings_base.py +0 -0
- {simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_versioning.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: simple_module_core
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.35
|
|
4
4
|
Summary: Module-system primitives for the simple_module framework — ModuleBase, discovery, diagnostics, events
|
|
5
5
|
Project-URL: Homepage, https://github.com/antosubash/simple_module_python
|
|
6
6
|
Project-URL: Repository, https://github.com/antosubash/simple_module_python
|
|
@@ -36,6 +36,11 @@ from simple_module_core.feature_flags import (
|
|
|
36
36
|
)
|
|
37
37
|
from simple_module_core.health import HealthCheck, HealthCheckResult, HealthRegistry, HealthStatus
|
|
38
38
|
from simple_module_core.i18n import I18nRegistry, Translator
|
|
39
|
+
from simple_module_core.invalidation import (
|
|
40
|
+
Invalidation,
|
|
41
|
+
InvalidationBus,
|
|
42
|
+
InvalidationTransport,
|
|
43
|
+
)
|
|
39
44
|
from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection
|
|
40
45
|
from simple_module_core.module import ModuleBase, ModuleMeta
|
|
41
46
|
from simple_module_core.permissions import PermissionRegistry
|
|
@@ -66,6 +71,9 @@ __all__ = [
|
|
|
66
71
|
"HealthStatus",
|
|
67
72
|
"I18nRegistry",
|
|
68
73
|
"InvalidModuleError",
|
|
74
|
+
"Invalidation",
|
|
75
|
+
"InvalidationBus",
|
|
76
|
+
"InvalidationTransport",
|
|
69
77
|
"LabelResolver",
|
|
70
78
|
"MenuItem",
|
|
71
79
|
"MenuRegistry",
|
{simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/diagnostics/_module.py
RENAMED
|
@@ -11,6 +11,7 @@ from simple_module_core.diagnostics._css import check_module_css
|
|
|
11
11
|
from simple_module_core.diagnostics._inertia_api import check_inertia_api_calls
|
|
12
12
|
from simple_module_core.diagnostics._js_workspace import check_js_workspace_files
|
|
13
13
|
from simple_module_core.diagnostics._pages import check_pages, find_render_calls
|
|
14
|
+
from simple_module_core.diagnostics._tenancy import check_tenant_unique_keys, module_tables
|
|
14
15
|
from simple_module_core.diagnostics._types import Diagnostic, DiagnosticLevel
|
|
15
16
|
|
|
16
17
|
if TYPE_CHECKING:
|
|
@@ -29,6 +30,8 @@ class ModuleDiagnostics:
|
|
|
29
30
|
diagnostics.extend(self._check_views_without_menu(modules))
|
|
30
31
|
diagnostics.extend(self._check_auth_provider_conflict(modules))
|
|
31
32
|
diagnostics.extend(check_framework_module_coupling(modules))
|
|
33
|
+
for mod in modules:
|
|
34
|
+
diagnostics.extend(check_tenant_unique_keys(module_tables(mod), mod.meta.name))
|
|
32
35
|
|
|
33
36
|
# File-based checks (need to find module source directories)
|
|
34
37
|
for mod in modules:
|
|
@@ -95,6 +98,7 @@ class ModuleDiagnostics:
|
|
|
95
98
|
"register_permissions",
|
|
96
99
|
"register_feature_flags",
|
|
97
100
|
"register_event_handlers",
|
|
101
|
+
"register_invalidations",
|
|
98
102
|
"register_middleware",
|
|
99
103
|
"register_health_checks",
|
|
100
104
|
"register_public_routes",
|
{simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/diagnostics/_pages.py
RENAMED
|
@@ -89,6 +89,15 @@ def _iter_render_components(tree: ast.Module, consts: dict[str, str]) -> list[st
|
|
|
89
89
|
return found
|
|
90
90
|
|
|
91
91
|
|
|
92
|
+
#: A ``*.test.tsx``/``*.spec.tsx`` beside a page is a test, not a page —
|
|
93
|
+
#: mirrors ``simple_module_hosting.page_globs.TEST_FILE_SUFFIXES``, which
|
|
94
|
+
#: excludes the same files from the Vite glob. Duplicated rather than
|
|
95
|
+
#: imported: ``simple_module_hosting`` depends on ``simple_module_core``,
|
|
96
|
+
#: not the other way around, so importing it here would invert the package
|
|
97
|
+
#: layering.
|
|
98
|
+
_TEST_FILE_SUFFIXES = (".test.tsx", ".spec.tsx")
|
|
99
|
+
|
|
100
|
+
|
|
92
101
|
def collect_tsx_pages(pages_dir: Path) -> set[str]:
|
|
93
102
|
"""Collect .tsx page identifiers relative to pages_dir, without extension.
|
|
94
103
|
|
|
@@ -96,10 +105,15 @@ def collect_tsx_pages(pages_dir: Path) -> set[str]:
|
|
|
96
105
|
directly against inertia.render("Module/Sub/Page") keys. Subdirectories
|
|
97
106
|
whose names start with a lowercase letter (``components/``, ``hooks/``,
|
|
98
107
|
...) are treated as helper folders, not Inertia page roots, matching the
|
|
99
|
-
PascalCase convention Inertia uses.
|
|
108
|
+
PascalCase convention Inertia uses. Files ending in ``.test.tsx`` or
|
|
109
|
+
``.spec.tsx`` are colocated tests, not pages, and are excluded the same
|
|
110
|
+
way the Vite glob excludes them — otherwise a colocated test with no
|
|
111
|
+
matching ``inertia.render()`` call is flagged as an SM003 orphan page.
|
|
100
112
|
"""
|
|
101
113
|
pages: set[str] = set()
|
|
102
114
|
for f in pages_dir.rglob("*.tsx"):
|
|
115
|
+
if f.name.endswith(_TEST_FILE_SUFFIXES):
|
|
116
|
+
continue
|
|
103
117
|
rel = f.relative_to(pages_dir)
|
|
104
118
|
if any(part[:1].islower() for part in rel.parts[:-1]):
|
|
105
119
|
continue
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""SM024: a unique key on a tenant-scoped table that ignores the tenant.
|
|
2
|
+
|
|
3
|
+
On a ``MultiTenantMixin`` table every business key is per tenant. A unique
|
|
4
|
+
constraint without ``tenant_id`` in it means the first tenant to claim a value
|
|
5
|
+
(a slug, a filename, a name) locks every other tenant out of it — and the
|
|
6
|
+
resulting IntegrityError tells the second tenant the value exists elsewhere.
|
|
7
|
+
|
|
8
|
+
Duck-typed on SQLAlchemy ``Table`` objects (core does not depend on
|
|
9
|
+
SQLAlchemy). Only tables of models that inherit ``MultiTenantMixin`` count: a
|
|
10
|
+
plain ``tenant_id`` column (``users_user``'s legacy one, the ``tenants``
|
|
11
|
+
registry's own tables) carries no isolation and no per-tenant key rule.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import importlib
|
|
17
|
+
import logging
|
|
18
|
+
from collections.abc import Iterable, Iterator
|
|
19
|
+
from typing import TYPE_CHECKING, Any
|
|
20
|
+
|
|
21
|
+
from simple_module_core.diagnostics._types import Diagnostic, DiagnosticLevel
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from simple_module_core.module import ModuleBase
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
TENANT_COLUMN = "tenant_id"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _unique_keys(table: Any) -> Iterator[tuple[str, set[str]]]:
|
|
32
|
+
"""Yield ``(label, column names)`` for every unique key except the PK."""
|
|
33
|
+
for col in table.columns:
|
|
34
|
+
if col.unique and not col.primary_key:
|
|
35
|
+
yield f"column '{col.name}'", {col.name}
|
|
36
|
+
for constraint in table.constraints:
|
|
37
|
+
if type(constraint).__name__ == "UniqueConstraint":
|
|
38
|
+
cols = {c.name for c in constraint.columns}
|
|
39
|
+
yield f"unique constraint {sorted(cols)}", cols
|
|
40
|
+
for index in table.indexes:
|
|
41
|
+
if index.unique:
|
|
42
|
+
cols = {c.name for c in index.columns}
|
|
43
|
+
label = f"unique index '{index.name}'"
|
|
44
|
+
# Expression indexes (lower(email)) expose no plain columns; treat
|
|
45
|
+
# them as not containing the tenant — they are just as global.
|
|
46
|
+
yield label, cols
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def check_tenant_unique_keys(tables: Iterable[Any], module_name: str) -> list[Diagnostic]:
|
|
50
|
+
diags: list[Diagnostic] = []
|
|
51
|
+
for table in tables:
|
|
52
|
+
if TENANT_COLUMN not in table.columns:
|
|
53
|
+
continue
|
|
54
|
+
reported: set[frozenset[str]] = set()
|
|
55
|
+
for label, cols in _unique_keys(table):
|
|
56
|
+
# ``unique=True`` surfaces both as a column flag and as a
|
|
57
|
+
# UniqueConstraint; report each key once. Expression indexes have
|
|
58
|
+
# no plain columns and are always reported.
|
|
59
|
+
key = frozenset(cols)
|
|
60
|
+
if TENANT_COLUMN in cols or (cols and key in reported):
|
|
61
|
+
continue
|
|
62
|
+
reported.add(key)
|
|
63
|
+
diags.append(
|
|
64
|
+
Diagnostic(
|
|
65
|
+
level=DiagnosticLevel.WARNING,
|
|
66
|
+
code="SM024",
|
|
67
|
+
message=(
|
|
68
|
+
f"Tenant-scoped table '{table.name}' has a {label} that does "
|
|
69
|
+
f"not include '{TENANT_COLUMN}' — two tenants cannot both own a value"
|
|
70
|
+
),
|
|
71
|
+
module_name=module_name,
|
|
72
|
+
suggestion=(
|
|
73
|
+
"Make the key per tenant: add tenant_id to the constraint, "
|
|
74
|
+
"e.g. Index(..., 'tenant_id', 'slug', unique=True)"
|
|
75
|
+
),
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
return diags
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _is_tenant_scoped(cls: type) -> bool:
|
|
82
|
+
return any(base.__name__ == "MultiTenantMixin" for base in cls.__mro__[1:])
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def module_tables(mod: ModuleBase) -> list[Any]:
|
|
86
|
+
"""Tables of the ``MultiTenantMixin`` models in the module's ``models``."""
|
|
87
|
+
pkg = type(mod).__module__.rsplit(".", 1)[0]
|
|
88
|
+
try:
|
|
89
|
+
models = importlib.import_module(f"{pkg}.models")
|
|
90
|
+
except ModuleNotFoundError:
|
|
91
|
+
return []
|
|
92
|
+
except Exception: # pragma: no cover - a broken models module fails elsewhere, loudly
|
|
93
|
+
logger.debug("Could not import %s.models for SM024", pkg, exc_info=True)
|
|
94
|
+
return []
|
|
95
|
+
seen: dict[str, Any] = {}
|
|
96
|
+
for value in vars(models).values():
|
|
97
|
+
table = getattr(value, "__table__", None)
|
|
98
|
+
if (
|
|
99
|
+
isinstance(value, type)
|
|
100
|
+
and table is not None
|
|
101
|
+
and hasattr(table, "columns")
|
|
102
|
+
and _is_tenant_scoped(value)
|
|
103
|
+
):
|
|
104
|
+
seen.setdefault(table.name, table)
|
|
105
|
+
return list(seen.values())
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
__all__ = ["check_tenant_unique_keys", "module_tables"]
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Cache-invalidation fan-out: in-process by default, cross-process when a
|
|
2
|
+
transport is installed.
|
|
3
|
+
|
|
4
|
+
Several modules keep a per-process cache of a row that is cheap to read and
|
|
5
|
+
expensive to read *often* — ``users`` caches ``User.session_version`` (the
|
|
6
|
+
revocation counter checked on nearly every authenticated request) and
|
|
7
|
+
``file_storage`` caches its aggregate size, both behind a 30-second TTL. They
|
|
8
|
+
share one failure shape: the worker that performed the write drops its own entry
|
|
9
|
+
immediately, and **every other worker keeps serving the stale value until its own
|
|
10
|
+
entry expires**.
|
|
11
|
+
|
|
12
|
+
``settings`` has the same shape without the TTL — ``apply_changes_and_reload``
|
|
13
|
+
hot-swaps the settings object in the worker that handled the save and announces it
|
|
14
|
+
on the in-process :class:`~simple_module_core.events.EventBus`, so the others serve
|
|
15
|
+
the old configuration until restart. It needs a re-read rather than an eviction, so
|
|
16
|
+
it is a consumer this bus makes *possible* rather than one it already serves.
|
|
17
|
+
|
|
18
|
+
The obvious fix — publish the write on Redis pub/sub so every worker drops its
|
|
19
|
+
entry at once — was not reachable from any of those modules. Redis belongs to
|
|
20
|
+
the ``background_tasks`` plugin, so ``users`` would have to open a second
|
|
21
|
+
connection with its own settings that can disagree with it; :class:`EventBus`
|
|
22
|
+
is deliberately in-process; and ``SM009`` makes a ``framework`` → plugin import
|
|
23
|
+
an error, so the framework cannot borrow the plugin's client either (GH #318).
|
|
24
|
+
|
|
25
|
+
This module is the missing middle. The framework owns the *bus* — the channel
|
|
26
|
+
registry, the wire format and the origin filtering — and knows nothing about
|
|
27
|
+
how a message reaches another process. Whoever owns a shared backend installs
|
|
28
|
+
an :class:`InvalidationTransport` on it; ``background_tasks`` does exactly that
|
|
29
|
+
with the Redis connection it already configures. With no transport installed
|
|
30
|
+
the bus is honest in-process pub/sub, which is precisely today's behaviour: the
|
|
31
|
+
publishing worker sees the invalidation, the others wait out their TTL.
|
|
32
|
+
|
|
33
|
+
What this is *not*: a message queue. There is no persistence, no retry, no
|
|
34
|
+
delivery guarantee. A dropped message costs staleness for the rest of a cache's
|
|
35
|
+
TTL, never correctness of the cache's contents — so every consumer must keep a
|
|
36
|
+
TTL as its floor and treat the bus as an accelerator. Anything that needs
|
|
37
|
+
delivery guarantees wants a Celery task, not this.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import inspect
|
|
43
|
+
import json
|
|
44
|
+
import logging
|
|
45
|
+
import uuid
|
|
46
|
+
from collections.abc import Awaitable, Callable
|
|
47
|
+
from dataclasses import dataclass
|
|
48
|
+
from typing import Protocol, runtime_checkable
|
|
49
|
+
|
|
50
|
+
logger = logging.getLogger(__name__)
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"WIRE_VERSION",
|
|
54
|
+
"Invalidation",
|
|
55
|
+
"InvalidationBus",
|
|
56
|
+
"InvalidationHandler",
|
|
57
|
+
"InvalidationTransport",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
WIRE_VERSION = 1
|
|
61
|
+
"""Bumped when :meth:`Invalidation.to_wire` changes shape.
|
|
62
|
+
|
|
63
|
+
A message carrying any other version is dropped rather than guessed at, so a
|
|
64
|
+
rolling deploy in which half the workers run new code degrades to "no
|
|
65
|
+
cross-process invalidation between the two halves" instead of to a decode
|
|
66
|
+
exception in the listener loop.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
type InvalidationHandler = Callable[["Invalidation"], Awaitable[None] | None]
|
|
70
|
+
"""Sync or async. The caches this exists for are plain dicts, so demanding
|
|
71
|
+
``async def`` for a one-line ``pop`` would be ceremony."""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True, slots=True)
|
|
75
|
+
class Invalidation:
|
|
76
|
+
"""One "this cached thing changed" notice.
|
|
77
|
+
|
|
78
|
+
Deliberately not a :class:`~simple_module_core.events.Event`: a domain event
|
|
79
|
+
describes something that happened in the business ("an order was placed")
|
|
80
|
+
and its handlers may do anything, while this describes something that
|
|
81
|
+
stopped being true in a *cache* and its handlers may only forget. Sharing
|
|
82
|
+
the type would invite a handler that writes to the database on a message
|
|
83
|
+
that carries no delivery guarantee.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
#: Who cares. Convention is ``<module>.<cache>`` — ``users.session_version``.
|
|
87
|
+
channel: str
|
|
88
|
+
#: Which entry, or ``None`` for "the whole channel". A string because it has
|
|
89
|
+
#: to survive JSON; a consumer keyed by something else converts on the way
|
|
90
|
+
#: back in (see ``users.session_revocation``).
|
|
91
|
+
key: str | None = None
|
|
92
|
+
#: The publishing process's :attr:`InvalidationBus.origin`. Lets a bus
|
|
93
|
+
#: ignore its own broadcast coming back off the transport, which it has
|
|
94
|
+
#: already applied locally.
|
|
95
|
+
origin: str = ""
|
|
96
|
+
|
|
97
|
+
def to_wire(self) -> str:
|
|
98
|
+
"""JSON, because a transport is free to be anything that moves text."""
|
|
99
|
+
return json.dumps(
|
|
100
|
+
{"v": WIRE_VERSION, "channel": self.channel, "key": self.key, "origin": self.origin}
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
@classmethod
|
|
104
|
+
def from_wire(cls, message: str | bytes) -> Invalidation | None:
|
|
105
|
+
"""Parse a wire message, or ``None`` when it isn't one of ours.
|
|
106
|
+
|
|
107
|
+
Returns rather than raises on anything unexpected. A Redis database is
|
|
108
|
+
routinely shared — with another app, with a stray ``redis-cli publish``,
|
|
109
|
+
with the previous version of this code — and a listener loop that dies
|
|
110
|
+
on one malformed payload takes cross-process invalidation down for the
|
|
111
|
+
life of the process. Logged at debug: on a shared database this is
|
|
112
|
+
normal traffic, not a fault.
|
|
113
|
+
"""
|
|
114
|
+
try:
|
|
115
|
+
data = json.loads(message)
|
|
116
|
+
if not isinstance(data, dict) or data.get("v") != WIRE_VERSION:
|
|
117
|
+
logger.debug("Ignoring invalidation message with unknown shape: %r", message)
|
|
118
|
+
return None
|
|
119
|
+
channel = data["channel"]
|
|
120
|
+
key = data.get("key")
|
|
121
|
+
if not isinstance(channel, str) or not isinstance(key, str | None):
|
|
122
|
+
logger.debug("Ignoring invalidation message with bad field types: %r", message)
|
|
123
|
+
return None
|
|
124
|
+
return cls(channel=channel, key=key, origin=str(data.get("origin") or ""))
|
|
125
|
+
except Exception:
|
|
126
|
+
logger.debug("Ignoring undecodable invalidation message: %r", message)
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@runtime_checkable
|
|
131
|
+
class InvalidationTransport(Protocol):
|
|
132
|
+
"""Moves an encoded :class:`Invalidation` to the other processes.
|
|
133
|
+
|
|
134
|
+
One method, because the inbound direction is the transport's own business:
|
|
135
|
+
it decides how it listens (a pub/sub subscription, a poll, a test double
|
|
136
|
+
calling straight through) and hands whatever it receives to
|
|
137
|
+
:meth:`InvalidationBus.deliver`, which owns decoding and the origin filter.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
async def publish(self, message: str) -> None:
|
|
141
|
+
"""Broadcast *message* to every other process. May raise."""
|
|
142
|
+
...
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class InvalidationBus:
|
|
146
|
+
"""Channel registry plus fan-out. One per process, on ``app.state.sm``.
|
|
147
|
+
|
|
148
|
+
Subscribers register in ``ModuleBase.register_invalidations``; a transport is
|
|
149
|
+
installed later, in whichever module owns the shared backend, during its
|
|
150
|
+
``on_startup``. The two are independent on purpose — subscriptions are what
|
|
151
|
+
make the bus useful in a single process, and the transport only widens their
|
|
152
|
+
reach.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
def __init__(self, origin: str | None = None) -> None:
|
|
156
|
+
#: Identifies this process for the lifetime of the process. Random
|
|
157
|
+
#: rather than the pid: pids repeat across containers, and two workers
|
|
158
|
+
#: that believe they share an origin would each ignore the other's
|
|
159
|
+
#: broadcasts — the exact bug this class exists to fix, but silent.
|
|
160
|
+
self.origin = origin or uuid.uuid4().hex
|
|
161
|
+
self._handlers: dict[str, list[InvalidationHandler]] = {}
|
|
162
|
+
self._transport: InvalidationTransport | None = None
|
|
163
|
+
|
|
164
|
+
# ── subscription ───────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
def subscribe(self, channel: str, handler: InvalidationHandler) -> None:
|
|
167
|
+
"""Run *handler* for every invalidation on *channel*, local or remote."""
|
|
168
|
+
self._handlers.setdefault(channel, []).append(handler)
|
|
169
|
+
logger.debug(
|
|
170
|
+
"Subscribed %s to invalidation channel %r",
|
|
171
|
+
getattr(handler, "__qualname__", repr(handler)),
|
|
172
|
+
channel,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def channels(self) -> tuple[str, ...]:
|
|
177
|
+
"""Channels with at least one subscriber, for diagnostics and tests."""
|
|
178
|
+
return tuple(sorted(self._handlers))
|
|
179
|
+
|
|
180
|
+
# ── transport ──────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
def set_transport(self, transport: InvalidationTransport) -> None:
|
|
183
|
+
"""Install the shared backend.
|
|
184
|
+
|
|
185
|
+
Replacing one is almost certainly a mistake, so it says so. The bus can
|
|
186
|
+
only forget the old transport's reference — it cannot stop the listener
|
|
187
|
+
that transport is running, and two live listeners on one channel deliver
|
|
188
|
+
every remote message to this bus twice. Whoever owns the transport has to
|
|
189
|
+
``stop()`` the old one; ``background_tasks`` does that in ``on_shutdown``.
|
|
190
|
+
"""
|
|
191
|
+
if self._transport is not None:
|
|
192
|
+
logger.warning(
|
|
193
|
+
"Replacing invalidation transport %s with %s — the old one's listener "
|
|
194
|
+
"is not stopped by this call and will keep delivering, so every remote "
|
|
195
|
+
"message will be applied twice",
|
|
196
|
+
type(self._transport).__name__,
|
|
197
|
+
type(transport).__name__,
|
|
198
|
+
)
|
|
199
|
+
self._transport = transport
|
|
200
|
+
logger.info("Invalidation transport installed: %s", type(transport).__name__)
|
|
201
|
+
|
|
202
|
+
def clear_transport(self) -> None:
|
|
203
|
+
"""Drop back to in-process only — the owning module's ``on_shutdown``."""
|
|
204
|
+
self._transport = None
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def has_transport(self) -> bool:
|
|
208
|
+
"""Whether invalidations currently reach other processes."""
|
|
209
|
+
return self._transport is not None
|
|
210
|
+
|
|
211
|
+
# ── publishing ─────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
async def publish(self, channel: str, key: str | None = None) -> None:
|
|
214
|
+
"""Apply locally, then broadcast.
|
|
215
|
+
|
|
216
|
+
Local first, and a transport failure never propagates: the caller is
|
|
217
|
+
typically finishing a write it has already committed — a password change
|
|
218
|
+
that bumped ``session_version`` — and turning "Redis is unreachable"
|
|
219
|
+
into a 500 on that request would trade a bounded staleness window for an
|
|
220
|
+
outright failure. Logged at error, because the operator needs to know
|
|
221
|
+
the fan-out is silently degraded to per-process.
|
|
222
|
+
"""
|
|
223
|
+
invalidation = Invalidation(channel=channel, key=key, origin=self.origin)
|
|
224
|
+
await self._dispatch(invalidation)
|
|
225
|
+
transport = self._transport
|
|
226
|
+
if transport is None:
|
|
227
|
+
return
|
|
228
|
+
try:
|
|
229
|
+
await transport.publish(invalidation.to_wire())
|
|
230
|
+
except Exception:
|
|
231
|
+
logger.exception(
|
|
232
|
+
"Invalidation broadcast failed for channel %r; other workers keep "
|
|
233
|
+
"their cached value until it expires",
|
|
234
|
+
channel,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
async def deliver(self, message: str | bytes) -> None:
|
|
238
|
+
"""Apply a message that arrived from another process.
|
|
239
|
+
|
|
240
|
+
Called by the transport. Messages this process published are dropped —
|
|
241
|
+
:meth:`publish` already ran the handlers, and running them twice would
|
|
242
|
+
be harmless for a ``pop`` but is not a property a handler should have to
|
|
243
|
+
have.
|
|
244
|
+
"""
|
|
245
|
+
invalidation = Invalidation.from_wire(message)
|
|
246
|
+
if invalidation is None:
|
|
247
|
+
return
|
|
248
|
+
if invalidation.origin and invalidation.origin == self.origin:
|
|
249
|
+
return
|
|
250
|
+
await self._dispatch(invalidation)
|
|
251
|
+
|
|
252
|
+
async def _dispatch(self, invalidation: Invalidation) -> None:
|
|
253
|
+
"""Run every handler on the channel, isolating failures.
|
|
254
|
+
|
|
255
|
+
Sequential rather than ``gather``: handlers are microsecond-scale cache
|
|
256
|
+
evictions, and a channel with enough of them for concurrency to matter
|
|
257
|
+
would be a design smell. A failing handler must not stop the others —
|
|
258
|
+
one module's broken eviction is not a reason for another's cache to stay
|
|
259
|
+
stale.
|
|
260
|
+
"""
|
|
261
|
+
# A snapshot, not the live list. A handler is free to call
|
|
262
|
+
# :meth:`subscribe` — nothing forbids it, and a module that lazily
|
|
263
|
+
# registers a cache would — and appending to the list being iterated
|
|
264
|
+
# would feed the new handler straight back into this loop. Because these
|
|
265
|
+
# handlers are typically *sync*, that loop contains no await point, so it
|
|
266
|
+
# does not merely recurse: it wedges the whole event loop, with
|
|
267
|
+
# ``asyncio.timeout`` unable to fire and the ``except`` below logging
|
|
268
|
+
# nothing. Measured at 3.1 million invocations from a single ``publish``
|
|
269
|
+
# before the copy was added.
|
|
270
|
+
for handler in tuple(self._handlers.get(invalidation.channel, ())):
|
|
271
|
+
try:
|
|
272
|
+
result = handler(invalidation)
|
|
273
|
+
if inspect.isawaitable(result):
|
|
274
|
+
await result
|
|
275
|
+
except Exception:
|
|
276
|
+
logger.exception(
|
|
277
|
+
"Invalidation handler %s failed for channel %r",
|
|
278
|
+
getattr(handler, "__qualname__", repr(handler)),
|
|
279
|
+
invalidation.channel,
|
|
280
|
+
)
|
|
@@ -17,6 +17,7 @@ if TYPE_CHECKING:
|
|
|
17
17
|
from simple_module_core.events import EventBus
|
|
18
18
|
from simple_module_core.feature_flags import FeatureFlagRegistry
|
|
19
19
|
from simple_module_core.health import HealthRegistry
|
|
20
|
+
from simple_module_core.invalidation import InvalidationBus
|
|
20
21
|
from simple_module_core.menu import MenuRegistry
|
|
21
22
|
from simple_module_core.permissions import PermissionRegistry
|
|
22
23
|
from simple_module_core.public_routes import PublicRouteRegistry
|
|
@@ -48,6 +49,7 @@ class ModuleBase(ABC):
|
|
|
48
49
|
- register_permissions(registry)
|
|
49
50
|
- register_feature_flags(registry)
|
|
50
51
|
- register_event_handlers(bus)
|
|
52
|
+
- register_invalidations(bus, app)
|
|
51
53
|
- register_health_checks(registry)
|
|
52
54
|
|
|
53
55
|
Phase 5: Database
|
|
@@ -116,6 +118,22 @@ class ModuleBase(ABC):
|
|
|
116
118
|
framework's engine instead of building their own.
|
|
117
119
|
"""
|
|
118
120
|
|
|
121
|
+
def register_invalidations(self, bus: InvalidationBus, app: FastAPI) -> None:
|
|
122
|
+
"""Subscribe this module's per-process caches to their channels.
|
|
123
|
+
|
|
124
|
+
Handlers may only *forget* — drop a cache entry, clear a dict. The bus
|
|
125
|
+
carries no delivery guarantee, so a handler that wrote to the database
|
|
126
|
+
would do it an unknowable number of times::
|
|
127
|
+
|
|
128
|
+
def register_invalidations(self, bus, app):
|
|
129
|
+
bus.subscribe("orders.totals", lambda inv: _TOTALS.pop(inv.key, None))
|
|
130
|
+
|
|
131
|
+
Publishing needs no hook — reach the same bus at
|
|
132
|
+
``request.app.state.sm.invalidation`` from a ``db.on_commit`` callback.
|
|
133
|
+
Cross-process delivery depends on a transport being installed, so every
|
|
134
|
+
cache keeps its TTL as a floor. See ``docs/framework/invalidation.md``.
|
|
135
|
+
"""
|
|
136
|
+
|
|
119
137
|
def register_health_checks(self, registry: HealthRegistry) -> None:
|
|
120
138
|
"""Contribute health checks for the ``/health/ready`` endpoint."""
|
|
121
139
|
|
|
@@ -16,12 +16,14 @@ from dataclasses import dataclass, field
|
|
|
16
16
|
from datetime import UTC, datetime
|
|
17
17
|
from typing import TYPE_CHECKING
|
|
18
18
|
|
|
19
|
+
from simple_module_core.invalidation import InvalidationBus
|
|
20
|
+
|
|
19
21
|
if TYPE_CHECKING:
|
|
20
22
|
from collections.abc import Callable
|
|
21
23
|
|
|
22
|
-
from inertia import InertiaConfig
|
|
23
24
|
from simple_module_db.session import DatabaseState
|
|
24
25
|
from simple_module_hosting.settings import Settings
|
|
26
|
+
from simple_module_inertia import InertiaConfig
|
|
25
27
|
|
|
26
28
|
from simple_module_core.audit_links import AuditLinkRegistry
|
|
27
29
|
from simple_module_core.design_packs import DesignPackRegistry
|
|
@@ -90,6 +92,11 @@ class Services:
|
|
|
90
92
|
i18n_registry: I18nRegistry
|
|
91
93
|
inertia_config: InertiaConfig
|
|
92
94
|
modules: tuple[ModuleBase, ...]
|
|
95
|
+
#: Cross-process cache invalidation. Defaulted for the same reason as
|
|
96
|
+
#: ``diagnostics``: an app assembled outside ``create_app`` (tests, a
|
|
97
|
+
#: single-module harness) gets a working in-process bus with no
|
|
98
|
+
#: subscribers rather than a ``TypeError`` at construction.
|
|
99
|
+
invalidation: InvalidationBus = field(default_factory=InvalidationBus)
|
|
93
100
|
#: Defaulted so an app built outside ``create_app`` still has a holder to
|
|
94
101
|
#: read; that one simply reports the checks as unsupported.
|
|
95
102
|
diagnostics: DiagnosticsState = field(default_factory=DiagnosticsState)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""SM024 — unique keys on tenant-scoped tables must include tenant_id."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from simple_module_core.diagnostics._tenancy import check_tenant_unique_keys
|
|
6
|
+
from sqlalchemy import Column, Index, Integer, MetaData, String, Table, UniqueConstraint, func
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _codes(table: Table) -> list[str]:
|
|
10
|
+
return [d.code for d in check_tenant_unique_keys([table], "demo")]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_global_unique_column_on_tenant_table_warns():
|
|
14
|
+
t = Table(
|
|
15
|
+
"demo_a",
|
|
16
|
+
MetaData(),
|
|
17
|
+
Column("id", Integer, primary_key=True),
|
|
18
|
+
Column("tenant_id", String(50)),
|
|
19
|
+
Column("slug", String(50), unique=True),
|
|
20
|
+
)
|
|
21
|
+
assert _codes(t) == ["SM024"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_per_tenant_unique_index_is_fine():
|
|
25
|
+
t = Table(
|
|
26
|
+
"demo_b",
|
|
27
|
+
MetaData(),
|
|
28
|
+
Column("id", Integer, primary_key=True),
|
|
29
|
+
Column("tenant_id", String(50)),
|
|
30
|
+
Column("slug", String(50)),
|
|
31
|
+
Index("ix_demo_b_tenant_slug", "tenant_id", "slug", unique=True),
|
|
32
|
+
UniqueConstraint("tenant_id", "id"),
|
|
33
|
+
)
|
|
34
|
+
assert _codes(t) == []
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_unique_index_without_tenant_warns():
|
|
38
|
+
t = Table(
|
|
39
|
+
"demo_c",
|
|
40
|
+
MetaData(),
|
|
41
|
+
Column("id", Integer, primary_key=True),
|
|
42
|
+
Column("tenant_id", String(50)),
|
|
43
|
+
Column("locale", String(8)),
|
|
44
|
+
Column("slug", String(50)),
|
|
45
|
+
Index("ix_demo_c_locale_slug", "locale", "slug", unique=True),
|
|
46
|
+
)
|
|
47
|
+
diags = check_tenant_unique_keys([t], "demo")
|
|
48
|
+
assert [d.code for d in diags] == ["SM024"]
|
|
49
|
+
assert "ix_demo_c_locale_slug" in diags[0].message
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_expression_index_counts_as_global():
|
|
53
|
+
t = Table(
|
|
54
|
+
"demo_d",
|
|
55
|
+
MetaData(),
|
|
56
|
+
Column("id", Integer, primary_key=True),
|
|
57
|
+
Column("tenant_id", String(50)),
|
|
58
|
+
Column("email", String(50)),
|
|
59
|
+
)
|
|
60
|
+
Index("ix_demo_d_email_lower", func.lower(t.c.email), unique=True)
|
|
61
|
+
assert _codes(t) == ["SM024"]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_non_tenant_table_is_ignored():
|
|
65
|
+
t = Table(
|
|
66
|
+
"demo_e",
|
|
67
|
+
MetaData(),
|
|
68
|
+
Column("id", Integer, primary_key=True),
|
|
69
|
+
Column("slug", String(50), unique=True),
|
|
70
|
+
)
|
|
71
|
+
assert _codes(t) == []
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_module_tables_only_counts_mixin_models(monkeypatch):
|
|
75
|
+
import sys
|
|
76
|
+
import types
|
|
77
|
+
|
|
78
|
+
from simple_module_core.diagnostics._tenancy import module_tables
|
|
79
|
+
|
|
80
|
+
class MultiTenantMixin: # stands in for simple_module_db's, matched by name
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
md = MetaData()
|
|
84
|
+
scoped = type(
|
|
85
|
+
"Scoped",
|
|
86
|
+
(MultiTenantMixin,),
|
|
87
|
+
{
|
|
88
|
+
"__table__": Table(
|
|
89
|
+
"demo_scoped",
|
|
90
|
+
md,
|
|
91
|
+
Column("id", Integer, primary_key=True),
|
|
92
|
+
Column("tenant_id", String(5)),
|
|
93
|
+
)
|
|
94
|
+
},
|
|
95
|
+
)
|
|
96
|
+
plain = type(
|
|
97
|
+
"Plain",
|
|
98
|
+
(),
|
|
99
|
+
{
|
|
100
|
+
"__table__": Table(
|
|
101
|
+
"demo_plain",
|
|
102
|
+
md,
|
|
103
|
+
Column("id", Integer, primary_key=True),
|
|
104
|
+
Column("tenant_id", String(5)),
|
|
105
|
+
)
|
|
106
|
+
},
|
|
107
|
+
)
|
|
108
|
+
pkg = types.ModuleType("sm024pkg")
|
|
109
|
+
models = types.ModuleType("sm024pkg.models")
|
|
110
|
+
models.Scoped, models.Plain = scoped, plain
|
|
111
|
+
monkeypatch.setitem(sys.modules, "sm024pkg", pkg)
|
|
112
|
+
monkeypatch.setitem(sys.modules, "sm024pkg.models", models)
|
|
113
|
+
|
|
114
|
+
mod_cls = type("DemoModule", (), {"__module__": "sm024pkg.module"})
|
|
115
|
+
assert [t.name for t in module_tables(mod_cls())] == ["demo_scoped"]
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""The invalidation bus: local fan-out, the wire, and the origin filter.
|
|
2
|
+
|
|
3
|
+
The properties worth pinning down are the ones a consumer's correctness rests
|
|
4
|
+
on. A revocation that reaches the publishing worker but silently skips the
|
|
5
|
+
others is the bug GH #318 is about, and a listener that dies on one malformed
|
|
6
|
+
payload reintroduces it for the life of the process.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
from simple_module_core.invalidation import WIRE_VERSION, Invalidation, InvalidationBus
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _RecordingTransport:
|
|
16
|
+
"""Captures what would have gone on the wire."""
|
|
17
|
+
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
self.sent: list[str] = []
|
|
20
|
+
|
|
21
|
+
async def publish(self, message: str) -> None:
|
|
22
|
+
self.sent.append(message)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _BrokenTransport:
|
|
26
|
+
async def publish(self, message: str) -> None:
|
|
27
|
+
raise ConnectionError("redis is down")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TestLocalFanOut:
|
|
31
|
+
async def test_publish_runs_subscribers_on_the_channel(self):
|
|
32
|
+
bus = InvalidationBus()
|
|
33
|
+
seen: list[Invalidation] = []
|
|
34
|
+
bus.subscribe("users.session_version", seen.append)
|
|
35
|
+
|
|
36
|
+
await bus.publish("users.session_version", key="abc")
|
|
37
|
+
|
|
38
|
+
assert [(i.channel, i.key) for i in seen] == [("users.session_version", "abc")]
|
|
39
|
+
|
|
40
|
+
async def test_other_channels_are_untouched(self):
|
|
41
|
+
bus = InvalidationBus()
|
|
42
|
+
calls: list[str] = []
|
|
43
|
+
bus.subscribe("a", lambda inv: calls.append("a"))
|
|
44
|
+
bus.subscribe("b", lambda inv: calls.append("b"))
|
|
45
|
+
|
|
46
|
+
await bus.publish("a")
|
|
47
|
+
|
|
48
|
+
assert calls == ["a"]
|
|
49
|
+
|
|
50
|
+
async def test_async_and_sync_handlers_both_run(self):
|
|
51
|
+
bus = InvalidationBus()
|
|
52
|
+
calls: list[str] = []
|
|
53
|
+
|
|
54
|
+
async def async_handler(inv):
|
|
55
|
+
calls.append("async")
|
|
56
|
+
|
|
57
|
+
bus.subscribe("c", async_handler)
|
|
58
|
+
bus.subscribe("c", lambda inv: calls.append("sync"))
|
|
59
|
+
|
|
60
|
+
await bus.publish("c")
|
|
61
|
+
|
|
62
|
+
assert sorted(calls) == ["async", "sync"]
|
|
63
|
+
|
|
64
|
+
async def test_one_failing_handler_does_not_starve_the_others(self):
|
|
65
|
+
"""One module's broken eviction must not leave another's cache stale."""
|
|
66
|
+
bus = InvalidationBus()
|
|
67
|
+
calls: list[str] = []
|
|
68
|
+
|
|
69
|
+
def boom(inv):
|
|
70
|
+
raise RuntimeError("bad handler")
|
|
71
|
+
|
|
72
|
+
bus.subscribe("c", boom)
|
|
73
|
+
bus.subscribe("c", lambda inv: calls.append("ran"))
|
|
74
|
+
|
|
75
|
+
await bus.publish("c")
|
|
76
|
+
|
|
77
|
+
assert calls == ["ran"]
|
|
78
|
+
|
|
79
|
+
async def test_publish_with_no_subscribers_is_a_no_op(self):
|
|
80
|
+
await InvalidationBus().publish("nobody.listening")
|
|
81
|
+
|
|
82
|
+
def test_channels_reports_what_has_subscribers(self):
|
|
83
|
+
bus = InvalidationBus()
|
|
84
|
+
bus.subscribe("z", lambda inv: None)
|
|
85
|
+
bus.subscribe("a", lambda inv: None)
|
|
86
|
+
assert bus.channels == ("a", "z")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class TestTransport:
|
|
90
|
+
async def test_publish_forwards_the_encoded_invalidation(self):
|
|
91
|
+
bus = InvalidationBus()
|
|
92
|
+
transport = _RecordingTransport()
|
|
93
|
+
bus.set_transport(transport)
|
|
94
|
+
|
|
95
|
+
await bus.publish("users.session_version", key="u1")
|
|
96
|
+
|
|
97
|
+
assert len(transport.sent) == 1
|
|
98
|
+
decoded = Invalidation.from_wire(transport.sent[0])
|
|
99
|
+
assert decoded is not None
|
|
100
|
+
assert (decoded.channel, decoded.key, decoded.origin) == (
|
|
101
|
+
"users.session_version",
|
|
102
|
+
"u1",
|
|
103
|
+
bus.origin,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
async def test_no_transport_means_local_only(self):
|
|
107
|
+
bus = InvalidationBus()
|
|
108
|
+
assert bus.has_transport is False
|
|
109
|
+
seen: list[Invalidation] = []
|
|
110
|
+
bus.subscribe("c", seen.append)
|
|
111
|
+
await bus.publish("c")
|
|
112
|
+
assert len(seen) == 1
|
|
113
|
+
|
|
114
|
+
async def test_a_broken_transport_still_applies_locally(self):
|
|
115
|
+
"""The caller has already committed the write; a dead Redis is not a 500."""
|
|
116
|
+
bus = InvalidationBus()
|
|
117
|
+
bus.set_transport(_BrokenTransport())
|
|
118
|
+
seen: list[Invalidation] = []
|
|
119
|
+
bus.subscribe("c", seen.append)
|
|
120
|
+
|
|
121
|
+
await bus.publish("c", key="k")
|
|
122
|
+
|
|
123
|
+
assert len(seen) == 1
|
|
124
|
+
|
|
125
|
+
async def test_clear_transport_drops_back_to_in_process(self):
|
|
126
|
+
bus = InvalidationBus()
|
|
127
|
+
transport = _RecordingTransport()
|
|
128
|
+
bus.set_transport(transport)
|
|
129
|
+
bus.clear_transport()
|
|
130
|
+
|
|
131
|
+
await bus.publish("c")
|
|
132
|
+
|
|
133
|
+
assert transport.sent == []
|
|
134
|
+
assert bus.has_transport is False
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class TestDeliver:
|
|
138
|
+
async def test_a_remote_message_runs_the_handlers(self):
|
|
139
|
+
publisher = InvalidationBus()
|
|
140
|
+
subscriber = InvalidationBus()
|
|
141
|
+
seen: list[Invalidation] = []
|
|
142
|
+
subscriber.subscribe("users.session_version", seen.append)
|
|
143
|
+
|
|
144
|
+
transport = _RecordingTransport()
|
|
145
|
+
publisher.set_transport(transport)
|
|
146
|
+
await publisher.publish("users.session_version", key="u9")
|
|
147
|
+
await subscriber.deliver(transport.sent[0])
|
|
148
|
+
|
|
149
|
+
assert [i.key for i in seen] == ["u9"]
|
|
150
|
+
|
|
151
|
+
async def test_a_bus_ignores_its_own_broadcast_coming_back(self):
|
|
152
|
+
"""Publish already ran the handlers; the echo must not run them twice."""
|
|
153
|
+
bus = InvalidationBus()
|
|
154
|
+
transport = _RecordingTransport()
|
|
155
|
+
bus.set_transport(transport)
|
|
156
|
+
seen: list[Invalidation] = []
|
|
157
|
+
bus.subscribe("c", seen.append)
|
|
158
|
+
|
|
159
|
+
await bus.publish("c", key="k")
|
|
160
|
+
await bus.deliver(transport.sent[0])
|
|
161
|
+
|
|
162
|
+
assert len(seen) == 1
|
|
163
|
+
|
|
164
|
+
async def test_bytes_are_accepted(self):
|
|
165
|
+
"""redis-py yields bytes unless the client decodes responses."""
|
|
166
|
+
bus = InvalidationBus()
|
|
167
|
+
seen: list[Invalidation] = []
|
|
168
|
+
bus.subscribe("c", seen.append)
|
|
169
|
+
|
|
170
|
+
await bus.deliver(Invalidation(channel="c", key="k", origin="elsewhere").to_wire().encode())
|
|
171
|
+
|
|
172
|
+
assert [i.key for i in seen] == ["k"]
|
|
173
|
+
|
|
174
|
+
@pytest.mark.parametrize(
|
|
175
|
+
"message",
|
|
176
|
+
[
|
|
177
|
+
b"not json at all",
|
|
178
|
+
b"[]",
|
|
179
|
+
b'{"v": 999, "channel": "c", "key": "k"}',
|
|
180
|
+
b'{"channel": "c"}',
|
|
181
|
+
b'{"v": 1, "channel": 7, "key": "k"}',
|
|
182
|
+
b'{"v": 1, "channel": "c", "key": 7}',
|
|
183
|
+
],
|
|
184
|
+
)
|
|
185
|
+
async def test_junk_is_dropped_rather_than_raised(self, message):
|
|
186
|
+
"""A shared Redis server carries other people's traffic."""
|
|
187
|
+
bus = InvalidationBus()
|
|
188
|
+
seen: list[Invalidation] = []
|
|
189
|
+
bus.subscribe("c", seen.append)
|
|
190
|
+
|
|
191
|
+
await bus.deliver(message)
|
|
192
|
+
|
|
193
|
+
assert seen == []
|
|
194
|
+
|
|
195
|
+
async def test_a_key_less_message_survives_the_round_trip(self):
|
|
196
|
+
"""``key=None`` means "forget the whole channel" and must stay None."""
|
|
197
|
+
bus = InvalidationBus()
|
|
198
|
+
seen: list[Invalidation] = []
|
|
199
|
+
bus.subscribe("c", seen.append)
|
|
200
|
+
|
|
201
|
+
await bus.deliver(Invalidation(channel="c", origin="elsewhere").to_wire())
|
|
202
|
+
|
|
203
|
+
assert [i.key for i in seen] == [None]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class TestRelaying:
|
|
207
|
+
"""A worker relays what it publishes even when it caches nothing itself."""
|
|
208
|
+
|
|
209
|
+
async def test_a_channel_with_no_local_subscribers_still_broadcasts(self):
|
|
210
|
+
"""Otherwise a heterogeneous fleet loses messages by luck of routing.
|
|
211
|
+
|
|
212
|
+
The worker that serves a revocation need not be one that caches the
|
|
213
|
+
counter — a module can publish for caches that only *other* modules keep.
|
|
214
|
+
If the transport were skipped when nothing local listens, whether the
|
|
215
|
+
other workers heard would depend on which worker got the request.
|
|
216
|
+
"""
|
|
217
|
+
bus = InvalidationBus()
|
|
218
|
+
transport = _RecordingTransport()
|
|
219
|
+
bus.set_transport(transport)
|
|
220
|
+
|
|
221
|
+
await bus.publish("nobody.local", key="k")
|
|
222
|
+
|
|
223
|
+
assert len(transport.sent) == 1
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class TestUnknownChannels:
|
|
227
|
+
async def test_a_remote_message_for_an_unsubscribed_channel_is_a_no_op(self):
|
|
228
|
+
"""A shared transport carries every channel; most are not ours."""
|
|
229
|
+
bus = InvalidationBus()
|
|
230
|
+
seen: list[Invalidation] = []
|
|
231
|
+
bus.subscribe("mine", seen.append)
|
|
232
|
+
|
|
233
|
+
await bus.deliver(Invalidation(channel="theirs", key="k", origin="other").to_wire())
|
|
234
|
+
|
|
235
|
+
assert seen == []
|
|
236
|
+
|
|
237
|
+
async def test_non_utf8_bytes_are_dropped_rather_than_raised(self):
|
|
238
|
+
"""``json.loads`` raises ``UnicodeDecodeError`` on these, not ValueError.
|
|
239
|
+
|
|
240
|
+
A listener that died on one such frame would take cross-process
|
|
241
|
+
invalidation down for the life of the process, and a shared Redis
|
|
242
|
+
database is exactly where arbitrary bytes come from.
|
|
243
|
+
"""
|
|
244
|
+
bus = InvalidationBus()
|
|
245
|
+
seen: list[Invalidation] = []
|
|
246
|
+
bus.subscribe("c", seen.append)
|
|
247
|
+
|
|
248
|
+
await bus.deliver(b"\xff\xfe\x00not json")
|
|
249
|
+
|
|
250
|
+
assert seen == []
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class TestOrigin:
|
|
254
|
+
def test_two_buses_do_not_share_an_origin(self):
|
|
255
|
+
"""A shared origin would make each ignore the other — silently."""
|
|
256
|
+
assert InvalidationBus().origin != InvalidationBus().origin
|
|
257
|
+
|
|
258
|
+
def test_wire_carries_the_current_version(self):
|
|
259
|
+
import json
|
|
260
|
+
|
|
261
|
+
assert json.loads(Invalidation(channel="c").to_wire())["v"] == WIRE_VERSION
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Handler-registration hazards, split from ``test_invalidation.py``.
|
|
2
|
+
|
|
3
|
+
That file asks whether the bus delivers the right message to the right handler;
|
|
4
|
+
this one asks what happens when the set of handlers changes while the bus is
|
|
5
|
+
walking it. Different question, and together they crossed the 300-line cap.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
|
|
12
|
+
from simple_module_core.invalidation import InvalidationBus
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TestReentrantSubscribe:
|
|
16
|
+
"""A handler that subscribes during dispatch must not wedge the event loop.
|
|
17
|
+
|
|
18
|
+
``_dispatch`` used to iterate the live handler list, so a handler that
|
|
19
|
+
re-armed itself appended to the list being walked. With sync handlers that
|
|
20
|
+
loop has no await point, so it did not merely recurse — it took the event
|
|
21
|
+
loop with it, and neither ``asyncio.timeout`` nor the per-handler ``except``
|
|
22
|
+
could intervene. Found by exploratory QA at 3.1 million invocations from one
|
|
23
|
+
``publish``; the fix is to iterate a snapshot.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
async def test_a_self_rearming_handler_terminates(self):
|
|
27
|
+
bus = InvalidationBus()
|
|
28
|
+
calls: list[int] = []
|
|
29
|
+
|
|
30
|
+
def rearm(inv):
|
|
31
|
+
calls.append(1)
|
|
32
|
+
bus.subscribe("orders.totals", rearm)
|
|
33
|
+
|
|
34
|
+
bus.subscribe("orders.totals", rearm)
|
|
35
|
+
|
|
36
|
+
# A real timeout, because the failure mode is a hang rather than a wrong
|
|
37
|
+
# answer. It can only fire if the loop yields, which is exactly what the
|
|
38
|
+
# bug prevented — so on a regression this test times out and fails.
|
|
39
|
+
async with asyncio.timeout(10):
|
|
40
|
+
await bus.publish("orders.totals", key="k")
|
|
41
|
+
|
|
42
|
+
assert calls == [1], "the handler ran more than once within a single publish"
|
|
43
|
+
|
|
44
|
+
async def test_a_handler_added_during_dispatch_waits_for_the_next_publish(self):
|
|
45
|
+
"""The snapshot's other consequence, stated so it is not a surprise."""
|
|
46
|
+
bus = InvalidationBus()
|
|
47
|
+
calls: list[str] = []
|
|
48
|
+
|
|
49
|
+
def adder(inv):
|
|
50
|
+
calls.append("adder")
|
|
51
|
+
bus.subscribe("c", lambda i: calls.append("added"))
|
|
52
|
+
|
|
53
|
+
bus.subscribe("c", adder)
|
|
54
|
+
|
|
55
|
+
async with asyncio.timeout(10):
|
|
56
|
+
await bus.publish("c")
|
|
57
|
+
assert calls == ["adder"]
|
|
58
|
+
|
|
59
|
+
calls.clear()
|
|
60
|
+
async with asyncio.timeout(10):
|
|
61
|
+
await bus.publish("c")
|
|
62
|
+
assert "added" in calls
|
|
@@ -108,6 +108,24 @@ class TestSm003PageRenderResolution:
|
|
|
108
108
|
mod = _FakeModule(meta=_FakeMeta(name="M"))
|
|
109
109
|
assert "admin/Browse" in find_render_calls(mod, src_dir) # pyright: ignore[reportArgumentType]
|
|
110
110
|
|
|
111
|
+
async def test_colocated_test_file_is_not_flagged_as_orphan_page(self, tmp_path: Path):
|
|
112
|
+
"""A ``*.test.tsx``/``*.spec.tsx`` beside a page is a test, not a page.
|
|
113
|
+
|
|
114
|
+
Regression test: ``collect_tsx_pages`` globbed ``pages/**/*.tsx``
|
|
115
|
+
with no exclusion, so a colocated ``Browse.test.tsx`` (the same
|
|
116
|
+
pattern Vite's import.meta.glob now excludes) was reported as an
|
|
117
|
+
SM003 orphan page since nothing renders "M/Browse.test".
|
|
118
|
+
"""
|
|
119
|
+
src_dir = tmp_path / "m" / "m"
|
|
120
|
+
(src_dir / "pages").mkdir(parents=True)
|
|
121
|
+
(src_dir / "pages" / "Browse.tsx").write_text("export default function B() {}")
|
|
122
|
+
(src_dir / "pages" / "Browse.test.tsx").write_text("test('renders', () => {})")
|
|
123
|
+
(src_dir / "pages" / "Browse.spec.tsx").write_text("test('renders', () => {})")
|
|
124
|
+
(src_dir / "endpoints.py").write_text(
|
|
125
|
+
'async def view(inertia):\n return await inertia.render("M/Browse", {})\n'
|
|
126
|
+
)
|
|
127
|
+
assert self._diags(src_dir, "M") == []
|
|
128
|
+
|
|
111
129
|
async def test_fstring_with_unknown_name_stays_flagged(self, tmp_path: Path):
|
|
112
130
|
"""An f-string over a runtime value is not static — don't guess."""
|
|
113
131
|
src_dir = tmp_path / "m" / "m"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/diagnostics/__init__.py
RENAMED
|
File without changes
|
{simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/diagnostics/_coupling.py
RENAMED
|
File without changes
|
{simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/diagnostics/_css.py
RENAMED
|
File without changes
|
{simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/diagnostics/_i18n.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/diagnostics/_migration.py
RENAMED
|
File without changes
|
{simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/diagnostics/_runner.py
RENAMED
|
File without changes
|
{simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/diagnostics/_types.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_core-0.0.34 → simple_module_core-0.0.35}/simple_module_core/redirect_safety.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_auth_provider_selection.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_core-0.0.34 → simple_module_core-0.0.35}/tests/test_feature_flags_decorator.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|