Building a Production Batch-Processing Pipeline

Ma Mahalakshmi V Updated 16 Sep 2026
8 min read

Building a Production Batch-Processing Pipeline

This lesson combines the decisions from every earlier lesson in this unit into one working system: a pipeline that processes a large set of records end to end, with bounded concurrency, adaptive backoff, progress tracking, classified failure handling, idempotent retries, and full crash resumability. This is not a repeat of the Batch API mechanics from Unit 12 or the simple worker pool from Lesson 4 — it is the architecture those pieces belong inside once a job needs to run unattended, at scale, and survive interruption.

The Scenario

Assume a system needs to generate a one-paragraph summary for each of a large set of customer feedback records stored in a source table, writing each summary into a destination table, and doing so reliably even if the process is restarted partway through a run of 50,000 records.

Architecture Overview

The pipeline follows the five-stage design from Lesson 2, with each stage's responsibility narrowed to exactly what was justified in the lessons that followed:

Ingest  → read source records, compute stable record_ids
Prepare → build prompts
Submit  → bounded-concurrency async workers with backoff (Lessons 4, 5)
Collect → checkpoint every outcome durably as it arrives (Lessons 6, 9)
Finalize → classify failures, route retryable ones, quarantine the rest (Lesson 7)

Resumability (Lesson 9) isn't a separate stage — it's a property of how Ingest and Collect are written: Ingest only enqueues records not already checkpointed as succeeded, and Collect writes every outcome durably the moment it's known.

The Checkpoint Store

This reuses the schema from Lesson 9, extended with a stage column so partial failures (Lesson 7) can be resumed from the correct step rather than from scratch.

import sqlite3
import time


def init_pipeline_db(path: str) -> sqlite3.Connection:
    conn = sqlite3.connect(path)
    conn.execute(
        """
        CREATE TABLE IF NOT EXISTS records (
            record_id TEXT PRIMARY KEY,
            status TEXT NOT NULL DEFAULT 'pending',
            failure_stage TEXT,
            failure_category TEXT,
            output TEXT,
            error TEXT,
            attempts INTEGER DEFAULT 0,
            updated_at REAL
        )
        """
    )
    conn.commit()
    return conn


def upsert_record(conn, record_id, status, output=None, error=None,
                   failure_stage=None, failure_category=None):
    conn.execute(
        """
        INSERT INTO records (record_id, status, output, error, failure_stage,
                              failure_category, attempts, updated_at)
        VALUES (?, ?, ?, ?, ?, ?, 1, ?)
        ON CONFLICT(record_id) DO UPDATE SET
            status = excluded.status,
            output = COALESCE(excluded.output, records.output),
            error = excluded.error,
            failure_stage = excluded.failure_stage,
            failure_category = excluded.failure_category,
            attempts = records.attempts + 1,
            updated_at = excluded.updated_at
        """,
        (record_id, status, output, error, failure_stage, failure_category, time.time()),
    )
    conn.commit()

COALESCE(excluded.output, records.output) is the detail that makes partial-failure recovery actually work end to end: if a record already has a saved output from a successful model call, and a later update comes in with output=None (because that update is reporting a downstream write failure, not a new model result), the existing output is preserved rather than being overwritten with nothing. This is the same "don't discard partial progress" principle from Lesson 7, now expressed at the storage layer.

The Adaptive, Checkpointed Worker

Each worker combines backoff (Lesson 5), stage-aware failure classification (Lesson 7), and immediate checkpointing (Lesson 9):

import asyncio
import random
from openai import AsyncOpenAI, RateLimitError


async def process_and_checkpoint(client, conn, record, max_retries=4):
    upsert_record(conn, record.record_id, "in_progress")

    # Stage 1: model call, with backoff on rate limits
    output_text = None
    for attempt in range(max_retries):
        try:
            response = await client.responses.create(
                model="gpt-5.6-terra",
                input=record.prompt,
            )
            output_text = response.output_text
            break
        except RateLimitError:
            if attempt == max_retries - 1:
                upsert_record(conn, record.record_id, "failed",
                              error="rate limit exhausted",
                              failure_stage="model_call", failure_category="transient")
                return
            await asyncio.sleep(2 ** attempt + random.uniform(0, 1))
        except Exception as exc:
            upsert_record(conn, record.record_id, "failed", error=str(exc),
                          failure_stage="model_call", failure_category="permanent")
            return

    # Stage 2: write result downstream
    try:
        await write_summary_to_destination(record.record_id, output_text)
        upsert_record(conn, record.record_id, "succeeded", output=output_text)
    except Exception as exc:
        upsert_record(conn, record.record_id, "failed", output=output_text,
                      error=str(exc), failure_stage="downstream_write",
                      failure_category="transient")


async def write_summary_to_destination(record_id: str, summary: str) -> None:
    # Placeholder for the real destination write (database, file, API call).
    pass

