Skip to content

Progress Callbacks

A progress callback lets a handler report its progress to your application while it runs. Use it to update a user interface, to send a WebSocket message, to record a metric, or to follow a job that takes a long time.

How it works

When a handler calls progress_hook(...), quiv sends the progress to the callback registered for that task. Two things decide the path it takes: whether an asyncio event loop is available, and whether the callback is sync or async.

flowchart TD
    A["Handler calls progress_hook(...)"] --> B{Callback registered?}
    B -- No --> C[Return silently]
    B -- Yes --> D{Event loop available?}
    D -- Yes --> E{Async callback?}
    D -- No --> F{Async callback?}
    E -- Yes --> G["run_coroutine_threadsafe()
    on main loop"]
    E -- No --> H["call_soon_threadsafe()
    on main loop"]
    F -- Yes --> I["Run in temporary event loop"]
    F -- No --> J["Call directly on
    worker thread"]

The four dispatch paths

Event loop Callback type What quiv does
Available Async Sends it to the main loop with run_coroutine_threadsafe
Available Sync Sends it to the main loop with call_soon_threadsafe
Unavailable Sync Calls it on the worker thread
Unavailable Async Runs it in a temporary event loop on the worker thread

Event loop resolution

quiv does not need an event loop at startup. It finds the main loop the first time a progress callback fires, and keeps it.

sequenceDiagram
    participant App as Application
    participant Q as Quiv()
    participant UV as Uvicorn / asyncio

    App->>Q: Quiv() at module level
    Note over Q: _main_loop = None
    UV->>UV: Event loop starts
    App->>Q: scheduler.start()
    Note over Q: Loop thread begins
    Q->>Q: Handler calls progress_hook
    Q->>Q: _resolve_main_loop()
    Q->>UV: asyncio.get_running_loop()
    Note over Q: _main_loop cached
    Q->>UV: Dispatch callback on loop

You can therefore create Quiv() at module level, before FastAPI or uvicorn creates an event loop. Larger applications usually do this.

Adding a progress callback

Pass progress_callback when you add the task:

async def on_progress(**payload):
    print("progress", payload)

scheduler.add_task(
    task_name="my-task",
    func=my_handler,
    interval=60,
    progress_callback=on_progress,
)

Writing a handler that reports progress

Add progress_hook to the signature of your handler. quiv reads the signature and injects the hook only when the parameter is there.

Renamed in v1.0.0 — was _progress_hook

quiv 0.x injected this parameter as _progress_hook. add_task() rejects a handler that still declares the old name, and raises ConfigurationError that names the new spelling. Rename the parameter. Its behavior is unchanged. See the v1.0.0 release notes.

import threading
from typing import Callable


def process_records(
    batch_size: int,
    stop_event: threading.Event | None = None,
    progress_hook: Callable | None = None,
):
    records = fetch_records(batch_size)
    total = len(records)

    for i, record in enumerate(records, 1):
        if stop_event and stop_event.is_set():
            return

        process(record)

        if progress_hook:
            progress_hook(
                step=i,
                total=total,
                pct=round(i / total * 100),
            )

The handler does not need to know whether the callback is sync or async, and it does not need to know whether an event loop exists. It calls progress_hook(...), and quiv chooses the path.

Async progress callback

An async callback runs on the main event loop, through run_coroutine_threadsafe. Use it in a FastAPI application to send updates to WebSocket clients:

from fastapi import WebSocket

connected_clients: list[WebSocket] = []


async def on_progress(**payload):
    for ws in connected_clients:
        await ws.send_json({"event": "progress", "data": payload})


scheduler.add_task(
    task_name="etl-pipeline",
    func=run_etl,
    interval=3600,
    progress_callback=on_progress,
)

The callback runs on the event loop of FastAPI, so you can await a WebSocket, a database session, or any other async API inside it.

Sync progress callback

A sync callback looks the same to the handler. When an event loop is available, it runs on the main loop through call_soon_threadsafe. When no loop is available, in a plain script for example, it runs on the worker thread.

import logging

logger = logging.getLogger(__name__)


def log_progress(**payload):
    logger.info("Task progress: %s", payload)


scheduler.add_task(
    task_name="cleanup",
    func=cleanup_handler,
    interval=300,
    progress_callback=log_progress,
)

Without an event loop

A sync progress callback still works in a script that does not use asyncio. It runs on the worker thread that runs the handler.

from quiv import Quiv

scheduler = Quiv()


def on_progress(**payload):
    print(f"Step {payload['step']}/{payload['total']}")


def my_task(progress_hook=None):
    for i in range(1, 6):
        if progress_hook:
            progress_hook(step=i, total=5)


scheduler.add_task(
    task_name="script-task",
    func=my_task,
    interval=10,
    progress_callback=on_progress,
)
scheduler.start()

An async progress callback works without an event loop too. quiv runs it in a temporary event loop on the worker thread, so an await inside the callback runs correctly.

Error handling

If a progress callback raises an exception, quiv writes the error to the log and the job continues. The job does not fail, and the handler keeps running. One broken callback therefore cannot stop your tasks.

flowchart TD
    A[Handler runs] --> B["progress_hook(...)"]
    B --> C[Callback dispatched]
    C --> D{Callback raises?}
    D -- No --> E[Continue]
    D -- Yes --> F[Log error]
    F --> E

Payload conventions

progress_hook accepts any *args and any **kwargs. quiv enforces no schema. This set of keys works well:

progress_hook(
    step=3,        # current step
    total=10,      # total steps
    stage="load",  # descriptive label
    pct=30,        # percentage complete
)

The progress callback receives what the handler passes, and nothing more. quiv reads the task_id to find the registered callback, and the dispatch layer keeps it. quiv does not add it to the payload.