chore: import upstream snapshot with attribution
Deploy Documentation / deploy (push) Has been cancelled
CPU Test / Test (Utilities, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (LLM proxy, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Others, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Store, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Utilities, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Weave, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (AgentOps, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (LLM proxy, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Others, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Weave, latest, Python 3.13) (push) Has been cancelled
Dashboard / Chromatic (push) Has been cancelled
CPU Test / Lint - fast (push) Has been cancelled
CPU Test / Lint - next (push) Has been cancelled
CPU Test / Lint - slow (push) Has been cancelled
CPU Test / Lint - JavaScript (push) Has been cancelled
CPU Test / Build documentation (push) Has been cancelled
CPU Test / Test (AgentOps, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (LLM proxy, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (Others, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (Store, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (Weave, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (AgentOps, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Store, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Utilities, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Weave, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (AgentOps, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (LLM proxy, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (Others, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (Store, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (Utilities, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (JavaScript) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:44:17 +08:00
commit 85742ab165
588 changed files with 320176 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
# Copyright (c) Microsoft. All rights reserved.
from .base import ExecutionStrategy
from .client_server import ClientServerExecutionStrategy
from .events import ExecutionEvent, MultiprocessingEvent, ThreadingEvent
from .shared_memory import SharedMemoryExecutionStrategy
__all__ = [
"ExecutionStrategy",
"ClientServerExecutionStrategy",
"ExecutionEvent",
"ThreadingEvent",
"MultiprocessingEvent",
"SharedMemoryExecutionStrategy",
]
+64
View File
@@ -0,0 +1,64 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import logging
from typing import Protocol
from agentlightning.store.base import LightningStore
from .events import ExecutionEvent
logger = logging.getLogger(__name__)
class AlgorithmBundle(Protocol):
"""Callable bundle produced by [`Trainer`][agentlightning.Trainer].
Execution strategies treat the returned coroutine as opaque, only providing
the shared store instance and cooperative stop event. Bundles typically
encapsulate algorithm setup plus adapter and LLM proxy, etc.
"""
async def __call__(self, store: LightningStore, event: ExecutionEvent) -> None:
"""Execute algorithm logic using ``store`` until completion or stop."""
class RunnerBundle(Protocol):
"""Callable bundle wrapping runner setup and the worker loop, as opposed to the
[`AlgorithmBundle`][agentlightning.AlgorithmBundle]."""
async def __call__(self, store: LightningStore, worker_id: int, event: ExecutionEvent) -> None:
"""Execute runner logic for ``worker_id`` using ``store`` and ``event``."""
class ExecutionStrategy:
"""Coordinate algorithm and runner bundles within a single process abstraction.
Strategies decide how many worker bundles to launch, whether to communicate
through shared memory or an HTTP boundary, and how to react to shutdown
signals. They intentionally avoid inspecting the bundle internals; instead,
each bundle remains responsible for its own scheduling semantics.
!!! note
Implementations must honor the [execute()][agentlightning.ExecutionStrategy.execute]
contract by propagating `KeyboardInterrupt` and ensuring resources are
released when an error occurs on either side of the algorithm/runner
pair.
"""
def execute(self, algorithm: AlgorithmBundle, runner: RunnerBundle, store: LightningStore) -> None:
"""Run the provided bundles using the configured orchestration model.
Args:
algorithm: Callable bundle responsible for algorithm execution.
runner: Callable bundle for runner workers.
store: Concrete [`LightningStore`][agentlightning.LightningStore]
shared across bundles.
Raises:
NotImplementedError: Subclasses must provide the orchestration
implementation.
"""
raise NotImplementedError()
+443
View File
@@ -0,0 +1,443 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import logging
import multiprocessing
import os
import signal
import time
from multiprocessing.context import BaseContext
from typing import Callable, Iterable, Literal, cast
from agentlightning.env_var import LightningEnvVar, resolve_bool_env_var, resolve_int_env_var, resolve_str_env_var
from agentlightning.store.base import LightningStore
from agentlightning.store.client_server import LightningStoreClient, LightningStoreServer
from .base import AlgorithmBundle, ExecutionStrategy, RunnerBundle
from .events import ExecutionEvent, MultiprocessingEvent
logger = logging.getLogger(__name__)
class ClientServerExecutionStrategy(ExecutionStrategy):
"""Run algorithm and runner bundles as separate processes over HTTP.
Execution Roles:
- `"algorithm"`: Start [`LightningStoreServer`][agentlightning.LightningStoreServer]
in-process and execute the algorithm bundle against it.
- `"runner"`: Connect to an existing server with
[`LightningStoreClient`][agentlightning.LightningStoreClient] and run the
runner bundle locally (spawning multiple processes when requested).
- `"both"`: Spawn runner processes first, then execute the algorithm and
server on the same machine. This mode orchestrates the full loop locally.
When `role == "both"` you may choose which side runs on the main process
via `main_process`. The runner-on-main option is limited to
`n_runners == 1` because each additional runner requires its own event
loop and process.
!!! warning
When `main_process == "runner"` the algorithm and HTTP server execute
in a child process. Store mutations remain isolated inside that process,
so the original store instance passed to
[execute()][agentlightning.ExecutionStrategy.execute] is not updated.
Abort Model (four-step escalation):
1. Cooperative stop. Every bundle receives a shared
[`MultiprocessingEvent`][agentlightning.MultiprocessingEvent] (`stop_evt`).
Any failure flips the event so peers can exit cleanly. Ctrl+C on the main
process also sets the flag.
2. KeyboardInterrupt synthesis. Remaining subprocesses receive ``SIGINT`` to
trigger `KeyboardInterrupt` handlers.
3. Termination. Stubborn processes are asked to ``terminate()``
(`SIGTERM` on POSIX).
4. Kill. As a last resort `kill()` is invoked (`SIGKILL` on POSIX).
This mirrors the semantics implemented in
[`SharedMemoryExecutionStrategy`][agentlightning.SharedMemoryExecutionStrategy]
but adapts them to multiple processes and the HTTP client/server boundary.
"""
alias: str = "cs"
def __init__(
self,
role: Literal["algorithm", "runner", "both"] | None = None,
server_host: str | None = None,
server_port: int | None = None,
n_runners: int = 1,
graceful_timeout: float = 10.0,
terminate_timeout: float = 10.0,
main_process: Literal["algorithm", "runner"] = "algorithm",
managed_store: bool | None = None,
allowed_exit_codes: Iterable[int] = (0, -15),
) -> None:
"""Configure the strategy.
Args:
role: Which side(s) to run in this process. When omitted, the
`AGL_CURRENT_ROLE` environment variable is used.
server_host: Interface the HTTP server binds to when running the
algorithm bundle locally. Defaults to `AGL_SERVER_HOST`
or `"localhost"` if unset.
server_port: Port for the HTTP server in "algorithm"/"both" modes.
Defaults to `AGL_SERVER_PORT` or `4747` if unset.
n_runners: Number of runner processes to spawn in "runner"/"both".
graceful_timeout: How long to wait (seconds) after setting the stop
event before escalating to signals.
terminate_timeout: How long to wait between escalation steps beyond
the cooperative phase (re-used for SIGINT, terminate, and kill).
main_process: Which bundle runs on the main process when
`role == "both"`. `"runner"` requires `n_runners == 1` and is
primarily intended for debugging.
managed_store: When `True` (default) the strategy constructs
LightningStore client/server wrappers automatically. When
`False` the provided `store` is passed directly to the
bundles, allowing callers to manage store wrappers manually.
allowed_exit_codes: Allowed exit codes for subprocesses.
By default, runner can exit gracefully with code 0 or terminated
by SIGTERM (-15).
"""
resolved_role = resolve_str_env_var(LightningEnvVar.AGL_CURRENT_ROLE, override=role, fallback="both")
if resolved_role not in ("algorithm", "runner", "both"):
raise ValueError("role must be one of 'algorithm', 'runner', or 'both'")
self.role: Literal["algorithm", "runner", "both"] = resolved_role
self.n_runners = n_runners
self.server_host = resolve_str_env_var(
LightningEnvVar.AGL_SERVER_HOST, override=server_host, fallback="localhost"
)
self.server_port = resolve_int_env_var(LightningEnvVar.AGL_SERVER_PORT, override=server_port, fallback=4747)
self.graceful_timeout = graceful_timeout
self.terminate_timeout = terminate_timeout
if main_process not in ("algorithm", "runner"):
raise ValueError("main_process must be 'algorithm' or 'runner'")
if main_process == "runner":
if self.role != "both":
raise ValueError("main_process='runner' is only supported when role='both'")
if n_runners != 1:
raise ValueError("main_process='runner' requires n_runners to be 1")
self.main_process = main_process
self.managed_store = resolve_bool_env_var(
LightningEnvVar.AGL_MANAGED_STORE, override=managed_store, fallback=True
)
self.allowed_exit_codes = tuple(allowed_exit_codes)
async def _execute_algorithm(
self, algorithm: AlgorithmBundle, store: LightningStore, stop_evt: ExecutionEvent
) -> None:
wrapper_store: LightningStore | None = None
if self.managed_store:
logger.info("Starting LightningStore server on %s:%s", self.server_host, self.server_port)
wrapper_store = LightningStoreServer(store, host=self.server_host, port=self.server_port)
server_started = False
else:
wrapper_store = store
server_started = False
try:
if self.managed_store and isinstance(wrapper_store, LightningStoreServer):
await wrapper_store.start()
server_started = True
logger.debug("Algorithm bundle starting against endpoint %s", wrapper_store.endpoint)
await algorithm(wrapper_store, stop_evt)
logger.debug("Algorithm bundle completed successfully")
except asyncio.CancelledError:
logger.info("Algorithm received CancelledError; signaling stop event")
stop_evt.set()
raise
except KeyboardInterrupt:
logger.warning("Algorithm received KeyboardInterrupt; signaling stop event")
stop_evt.set()
raise
except BaseException:
logger.exception("Algorithm bundle crashed; signaling stop event")
stop_evt.set()
raise
finally:
if self.managed_store and isinstance(wrapper_store, LightningStoreServer) and server_started:
try:
await wrapper_store.stop()
except Exception:
logger.exception("Error stopping LightningStore server")
else:
logger.debug("LightningStore server shutdown completed")
async def _execute_runner(
self,
runner: RunnerBundle,
worker_id: int,
store: LightningStore,
stop_evt: ExecutionEvent,
) -> None:
if self.managed_store:
# If managed, we actually do not use the provided store
client_store = LightningStoreClient(f"http://{self.server_host}:{self.server_port}")
else:
client_store = store
try:
if self.managed_store:
logger.debug("Runner %s connecting to server at %s:%s", worker_id, self.server_host, self.server_port)
else:
logger.debug("Runner %s executing with provided store", worker_id)
await runner(client_store, worker_id, stop_evt)
logger.debug("Runner %s completed successfully", worker_id)
except asyncio.CancelledError:
logger.debug("Runner %s received CancelledError; signaling stop event", worker_id)
stop_evt.set()
raise
except KeyboardInterrupt:
logger.warning("Runner %s received KeyboardInterrupt; signaling stop event", worker_id)
stop_evt.set()
raise
except BaseException:
logger.exception("Runner %s crashed; signaling stop event", worker_id)
stop_evt.set()
raise
finally:
if self.managed_store and isinstance(client_store, LightningStoreClient):
try:
await client_store.close()
except Exception:
logger.exception("Error closing LightningStore client for runner %s", worker_id)
else:
logger.debug("Runner %s closed LightningStore client", worker_id)
def _spawn_runners(
self,
runner: RunnerBundle,
store: LightningStore,
stop_evt: ExecutionEvent,
*,
ctx: BaseContext,
) -> list[multiprocessing.Process]:
"""Used when `role == "runner"` or `role == "both"` and `n_runners > 1`."""
processes: list[multiprocessing.Process] = []
def _runner_sync(runner: RunnerBundle, worker_id: int, store: LightningStore, stop_evt: ExecutionEvent) -> None:
# Runners are executed in child processes; each process owns its own
# event loop to keep the asyncio scheduler isolated.
try:
asyncio.run(self._execute_runner(runner, worker_id, store, stop_evt))
except KeyboardInterrupt:
logger.warning("Runner (asyncio) %s received KeyboardInterrupt; exiting gracefully", worker_id)
except BaseException as exc:
logger.exception("Runner (asyncio) %s crashed by %s; signaling stop event", worker_id, exc)
raise
for i in range(self.n_runners):
process = cast(
multiprocessing.Process,
ctx.Process(target=_runner_sync, args=(runner, i, store, stop_evt), name=f"runner-{i}"), # type: ignore
)
process.start()
logger.debug("Spawned runner process %s (pid=%s)", process.name, process.pid)
processes.append(process)
return processes
def _spawn_algorithm_process(
self,
algorithm: AlgorithmBundle,
store: LightningStore,
stop_evt: ExecutionEvent,
*,
ctx: BaseContext,
) -> multiprocessing.Process:
"""Used when `main_process == "runner"`."""
def _algorithm_sync(algorithm: AlgorithmBundle, store: LightningStore, stop_evt: ExecutionEvent) -> None:
try:
asyncio.run(self._execute_algorithm(algorithm, store, stop_evt))
except KeyboardInterrupt:
logger.warning("Algorithm (asyncio.run) received KeyboardInterrupt; exiting gracefully")
except BaseException as exc:
logger.exception("Algorithm (asyncio.run) crashed by %s; signaling stop event", exc)
raise
process = cast(
multiprocessing.Process,
ctx.Process(target=_algorithm_sync, args=(algorithm, store, stop_evt), name="algorithm"), # type: ignore
)
process.start()
logger.debug("Spawned algorithm process %s (pid=%s)", process.name, process.pid)
return process
def _join_until_deadline(
self,
processes: Iterable[multiprocessing.Process],
timeout: float,
) -> list[multiprocessing.Process]:
"""Join ``processes`` until ``timeout`` elapses, returning those still alive."""
deadline = time.monotonic() + timeout
still_alive: list[multiprocessing.Process] = []
for process in processes:
remaining = deadline - time.monotonic()
if remaining > 0:
process.join(remaining)
else:
process.join(0)
if process.is_alive():
still_alive.append(process)
return still_alive
def _signal_processes(
self,
processes: Iterable[multiprocessing.Process],
action: Callable[[multiprocessing.Process], None],
) -> None:
"""Invoke ``action`` on each process while suppressing individual failures."""
for process in processes:
try:
action(process)
except Exception:
logger.exception("Error signaling process %s (pid=%s)", process.name, process.pid)
def _shutdown_processes(
self,
processes: list[multiprocessing.Process],
stop_evt: ExecutionEvent,
) -> None:
"""4-step escalation shutdown of ``processes``."""
if not processes:
logger.debug("No subprocesses to shutdown")
return
if not stop_evt.is_set():
logger.debug("Sending cooperative stop signal to subprocesses")
stop_evt.set()
else:
logger.debug("Stop event already set; waiting for subprocesses to exit")
alive = self._join_until_deadline(processes, self.graceful_timeout)
if not alive:
return
logger.warning(
"Subprocesses still alive after cooperative wait; sending SIGINT to %s",
", ".join(p.name or str(p.pid) for p in alive),
)
# SIGINT is not reliable on Windows, but we do not consider such case yet.
self._signal_processes(alive, lambda p: os.kill(cast(int, p.pid), signal.SIGINT))
alive = self._join_until_deadline(alive, self.terminate_timeout)
if not alive:
return
logger.warning(
"Subprocesses still alive after SIGINT wait; sending terminate() to %s",
", ".join(p.name or str(p.pid) for p in alive),
)
self._signal_processes(alive, lambda p: p.terminate())
alive = self._join_until_deadline(alive, self.terminate_timeout)
if not alive:
return
logger.error(
"Subprocesses still alive after terminate(); sending kill() to %s",
", ".join(p.name or str(p.pid) for p in alive),
)
self._signal_processes(alive, lambda p: p.kill())
alive = self._join_until_deadline(alive, self.terminate_timeout)
if alive:
logger.error(
"Subprocesses failed to exit even after kill(): %s", ", ".join(p.name or str(p.pid) for p in alive)
)
def _check_process_exitcodes(self, processes: Iterable[multiprocessing.Process]) -> None:
"""Raise an error if any managed process exited with a non-zero status."""
failed = [p for p in processes if p.exitcode not in self.allowed_exit_codes + (None,)]
if failed:
formatted = ", ".join(f"{p.name or p.pid} (exitcode={p.exitcode})" for p in failed)
raise RuntimeError(f"Subprocesses failed with unexpected exit codes: {formatted}")
def execute(self, algorithm: AlgorithmBundle, runner: RunnerBundle, store: LightningStore) -> None:
logger.info(
"Starting client-server execution with %d runner(s) [role=%s, main_process=%s]",
self.n_runners,
self.role,
self.main_process,
)
# Re-use the active multiprocessing context so the event and processes
# agree on the start method (fork/spawn/forkserver).
ctx = multiprocessing.get_context()
stop_evt = MultiprocessingEvent(ctx=ctx)
# Track spawned processes so we can enforce termination ordering and
# surface non-zero exit codes back to the caller.
processes: list[multiprocessing.Process] = []
exception: BaseException | None = None
keyboard_interrupt = False
try:
if self.role == "algorithm":
logger.info("Running algorithm solely...")
asyncio.run(self._execute_algorithm(algorithm, store, stop_evt))
elif self.role == "runner":
if self.n_runners == 1:
logger.info("Running runner solely...")
asyncio.run(self._execute_runner(runner, 0, store, stop_evt))
else:
logger.info("Spawning runner processes...")
processes = self._spawn_runners(runner, store, stop_evt, ctx=ctx)
# Wait for the processes to finish naturally.
for process in processes:
process.join()
self._check_process_exitcodes(processes)
elif self.role == "both":
if self.main_process == "algorithm":
logger.info("Spawning runner processes...")
processes = self._spawn_runners(runner, store, stop_evt, ctx=ctx)
try:
logger.info("Running algorithm...")
asyncio.run(self._execute_algorithm(algorithm, store, stop_evt))
finally:
# Always request the runner side to unwind once the
# algorithm/server portion finishes (successfully or not).
stop_evt.set()
else: # main_process == "runner"
if self.n_runners > 1:
raise ValueError("main_process='runner' requires n_runners to be 1")
logger.info("Spawning algorithm process...")
algorithm_process = self._spawn_algorithm_process(algorithm, store, stop_evt, ctx=ctx)
processes = [algorithm_process]
# Run the lone runner cooperatively in-process so users can
# attach a debugger. The algorithm + HTTP server live in
# the background process spawned above (the provided
# store must therefore be picklable when using spawn).
logger.info("Running runner...")
asyncio.run(self._execute_runner(runner, 0, store, stop_evt))
# Wait for the algorithm process to finish.
algorithm_process.join()
else:
raise ValueError(f"Unknown role: {self.role}")
except KeyboardInterrupt:
logger.warning("KeyboardInterrupt received; initiating shutdown")
stop_evt.set()
keyboard_interrupt = True
except BaseException as exc:
logger.exception("Unhandled exception in execute method")
stop_evt.set()
# Preserve the original exception so we can avoid masking it during
# the cleanup phase.
exception = exc
raise
finally:
logger.info("Shutting down subprocesses")
self._shutdown_processes(processes, stop_evt)
if processes:
try:
self._check_process_exitcodes(processes)
except RuntimeError as err:
if exception is not None or keyboard_interrupt:
# We already propagate/handled a different failure, so
# emit a warning instead of raising a secondary error.
logger.warning("Subprocesses ended abnormally during shutdown: %s", err)
else:
raise
+69
View File
@@ -0,0 +1,69 @@
# Copyright (c) Microsoft. All rights reserved.
import multiprocessing as mp
import threading
from multiprocessing.context import BaseContext
from typing import Optional, Protocol
class ExecutionEvent(Protocol):
"""Protocol capturing the cooperative stop contract shared by strategies.
Implementations mirror the API of ``threading.Event`` and
``multiprocessing.Event`` so the rest of the execution layer can remain
agnostic to the underlying concurrency primitive.
Methods:
set: Signal cancellation. The call must be idempotent.
clear: Reset the event to the unsignaled state.
is_set: Return ``True`` when cancellation has been requested.
wait: Block until the event is signaled or an optional timeout elapses.
"""
def set(self) -> None: ...
def clear(self) -> None: ...
def is_set(self) -> bool: ...
def wait(self, timeout: Optional[float] = None) -> bool: ...
class ThreadingEvent:
"""Thread-safe implementation of [`ExecutionEvent`][agentlightning.ExecutionEvent]."""
__slots__ = ("_evt",)
def __init__(self) -> None:
self._evt = threading.Event()
def set(self) -> None:
self._evt.set()
def clear(self) -> None:
self._evt.clear()
def is_set(self) -> bool:
return self._evt.is_set()
def wait(self, timeout: Optional[float] = None) -> bool:
return self._evt.wait(timeout)
class MultiprocessingEvent:
"""Process-safe implementation of [`ExecutionEvent`][agentlightning.ExecutionEvent]."""
__slots__ = ("_evt",)
def __init__(self, *, ctx: Optional[BaseContext] = None) -> None:
self._evt = (ctx or mp).Event()
def set(self) -> None:
self._evt.set()
def clear(self) -> None:
self._evt.clear()
def is_set(self) -> bool:
return self._evt.is_set()
def wait(self, timeout: Optional[float] = None) -> bool:
return self._evt.wait(timeout)
+16
View File
@@ -0,0 +1,16 @@
# Copyright (c) Microsoft. All rights reserved.
from .base import ExecutionStrategy
class InterProcessExecutionStrategy(ExecutionStrategy):
"""Placeholder strategy for future inter-process primitives.
The class exists to reserve the `ipc` alias and make the planned
implementation discoverable. Attempting to use it today will raise
`NotImplementedError` once the execution contract is finalized.
"""
alias: str = "ipc"
# TODO: to be implemented
+282
View File
@@ -0,0 +1,282 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import logging
import threading
from contextlib import suppress
from queue import SimpleQueue
from typing import Any, Awaitable, Callable, List, Literal, Optional, Tuple
from agentlightning.env_var import LightningEnvVar, resolve_bool_env_var
from agentlightning.store.base import LightningStore
from agentlightning.store.threading import LightningStoreThreaded
from .base import AlgorithmBundle, ExecutionStrategy, RunnerBundle
from .events import ExecutionEvent, ThreadingEvent
logger = logging.getLogger(__name__)
class SharedMemoryExecutionStrategy(ExecutionStrategy):
"""Execute bundles in a single process with cooperative worker threads.
Stop Model:
- All bundles share one [`ThreadingEvent`][agentlightning.ThreadingEvent]
named `stop_evt`.
- Only the main thread receives `KeyboardInterrupt`. When Ctrl+C occurs we
set `stop_evt`.
- Any exception raised inside a bundle sets `stop_evt` so other threads can
unwind cooperatively.
- Once the bundle running on the main thread exits successfully the
treatment depends on `main_thread`:
- `"algorithm"`: the runners are asked to stop by setting `stop_evt`.
- `"runner"`: the algorithm keeps running until it exits naturally.
- Background threads are marked as daemons. We join them briefly and log any
stragglers before shutting down.
!!! note
Signals other than `SIGINT` (such as `SIGTERM`) are not intercepted;
Python's default behavior for those signals is preserved.
"""
alias: str = "shm"
def __init__(
self,
n_runners: int = 1,
main_thread: Literal["algorithm", "runner"] = "runner",
join_timeout: float = 15.0,
graceful_delay: float = 5.0,
poll_interval: float = 0.05,
managed_store: bool | None = None,
) -> None:
if main_thread not in ("algorithm", "runner"):
raise ValueError("main_thread must be 'algorithm' or 'runner'")
if main_thread == "runner" and n_runners != 1:
raise ValueError(
"When main_thread is 'runner', n_runners must be 1. "
"Either use 'algorithm' on the main thread or set n_runners to 1."
)
self.n_runners = n_runners
self.main_thread = main_thread
self.join_timeout = join_timeout
self.graceful_delay = graceful_delay
self.poll_interval = poll_interval
self.managed_store = resolve_bool_env_var(
LightningEnvVar.AGL_MANAGED_STORE, override=managed_store, fallback=True
)
async def _run_until_completed_or_canceled(self, coro: Awaitable[Any], stop_evt: ExecutionEvent) -> Any:
"""Run `coro` until it finishes or a cooperative stop is requested.
Control flow:
1. Start the bundle coroutine as `task`.
2. Launch a watcher that polls `stop_evt` without blocking the loop.
3. When the stop event flips:
a. Give the bundle `graceful_delay` seconds to finish on its own,
because well-behaved bundles will check the event and return.
b. Cancel the bundle task if it is still running after the grace
period.
4. Await both tasks and swallow `CancelledError` where appropriate.
This is a *backup* mechanism for bundles that might not poll the event
frequently; cooperative shutdown (checking `stop_evt` inside the
bundle) remains the preferred approach.
"""
task: asyncio.Task[Any] = asyncio.create_task(coro) # type: ignore
task_exception: Optional[BaseException] = None
async def watcher() -> None:
# Poll the threading event without blocking the event loop. Using a
# background thread via ``asyncio.to_thread`` makes cancellation
# difficult because ``ThreadingEvent.wait`` is not interruptible.
# Instead we cooperatively check the flag from the loop so the
# watcher task stays cancellable and tests don't hang when the
# bundle finishes naturally before the stop event is set.
while not stop_evt.is_set():
await asyncio.sleep(self.poll_interval)
# Grace period: let a cooperative bundle exit on its own.
try:
# At this point of waiting, the main task should already see the stop event.
await asyncio.wait_for(asyncio.shield(task), timeout=self.graceful_delay) # type: ignore
logger.debug("Bundle finished by itself during grace period.")
return # bundle finished by itself during grace period
except asyncio.TimeoutError:
# Still running after the grace window.
pass
except asyncio.CancelledError:
# If someone else canceled the task already, we're done.
logger.debug("Bundle already canceled by someone else; exiting watcher.")
return
# Still running after the grace window: cancel it.
if not task.done():
logger.debug("Graceful delay elapsed; canceling bundle task...")
task.cancel()
watcher_task = asyncio.create_task(watcher())
result: Any = None
try:
# We don't wait on FIRST_COMPLETED here, because we want the watcher
# to be able to grant a grace window after stop_evt flips.
await asyncio.wait(
{task, watcher_task}, return_when=asyncio.FIRST_COMPLETED
) # pyright: ignore[reportUnknownArgumentType]
finally:
# If the main task hasn't completed yet (e.g., watcher scheduled cancel),
# finish the cancellation handshake.
if not task.done():
try:
await asyncio.wait_for(task, timeout=self.graceful_delay) # second chance
except asyncio.TimeoutError:
logger.error(
"Bundle task did not stop after cancellation; abandoning task."
"This thread could live until the process exits."
)
# We return without awaiting it. asyncio.run will still try to cancel
# pending tasks on loop close; if the task ignores cancellation, this
# thread may still stick. It's the best we can do in Python.
# We don't raise an exception here, but the thread could be a zombie.
return result
else:
# Task completed naturally; retrieve result.
try:
result = await task # type: ignore
except asyncio.CancelledError:
pass
except BaseException as exc:
task_exception = exc
watcher_task.cancel()
with suppress(asyncio.CancelledError):
await watcher_task
if task_exception is not None:
raise task_exception
return result # type: ignore
def _run_algorithm(
self,
algorithm: AlgorithmBundle,
store: LightningStore,
stop_evt: ExecutionEvent,
thread_exceptions: Optional[SimpleQueue[BaseException]],
) -> None:
try:
asyncio.run(self._run_until_completed_or_canceled(algorithm(store, stop_evt), stop_evt))
except asyncio.CancelledError:
logger.info("Algorithm bundle canceled due to stop signal.")
except BaseException as exc:
logger.exception("Algorithm bundle crashed; signaling stop to others.")
if thread_exceptions is not None:
thread_exceptions.put(exc)
stop_evt.set()
raise
def _run_runner(
self,
runner: RunnerBundle,
store: LightningStore,
worker_id: int,
stop_evt: ExecutionEvent,
thread_exceptions: Optional[SimpleQueue[BaseException]],
) -> None:
try:
asyncio.run(self._run_until_completed_or_canceled(runner(store, worker_id, stop_evt), stop_evt))
except asyncio.CancelledError:
logger.info("Runner bundle (worker_id=%s) canceled due to stop signal.", worker_id)
except BaseException as exc:
logger.exception("Runner bundle crashed (worker_id=%s); signaling stop to others.", worker_id)
if thread_exceptions is not None:
thread_exceptions.put(exc)
stop_evt.set()
raise
def execute(self, algorithm: AlgorithmBundle, runner: RunnerBundle, store: LightningStore) -> None:
logger.info(
"Starting shm execution with %d runner(s); main thread runs '%s'",
self.n_runners,
self.main_thread,
)
# Create stop event and thread-safe store.
stop_evt = ThreadingEvent()
if self.managed_store:
thread_safe_store = LightningStoreThreaded(store)
else:
thread_safe_store = store
thread_exceptions: SimpleQueue[BaseException] = SimpleQueue()
raised_from_thread: Optional[BaseException] = None
def make_thread(name: str, target: Callable[..., Any], args: Tuple[Any, ...]) -> threading.Thread:
t = threading.Thread(name=name, target=target, args=args, daemon=True)
t.start()
return t
threads: List[threading.Thread] = []
try:
if self.main_thread == "algorithm":
# Start runner threads; algorithm runs on main thread.
for i in range(self.n_runners):
thread = make_thread(
name=f"runner-{i}",
target=self._run_runner,
args=(runner, thread_safe_store, i, stop_evt, thread_exceptions),
)
threads.append(thread)
# Ctrl+C here raises KeyboardInterrupt on this stack.
# Main thread doesn't need to collect exceptions.
self._run_algorithm(algorithm, thread_safe_store, stop_evt, None)
# If algo finishes naturally, request runners to stop.
stop_evt.set()
else: # main_thread == "runner"
# Start algorithm in background; runner runs on main thread.
thread = make_thread(
name="algorithm",
target=self._run_algorithm,
args=(algorithm, thread_safe_store, stop_evt, thread_exceptions),
)
threads.append(thread)
# Ctrl+C here raises KeyboardInterrupt on this stack.
# Main thread doesn't need to collect exceptions.
self._run_runner(runner, thread_safe_store, 0, stop_evt, None)
# If runner finishes naturally, WAIT FOR ALGORITHM TO FINISH.
thread.join()
if not thread_exceptions.empty():
raised_from_thread = thread_exceptions.get()
except KeyboardInterrupt:
logger.warning("KeyboardInterrupt received on main thread; initiating cooperative shutdown...")
stop_evt.set()
finally:
# Attempt a clean join; if some threads don't comply, log and move on.
for t in threads:
logger.debug("Joining thread %s...", t.name)
t.join(timeout=self.join_timeout)
alive = [t.name for t in threads if t.is_alive()]
if alive:
logger.error(
"Threads still alive after %.1fs: %s. They are daemons; continuing shutdown.",
self.join_timeout,
", ".join(alive),
)
if raised_from_thread is None and not thread_exceptions.empty():
raised_from_thread = thread_exceptions.get()
if raised_from_thread is not None:
raise raised_from_thread