diff --git a/CHANGELOG.md b/CHANGELOG.md index c56e4d7..44bbd14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,32 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## v26.09.04 (2026-09-10) + +### Fixed + +- **A restart reproduces a cold start.** `26.09.03` made `stop()` release the singletons it built, but + the REGISTRATIONS the previous `start()` created were left in place, so the next `start()` layered a + second pipeline on top of them instead of rebuilding. Two things went wrong, both silent: + + - a `@bean` reachable under several keys — its concrete class and the protocol it satisfies — stopped + being one object. The first start aliased those keys to a single instance; the restart resolved each + key independently and called the factory once per key, so `get_bean(Protocol)` and + `get_bean(Concrete)` returned **different** singletons. + - the registration set drifted, because the `@conditional_on_*` passes re-evaluated against a registry + that already held the previous run's output rather than against the user's own definitions. + + `start()` now records exactly which registrations its pipeline added and drops them at the next start, + so the registry a restart begins from is the one a cold start begins from. Two tests pin it: a bean + published under an interface stays one instance across a restart, and the registration set after a + restart is identical to the set after the cold start. + + This completes the lifecycle trilogy of `26.09.02` (idempotent `start()`), `26.09.03` (`stop()` + releases what it destroyed) and this release, all found by one service that shares a module-level + application across test modules. + +--- + ## v26.09.03 (2026-09-10) ### Fixed diff --git a/README.md b/README.md index 5710fa7..9ea7446 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Firefly Framework Python 3.12+ License: Apache 2.0 - Version: 26.09.03 + Version: 26.09.04 Type Checked: mypy strict Code Style: Ruff Async First diff --git a/pyproject.toml b/pyproject.toml index eec1c5f..05b4dd7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "pyfly" # CalVer YY.MM.PATCH — package metadata uses PEP 440 normalized form (26.5.4); # git tag, GitHub release and human-readable display use leading-zero form # (v26.05.04) to match the Java/.NET/Go siblings. -version = "26.9.3" +version = "26.9.4" description = "The official Python implementation of the Firefly Framework — DI, CQRS, EDA, hexagonal architecture, and more." readme = "README.md" license = "Apache-2.0" diff --git a/src/pyfly/__init__.py b/src/pyfly/__init__.py index 7afe1ec..25f246c 100644 --- a/src/pyfly/__init__.py +++ b/src/pyfly/__init__.py @@ -13,4 +13,4 @@ # limitations under the License. """PyFly — Enterprise Python Framework.""" -__version__ = "26.09.03" +__version__ = "26.09.04" diff --git a/src/pyfly/context/application_context.py b/src/pyfly/context/application_context.py index 9d8ef01..56fd734 100644 --- a/src/pyfly/context/application_context.py +++ b/src/pyfly/context/application_context.py @@ -73,6 +73,9 @@ def __init__(self, config: Config) -> None: self._started = False #: Instances the container was HANDED rather than built; stop() must not release them. self._preexisting_instances: set[int] = set() + #: Registration keys the last start() pipeline added; dropped at the next start so a restart + #: rebuilds from the same registry a cold start sees. + self._pipeline_registrations: frozenset[Any] = frozenset() self._infrastructure_adapters: list[Any] = [] self._task_scheduler: Any | None = None self._background_tasks: list[asyncio.Task[Any]] = [] @@ -198,6 +201,22 @@ async def start(self) -> None: async def _do_start(self) -> None: """Internal startup logic.""" + # A RESTART MUST REPRODUCE A COLD START. + # + # stop() releases the singletons it built, but the REGISTRATIONS this pipeline created are + # still here, and layering a second pipeline on top of them is not a rebuild. Two things go + # wrong, both silent. A @bean reachable under several keys — its concrete class and the + # protocol it satisfies — stops being one object, because the first start aliased those keys to + # a single instance and a second pass resolves each key independently, calling the factory once + # per key. And the registry drifts, because the conditional passes re-evaluate against a + # registry that already holds the previous run's output rather than against the user's own + # definitions. Dropping what the last pipeline added puts the registry back to the state a cold + # start begins from. + for key in getattr(self, "_pipeline_registrations", frozenset()): + self._container._registrations.pop(key, None) + + registrations_before = set(self._container._registrations.keys()) + # Whatever already carries an instance was HANDED to the container, not built by it — the # container's self-registration, the context's own, anything an embedder registered as a # ready-made object. stop() releases what this start creates and leaves these alone, because @@ -310,6 +329,10 @@ async def _do_start(self) -> None: await self._event_bus.publish(ContextRefreshedEvent()) await self._event_bus.publish(ApplicationReadyEvent()) await self._invoke_runners() + # Everything this pipeline added, so the next start can drop it and begin from the same + # registry a cold start begins from. + self._pipeline_registrations = frozenset(self._container._registrations.keys()) - registrations_before + self._started = True # Lazily-created singletons (built post-startup on first resolve) must still # run the full init pipeline. Installed now so the batched startup passes diff --git a/tests/context/test_application_context.py b/tests/context/test_application_context.py index 06234f7..bd108f4 100644 --- a/tests/context/test_application_context.py +++ b/tests/context/test_application_context.py @@ -788,3 +788,83 @@ async def test_stop_releases_the_singletons_it_destroyed() -> None: assert id(first) not in live, "the destroyed singleton is still registered after the restart" await context.stop() + + +# --------------------------------------------------------------------------- +# A restart must reproduce a cold start +# +# stop() releases the singletons it built (26.09.03), but the REGISTRATIONS the +# previous start created were left in place, so the next start layered a second +# pipeline on top of them rather than rebuilding. Two things went wrong, and both +# are silent: +# +# * a @bean reachable under several keys — its concrete class and the protocol +# it satisfies — stopped being one object. The first start aliased the keys to +# a single instance; the restart resolved each key independently and called the +# factory once per key, so `get_bean(Protocol)` and `get_bean(Concrete)` +# returned DIFFERENT singletons. +# * the registration set drifted, because the conditional passes re-evaluated +# against a registry that already held the previous run's output. +# --------------------------------------------------------------------------- + + +class _Port: + """The interface a bean is published under, alongside its concrete class.""" + + +class _Adapter(_Port): + pass + + +@configuration +class _PortConfiguration: + @bean + def adapter(self) -> _Port: + return _Adapter() + + +@pytest.mark.asyncio +async def test_a_restart_keeps_one_instance_per_bean_across_every_key() -> None: + context = ApplicationContext(Config({})) + context.register_bean(_PortConfiguration) + + await context.start() + assert context.get_bean(_Port) is context.get_bean(_Port) + await context.stop() + + await context.start() + by_port = context.get_bean(_Port) + + reachable = { + id(reg.instance) + for reg in context._container._registrations.values() + if isinstance(getattr(reg, "instance", None), _Adapter) + } + assert len(reachable) == 1, ( + f"after a restart the bean is registered as {len(reachable)} distinct instances; " + "a singleton reachable under several keys must stay one object" + ) + assert id(by_port) in reachable + + await context.stop() + + +@pytest.mark.asyncio +async def test_a_restart_does_not_drift_the_registration_set() -> None: + context = ApplicationContext(Config({})) + context.register_bean(_PortConfiguration) + + await context.start() + first = set(context._container._registrations.keys()) + await context.stop() + + await context.start() + second = set(context._container._registrations.keys()) + + assert second == first, ( + "a restart produced a different registry than the cold start: " + f"added {sorted(str(k) for k in second - first)}, " + f"lost {sorted(str(k) for k in first - second)}" + ) + + await context.stop() diff --git a/uv.lock b/uv.lock index e2dcc51..50a4fc6 100644 --- a/uv.lock +++ b/uv.lock @@ -2285,7 +2285,7 @@ wheels = [ [[package]] name = "pyfly" -version = "26.9.2" +version = "26.9.3" source = { editable = "." } dependencies = [ { name = "pydantic" },