Skip to content

Service shutdown

Stop new input and let the current submission finish while results drain. A deadline cancels remaining work and waits for cleanup. The returned boolean reports whether draining timed out. Cancellation is cooperative; the deadline cannot force a coroutine to terminate. The demonstration uses finite local work.

uv run --locked python -m examples.service_shutdown
"""Stop input, drain bounded results under a deadline, then cancel if needed."""

import asyncio
import logging
from collections.abc import Callable, Coroutine
from typing import Any

from aqute import Aqute, AquteError

logger = logging.getLogger(__name__)
SHUTDOWN_AFTER = 3


async def serve(
    handler: Callable[[int], Coroutine[Any, Any, int]],
    *,
    drain_timeout: float = 1.0,
) -> tuple[list[int], bool]:
    engine = Aqute(handler, 2, input_task_queue_size=2, result_queue=asyncio.Queue(2))
    stop_input = asyncio.Event()
    ready = asyncio.Event()
    production_done = asyncio.Event()
    submitted = 0
    values: list[int] = []
    timed_out = False

    async def produce() -> None:
        nonlocal submitted
        try:
            job_id = 0
            while not stop_input.is_set():
                await engine.add_task(job_id)
                submitted += 1
                job_id += 1
                if submitted >= SHUTDOWN_AFTER:
                    ready.set()
        finally:
            production_done.set()

    async def collect() -> None:
        while not production_done.is_set() or len(values) < submitted:
            try:
                task = await engine.get_result()
            except AquteError:
                if production_done.is_set() and len(values) == submitted:
                    return
                raise
            if task.error is not None:
                raise task.error
            assert task.result is not None
            values.append(task.result)
            logger.info("Progress: %s", engine.counters)

    async with engine, asyncio.TaskGroup() as group:
        producer = group.create_task(produce())
        collector = group.create_task(collect())
        # In a service, wait for its shutdown signal here.
        await ready.wait()
        stop_input.set()
        try:
            async with asyncio.timeout(drain_timeout):
                await producer  # Complete any submission already in flight.
                await engine.finish()
                await collector  # Results must drain while finish() waits.
        except TimeoutError:
            timed_out = True
            producer.cancel()
            collector.cancel()
            await engine.stop()
    return values, timed_out


async def main() -> list[int]:
    async def handle(job_id: int) -> int:
        await asyncio.sleep(0.001)
        return job_id

    values, timed_out = await serve(handle)
    assert not timed_out
    assert sorted(values) == list(range(len(values)))
    assert len(values) >= SHUTDOWN_AFTER
    return values


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO)
    logging.info("Results: %s", sorted(asyncio.run(main())))

See usage for the complete lifecycle and error contracts.