<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged task-scheduling</title>
<link>https://coderlegion.com/tag/task-scheduling</link>
<description>Powered by Question2Answer</description>
<item>
<title>DevLog 20250807: Microservice for Cloud Scheduling (and Running)</title>
<link>https://coderlegion.com/4500/devlog-20250807-microservice-for-cloud-scheduling-and-running</link>
<description>&lt;h3 id=&quot;motivation&quot;&gt;Motivation&lt;/h3&gt;
&lt;p&gt;When working with routine tasks, it's convenient to have some form of automatic scheduling. Windows has &lt;strong&gt;Task Scheduler&lt;/strong&gt;, Linux has &lt;code&gt;cron&lt;/code&gt;, and in AWS, you can use &lt;strong&gt;Lambda&lt;/strong&gt; with &lt;strong&gt;EventBridge&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Wouldn't it be nice if we could periodically pull data from the web, do some light processing, and then dump the results into a database - maybe update a website, trigger a CI/CD process, or send a few emails?&lt;/p&gt;
&lt;p&gt;I haven't used it, but I imagine that's what &lt;strong&gt;Zapier&lt;/strong&gt; is designed for. Azure/GitHub Actions are certainly handy for CI/CD. But what if we just want to quickly prototype something, ship it to a server, and forget about it?&lt;/p&gt;
&lt;p&gt;That's the vision, anyway.&lt;/p&gt;
&lt;h3 id=&quot;the-first-attempts&quot;&gt;The First Attempts&lt;/h3&gt;
&lt;p&gt;When I first started building services for cloud computing, I combined both &quot;trigger a run&quot; and &quot;schedule repeated tasks&quot; into a single service. That sort of worked - but it didn't feel elegant. &lt;strong&gt;Something was off.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Even now, after deciding to completely separate the &quot;runner&quot; and &quot;scheduler&quot; into two distinct microservices, I can't fully articulate &lt;em&gt;why&lt;/em&gt; this approach feels better. It just does. Of course, this means maintaining three or four separate programs now (runner, runner service, scheduler, scheduler service) - but each one is responsible for a single task, and that somehow feels more reassuring.&lt;/p&gt;
&lt;h3 id=&quot;separation-of-concerns-amp-the-single-responsibility-principle&quot;&gt;Separation of Concerns &amp;amp; the Single Responsibility Principle&lt;/h3&gt;
&lt;p&gt;I asked ChatGPT for a rationale that aligned with my intuition, and here's what it gave me:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;What you're discovering is a classic case of &lt;strong&gt;separation of concerns&lt;/strong&gt; (and in the microservice world, the &quot;scheduler-worker&quot; or &quot;cron-vs-runner&quot; pattern), underpinned by the Single Responsibility Principle. Here's why splitting &quot;who decides when&quot; from &quot;what actually does the work&quot; just &lt;em&gt;feels&lt;/em&gt; better.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;1-single-responsibility-amp-cognitive-load&quot;&gt;1. Single Responsibility &amp;amp; Cognitive Load&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Scheduler service&lt;/strong&gt; - its sole job is: &quot;At 8 AM on Tuesdays, kick off job X.&quot;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Runner service&lt;/strong&gt; - its sole job is: &quot;When told to run X, do the fetch/process/dump.&quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;By assigning each service a clear, focused purpose, you reduce logic complexity in any single codebase. You don't need to sift through your fetch logic to understand date calculations, and you don't need to worry about HTTP clients inside your scheduler.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Feels better&lt;/strong&gt; because each code path is narrow, easy to reason about, and small enough to hold entirely in your head.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;2-loose-coupling-amp-replaceability&quot;&gt;2. Loose Coupling &amp;amp; Replaceability&lt;/h4&gt;
&lt;p&gt;When the scheduler and runner communicate through a simple interface (e.g., message queue, webhook, or HTTP call), either can be swapped out independently:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Want to switch from cron-style fixed schedules to GitHub webhook events?&lt;/li&gt;
&lt;li&gt;Need to rewrite your worker in another language for better library support?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You only have to reimplement one microservice - not the whole system.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Feels better&lt;/strong&gt; because the impact of change is localized.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;3-independent-scaling-amp-reliability&quot;&gt;3. Independent Scaling &amp;amp; Reliability&lt;/h4&gt;
&lt;p&gt;Scheduling frequency and execution workload rarely match 1:1.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Maybe you schedule jobs every minute, but each run only takes a few seconds.&lt;/li&gt;
&lt;li&gt;Or you only need one scheduler, but dozens of parallel workers to clear a backlog.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Separating the two allows for independent sizing, deployment, and monitoring.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Feels better&lt;/strong&gt; because you avoid resource contention (e.g., a busy worker delaying the next schedule) and can autoscale each component based on its own metrics.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;4-clear-boundaries-for-observability-amp-error-handling&quot;&gt;4. Clear Boundaries for Observability &amp;amp; Error Handling&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Scheduler logs&lt;/strong&gt; should only show: &quot;Job X was triggered at time T.&quot;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Runner logs&lt;/strong&gt; should only show: &quot;Processing of job X succeeded/failed.&quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This allows for more targeted alerting and diagnostics:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Alert if a scheduled trigger &lt;em&gt;never&lt;/em&gt; reaches the runner.&lt;/li&gt;
&lt;li&gt;Alert if the runner fails more than N times, regardless of the schedule.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Feels better&lt;/strong&gt; because root cause analysis is straightforward - you know where to look first.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;5-pattern-analogues-amp-ecosystem-support&quot;&gt;5. Pattern Analogues &amp;amp; Ecosystem Support&lt;/h4&gt;
&lt;p&gt;This approach mirrors patterns built into major platforms:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;AWS CloudWatch Events (scheduler) → Lambda (worker)&lt;/li&gt;
&lt;li&gt;Kubernetes CronJob (scheduler) → Job pods (workers)&lt;/li&gt;
&lt;li&gt;Celery Beat (scheduler) → Celery workers (task executors)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;All of them separate the &quot;when&quot; from the &quot;what,&quot; and you're starting to feel the same advantages they offer at scale.&lt;/p&gt;
&lt;h4 id=&quot;in-summary&quot;&gt;In Summary&lt;/h4&gt;
&lt;p&gt;You're gravitating toward a &lt;strong&gt;Scheduler–Worker&lt;/strong&gt; (or &quot;Cron-as-a-Service&quot; + &quot;Stateless Runner&quot;) pattern because it:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Reduces cognitive load&lt;/strong&gt; by narrowing each service's scope&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Decouples&lt;/strong&gt; timing logic from business logic&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Enables independent scaling&lt;/strong&gt; and more precise reliability controls&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Improves testability&lt;/strong&gt; (you can mock the scheduler in runner tests, and vice versa)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Aligns&lt;/strong&gt; with proven cloud-native and messaging patterns&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That &quot;reassuring&quot; feeling is the comfort of a clean, modular architecture - you can see exactly &lt;em&gt;what&lt;/em&gt; does &lt;em&gt;what&lt;/em&gt;, and update or extend each part without unraveling the rest.&lt;/p&gt;
&lt;h3 id=&quot;the-analogy&quot;&gt;The Analogy&lt;/h3&gt;
&lt;p&gt;Here is the way I understand it: a rough analogy might be batch-processing vs programming - when chaining multiple operations, it's better to use shell scripting than to rely on spawning subprocesses directly in your source code.&lt;/p&gt;
</description>
<category>Backend Dev</category>
<guid isPermaLink="true">https://coderlegion.com/4500/devlog-20250807-microservice-for-cloud-scheduling-and-running</guid>
<pubDate>Fri, 08 Aug 2025 04:22:53 +0000</pubDate>
</item>
<item>
<title>AsyncIO Task Management: A Hands-On Scheduler Project</title>
<link>https://coderlegion.com/1756/asyncio-task-management-a-hands-on-scheduler-project</link>
<description>&lt;p&gt;We discussed Awaitables &lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/jeffrey04/understanding-awaitables-coroutines-tasks-and-futures-in-python-gk7&quot;&gt;last week&lt;/a&gt;. The article covered coroutines, tasks and futures as well as a quick introduction to the event loop. Let’s build an example task management project to continue our learning journey.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/1024/1*LlAB5imhzAROhennWMDxKg.jpeg&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;em&gt;A cute cartoon generated by Microsoft Copilot on the topic&lt;/em&gt;&lt;/p&gt;
&lt;h4 id=&quot;introduction-building-our-task-scheduler&quot;&gt;&lt;strong&gt;Introduction: Building Our Task Scheduler&lt;/strong&gt;&lt;/h4&gt;
&lt;p&gt;In the previous article, we mentioned briefly on event loops. Think of it as a timetable-like structure, which is executed throughout the lifetime of an application. Awaitables are then scheduled for concurrent execution within it. In recent releases, the Python developers provided more abstractions and reduced the need to manually manage event loops.&lt;/p&gt;
&lt;p&gt;In the article on &lt;a rel=&quot;nofollow&quot; href=&quot;https://kitfucoda.medium.com/writing-a-telegram-bot-in-python-866972ab63f5&quot;&gt;programming a chatbot&lt;/a&gt;, we created a new process each for the chatbot and the web application. We then call asyncio.run in each of the processes to execute the first coroutine. So let’s copy that to bootstrap our sample application:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import asyncio

async def main():
    await asyncio.sleep(5)

if __name__ == ' __main__':
    asyncio.run(main()) 
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If we run the program, it will run for 5 seconds through asyncio.sleep, and then exits. As mentioned last week, calling a coroutine (asyncio.sleep) would not cause it to execute. Omitting the await keyword will result in an error. This is because the coroutine object returned by asyncio.sleep(5) does not get scheduled for execution.&lt;/p&gt;
&lt;p&gt;In the snippet, calling asyncio.run creates an event loop, schedule the coroutine (main()) for execution, and then perform clean-up after completion.&lt;/p&gt;
&lt;p&gt;We are building a task management system, where we submit tasks, and then run a check for result manually. So the first thing we need is a simple user interface. I like the simplicity of repl-like (read-evaluate-parse-loop) interface, so we build one. To interact with our application, users can issue simple commands. We start with 2 commands below, by adapting from the implementation we did last week.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;dex : fetch the name of a Pokémon from the Pokédex based on the number &lt;/li&gt;
&lt;li&gt;fib : Returns the th Fibonacci number&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Let’s start by implementing dex and fib, each represents an I/O bound and CPU-bound operation respectively.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;async def dex(client: httpx.AsyncClient, id: int) -&amp;gt; str:
    assert isinstance(id, int)

    response = await client.get(f&quot;https://pokeapi.co/api/v2/pokemon/{id}/&quot;)

    return f&quot;The pokemon with id {id} is {response.json()['name']}&quot;

def fib(nth: int) -&amp;gt; str:
    assert isinstance(nth, int) and nth &amp;gt; 0

    result = ()

    for i in range(1, nth + 1):
        match i:
            case 1:
                result = (0,)

            case 2:
                result += (1,)

            case _:
                result = result[1:] + (sum(result),)

    assert len(result) &amp;gt; 0

    return f&quot;The {nth}th fibonacci number is {result[-1]}&quot; 
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For dex, we want to reuse the httpx.AsyncClient, so we adapt our code to require it in the arguments.&lt;/p&gt;
&lt;h4 id=&quot;tasks-and-futures-in-action&quot;&gt;&lt;strong&gt;Tasks and Futures in Action&lt;/strong&gt;&lt;/h4&gt;
&lt;p&gt;Assembling the repl-like interface would need a little bit of work. We need to be able to parse the command entered by our users and schedule the correct task. For this, we use funcparserlib as featured in a &lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/jeffrey04/how-to-parse-computer-code-advent-of-code-2024-day-3-2g84&quot;&gt;previous Advent of Code article&lt;/a&gt;. We are skipping the implementation detail for parser and lexer in this article, and only focus on the evaluation part, as shown below:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;async def evaluate(
    client: httpx.AsyncClient,
    expression: Expression,
) -&amp;gt; Awaitable[str] | str:
match expression:
    case Expression(command=Command.DEX):
        return asyncio.create_task(dex(client, expression.args[0]))

    case Expression(command=Command.FIB):
        return asyncio.create_task(asyncio.to_thread(fib, expression.args[0]))

    case _:
        raise Exception(&quot;Unknown command&quot;) 
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We want to enable users to check the progress of tasks later, so we return them as tasks. For dex requests, it is implemented as a coroutine, hence we just wrap it as a task, and return it. However, for fib, as it is CPU-bound, it will not benefit much from asynchronous execution. As discussed last week, we need to turn it into a future, so it can be executed as a thread of process, as shown below&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;loop = asyncio.get_running_loop()

# get the 100th Fibonacci number
future = loop.run_in_executor(None, fib, 100)

# later in the execution
result = await future 
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;As part of the abstraction effort, asyncio.get_running_loop() will fetch the event loop created by asyncio.run we called earlier. However, if the code snippet is executed after the main() coroutine is completed, it will result in an error. The first argument in run_in_executor expects either a ProcessPoolExecutor, or a ThreadPoolExecutor, but will use a default ThreadPoolExecutor if None is passed. For most cases, it is sufficient to just use the default, and hence we can turn it into a task instead&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;fib_coro = asyncio.to_thread(fib, 100)

task = asyncio.create_task(fib_coro)

# later
result = await task
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now we have the executor done, let’s assemble the repl. We use prompt-toolkit to prompt the user for command, and then it does the following operations in sequence&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Read the user entered command and arguments&lt;/li&gt;
&lt;li&gt;Parse the command and arguments, and return an Expression object&lt;/li&gt;
&lt;li&gt;Evaluate the expression object, and schedule tasks with asyncio.create_task&lt;/li&gt;
&lt;li&gt;Loop back to step 1&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The main coroutine, is now written as follows, with the corresponding steps are annotated as comments in the snippet&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;async def main() -&amp;gt; None:
    t, g = TOKENIZER, grammar()
    session = PromptSession()

    tasks = []

    async with httpx.AsyncClient() as client:
        # step 4: loop
        while True:
            # step 1: read input
            line = await session.prompt_async(&quot;&amp;gt; &quot;, handle_sigint=False)

            match result := await thread_last(
                line,
                # step 2a: tokenize the input
                (tokenize, t),
                # step 2b: parse the input
                (parse, g),
                # step 3: evaluate
                (evaluate, client),
            ):
                case asyncio.Task():
                    tasks.append((line, result))
                    print(f&quot;Task {len(tasks)} is submitted&quot;)

                case _:
                    print(result)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Some notes on the snippet:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;t is for the definition of tokens (e.g, a sequence of letters is the name of a command)&lt;/li&gt;
&lt;li&gt;g is for the definition of the commands (what dex and fib means)&lt;/li&gt;
&lt;li&gt;The thread_last call in the snippet is taken from toolz. What it does is simply evaluate(client, parse(g, tokenize(t, line))) but we keep it that way to aid readability.&lt;/li&gt;
&lt;li&gt;We collect the tasks after evaluations into a simple list, we will work with it later&lt;/li&gt;
&lt;li&gt;In the match statement, we print task ID if the evaluator returns a Task, or the returned string otherwise.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;So let’s recap what we have built so far. We should be able to run the program, and then issue commands to fetch Pokémon names, and calculate Fibonacci numbers. You may have noticed, we have yet to print the result on screen.&lt;/p&gt;
&lt;p&gt;On the other hand, we also know that main() is calling the evaluator to schedule tasks to the event loop with asyncio.create_task(). We also see await is used when the order of execution is crucial:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;When we perform a GET HTTP request, so we can parse the response JSON after receiving it&lt;/li&gt;
&lt;li&gt;When we are receiving a user input, as we need to pass the input to parser later&lt;/li&gt;
&lt;li&gt;We need to wait for evaluator to create a task, before we loop back to receiving another input from the user&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;So what are the differences between the two, when should we use asyncio.create_task() and await? As a rule of thumb, if the order of execution does not matter, or if we only care about the outcome much later, use asyncio.create_task. If the coroutine does something that is needed later as shown above, then use await to ensure it is run as soon as possible, before we move on to the next line in code.&lt;/p&gt;
&lt;h4 id=&quot;managing-task-execution-and-displaying-results&quot;&gt;&lt;strong&gt;Managing Task Execution and Displaying Results&lt;/strong&gt;&lt;/h4&gt;
&lt;p&gt;Now we can start working on the tracker, and then build a dashboard to list tasks. These are the two command we want to build next:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;job : We show the result of computation, or a message indicating it is still waiting for a result&lt;/li&gt;
&lt;li&gt;dash: We draw a simple table to show some information about all submitted tasks&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Starting with the actual function again, first we have one for job&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def job(task: tuple[str, asyncio.Task[str]]) -&amp;gt; str:
    try:
        return task[-1].result()

    except asyncio.InvalidStateError:
        return &quot;Still waiting&quot; 
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each entry in the task list, is a tuple of user input, and the actual Task object. So in this function, we are interested in finding the result. However, if the task is still not ready, then calling .result() will raise InvalidStateError, then we can return a message to tell users that it is still waiting.&lt;/p&gt;
&lt;p&gt;Then a dashboard to display an overview of the tasks&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def dash(tasks: Sequence[tuple[str, asyncio.Task[str]]]) -&amp;gt; str:
    result = [
        &quot;\t&quot;.join((&quot;id&quot;, f&quot;{'command':16s}&quot;, &quot;done?&quot;, &quot;result&quot;)),
        &quot;\t&quot;.join((&quot;==&quot;, f&quot;{'=' * 16}&quot;, &quot;=====&quot;, &quot;======&quot;)),
    ]

    for num, (line, task) in enumerate(tasks, 1):
        result.append(
            &quot;\t&quot;.join(
                (
                    str(num),
                    f&quot;{line:16s}&quot;,
                    str(task.done()),
                    task.done() and str(task.result())[:16] or &quot;&quot;,
                )
            )
        )

    return &quot;\n&quot;.join(result)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The function simply returns a table-like report to show if tasks are done, and the corresponding result whenever applicable.&lt;/p&gt;
&lt;p&gt;Then we need to expand the evaluator, to call the corresponding job tracking functions we just added above.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;async def evaluate(
    client: httpx.AsyncClient,
    tasks: Sequence[tuple[str, asyncio.Task[str]]],
    expression: Expression,
) -&amp;gt; Awaitable[str] | str:
    match expression:
        ...

        case Expression(command=Command.JOB):
            return job(tasks[expression.args[0] - 1])

        case Expression(command=Command.DASH):
            return dash(tasks)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Previously, every time a user submits a task with either dex or fib, we return a job number. Now they can check the progress with the job command with corresponding job number, or display an overview of submitted jobs with dash.&lt;/p&gt;
&lt;h4 id=&quot;task-cancellation-and-graceful-shutdown&quot;&gt;Task Cancellation and Graceful Shutdown&lt;/h4&gt;
&lt;p&gt;We now already have a mostly complete program. While prompt-toolkit provides some handling for some signals, we can opt to do it on our own too (hence the handle_sigint=False in our session.prompt_async). For that, we start by declaring the ShutdownHandler, to gracefully shut down all scheduled unfinished tasks:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@dataclass
class ShutdownHandler:
    signal: int | None = None

    async def __call__ (self) -&amp;gt; None:
        logger.info(&quot;Shutting down tasks. signal:%s&quot;, self.signal or &quot;&quot;)

        tasks = tuple(
            task for task in asyncio.all_tasks() if task is not asyncio.current_task()
        )

        for task in tasks:
            task.cancel()

        await asyncio.gather(*tasks, return_exceptions=True)

        asyncio.get_running_loop().stop()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We are applying the __call__ dunder &lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/jeffrey04/the-versatility-of-call-a-python-developers-secret-weapon-4akd&quot;&gt;discussed previously&lt;/a&gt; to avoid usage of functools.partial. In the coroutine, we are shutting down all the remaining tasks, and then stop the event loop (hence ending the asyncio.run we did in the beginning). Notice we are excluding the current coroutine so asyncio.get_running_loop().stop() will get to run.&lt;/p&gt;
&lt;p&gt;Next, we set up the shutdown handler to run, whenever the program receives certain signals from the operating system. In our example, we are handling SIGTERM, SIGHUP and SIGINT. We can then register the ShutdownHandler we just implemented to the event loop to respond to the signals we listed.&lt;/p&gt;
&lt;p&gt;Now we can proceed to add 2 more commands, namely&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;kill : Kill the job number &lt;/li&gt;
&lt;li&gt;quit: Exit the program&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The corresponding functions for both commands are shown below:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def kill(task: tuple[str, asyncio.Task[str]]) -&amp;gt; str:
    task[-1].cancel()

    return f'Task &quot;{task[0]}&quot; is killed'

def quit(shutdown_handler: Callable[..., Coroutine[None, None, None]]) -&amp;gt; str:
    asyncio.create_task(shutdown_handler())

    return &quot;Exiting&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every time the method .cancel() is called on a task, a CancelledError is raised. If the task is executing SQL statements in a transaction, this serves as an opportunity to quit the transaction, and rollback to undo changes done so far. But in some cases, we can just ignore the error and quit quietly. For instance, we can just suppress the error in our main() coroutine. While we revisit main(), we need to also ensure init() is called to set up the ShutdownHandler().&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from contextlib import suppress

def init() -&amp;gt; None:
    loop = asyncio.get_running_loop()

    for sig in (signal.SIGTERM, signal.SIGHUP, signal.SIGINT):
        handler = ShutdownHandler(sig)

        loop.add_signal_handler(sig, lambda: asyncio.create_task(handler()))

async def main() -&amp;gt; None:
    with suppress(asyncio.CancelledError):
        init()

        t, g = TOKENIZER, grammar()
        session = PromptSession()

        ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And the updated evaluator function is slightly revised to add support for the two new commands:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;async def evaluate(
    client: httpx.AsyncClient,
    tasks: Sequence[tuple[str, asyncio.Task[str]]],
    shutdown_handler: Callable[..., Coroutine[None, None, None]],
    expression: Expression,
) -&amp;gt; Awaitable[str] | str:
    match expression:
        ...

        case Expression(command=Command.KILL):
            return kill(tasks[expression.args[0]] - 1)

        case Expression(command=Command.QUIT):
            return quit(shutdown_handler)
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;background-tasks-and-event-management&quot;&gt;Background Tasks and Event Management&lt;/h4&gt;
&lt;p&gt;The implementation seems straightforward, as we are constantly interacting with the main() coroutine. It is always waiting for user commands, and by exiting we just terminate the infinite while loop. However, there are cases where the application would just run autonomously. For instance we have a simple application as follows:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;async def background_task():
    while True:
        await asyncio.sleep(10)

        print(&quot;Print something&quot;)

async def main():
    # send work to execute in the background
    asyncio.create_task(background_task())

    # do some work
    ultimate_answer = 42

    # hmm, we are done here

if __name__ == ' __main__':
    asyncio.run(main()) 
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A similar pattern can be observed in the article where we discussed chatbot programming, where the background_task is a coroutine to consume messages from a queue. In this mini example program, it will end silently, and the first print() in background_task() will not happen, because main() ended so soon. In the past, I have seen people attaching another infinite loop at the end of main() so background_task() gets to run indefinitely.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;async def main():
    ...

    # let's pretend we are doing work
    while True:
        pass
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;While it seems to work, such that the application seems to continue to run. However, background_task() is not printing because the infinite loop in main() is blocking other coroutines from running. As a workaround, we could replace pass with something like await asyncio.sleep(2), but it felt like a hack and I don’t quite like it.&lt;/p&gt;
&lt;p&gt;Alternatively, we could create an asyncio.Event object, and then pass it to main(). Then we adapt the ShutdownHandler discussed previously, to receive an event object, as shown below:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@dataclass
class ShutdownHandler:
    exit_event: asyncio.Event
    signal: int | None = None

    async def __call__ (self) -&amp;gt; None:
        logger.info(&quot;Shutting down tasks. signal:%s&quot;, self.signal or &quot;&quot;)
        self.exit_event.set()

async def main(exit_event: asyncio.Event):
    loop = asyncio.get_running_loop()

    for sig in (signal.SIGTERM, signal.SIGHUP, signal.SIGINT):
        handler = ShutdownHandler(exit_event, sig)

        loop.add_signal_handler(sig, lambda: asyncio.create_task(handler()))

    # send work to execute in the background
    task = asyncio.create_task(background_task())

    # do some work
    ultimate_answer = 42

    await exit_event.wait()
    task.cancel()

if __name__ == &quot; __main__&quot;:
    asyncio.run(main(asyncio.Event()))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So now main() now receives an event, setup the ShutdownHandler, and then upon receiving from the event, cancel the background_task(). For completeness’ sake, we can also handle the CancelledError in background_task, as follows:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;async def background_task():
    try:
        while True:
            await asyncio.sleep(10)

            print(&quot;Print something&quot;)

    except asyncio.CancelledError:
        print(&quot;Bye bye&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;handling-exceptions&quot;&gt;&lt;strong&gt;Handling Exceptions&lt;/strong&gt;&lt;/h4&gt;
&lt;p&gt;Before we conclude, is on error handling. When we work with asyncio enough, we will notice if an awaitable running in background fails, it is not always obvious to us. Fortunately, like signals, we can also attach a handler if an exception is raised. Back to our example project, let’s implement an ExceptionHandler for the purpose.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@dataclass
class ExceptionHandler:
    shutdown_handler: ShutdownHandler

    def __call__ (self, loop, context) -&amp;gt; None:
        logger.error(&quot;Application failed. context:%s&quot;, context)

        asyncio.create_task(self.shutdown_handler())
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then, add a new line to register the handler after the signal handler registration&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;async def init():
    ...

    loop.set_exception_handler(ExceptionHandler(ShutdownHandler())) 
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;While this is a more a catch-all workaround, we should still properly handle exceptions in all our coroutines. For instance, in addition to CancelledError, we should probably also handle errors corresponding to network problems in dex. However we are keeping our example concise to avoid making this article into an epic novel.&lt;/p&gt;
&lt;h4 id=&quot;conclusion-mastering-asynchronous-task-orchestration&quot;&gt;Conclusion: Mastering Asynchronous Task Orchestration&lt;/h4&gt;
&lt;p&gt;That’s all I want to cover in this article. While we spent quite some time building the application, we still barely scratched the surface of the capability of AsyncIO. We discussed about how to start executing a coroutine, and then from there how to queue more tasks and awaitables into the event loop. We also discussed about exiting the program gracefully, and how to handle errors.&lt;/p&gt;
&lt;p&gt;I owe a lot to &lt;a rel=&quot;nofollow&quot; href=&quot;https://roguelynn.com/words/asyncio-we-did-it-wrong/&quot;&gt;this series of articles by Lynn Root&lt;/a&gt; while I was re-learning AsyncIO when rewriting &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/coolsilon/bigmeow_bot&quot;&gt;bigmeow&lt;/a&gt;, the chatbot project. Some part of the code in our example project today is adapted from her implementation. The complete project &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Jeffrey04/asyncio-post&quot;&gt;can be found at GitHub&lt;/a&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Jeffrey04/asyncio-post&quot;&gt;,&lt;/a&gt;,) with some slight changes.&lt;/p&gt;
&lt;p&gt;Thank you so much for reading this far, and I shall write again, next week.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;To ensure clarity and address potential language errors, this article received editorial assistance from a large language model. Please note that all ideas and concepts presented herein are my own. The code examples provided were not generated or edited by an LLM. If you’re interested in project collaboration or job opportunities, feel free to reach out to me via a message here on Medium or through LinkedIn for further discussion. This article was &lt;a rel=&quot;nofollow&quot; href=&quot;https://kitfucoda.medium.com/asyncio-task-management-a-hands-on-scheduler-project-bd7b7fe58c7e&quot;&gt;originally published at Medium&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Adv Concepts</category>
<guid isPermaLink="true">https://coderlegion.com/1756/asyncio-task-management-a-hands-on-scheduler-project</guid>
<pubDate>Tue, 01 Apr 2025 06:46:58 +0000</pubDate>
</item>
</channel>
</rss>