Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions facetwork/runtime/dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,8 @@ def __init__(
) -> None:
self._persistence = persistence
self._topics = topics or []
self._module_cache: dict[tuple[str, str], Callable] = {}
# keyed by (module_uri, checksum, entrypoint) — see _load_handler
self._module_cache: dict[tuple[str, str, str], Callable] = {}
self._import_lock = threading.Lock()
# In-memory registration cache: facet_name -> registration
self._reg_cache: dict[str, Any] = {}
Expand Down Expand Up @@ -245,7 +246,13 @@ def _find_registration(self, facet_name: str) -> Any:

def _load_handler(self, reg: Any) -> Callable:
"""Load a handler callable, using cache when possible."""
cache_key = (reg.module_uri, reg.checksum)
# The cache stores the RESOLVED callable, so the entrypoint MUST be part
# of the key: two facets registered from the same module (same module_uri
# + checksum) but with different entrypoints would otherwise both get the
# first-cached callable, silently mis-routing every facet after the first
# to one handler. (Domain packages use a single dispatch entrypoint, which
# masked this — but a module with N distinct entrypoints hit it.)
cache_key = (reg.module_uri, reg.checksum, reg.entrypoint)
if cache_key in self._module_cache:
return self._module_cache[cache_key]

Expand Down Expand Up @@ -357,8 +364,8 @@ def _import_from_file(file_path: str) -> Any:
raise

@property
def module_cache(self) -> dict[tuple[str, str], Callable]:
"""Expose cache for testing."""
def module_cache(self) -> dict[tuple[str, str, str], Callable]:
"""Expose cache for testing. Keyed by (module_uri, checksum, entrypoint)."""
return self._module_cache


Expand Down
32 changes: 32 additions & 0 deletions tests/runtime/test_dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,38 @@ def test_dispatch_sync_handler(self, store, handler_file):
result = dispatcher.dispatch("ns.Double", {"input": 5})
assert result == {"output": 10}

def test_distinct_entrypoints_same_module_do_not_collide(self, store, tmp_path):
"""Two facets from ONE module with DIFFERENT entrypoints must each
dispatch to their own handler.

Regression: the handler cache was keyed by ``(module_uri, checksum)``
without the entrypoint, so the first-dispatched facet's callable was
cached and returned for every other facet sharing that module —
silently mis-routing every facet after the first. (Domain packages use
a single dispatch entrypoint, which masked it; a module with N distinct
entrypoints hit it.)
"""
f = tmp_path / "multi_handler.py"
f.write_text(
"def alpha(payload):\n return {'who': 'alpha'}\n\n"
"def beta(payload):\n return {'who': 'beta'}\n"
)
mod = f"file://{f}"
# same module_uri + (default) checksum, different entrypoints
store.save_handler_registration(
HandlerRegistration(facet_name="ns.Alpha", module_uri=mod, entrypoint="alpha")
)
store.save_handler_registration(
HandlerRegistration(facet_name="ns.Beta", module_uri=mod, entrypoint="beta")
)
dispatcher = RegistryDispatcher(persistence=store)
# Dispatch alpha first (populates the cache), then beta — and again in
# the reverse order to prove the cache is genuinely per-entrypoint.
assert dispatcher.dispatch("ns.Alpha", {}) == {"who": "alpha"}
assert dispatcher.dispatch("ns.Beta", {}) == {"who": "beta"}
assert dispatcher.dispatch("ns.Beta", {}) == {"who": "beta"}
assert dispatcher.dispatch("ns.Alpha", {}) == {"who": "alpha"}

def test_dispatch_async_handler(self, store, tmp_path):
"""Async handler detected and invoked via asyncio.run()."""
f = tmp_path / "async_handler.py"
Expand Down
8 changes: 5 additions & 3 deletions tests/runtime/test_registry_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -380,9 +380,11 @@ def test_checksum_change_evicts_cache(self, store, evaluator, handler_file):
_handler_v1 = runner._dispatcher._load_handler(reg_v1)
_handler_v2 = runner._dispatcher._load_handler(reg_v2)
# Different checksum -> different cache entry -> different import
# (they're functionally equal but are separate function objects)
assert (reg_v1.module_uri, reg_v1.checksum) in runner._module_cache
assert (reg_v2.module_uri, reg_v2.checksum) in runner._module_cache
# (they're functionally equal but are separate function objects).
# Cache key is (module_uri, checksum, entrypoint) — the entrypoint is
# part of the key so distinct entrypoints in one module don't collide.
assert (reg_v1.module_uri, reg_v1.checksum, reg_v1.entrypoint) in runner._module_cache
assert (reg_v2.module_uri, reg_v2.checksum, reg_v2.entrypoint) in runner._module_cache


# =========================================================================
Expand Down
Loading