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
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<a href="https://github.com/fireflyframework"><img src="https://img.shields.io/badge/Firefly_Framework-official-ff6600?logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0id2hpdGUiIGQ9Ik0xMiAyQzYuNDggMiAyIDYuNDggMiAxMnM0LjQ4IDEwIDEwIDEwIDEwLTQuNDggMTAtMTBTMTcuNTIgMiAxMiAyeiIvPjwvc3ZnPg==" alt="Firefly Framework"></a>
<a href="https://www.python.org/"><img src="https://img.shields.io/badge/python-3.12%2B-blue?logo=python&logoColor=white" alt="Python 3.12+"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-green" alt="License: Apache 2.0"></a>
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/version-26.09.03-brightgreen" alt="Version: 26.09.03"></a>
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/version-26.09.04-brightgreen" alt="Version: 26.09.04"></a>
<a href="https://mypy-lang.org/"><img src="https://img.shields.io/badge/type--checked-mypy%20strict-blue?logo=python&logoColor=white" alt="Type Checked: mypy strict"></a>
<a href="https://docs.astral.sh/ruff/"><img src="https://img.shields.io/badge/code%20style-ruff-purple?logo=ruff&logoColor=white" alt="Code Style: Ruff"></a>
<a href="#philosophy"><img src="https://img.shields.io/badge/async-first-brightgreen" alt="Async First"></a>
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/pyfly/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@
# limitations under the License.
"""PyFly — Enterprise Python Framework."""

__version__ = "26.09.03"
__version__ = "26.09.04"
23 changes: 23 additions & 0 deletions src/pyfly/context/application_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]] = []
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
80 changes: 80 additions & 0 deletions tests/context/test_application_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading