Production Batch Pipeline

Ma Mahalakshmi V Updated 19 Sep 2026
8 min read ·Lesson 144 of 224

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.output_text 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 Memorizing Names 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 Reducing Unsupported Claims with Grounded Generation Testing Freshness-Sensitive AI Answers Production Considerations for Web-Grounded Applications Understanding File Search and Retrieval-Augmented Generation Creating and Organizing Vector Stores Uploading Documents for Retrieval Connecting Vector Stores to Responses API Requests Designing Document Metadata and Filtering Strategies Building a PDF Question-Answering Application Improving Retrieval Quality With Better Document Preparation Handling Missing Evidence and Retrieval Failures Combining File Search With Web Search Building a Production Knowledge-Base Assistant What the Code Interpreter Tool Is Designed For Running Python-Based Analysis Through the OpenAI SDK Uploading Datasets for Analysis Analyzing CSV and Spreadsheet Data Generating Charts and Data Summaries Handling Generated Files and Downloadable Artifacts Building a Data-Analysis Assistant Combining Code Execution with Structured Outputs Validating Generated Calculations and Results Security and Sandbox Considerations for Code Execution Understanding Multimodal Input with the OpenAI SDK Sending Images to a Model Image Analysis from URLs and Uploaded Files Extracting Text and Information from Screenshots Building an Image-Question-Answering Application Combining Image Input with Structured Output Analyzing Multiple Images in One Request Handling Image Quality and Input Limitations Designing Multimodal Prompts for Reliable Results Building a Practical Vision-Powered Python Application Understanding Speech-to-Text and Text-to-Speech Workflows Transcribing Audio with the OpenAI SDK Working with Uploaded Audio Files Handling Timestamps and Transcription Metadata Building a Meeting Transcription Workflow Generating Spoken Responses from Text Handling Long Audio and Processing Failures Combining Audio with Text and Tool Calling Building an End-to-End Python Voice Application What Embeddings Are and When to Use Them Generating Embeddings With the OpenAI API Preparing Text for Embedding Comparing Vectors With Cosine Similarity Building a Simple Semantic Search Engine in Python Storing Embeddings in a Database Metadata Filtering for Semantic Search Chunking Strategies for Better Retrieval Evaluating Semantic Search Quality Building a Document Similarity Application When Batch Processing Makes Sense Designing Large-Volume AI Processing Pipelines Using Asynchronous Python with the OpenAI SDK Running Concurrent Requests Safely Controlling Concurrency and Avoiding Rate Limits Tracking Batch Job Progress Handling Partial Failures in Bulk Workloads Retrying Failed Items Without Duplicating Successful Work Designing Resumable AI Processing Jobs Building a Production Batch-Processing Pipeline Batch Processing Makes Sense Large-Scale AI Processing Pipelines Async Python with OpenAI SDK Safe Concurrent Requests Concurrency & Rate Limits Batch Progress Tracking Partial Failure Handling Safe Retry Handling Resumable AI Jobs Production Batch Pipeline System–User Data Separation Reusable App Instructions Prompt Templates & Variables Extraction & Classification Prompts Summarization & Transformation Prompts Explicit Output Requirements Prompt Version Management Prompt Testing & Evaluation Reusable Python Prompt Library API Key Security Secure API Key Storage Secure Secret Management Prompt Injection Prevention Trusted vs. Untrusted Content Tool Argument Validation Sensitive Data Handling Secure Logging AI Action Authorization Production AI Security Checklist Why AI Applications Need Evaluation Beyond Unit Tests Unit Testing OpenAI SDK Integration Code Mocking API Responses in Python Tests Testing Structured Outputs Against Schemas Testing Tool-Calling Workflows Building a Small Evaluation Dataset Measuring Accuracy, Consistency, and Failure Rates Regression Testing Prompts and Model Changes Human Evaluation Versus Automated Evaluation Creating a Repeatable Evaluation Pipeline AI Request Monitoring Token Cost Management Usage Metrics Design Reducing Model Calls Prompt & Context Optimization Model Selection & Optimization AI Caching Strategies Interactive Latency Optimization Usage Dashboards & Budget Alerts Performance & Cost Checklist Every API Call Starts Fresh Fixing API Statelessness Server-Side Conversation Memory Limits of Response Chaining What We're Building Conversation Memory Challenges Preparing an OpenAI SDK Application for Deployment Environment-Specific Configuration for Development and Production Deploying a Python AI Service with Docker Container Health Checks and Startup Configuration Managing Secrets in Cloud Deployments Background Workers for Long-Running AI Tasks Queues and Asynchronous Job Architectures Scaling AI Workloads Horizontally Monitoring Production Incidents and Failures Production Deployment Checklist for OpenAI SDK Applications Reusable OpenAI Service Classes AI Client Dependency Injection Typed AI Responses Python Configuration Management AI Request Decorators Centralized AI Error Handling Clean SDK Abstractions Reusable OpenAI Utilities Internal AI Python Libraries SDK Integration Maintenance Production AI Chatbot Document Q&A System Web Research Assistant Customer Support Agent AI Data Analysis Assistant Image Analysis App Meeting Transcription & Summary Semantic Document Search Multi-Tool AI Agent Production OpenAI SDK App Why "It Looked Fine When I Tested It" Isn't Enough Timing Note Status Note Pre-Decision Status Note Current Availability Note
Ask about this post
AI Ask about this post

Ask questions about Production Batch Pipeline and get answers drawn from it.

Signed-in readers only.