Skip to content

Resumable batch

Aqute schedules attempts; the application owns checkpoints and idempotency. Save each successful output with a stable completion key. On restart, read the saved keys and submit only inputs without a completion record.

This recipe uses Python's sqlite3 module and needs no additional dependency. It is a single-process application with one checkpoint writer and unique inputs. It is not a durable queue. Pending work and in-flight tasks still die with the process; the application must retain or regenerate the original input batch.

Run the offline demonstration

After installing aqute==0.10.3, save the source below as resumable_batch.py and run python resumable_batch.py. From a development checkout, run:

uv run --locked python examples/resumable_batch.py

The first run submits eight inputs and stops after saving three results. The managed stream awaits producer and worker cleanup. A second run opens the same database, logs Submitting 5 unfinished inputs, and saves the five remaining results. The final log reports Resume: 5 new completions; 8 stored outputs. Assertions check the completion counts and all stored key/output pairs.

Both runs happen in one process to demonstrate stopping and resuming without killing the script. The database connection closes between runs. The temporary directory is removed when the demonstration finishes. For restart across process exits, replace TemporaryDirectory() with a persistent application-owned path and run the complete batch against the same database each time.

Checkpoint boundary

completion_key() combines the integer input with the operation version double:v1. Keep keys stable across restarts. Include every input and setting that changes the result; for inference, include the model and request configuration. Change the version when the operation changes. This example assumes unique keys within the original batch.

The consumer calls task.unwrap() before writing. A terminal handler error raises and leaves that input without a checkpoint; earlier records remain. Each successful result uses one SQLite INSERT to store its key and output together. isolation_level=None enables autocommit, so no transaction remains open while the consumer waits for the next result. A database write failure also stops the batch and propagates to the caller.

A crash after the handler completes but before the checkpoint commits can repeat that input on restart. The same applies to results that finish but are not consumed before the early stop. This is at-least-once processing, not exactly-once execution. Handlers and their side effects must tolerate repetition; use a service's idempotency key where supported. The local SQLite transaction cannot make an external side effect atomic with its checkpoint.

SQLite calls are synchronous and can block the event loop during disk I/O. This small recipe loads completed keys and pending inputs into memory. Those objects, the input source, and stored outputs are outside Aqute's queue bounds. There is no cross-process coordination or recovery of in-flight tasks here.

Runnable source

"""Resume an offline batch with application-owned SQLite completion records."""

import asyncio
import logging
import sqlite3
from contextlib import closing
from pathlib import Path
from tempfile import TemporaryDirectory

from aqute import Aqute

logger = logging.getLogger(__name__)
FIRST_RUN_LIMIT = 3


def completion_key(value: int) -> str:
    return f"double:v1:{value}"


async def handle(value: int) -> int:
    await asyncio.sleep(0.025)  # Simulate asynchronous I/O.
    return value * 2


async def run_batch(
    inputs: range, database: Path, *, stop_after: int | None = None
) -> int:
    # Autocommit keeps each statement in its own transaction; close on every exit.
    with closing(sqlite3.connect(database, isolation_level=None)) as checkpoint:
        checkpoint.execute(
            "CREATE TABLE IF NOT EXISTS completions "
            "(key TEXT PRIMARY KEY, output INTEGER NOT NULL)"
        )
        completed_keys = {
            row[0] for row in checkpoint.execute("SELECT key FROM completions")
        }
        pending = [
            value for value in inputs if completion_key(value) not in completed_keys
        ]
        logger.info("Submitting %s unfinished inputs", len(pending))
        engine = Aqute(handle, workers_count=3)
        completed = 0
        async with engine.iter_results(pending) as results:
            async for task in results:
                output = task.unwrap()
                checkpoint.execute(
                    "INSERT INTO completions (key, output) VALUES (?, ?)",
                    (completion_key(task.data), output),
                )
                completed += 1
                logger.info("Checkpointed %s: %s", task.data, output)
                if completed == stop_after:
                    break
        return completed


async def main() -> None:
    inputs = range(8)
    with TemporaryDirectory() as directory:
        database = Path(directory) / "checkpoint.sqlite"
        first = await run_batch(inputs, database, stop_after=FIRST_RUN_LIMIT)
        assert first == FIRST_RUN_LIMIT
        logger.info("First run stopped after %s checkpoints", first)
        resumed = await run_batch(inputs, database)
        assert resumed == len(inputs) - first
        with closing(sqlite3.connect(database)) as checkpoint:
            outputs = dict(checkpoint.execute("SELECT key, output FROM completions"))
        assert outputs == {completion_key(value): value * 2 for value in inputs}
        logger.info(
            "Resume: %s new completions; %s stored outputs", resumed, len(outputs)
        )


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO)
    asyncio.run(main())

JSONL alternative

For a small single-writer batch, an append-only JSONL file can replace the table. At startup, read each record's key into completed_keys, then use the same pending-input filter. Treat repeated keys as one completion; the last complete record supplies its output. For each successful result, append both fields:

import json
import os

with checkpoint_path.open("a", encoding="utf-8") as checkpoint:
    checkpoint.write(json.dumps({"key": key, "output": output}) + "\n")
    checkpoint.flush()
    os.fsync(checkpoint.fileno())

Use a persistent path and open the file once around the result stream in an application. JSONL does not provide SQLite's transaction boundary: interruption can leave an incomplete final line. Before resuming, inspect and remove only that incomplete tail, then load the complete records. Stop on other malformed records instead of silently skipping them. Outputs must be JSON-serializable, and the handler still needs to tolerate repetition. Prefer SQLite when manual tail repair is unsuitable.