Note that output is passed to upsert_record in the downstream_write failure branch — this is what allows a later retry to skip the model call for that record entirely, exactly as designed in Lesson 8.

Resuming: Loading Only What's Left to Do

def load_remaining_records(conn, all_records: list) -> list:
    ids = [r.record_id for r in all_records]
    placeholders = ",".join("?" for _ in ids)
    rows = conn.execute(
        f"SELECT record_id, output FROM records "
        f"WHERE record_id IN ({placeholders}) AND status = 'succeeded'",
        ids,
    ).fetchall()
    done_ids = {row[0] for row in rows}
    conn.execute(
        "UPDATE records SET status = 'pending' WHERE status = 'in_progress'"
    )
    conn.commit()
    return [r for r in all_records if r.record_id not in done_ids]

Assembling the Full Run

async def run_production_pipeline(db_path: str, source_rows: list[dict],
                                    prompt_template: str, concurrency: int = 10):
    conn = init_pipeline_db(db_path)

    records = [
        PipelineRecord(record_id=str(row["id"]), source_data=row,
                        prompt=prompt_template.format(**row))
        for row in source_rows
    ]

    remaining = load_remaining_records(conn, records)
    print(f"{len(records) - len(remaining)} already done, {len(remaining)} to process")

    tracker = ProgressTracker(total=len(records))
    tracker.succeeded = len(records) - len(remaining)

    client = AsyncOpenAI()
    queue: asyncio.Queue = asyncio.Queue()
    for r in remaining:
        queue.put_nowait(r)

    async def worker():
        while True:
            record = await queue.get()
            if record is None:
                queue.task_done()
                break
            tracker.report_started()
            try:
                await process_and_checkpoint(client, conn, record)
                row = conn.execute(
                    "SELECT status FROM records WHERE record_id = ?", (record.record_id,)
                ).fetchone()
                if row and row[0] == "succeeded":
                    tracker.report_succeeded()
                else:
                    tracker.report_failed()
            except Exception:
                tracker.report_failed()
            queue.task_done()

    async def report_loop():
        while tracker.pending > 0 or tracker.in_progress > 0:
            print(tracker.summary())
            await asyncio.sleep(5)
        print(tracker.summary())

    workers = [asyncio.create_task(worker()) for _ in range(concurrency)]
    reporter = asyncio.create_task(report_loop())

    await queue.join()
    for _ in workers:
        queue.put_nowait(None)
    await asyncio.gather(*workers)
    await reporter

    quarantined = conn.execute(
        "SELECT record_id, error FROM records WHERE failure_category = 'permanent'"
    ).fetchall()
    if quarantined:
        print(f"{len(quarantined)} record(s) need manual review:")
        for record_id, error in quarantined:
            print(f"  {record_id}: {error}")

    conn.close()

This function is what an operator or a scheduler actually invokes. Run it once, and it processes every remaining record. If it's killed at any point — deliberately or by a crash — running it again with the same db_path and the same source_rows resumes exactly where it stopped: already-succeeded records are excluded before any new request is made, stuck in_progress records are reset and safely retried, and partial output from downstream_write failures is preserved rather than recomputed.

Testing the Full Pipeline with a Fake Client

Because every dependency (client, conn) is passed in rather than constructed globally, the entire pipeline is testable end to end without touching the real API:

class FakePipelineClient:
    def __init__(self, fail_ids: set = frozenset()):
        self.fail_ids = fail_ids
        self.responses = self

    async def create(self, model, input):
        if any(fid in input for fid in self.fail_ids):
            raise RuntimeError("simulated permanent failure")

        class FakeResponse:
            output_text = f"summary of: {input}"
        return FakeResponse()


async def test_pipeline_resumes_after_simulated_crash():
    conn = init_pipeline_db(":memory:")
    records = [
        PipelineRecord(record_id=str(i), source_data={}, prompt=f"feedback {i}")
        for i in range(5)
    ]

    # First "run": process records 0-2, simulate a crash before 3 and 4 run.
    client = FakePipelineClient()
    for r in records[:3]:
        await process_and_checkpoint(client, conn, r)

    # "Restart": load_remaining_records should only return records 3 and 4.
    remaining = load_remaining_records(conn, records)
    remaining_ids = {r.record_id for r in remaining}
    assert remaining_ids == {"3", "4"}

    for r in remaining:
        await process_and_checkpoint(client, conn, r)

    succeeded = conn.execute(
        "SELECT COUNT(*) FROM records WHERE status = 'succeeded'"
    ).fetchone()[0]
    assert succeeded == 5
    print("PASS: pipeline resumes after simulated crash and completes all records")


asyncio.run(test_pipeline_resumes_after_simulated_crash())

