diff --git a/CHANGELOG.md b/CHANGELOG.md index dc9e39b..f4fb85b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## v26.06.67 (2026-06-07) + +### Added (scheduling — pluggable task executor; fixes a weak default) + +- The `TaskScheduler`'s executor is now selectable via `pyfly.scheduling.executor.type` + (`asyncio`, default — in-loop tasks; or `thread` — offload blocking jobs to a pool of + `pyfly.scheduling.executor.max-workers`, default 4). The `ThreadPoolTaskExecutor` already + existed and was tested but was never reachable through configuration — the auto-config + hardcoded `AsyncIOTaskExecutor`. Found by the ports/adapters audit (weak default). + ## v26.06.66 (2026-06-07) ### Added (distributed lock — Postgres advisory-lock adapter; Postgres parity) diff --git a/README.md b/README.md index 6650f05..a0731c1 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Firefly Framework Python 3.12+ License: Apache 2.0 - Version: 26.06.66 + Version: 26.06.67 Type Checked: mypy strict Code Style: Ruff Async First diff --git a/pyproject.toml b/pyproject.toml index d717c43..18d31a6 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.6.66" +version = "26.6.67" 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 5a21ca6..b0b7b6e 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.06.66" +__version__ = "26.06.67" diff --git a/src/pyfly/scheduling/auto_configuration.py b/src/pyfly/scheduling/auto_configuration.py index aefb5c9..fa050c2 100644 --- a/src/pyfly/scheduling/auto_configuration.py +++ b/src/pyfly/scheduling/auto_configuration.py @@ -30,6 +30,7 @@ from pyfly.context.conditions import auto_configuration, conditional_on_class from pyfly.core.config import Config from pyfly.scheduling.lock import DistributedLock, InProcessDistributedLock, LocalLock +from pyfly.scheduling.ports.outbound import TaskExecutorPort @auto_configuration @@ -74,11 +75,19 @@ def _engine() -> Any: return LocalLock() @bean - def task_scheduler(self, container: Container) -> TaskScheduler: + def task_scheduler(self, container: Container, config: Config) -> TaskScheduler: # Resolve the DistributedLock bean above for @scheduled(lock=...) coordination; # fall back to the scheduler's own LocalLock if (unexpectedly) absent. try: lock = container.resolve(DistributedLock) # type: ignore[type-abstract] except (NoSuchBeanError, NoUniqueBeanError): lock = None - return TaskScheduler(lock=lock) + + # Executor backend: pyfly.scheduling.executor.type = 'asyncio' (default, in-loop tasks) + # or 'thread' (offload blocking jobs to a pool of pyfly.scheduling.executor.max-workers). + executor: TaskExecutorPort | None = None + if str(config.get("pyfly.scheduling.executor.type", "asyncio")).lower() == "thread": + from pyfly.scheduling.adapters.thread_executor import ThreadPoolTaskExecutor + + executor = ThreadPoolTaskExecutor(max_workers=int(config.get("pyfly.scheduling.executor.max-workers", 4))) + return TaskScheduler(executor=executor, lock=lock) diff --git a/tests/scheduling/test_executor_selection.py b/tests/scheduling/test_executor_selection.py new file mode 100644 index 0000000..84300e4 --- /dev/null +++ b/tests/scheduling/test_executor_selection.py @@ -0,0 +1,44 @@ +# Copyright 2026 Firefly Software Foundation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""TaskScheduler executor backend selection (v26.06.67).""" + +from __future__ import annotations + +from typing import Any + +from pyfly.container.container import Container +from pyfly.core.config import Config +from pyfly.scheduling.adapters.asyncio_executor import AsyncIOTaskExecutor +from pyfly.scheduling.adapters.thread_executor import ThreadPoolTaskExecutor +from pyfly.scheduling.auto_configuration import SchedulingAutoConfiguration + + +def _executor(config: Config) -> Any: + return SchedulingAutoConfiguration().task_scheduler(Container(), config)._executor + + +def test_default_executor_is_asyncio() -> None: + assert isinstance(_executor(Config({})), AsyncIOTaskExecutor) + + +def test_thread_executor_selected_by_config() -> None: + cfg = Config({"pyfly": {"scheduling": {"executor": {"type": "thread"}}}}) + assert isinstance(_executor(cfg), ThreadPoolTaskExecutor) + + +def test_thread_executor_respects_max_workers() -> None: + cfg = Config({"pyfly": {"scheduling": {"executor": {"type": "thread", "max-workers": 7}}}}) + executor = _executor(cfg) + assert isinstance(executor, ThreadPoolTaskExecutor) + assert executor._executor._max_workers == 7 diff --git a/uv.lock b/uv.lock index 9cde743..da06443 100644 --- a/uv.lock +++ b/uv.lock @@ -1981,7 +1981,7 @@ wheels = [ [[package]] name = "pyfly" -version = "26.6.66" +version = "26.6.67" source = { editable = "." } dependencies = [ { name = "pydantic" },