From c803adc3d0debd8f60100379b1b0abd707d610d3 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Wed, 9 Sep 2026 21:15:20 -0700 Subject: [PATCH] fix(context): a restart reproduces a cold start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. 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 sharing a module-level application across test modules. Release 26.09.04. --- CHANGELOG.md | 26 ++++++++ README.md | 2 +- pyproject.toml | 2 +- src/pyfly/__init__.py | 2 +- src/pyfly/context/application_context.py | 23 +++++++ tests/context/test_application_context.py | 80 +++++++++++++++++++++++ uv.lock | 2 +- 7 files changed, 133 insertions(+), 4 deletions(-) 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" },