This test simulates a crash by simply stopping short of processing every record in the first loop, then verifies that a fresh call to load_remaining_records — exactly what a real restart would do — correctly identifies only the unfinished records, and that the second pass completes the job. No real network call, no real delay, and no dependency on wall-clock timing is involved, which is what makes it suitable to run on every commit.

What This Pipeline Deliberately Leaves Out

A genuinely complete production deployment would add a few things beyond this lesson's scope: structured logging to a centralized log system rather than print, metrics exported to a monitoring system rather than only a terminal summary, and a supervisor process (a process manager, a container orchestrator, or a scheduled job runner) that actually restarts the pipeline process after a crash rather than requiring a human to notice and re-invoke it. None of that changes the pipeline's internal design — those are operational concerns that wrap around exactly the resumable, checkpointed, failure-aware core built in this lesson.

Common Mistakes

  • Building the concurrency, retry, and checkpoint logic as three separate, uncoordinated systems that were each tested individually but never verified together — the interaction between backoff delays, checkpoint timing, and resumability is exactly where subtle bugs hide, which is why this lesson's test exercises the combination directly.
  • Forgetting to reset in_progress records on restart in the full pipeline, which silently reproduces the exact "stuck record" bug from Lesson 9 even though the individual piece was solved there — integration is where previously-fixed problems reappear if they aren't wired in everywhere they're needed.
  • Skipping an end-to-end resumability test and only testing each lesson's concept in isolation. Unit tests for upsert_record, process_and_checkpoint, and load_remaining_records individually can all pass while the full restart sequence still has a gap between them.

Best Practices

  • Test the crash-and-resume path explicitly, not just the happy path. A test that processes some records, "restarts," and verifies the rest complete correctly is the single most valuable test for any pipeline built around checkpointing.
  • Keep the checkpoint schema rich enough to support partial-failure recovery, not just a plain succeeded/failed flag — the failure_stage, failure_category, and preserved output columns are what make retries cheap and correct rather than wasteful.
  • Treat quarantined (permanent-failure) records as a required output of every run, not an afterthought. A production pipeline is not finished when it stops running — it's finished when every record has either succeeded or been placed somewhere a human can act on it.

0 Comments

Reviewed before they appear

No comments yet.

OpenAI SDK
Introduction to the OpenAI SDK Setting Up Python Creating an API Key Your First Call — client.responses.create() and response.out Understanding Billing, Credits, and What a Request Costs Why Responses Replaced Chat Completions Anatomy of a Request: model, input, and instructions Anatomy of a Response: The Typed output Array, Not Just Text Roles: User, Assistant, and Developer/System Choosing a Model, and Reading the Models Page Instead of Mem Instructions vs. Input Writing Prompts That Get Consistent Results Few-Shot Examples Reasoning Models and the reasoning Parameter Debugging a Prompt That Misbehaves Why Streaming Matters for User Experience stream=True and Iterating Over Events Handling the Event Types You Actually Care About Background Mode for Long-Running Jobs Project — Add Live Streaming to Your Chatbot The Problem With Parsing Free Text JSON Schema and Strict Mode Pydantic Models With the SDK's Parse Helpers Handling Refusals and Validation Failures Project — A Resume-to-JSON Extractor Working With input_image input_file, PDFs, and the Files API Image Generation Speech-to-Text and Text-to-Speech Project: A PDF Question-Answering Script What Function Calling Is Defining a Tool Schema The Full Loop Multiple Tools Errors, Timeouts, and Untrusted Arguments Project: A Weather Assistant Web Search File Search and Vector Stores Code Interpreter Remote MCP Servers and Connectors Project: A Research Assistant What an Embedding Is, Without the Maths Generating and Storing Embeddings Similarity Search From Scratch Hosted Vector Stores vs. Rolling Your Own A Small RAG App Over a Folder of Notes Agents vs. a Single API Call — When You Need One pip install openai Giving Agents Tools Handoffs and Multi-Agent Triage Guardrails and Approvals Tracing and Observing What Your Agent Did A Multi-Agent Support Desk Error Codes and What Each One Means Retries, Timeouts, and Backoff Rate Limits and Spend Limits Prompt Caching and Cost Optimisation The Batch API for Bulk Work Async Clients and Concurrency Moderation and Safety Best Practices Designing the App Backend With FastAPI Streaming to a Simple Frontend Deploying and a Cost/Safety Checklist Why Web Search Is Useful for Current Information Using the Web Search Tool with the Responses API Configuring Search Behavior for Application Use Cases Understanding Citations and Source Attribution Where to Go Next Building a Research Assistant with Web Search Combining Web Search with Structured Outputs Handling Conflicting or Low-Quality Web Sources
Ask about this post
AI Ask about this post

Ask questions about Building a Production Batch-Processing Pipeline and get answers drawn from it.

Signed-in readers only.