From bce3fc1a547f5d3fa36c3d2c187617e910c2568d Mon Sep 17 00:00:00 2001 From: Vlasov Dmitriy Date: Thu, 2 Oct 2025 22:09:52 +0300 Subject: [PATCH 1/6] cicd fixes --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index dd981f7..b649887 100644 --- a/README.rst +++ b/README.rst @@ -19,8 +19,8 @@ MicroAgent .. image:: https://readthedocs.org/projects/microagent/badge/?version=latest&style=flat :target: https://microagent.readthedocs.io/ -.. image:: https://github.com/scailer/microagent/workflows/Tests/badge.svg - :target: https://github.com/scailer/microagent/actions +.. image:: https://github.com/scailer/microagent/actions + :target: https://github.com/scailer/microagent/workflows/Tests/badge.svg The goal of this project is to facilitate the creation of **microservices** From 9bf2f96dec320ff14cb7ad4ae136abf47f3869c3 Mon Sep 17 00:00:00 2001 From: Vlasov Dmitriy Date: Thu, 2 Oct 2025 22:21:03 +0300 Subject: [PATCH 2/6] update dependencies --- README.rst | 4 ++-- pyproject.toml | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index b649887..dd981f7 100644 --- a/README.rst +++ b/README.rst @@ -19,8 +19,8 @@ MicroAgent .. image:: https://readthedocs.org/projects/microagent/badge/?version=latest&style=flat :target: https://microagent.readthedocs.io/ -.. image:: https://github.com/scailer/microagent/actions - :target: https://github.com/scailer/microagent/workflows/Tests/badge.svg +.. image:: https://github.com/scailer/microagent/workflows/Tests/badge.svg + :target: https://github.com/scailer/microagent/actions The goal of this project is to facilitate the creation of **microservices** diff --git a/pyproject.toml b/pyproject.toml index 271d9a1..035096d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,6 @@ Changelog = 'https://github.com/scailer/microagent/blob/master/CHANGELOG.rst' redis = ['redis[hiredis]>5'] amqp = ['aiormq==6.9.*'] kafka = ['aiokafka==0.13.*'] - dev = [ 'pytest', 'pytest-runner', From 43923a69e852d3b58ae691f522ef16fc1e66b183 Mon Sep 17 00:00:00 2001 From: Vlasov Dmitriy Date: Thu, 2 Oct 2025 22:09:52 +0300 Subject: [PATCH 3/6] cicd fixes --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index dd981f7..b649887 100644 --- a/README.rst +++ b/README.rst @@ -19,8 +19,8 @@ MicroAgent .. image:: https://readthedocs.org/projects/microagent/badge/?version=latest&style=flat :target: https://microagent.readthedocs.io/ -.. image:: https://github.com/scailer/microagent/workflows/Tests/badge.svg - :target: https://github.com/scailer/microagent/actions +.. image:: https://github.com/scailer/microagent/actions + :target: https://github.com/scailer/microagent/workflows/Tests/badge.svg The goal of this project is to facilitate the creation of **microservices** From 793eb0e3f49032e469533e1fff1596078793953f Mon Sep 17 00:00:00 2001 From: Vlasov Dmitriy Date: Thu, 2 Oct 2025 22:21:03 +0300 Subject: [PATCH 4/6] update dependencies --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index b649887..dd981f7 100644 --- a/README.rst +++ b/README.rst @@ -19,8 +19,8 @@ MicroAgent .. image:: https://readthedocs.org/projects/microagent/badge/?version=latest&style=flat :target: https://microagent.readthedocs.io/ -.. image:: https://github.com/scailer/microagent/actions - :target: https://github.com/scailer/microagent/workflows/Tests/badge.svg +.. image:: https://github.com/scailer/microagent/workflows/Tests/badge.svg + :target: https://github.com/scailer/microagent/actions The goal of this project is to facilitate the creation of **microservices** From ba1b2b2dbd6d23bb8108b36c07c3a329af93488b Mon Sep 17 00:00:00 2001 From: Vlasov Dmitriy Date: Fri, 5 Jun 2026 16:18:30 +0300 Subject: [PATCH 5/6] launcher: improves os process control, fix graceful exit --- CHANGELOG.rst | 6 ++++++ microagent/__init__.py | 2 +- microagent/launcher.py | 37 ++++++++++++++++++++++--------------- 3 files changed, 29 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 694bb2f..eed7728 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -3,6 +3,12 @@ Changelog ========= +1.8.1 (2026-06-06) +================== + +- Improves os process control, fix graceful exit + + 1.8.0 (2026-02-26) ================== diff --git a/microagent/__init__.py b/microagent/__init__.py index 40064a0..fbd3ddc 100644 --- a/microagent/__init__.py +++ b/microagent/__init__.py @@ -1,4 +1,4 @@ -__version__ = '1.8.0' +__version__ = '1.8.1' import importlib import json diff --git a/microagent/launcher.py b/microagent/launcher.py index 9908382..e4eff3f 100644 --- a/microagent/launcher.py +++ b/microagent/launcher.py @@ -78,9 +78,8 @@ def load_configuration(config_path: str) -> Iterator[tuple[str, CFG_T]]: def _configuration(data: dict[str, dict[str, str]]) -> Iterator[tuple[str, CFG_T]]: for name, params in data.items(): - backend = params.pop('backend', None) - if backend: - yield name, (backend, params) + if backend := params.get('backend'): + yield name, (backend, {k: v for k, v in params.items() if k != 'backend'}) def init_agent(backend: str, cfg: dict[str, Any]) -> 'MicroAgent': @@ -121,14 +120,16 @@ def run_agent(name: str, backend: str, cfg: dict[str, Any]) -> None: async def _run_agent(name: str, backend: str, cfg: dict[str, Any]) -> None: - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() # Interrupt process when master shutdown loop.add_signal_handler(signal.SIGINT, partial(_interrupter, name, 'INT')) loop.add_signal_handler(signal.SIGTERM, partial(_interrupter, name, 'TERM')) # Check master & force break - loop.call_later(MASTER_WATCHER_PERIOD, _master_watcher, name) + watcher_handle = loop.call_later(MASTER_WATCHER_PERIOD, _master_watcher, name) + + agent: MicroAgent | None = None try: agent = init_agent(backend, cfg) @@ -141,17 +142,21 @@ async def _run_agent(name: str, backend: str, cfg: dict[str, Any]) -> None: except (KeyboardInterrupt, GroupInterrupt, ServerInterrupt) as exc: logger.warning('AgentProc[%s]: Catch interrupt %s', name, exc) - for t in asyncio.all_tasks(loop=loop): - t.cancel() + current = asyncio.current_task() + for t in asyncio.all_tasks(): + if t is not current: + t.cancel() await asyncio.sleep(.1) - loop.stop() except Exception as exc: logger.exception('AgentProc[%s]: Catch error %s', name, exc) raise finally: + watcher_handle.cancel() + if agent is not None: + await agent.stop() raise SystemExit() @@ -210,18 +215,14 @@ def start(self) -> None: proc = self.mp_ctx.Process(target=run_agent, name=name, args=(name, *_cfg), daemon=True) proc.start() - if not proc.pid: - logger.error('AgentsManager: fail starting %s', name) - self.close() - return - + assert proc.pid is not None self.processes[proc.pid] = proc logger.info('AgentsManager: %s started', name) logger.info('AgentsManager: started\n%s', self._get_state()) while self.running: - time.sleep(.01) + time.sleep(1) if any(not p.is_alive() for p in self.processes.values()): self.running = False # one finished - all finished @@ -238,7 +239,13 @@ def close(self) -> None: logger.info('AgentsManager: terminate %s [%s]', process.name, process.pid) process.terminate() - time.sleep(.5) + for process in self.processes.values(): + process.join(timeout=1) + if process.is_alive(): + logger.warning('AgentsManager: kill %s [%s]', process.name, process.pid) + process.kill() + process.join(timeout=1) + logger.info('AgentsManager: forked processes killed\n%s', self._get_state()) def _get_state(self) -> str: From 255f9283cff5c49e81e8034e21d73f2ea33d8b15 Mon Sep 17 00:00:00 2001 From: Vlasov Dmitriy Date: Fri, 5 Jun 2026 18:16:02 +0300 Subject: [PATCH 6/6] timer: cancellation fixes, fix cron days logic --- CHANGELOG.rst | 2 ++ microagent/__init__.py | 4 ++-- microagent/agent.py | 6 +++++- microagent/timer.py | 41 +++++++++++++++++++++++++++++++---------- 4 files changed, 40 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index eed7728..b902619 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -7,6 +7,8 @@ Changelog ================== - Improves os process control, fix graceful exit +- CRON tasks now has OR logic for calendar and week days +- Periodic/cron graceful cancellation 1.8.0 (2026-02-26) diff --git a/microagent/__init__.py b/microagent/__init__.py index fbd3ddc..77a01f1 100644 --- a/microagent/__init__.py +++ b/microagent/__init__.py @@ -193,11 +193,11 @@ def cron(spec: str, timeout: int | float = 1) -> abc.Callable[[PeriodicFunc], Pe .. code-block:: python - @periodic('0 */4 * * *') + @cron('0 */4 * * *') async def handler_1(self): log.info('Called handler 1') - @periodic('*/15 * * * *', timeout=10) + @cron('*/15 * * * *', timeout=10) async def handler_2(self): log.info('Called handler 2') ''' diff --git a/microagent/agent.py b/microagent/agent.py index 9f33c8a..d48f495 100644 --- a/microagent/agent.py +++ b/microagent/agent.py @@ -204,6 +204,10 @@ async def start(self, await self.run_servers(self.hook.servers) async def stop(self) -> None: + for periodic_task in self.periodic_tasks: + periodic_task.cancel() + for cron_task in self.cron_tasks: + cron_task.cancel() await self.hook.pre_stop() @staticmethod @@ -220,7 +224,7 @@ def run_periodic_tasks(self, periodic_tasks: Iterable[PeriodicTask], start_at = datetime.now(tz=timezone.utc) + timedelta(seconds=start_after) self.log.debug('Set %s at %s', task, f'{start_at:%H:%M:%S}') else: - self.log.debug('Set %s after %d sec', task, start_after) + self.log.debug('Set %s after %s sec', task, start_after) task.start(start_after) diff --git a/microagent/timer.py b/microagent/timer.py index 4edf327..c771b46 100644 --- a/microagent/timer.py +++ b/microagent/timer.py @@ -36,7 +36,7 @@ async def cron_handler(self): RANGES = ((0, 59), (0, 23), (1, 31), (1, 12), (0, 7)) DAYS = (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) -MAX_DIFF = 2 * 356 * 24 * 60 * 60 +MAX_DIFF = 2 * 365 * 24 * 60 * 60 class CRON(NamedTuple): @@ -58,6 +58,8 @@ class PeriodicMixin: agent: 'MicroAgent' handler: Callable timeout: float + _timer_handle: asyncio.TimerHandle | None = None + _running_task: asyncio.Task | None = None async def call(self) -> None: try: @@ -73,7 +75,13 @@ async def call(self) -> None: self.agent.log.exception(f'Periodic Exception: {exc}') def start(self, start_after: float) -> None: - asyncio.get_running_loop().call_later(start_after, _periodic, self) # type: ignore[arg-type] + self._timer_handle = asyncio.get_running_loop().call_later(start_after, _periodic, self) # type: ignore[arg-type] + + def cancel(self) -> None: + if self._timer_handle is not None: + self._timer_handle.cancel() + if self._running_task is not None and not self._running_task.done(): + self._running_task.cancel() class PeriodicArgs(TypedDict): @@ -123,17 +131,14 @@ def start_after(self) -> float: @property def period(self) -> float: - ''' - *period* property of **CRONTask** object is a next value of - generator behind facade. Be carefully with manual manipulation with it. - ''' - self.agent.log.debug('Run %r', self) return self.cron.next().timestamp() - time.time() # next step delay def _periodic(task: PeriodicTask | CRONTask) -> asyncio.Task: - asyncio.get_running_loop().call_later(task.period, _periodic, task) - return asyncio.create_task(task.call()) + task._timer_handle = asyncio.get_running_loop().call_later(task.period, _periodic, task) + running = asyncio.create_task(task.call()) + task._running_task = running + return running def cron_parser(spec: str) -> CRON: @@ -201,7 +206,23 @@ def next_moment(cron: CRON, now: datetime) -> datetime: return next_moment(cron, now) - if now.day not in cron.days or now.weekday() not in cron.weekdays: + # calculating whether a task needs to be started on that day + days_all = set(cron.days) == set(range(1, 32)) # run every calendar day + wdays_all = set(cron.weekdays) >= set(range(7)) # run every week day + + day_ok = now.day in cron.days # run today + wday_ok = now.weekday() in cron.weekdays # run today + + if not days_all and not wdays_all: + ok = day_ok or wday_ok # POSIX: both restricted → OR + elif not days_all: + ok = day_ok + elif not wdays_all: + ok = wday_ok + else: # if run every day + ok = True + + if not ok: now += timedelta(days=1) now = datetime(year=now.year, month=now.month, day=now.day, tzinfo=timezone.utc) return next_moment(cron, now)