The Ovalleaf Weeklychevron_rightIssue 011

Designing Data Pipelines That Won't Break at 2 AM

Real-time ETL pipelines sound great until you are debugging why 500k records disappeared. We share the principles that make data pipelines reliable, observable, and survivable.

The Ovalleaf Team3 min read

Why Data Pipelines Fail

We recently reviewed a data pipeline that processed insurance claims in real-time. It ran for 47 days without issues, then suddenly stopped producing output. Nobody noticed for 6 hours. By the time anyone realized something was wrong, 15,000 claims were stuck in limbo.

The investigation revealed: a database connection pool exhausted itself. The pipeline kept opening connections and never closing them. After 47 days (which is how long the pool's lifecycle settings allowed), it ran out of connections and hung silently.

This is the norm, not the exception. Data pipelines are fragile because they sit at the boundary between your application code (which you test) and external systems (databases, APIs, message queues) which can fail in ways you never anticipated.

The good news: there are patterns that work.

Pattern 1: Idempotency First

The Problem: Your pipeline crashes halfway through processing a batch. When you restart it, do you reprocess the batch (and risk double-processing records)? Skip it (and lose data)? Nobody knows.

The Solution: Make every operation idempotent. Processing the same record twice should produce the same result as processing it once.

class IdempotentPipeline:
    def __init__(self, db):
        self.db = db
    
    def process_record(self, source_record):
        """Process a record idempotently."""
        
        # Create a stable, deterministic key
        idempotency_key = hashlib.sha256(
            source_record.encode()
        ).hexdigest()
        
        # Check if already processed
        existing = self.db.query("""
            SELECT output_record FROM processed_records
            WHERE idempotency_key = %s
        """, [idempotency_key])
        
        if existing:
            # Already processed—return cached result
            return existing[0]['output_record']
        
        # Process the record
        output = expensive_transformation(source_record)
        
        # Store both the key and output
        self.db.execute("""
            INSERT INTO processed_records 
            (idempotency_key, source_record, output_record, timestamp)
            VALUES (%s, %s, %s, %s)
        """, [idempotency_key, source_record, output, now()])
        
        return output

# Usage
pipeline = IdempotentPipeline(db)

# This is safe to call multiple times
result = pipeline.process_record("{ ... }")
result = pipeline.process_record("{ ... }")  # Returns cached result

Now your pipeline is bulletproof: if it crashes and restarts, it just skips the records it already processed.

Pattern 2: Checkpoints and Resumption

The Problem: Your pipeline processes 1 million records in a batch. It fails on record 847,392. When you restart, does it start from the beginning or from record 847,393? How do you even know which record it failed on?

The Solution: Checkpoint your progress:

class CheckpointedPipeline:
    def __init__(self, db, pipeline_name):
        self.db = db
        self.pipeline_name = pipeline_name
    
    def get_checkpoint(self):
        """Get the last successfully processed record."""
        result = self.db.query("""
            SELECT checkpoint_value FROM pipeline_checkpoints
            WHERE pipeline_name = %s
            ORDER BY timestamp DESC
            LIMIT 1
        """, [self.pipeline_name])
        
        return result[0]['checkpoint_value'] if result else 0
    
    def save_checkpoint(self, checkpoint_value):
        """Save progress."""
        self.db.execute("""
            INSERT INTO pipeline_checkpoints 
            (pipeline_name, checkpoint_value, timestamp)
            VALUES (%s, %s, %s)
        """, [self.pipeline_name, checkpoint_value, now()])
    
    def process_batch(self, records):
        """Process from last checkpoint."""
        start_idx = self.get_checkpoint()
        
        for idx, record in enumerate(records[start_idx:], start=start_idx):
            try:
                self.process_record(record)
                self.save_checkpoint(idx)
            except Exception as e:
                logger.error(f"Failed at record {idx}: {e}")
                raise  # Stop here; restart will resume from checkpoint

# Usage
pipeline = CheckpointedPipeline(db, "claims_processing")
pipeline.process_batch(all_claims)

# If it crashes at record 847,392, restarting picks up from 847,393

Now when your pipeline crashes, restarting it automatically resumes from where it left off. No manual intervention needed.

Pattern 3: Observability at Every Step

The Problem: Your pipeline runs silently. You don't know how many records it processed, how many it skipped, how many failed. You just know it "ran" because the process exited with code 0.

The Solution: Emit metrics at every step:

from prometheus_client import Counter, Histogram, Gauge
import time

class ObservablePipeline:
    def __init__(self, db, pipeline_name):
        self.db = db
        self.pipeline_name = pipeline_name
        
        # Metrics
        self.records_processed = Counter(
            'pipeline_records_processed_total',
            'Total records processed',
            ['pipeline_name']
        )
        self.records_failed = Counter(
            'pipeline_records_failed_total',
            'Total records that failed',
            ['pipeline_name']
        )
        self.processing_time = Histogram(
            'pipeline_processing_time_seconds',
            'Time to process a record',
            ['pipeline_name']
        )
        self.pipeline_lag = Gauge(
            'pipeline_lag_seconds',
            'How far behind we are',
            ['pipeline_name']
        )
    
    def process_record(self, record):
        """Process with observability."""
        start = time.time()
        
        try:
            result = self.do_processing(record)
            self.records_processed.labels(self.pipeline_name).inc()
            return result
        except Exception as e:
            self.records_failed.labels(self.pipeline_name).inc()
            logger.error(f"Record failed: {e}")
            raise
        finally:
            duration = time.time() - start
            self.processing_time.labels(self.pipeline_name).observe(duration)
    
    def get_pipeline_lag(self):
        """How far behind are we?"""
        latest_source = self.db.query("""
            SELECT MAX(timestamp) as latest FROM source_data
        """)[0]['latest']
        
        latest_processed = self.db.query(f"""
            SELECT MAX(timestamp) as latest FROM processed_data
        """)[0]['latest']
        
        lag = (latest_source - latest_processed).total_seconds()
        self.pipeline_lag.labels(self.pipeline_name).set(lag)
        return lag

# In your alerting:
# Alert if records_failed_total increased
# Alert if pipeline_lag > 3600  (more than 1 hour behind)
# Alert if processing_time_seconds > 5  (single records taking too long)

Now your monitoring dashboard shows:

  • How many records have been processed
  • How many have failed
  • How far behind you are
  • How long each record takes to process

When something breaks, you see it immediately.

Pattern 4: Isolated Failure Domains

The Problem: Your pipeline calls an external API. The API goes down. Your pipeline crashes and stops processing all records, even ones that don't depend on that API.

The Solution: Isolate failures:

class ResilientPipeline:
    def __init__(self, db):
        self.db = db
        self.api_client = APIClient()
    
    def enrich_record(self, record):
        """Enrich a record with external data."""
        
        # This can fail; that's OK
        try:
            enrichment = self.api_client.lookup(record['id'])
        except APIError as e:
            logger.warning(f"API lookup failed for {record['id']}: {e}")
            enrichment = None  # Continue without enrichment
        
        # Process the record regardless
        processed = {
            **record,
            'enrichment': enrichment,
            'enrichment_status': 'success' if enrichment else 'skipped'
        }
        
        return processed

# Result: API goes down? Fine, records still get processed.
# They're just missing enrichment. You can retry enrichment later.

The key insight: your pipeline should have a core path (record → database) and optional paths (record → API enrichment). If an optional path fails, the core path continues.

Pattern 5: Dead Letter Queues

The Problem: A record fails processing. It's not your fault—the record itself is malformed. You can't skip it (audit trail) but it's blocking the pipeline.

The Solution: Dead letter queues:

class PipelineWithDLQ:
    def __init__(self, db):
        self.db = db
        self.dlq = "failed_records_queue"
    
    def process_with_dlq(self, record):
        """Process, with fallback to DLQ."""
        try:
            self.process_record(record)
        except MalformedRecordError as e:
            logger.error(f"Malformed record: {e}")
            # Send to DLQ for manual review
            self.db.execute("""
                INSERT INTO dead_letter_queue 
                (original_record, error, timestamp)
                VALUES (%s, %s, %s)
            """, [record, str(e), now()])
            # Don't raise; continue processing
        except Exception as e:
            logger.error(f"Unexpected error: {e}")
            raise  # Stop the pipeline on unexpected errors

Now you have two queues:

  • Main pipeline: Good records that processed successfully
  • DLQ: Bad records that need manual review

Your pipeline never gets blocked by malformed data. You handle it asynchronously.

Putting It Together

A production data pipeline looks like this:

class ProductionPipeline:
    def __init__(self, db, pipeline_name):
        self.db = db
        self.name = pipeline_name
        self.checkpoint = CheckpointedPipeline(db, pipeline_name)
        self.idempotent = IdempotentPipeline(db)
        self.resilient = ResilientPipeline(db)
        self.observability = ObservablePipeline(db, pipeline_name)
    
    def run(self):
        """Main pipeline loop."""
        records = self.fetch_source_data()
        
        for record in records:
            try:
                # Idempotent processing
                result = self.idempotent.process_record(record)
                
                # Resilient enrichment
                enriched = self.resilient.enrich_record(result)
                
                # Store
                self.db.insert('processed_data', enriched)
                
                # Checkpoint
                self.checkpoint.save_checkpoint(record['id'])
                
            except MalformedRecordError as e:
                # DLQ
                self.send_to_dlq(record, e)
            except Exception as e:
                # Unexpected—stop and alert
                logger.error(f"Pipeline failed: {e}")
                raise
        
        # Monitor
        lag = self.observability.get_pipeline_lag()
        print(f"Pipeline lag: {lag}s")

# Run it
pipeline = ProductionPipeline(db, "claims_processing")
pipeline.run()

This pipeline:

  • ✅ Resumes from checkpoints if it crashes
  • ✅ Handles duplicates safely (idempotency)
  • ✅ Keeps processing even if external APIs fail
  • ✅ Routes malformed data to DLQ
  • ✅ Emits metrics so you know what's happening
  • ✅ Fails fast on unexpected errors

The 2 AM Test

The real test of a data pipeline is whether it survives the 2 AM failure. Your database goes down for 10 minutes. Your external API returns 500 errors. A corrupted record enters the pipeline.

With these patterns:

  • The pipeline continues processing (where possible)
  • Failures are logged and visible
  • When services recover, the pipeline resumes
  • You don't wake up to "data is missing"

That's the goal.


Building data pipelines? We've helped enterprises move from "we hope it works" to "we know exactly what's happening." Let's talk about your architecture.

Tech Stack

PythonPostgreSQLKubernetesDocker

The Ovalleaf Weekly, every week.

What we built, what broke, and what we learned — straight from the engineers doing the work.

One email a week. No spam, unsubscribe any time. See our Privacy Policy.