~learn.
?

Day 0242 minIntro+60 XP

Indexing Pipeline.

System Design Mastery Series — Building Blocks Week

6 stages11 code

watch it first · newThe whole lesson in a 18-minute animated lecture5 quiz checkpoints · ask the AI about any moment · captions, chapters and speed

Intro · 1 min

Introduction#

Yesterday we learned how search engines think — inverted indexes, text analysis, and document modeling. But we left a critical question unanswered:

The freshness problem
  • 09:00:00 —
    Elasticsearch (Search Index) last indexed: Product "Nike Air Max" price: $120
  • 10:00:00 —
    PostgreSQL (Source of Truth) updated: Product "Nike Air Max" price: $150. Elasticsearch still says $120 (stale!)
  • 10:00:01 —
    Customer searches: sees price $120 → adds to cart → checkout shows $150

Customer: "This is a scam!"

Today's Theme: "Keeping search fresh without killing the database"

We'll cover:

  • Change Data Capture (CDC) fundamentals
  • Debezium for PostgreSQL → Kafka streaming
  • Streaming vs batch indexing trade-offs
  • Bulk indexing without overwhelming Elasticsearch
  • Zero-downtime reindexing strategies
  • Handling indexing failures and lag
ExplainerThe whole lesson: the indexing pipeline

PostgreSQL updates Nike Air Max to $150 while search still says $120, and dual-writing from the app breaks when the Elasticsearch write fails, the process crashes in between or retries reorder the writes. Change data capture fixes it: the app writes only to PostgreSQL, Debezium reads the WAL into Kafka (persisted, ordered per partition, replayable) and the indexing pipeline sends bulk requests of 500 or every 5 s, retrying 3 times before dlq.products, while lag is monitored. A 1M-product import at ~100/s through CDC would take about 3 hours, so a separate rate-limited, resumable bulk indexer handles it; mapping changes go to a new index (products_v2) behind the products alias, switched in one atomic call.

Part I · Stage 1 of 6 · 6 min

Part I: The Indexing Challenge#

Chapter 1: Why Indexing Is Hard#

Your turn · checkpoint 1not answered

Write to both, in the request: what breaks?

The obvious design: update_product writes the row to PostgreSQL, then calls Elasticsearch to index the same data, all inside the request. List the ways PostgreSQL and the search index can end up disagreeing, and one way this hurts the database itself.

Think it through, or jot it down. Then compare.

One strong answer

  • ES write fails: PostgreSQL has the new price, search shows the old one.
  • Process crashes between the two writes: same result, and nothing remembers to retry.
  • Two updates race: PostgreSQL applies $100 then $120, but a retry lands in ES in the other order, so search ends on $100.
  • ES is slow or partitioned: the request waits and retries while holding its transaction open, and the database runs out of connections.
  • So the application shouldn't talk to the search index at all.

1.1 The Naive Approach (Don't Do This)#

pythonupdate_product · 15 lines
# BAD: Synchronous dual-write

async def update_product(product_id: str, data: dict):
    # Update PostgreSQL
    await db.execute(
        "UPDATE products SET ... WHERE id = $1",
        product_id
    )
    
    # Update Elasticsearch
    await es.index(
        index="products",
        id=product_id,
        document=data
    )

Problems with dual-write:

Dual-write failures
Scenario 1: ES write fails
  • PostgreSQL: Updated — ok
  • Elasticsearch: Failed
  • Result: Data inconsistent, search shows old data
Scenario 2: Process crashes between writes
  • PostgreSQL: Updated — ok
  • Elasticsearch: Never reached
  • Result: Data inconsistent
Scenario 3: Network partition
  • PostgreSQL: Updated — ok
  • Elasticsearch: Times out, retries...
  • Transaction held open
  • Result: Database connection exhaustion
Scenario 4: Order of operations
  • Request A: Update price to $100
  • Request B: Update price to $120
  • PostgreSQL order: A then B → $120 — correct
  • ES order (due to retries): B then A → $100 — wrong
  • Result: Elasticsearch has wrong value!

1.2 What We Need#

Requirements for reliable indexing
  1. Consistency — Eventually consistent with source of truth (PostgreSQL)
  2. Reliability — Never lose an update, even during failures
  3. Ordering — Updates applied in correct order
  4. Performance — Don't slow down the main application
  5. Scalability — Handle bulk imports (millions of products)
  6. Observability — Know when indexing falls behind
Your turn · checkpoint 2not answered

Where should the indexer learn about changes?

The application should write only to PostgreSQL, yet search must hear about every insert, update and delete, in order, without slowing requests down and without losing changes when the indexer is down. Where would you read the changes from, and what would you put between the database and Elasticsearch?

Think it through, or jot it down. Then compare.

One strong answer

  • Read the database's own transaction log (the WAL). Every committed change is already there, in commit order, and reading it adds no writes to the request path. That's Change Data Capture, done here by Debezium.
  • Put Kafka in between: events are stored durably, ordered per partition, and can be replayed (within retention) or read by more consumers. Consumer lag shows how far behind you are.
  • The indexing pipeline consumes from Kafka and updates Elasticsearch. The app never knows ES exists.

1.3 The Solution: Change Data Capture#

Change Data Capture (CDC) pattern

Instead of — dual-write

  • Application → PostgreSQL
  • Application → Elasticsearch (dual-write)

Use — CDC

Application → PostgreSQL → CDC → Kafka → Elasticsearch

  • PostgreSQL: Single source of truth
    • Changes captured as events
      • Events processed into index
Benefits
  • Decoupled: App doesn't know about ES
  • Reliable: Events persisted in Kafka
  • Ordered: Kafka maintains order per partition
  • Scalable: Can replay events, add consumers
  • Observable: Monitor Kafka lag

Chapter 2: Change Data Capture with Debezium#

2.1 How CDC Works#

DiagramCDC architecture · change data capture flow
%% title: CDC architecture · change data capture flow
%% steps:
%% APP --> PG: The application writes to PostgreSQL as usual
%% PG --> WAL: Every change lands in the write-ahead log
%% WAL --> DBZ: Debezium reads the WAL through logical replication
%% DBZ --> K: and publishes change events to Kafka topics
%% K --> IP, IP --> ES: The indexing pipeline consumes them and updates Elasticsearch
flowchart TD
  APP@{ icon: "sd:server", label: "Application" } -->|"INSERT/UPDATE/DELETE"| PG@{ icon: "tech:postgresql", label: "PostgreSQL" }
  PG --> WAL@{ icon: "sd:log", label: "WAL (Write Ahead Log)" }
  WAL -->|"Logical Replication"| DBZ["Debezium Connector"]:::core
  DBZ -->|"CDC Events"| K@{ icon: "tech:kafka", label: "Kafka Topics" }
  K -->|"Consume"| IP@{ icon: "sd:worker", label: "Indexing Pipeline" }
  IP --> ES@{ icon: "tech:elasticsearch", label: "Elasticsearch" }
  class APP client
  class PG,WAL,ES store
  class K queue
  class IP service
  1. The application writes to PostgreSQL as usual
  2. Every change lands in the write-ahead log
  3. Debezium reads the WAL through logical replication
  4. and publishes change events to Kafka topics
  5. The indexing pipeline consumes them and updates Elasticsearch
  • Captures ALL changes (INSERT, UPDATE, DELETE)
  • Maintains transaction order
  • Minimal impact on application performance (no extra writes in the request path)
  • Events can be replayed from Kafka (within its retention)

2.2 Debezium Configuration#

python83 lines
# config/debezium_connector.py

"""
Debezium PostgreSQL connector configuration.

Debezium reads PostgreSQL's Write-Ahead Log (WAL)
and publishes changes to Kafka topics.
"""

DEBEZIUM_CONNECTOR_CONFIG = {
    "name": "products-connector",
    "config": {
        # Connector class
        "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
        
        # Database connection
        "database.hostname": "postgres.internal",
        "database.port": "5432",
        "database.user": "debezium",
        "database.password": "${secrets:debezium-password}",
        "database.dbname": "ecommerce",
        
        # Logical replication slot + publication (created below)
        "slot.name": "debezium_products",
        "plugin.name": "pgoutput",  # PostgreSQL 10+ native
        "publication.name": "debezium_publication",
        
        # Tables to capture
        "table.include.list": "public.products,public.product_variants,public.product_images",
        
        # Topic configuration
        "topic.prefix": "ecommerce",
        # Results in topics like: ecommerce.public.products
        
        # Message transformations: keep the full Debezium envelope
        # (before/after/op/source), which the pipeline below parses;
        # only simplify the key to the bare id
        "transforms": "extractKey",
        "tombstones.on.delete": "false",  # pipeline handles op="d" itself
        "transforms.extractKey.type": "org.apache.kafka.connect.transforms.ExtractField$Key",
        "transforms.extractKey.field": "id",
        
        # Snapshot configuration
        "snapshot.mode": "initial",  # Full snapshot on first run
        
        # Error handling: fail loudly rather than silently skip changes.
        # (Kafka Connect's dead-letter queue exists only for sink
        # connectors; the indexing pipeline writes failures to dlq.products)
        "errors.tolerance": "none",
        "errors.log.enable": "true",
        
        # Performance tuning
        "max.batch.size": 2048,
        "max.queue.size": 8192,
        "poll.interval.ms": 100,
    }
}


# PostgreSQL setup required for CDC
POSTGRESQL_CDC_SETUP = """
-- Enable logical replication (takes effect after a restart)
ALTER SYSTEM SET wal_level = logical;

-- Create replication slot (if not using Debezium's auto-creation)
SELECT pg_create_logical_replication_slot('debezium_products', 'pgoutput');

-- Create publication for CDC
CREATE PUBLICATION debezium_publication FOR TABLE 
    products, 
    product_variants, 
    product_images;

-- By default Debezium's "before" holds only the primary-key columns.
-- The priority logic below compares old and new values (stock, price),
-- so capture the full old row (more WAL volume on UPDATE/DELETE)
ALTER TABLE products REPLICA IDENTITY FULL;

-- Grant permissions to Debezium user
CREATE USER debezium WITH REPLICATION PASSWORD 'secure_password';
GRANT SELECT ON ALL TABLES IN SCHEMA public TO debezium;
GRANT USAGE ON SCHEMA public TO debezium;
"""

2.3 CDC Event Structure#

pythonOperation · 87 lines
# models/cdc_events.py

"""
CDC event structures from Debezium.

Debezium produces events with before/after state,
operation type, and metadata.
"""

from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Optional, Dict, Any
from enum import Enum


class Operation(Enum):
    CREATE = "c"  # Insert
    UPDATE = "u"  # Update
    DELETE = "d"  # Delete
    READ = "r"    # Snapshot read


@dataclass
class CDCEvent:
    """
    Debezium CDC event.
    
    Example Kafka message (a full "before" row like this needs
    REPLICA IDENTITY FULL on the table; with PostgreSQL's default,
    "before" holds only the primary-key columns):
    {
        "before": {"id": 123, "name": "Old Name", "price": 100},
        "after": {"id": 123, "name": "New Name", "price": 120},
        "source": {
            "version": "2.0.0",
            "connector": "postgresql",
            "name": "ecommerce",
            "ts_ms": 1672531200000,
            "db": "ecommerce",
            "schema": "public",
            "table": "products",
            "txId": 12345,
            "lsn": 123456789
        },
        "op": "u",
        "ts_ms": 1672531200100
    }
    """
    
    operation: Operation
    before: Optional[Dict[str, Any]]  # State before change (null for inserts; PK only unless REPLICA IDENTITY FULL)
    after: Optional[Dict[str, Any]]   # State after change (null for deletes)
    table: str
    timestamp: datetime
    transaction_id: int
    lsn: int  # Log Sequence Number (for ordering)
    
    @classmethod
    def from_kafka_message(cls, message: dict) -> "CDCEvent":
        """Parse Debezium Kafka message."""
        
        source = message.get("source", {})
        
        return cls(
            operation=Operation(message["op"]),
            before=message.get("before"),
            after=message.get("after"),
            table=source.get("table", "unknown"),
            timestamp=datetime.fromtimestamp(message["ts_ms"] / 1000, timezone.utc),
            transaction_id=source.get("txId", 0),
            lsn=source.get("lsn", 0),
        )
    
    @property
    def is_delete(self) -> bool:
        return self.operation == Operation.DELETE
    
    @property
    def document_id(self) -> Optional[str]:
        """Get the document ID from before or after state."""
        state = self.after or self.before
        return str(state.get("id")) if state else None
    
    @property
    def current_state(self) -> Optional[Dict[str, Any]]:
        """Get current state (after for create/update, before for delete)."""
        return self.after if not self.is_delete else self.before
Quick check2 questions · +5 XP each

Dual-write vs CDC

  1. Q1/2 Your service writes to PostgreSQL and then to Elasticsearch. Two price updates ($100, then $120) race, and an ES retry lands late. What can happen?

    Answer & explanation

    Answer: A

    With dual-write, each store sees the writes in whatever order they arrive. Retries can reorder them in ES, leaving it on the old price while PostgreSQL has the new one. A plain index call doesn't compare timestamps for you.

  2. Q2/2 Why does reading changes from the PostgreSQL WAL (CDC) fix the problems of dual-write?

    Answer & explanation

    Answer: C

    CDC captures every committed INSERT/UPDATE/DELETE from the transaction log in order, and the application never talks to Elasticsearch. There's no distributed transaction; Kafka then stores the events durably for replay.

Part II · Stage 2 of 6 · 11 min

Part II: Indexing Pipeline Implementation#

Chapter 3: Building the Pipeline#

3.1 Core Pipeline Architecture#

pythonIndexingConfig · 421 lines
# pipeline/indexing_pipeline.py

"""
Indexing pipeline that consumes CDC events and updates Elasticsearch.

Key responsibilities:
1. Consume CDC events from Kafka
2. Transform to Elasticsearch documents
3. Batch updates for efficiency
4. Handle errors and retries
5. Track indexing progress
"""

import asyncio
import logging
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import List, Dict, Any, Optional
from collections import defaultdict
from elasticsearch.helpers import async_bulk

logger = logging.getLogger(__name__)


@dataclass
class IndexingConfig:
    """Indexing pipeline configuration."""
    
    # Kafka settings
    kafka_brokers: str = "kafka:9092"
    consumer_group: str = "product-indexer"
    topics: List[str] = field(default_factory=lambda: ["ecommerce.public.products"])
    
    # Batching settings
    batch_size: int = 500  # Documents per bulk request
    batch_timeout_seconds: float = 5.0  # Max wait before flushing
    
    # Elasticsearch settings
    es_index: str = "products"
    es_timeout_seconds: int = 30
    
    # Error handling
    max_retries: int = 3
    retry_backoff_seconds: float = 1.0
    
    # Performance
    max_concurrent_batches: int = 3


@dataclass
class IndexingMetrics:
    """Metrics for monitoring indexing pipeline."""
    
    events_received: int = 0
    documents_indexed: int = 0
    documents_deleted: int = 0
    errors: int = 0
    last_event_timestamp: Optional[datetime] = None
    lag_seconds: float = 0.0
    
    def to_dict(self) -> dict:
        return {
            "events_received": self.events_received,
            "documents_indexed": self.documents_indexed,
            "documents_deleted": self.documents_deleted,
            "errors": self.errors,
            "lag_seconds": self.lag_seconds,
        }


class ProductIndexingPipeline:
    """
    Main indexing pipeline for products.
    
    Consumes CDC events and maintains Elasticsearch index.
    """
    
    def __init__(
        self,
        config: IndexingConfig,
        kafka_consumer,
        es_client,
        document_transformer,
    ):
        self.config = config
        self.kafka = kafka_consumer
        self.es = es_client
        self.transformer = document_transformer
        
        self.metrics = IndexingMetrics()
        self._batch: List[Dict[str, Any]] = []
        self._batch_lock = asyncio.Lock()
        self._last_flush = datetime.now(timezone.utc)
        self._running = False
    
    async def start(self):
        """Start the indexing pipeline."""
        
        logger.info("Starting indexing pipeline")
        self._running = True
        
        # Start background tasks
        await asyncio.gather(
            self._consume_events(),
            self._flush_periodically(),
            self._report_metrics(),
        )
    
    async def stop(self):
        """Stop the pipeline gracefully."""
        
        logger.info("Stopping indexing pipeline")
        self._running = False
        
        # Flush remaining batch
        await self._flush_batch()
    
    async def _consume_events(self):
        """Consume CDC events from Kafka."""
        
        async for message in self.kafka.consume(self.config.topics):
            if not self._running:
                break
            
            try:
                await self._process_message(message)
            except Exception as e:
                logger.error(f"Error processing message: {e}", exc_info=True)
                self.metrics.errors += 1
    
    async def _process_message(self, message):
        """Process a single CDC event."""
        
        # Parse CDC event
        event = CDCEvent.from_kafka_message(message.value)
        self.metrics.events_received += 1
        self.metrics.last_event_timestamp = event.timestamp
        
        # Calculate lag
        self.metrics.lag_seconds = (
            datetime.now(timezone.utc) - event.timestamp
        ).total_seconds()
        
        # Transform to index operation
        operation = await self._event_to_operation(event)
        
        if operation:
            async with self._batch_lock:
                self._batch.append(operation)
                should_flush = len(self._batch) >= self.config.batch_size
            
            # Flush outside the lock: _flush_batch takes it too,
            # and asyncio.Lock is not re-entrant
            if should_flush:
                await self._flush_batch()
    
    async def _event_to_operation(self, event: CDCEvent) -> Optional[dict]:
        """Transform CDC event to Elasticsearch bulk operation."""
        
        # External version = WAL position: if an older change arrives
        # late (retries, reordered batches), ES rejects it with a
        # version conflict instead of overwriting newer data
        version = {"version": event.lsn, "version_type": "external"}
        
        if event.is_delete:
            return {
                "_op_type": "delete",
                "_index": self.config.es_index,
                "_id": event.document_id,
                **version,
            }
        
        # Transform to search document
        document = await self.transformer.transform(event.after)
        
        if document:
            return {
                "_op_type": "index",
                "_index": self.config.es_index,
                "_id": event.document_id,
                "_source": document,
                **version,
            }
        
        return None
    
    async def _flush_batch(self):
        """Flush accumulated batch to Elasticsearch."""
        
        async with self._batch_lock:
            if not self._batch:
                return
            
            batch = self._batch
            self._batch = []
            self._last_flush = datetime.now(timezone.utc)
        
        # Execute bulk request with retry
        success = await self._bulk_index_with_retry(batch)
        
        if success:
            # Update metrics
            for op in batch:
                if op["_op_type"] == "delete":
                    self.metrics.documents_deleted += 1
                else:
                    self.metrics.documents_indexed += 1
    
    async def _bulk_index_with_retry(self, operations: List[dict]) -> bool:
        """Execute bulk index with exponential backoff retry."""
        
        for attempt in range(self.config.max_retries):
            try:
                # async_bulk understands the _op_type/_index/_id dicts
                _, failed = await async_bulk(
                    self.es,
                    operations,
                    raise_on_error=False,
                    timeout=f"{self.config.es_timeout_seconds}s",
                )
                
                # Check for per-document errors
                if failed:
                    logger.warning(f"Bulk request had {len(failed)} failures")
                    # Could implement partial retry here
                
                return True
                
            except Exception as e:
                logger.warning(
                    f"Bulk index attempt {attempt + 1} failed: {e}"
                )
                
                if attempt < self.config.max_retries - 1:
                    wait_time = self.config.retry_backoff_seconds * (2 ** attempt)
                    await asyncio.sleep(wait_time)
        
        logger.error(f"Bulk index failed after {self.config.max_retries} attempts")
        self.metrics.errors += len(operations)
        return False
    
    async def _flush_periodically(self):
        """Background task to flush batches on timeout."""
        
        while self._running:
            await asyncio.sleep(1)
            
            time_since_flush = (
                datetime.now(timezone.utc) - self._last_flush
            ).total_seconds()
            
            # _flush_batch takes the lock itself
            if (self._batch and 
                time_since_flush >= self.config.batch_timeout_seconds):
                await self._flush_batch()
    
    async def _report_metrics(self):
        """Periodically report metrics."""
        
        while self._running:
            await asyncio.sleep(10)
            logger.info(f"Indexing metrics: {self.metrics.to_dict()}")


class DocumentTransformer:
    """
    Transforms database rows to Elasticsearch documents.
    
    Handles:
    - Field mapping
    - Data enrichment
    - Denormalization
    """
    
    def __init__(self, db_pool, cache):
        self.db = db_pool
        self.cache = cache
    
    async def transform(self, row: dict) -> Optional[dict]:
        """Transform database row to search document."""
        
        if not row:
            return None
        
        # Basic field mapping
        document = {
            "product_id": str(row["id"]),
            "name": row["name"],
            "description": row.get("description", ""),
            "brand": row.get("brand", ""),
            "category": row.get("category", ""),
            "price": float(row.get("price", 0)),
            "sale_price": float(row.get("sale_price", 0)) if row.get("sale_price") else None,
            "in_stock": row.get("stock_quantity", 0) > 0,
            "stock_quantity": row.get("stock_quantity", 0),
            "rating": float(row.get("rating", 0)),
            "review_count": row.get("review_count", 0),
            "created_at": row.get("created_at"),
            "updated_at": row.get("updated_at"),
        }
        
        # Enrich with related data (denormalization)
        document = await self._enrich_document(document, row)
        
        # Calculate derived fields
        document = self._calculate_derived_fields(document)
        
        return document
    
    async def _enrich_document(self, document: dict, row: dict) -> dict:
        """Enrich document with related data."""
        
        product_id = row["id"]
        
        # Get categories path (for hierarchical faceting)
        categories = await self._get_category_path(row.get("category_id"))
        if categories:
            document["categories"] = categories
        
        # Get product tags
        tags = await self._get_product_tags(product_id)
        if tags:
            document["tags"] = tags
        
        # Get variant information (colors, sizes)
        variants = await self._get_product_variants(product_id)
        if variants:
            document["colors"] = list(set(v["color"] for v in variants if v.get("color")))
            document["sizes"] = list(set(v["size"] for v in variants if v.get("size")))
        
        return document
    
    def _calculate_derived_fields(self, document: dict) -> dict:
        """Calculate derived/computed fields."""
        
        # Discount percentage
        if document.get("sale_price") and document["price"] > 0:
            discount = (document["price"] - document["sale_price"]) / document["price"]
            document["discount_percent"] = int(discount * 100)
        else:
            document["discount_percent"] = 0
        
        # Popularity score (for boosting)
        document["popularity_score"] = self._calculate_popularity(document)
        
        # Search boost field (combined text for searching)
        document["search_text"] = " ".join([
            document.get("name", ""),
            document.get("brand", ""),
            document.get("description", "")[:500],
            " ".join(document.get("tags", [])),
        ])
        
        return document
    
    def _calculate_popularity(self, document: dict) -> float:
        """Calculate popularity score for ranking."""
        
        # Weighted combination of signals
        rating_score = document.get("rating", 0) / 5.0  # Normalize to 0-1
        review_score = min(1.0, document.get("review_count", 0) / 1000)  # Cap at 1000
        
        # Recent products get a boost
        # (would need actual date comparison in production)
        recency_score = 0.5
        
        return (rating_score * 0.4) + (review_score * 0.4) + (recency_score * 0.2)
    
    async def _get_category_path(self, category_id: int) -> List[str]:
        """Get full category path for hierarchical faceting."""
        
        if not category_id:
            return []
        
        # Check cache first
        cache_key = f"category_path:{category_id}"
        cached = await self.cache.get(cache_key)
        if cached:
            return cached
        
        # Query database
        path = await self.db.fetchval("""
            WITH RECURSIVE cat_path AS (
                SELECT id, name, parent_id, ARRAY[name] as path
                FROM categories WHERE id = $1
                
                UNION ALL
                
                SELECT c.id, c.name, c.parent_id, c.name || cp.path
                FROM categories c
                JOIN cat_path cp ON c.id = cp.parent_id
            )
            SELECT path FROM cat_path WHERE parent_id IS NULL
        """, category_id)
        
        if path:
            await self.cache.set(cache_key, path, ttl=3600)
        
        return path or []
    
    async def _get_product_tags(self, product_id: int) -> List[str]:
        """Get product tags."""
        
        rows = await self.db.fetch("""
            SELECT t.name FROM tags t
            JOIN product_tags pt ON t.id = pt.tag_id
            WHERE pt.product_id = $1
        """, product_id)
        
        return [r["name"] for r in rows]
    
    async def _get_product_variants(self, product_id: int) -> List[dict]:
        """Get product variants (colors, sizes)."""
        
        rows = await self.db.fetch("""
            SELECT color, size, sku, stock_quantity
            FROM product_variants
            WHERE product_id = $1 AND stock_quantity > 0
        """, product_id)
        
        return [dict(r) for r in rows]
Your turn · checkpoint 3not answered

One batch size for every update?

The pipeline sends 500-document bulk requests. That's efficient, but a product that just sold out sits in the same batch as a hundred description edits. Would you keep one batch size for everything? If not, how would you split it, and what new problem does splitting create?

Think it through, or jot it down. Then compare.

One strong answer

  • Big batches are cheap per document but make each update wait; small batches are fast but cost more requests.
  • So split by what changed: stock going to zero (or back) is critical and goes in tiny batches (10) that flush fast; price changes get 100; description edits 500; bulk imports 1,000. Changes to fields search doesn't use are skipped.
  • New problem: batches now flush out of order, so an older edit can land after a newer one. Using the WAL position as an external version makes ES reject the stale write.

3.2 Handling Different Update Patterns#

pythonUpdatePriority · 158 lines
# pipeline/update_handlers.py

"""
Specialized handlers for different types of updates.

Some updates require different handling:
- Price changes: High priority, need fast indexing
- Stock changes: Critical for availability
- Description changes: Lower priority, can batch more
- Bulk imports: Need special handling to not overwhelm
"""

from enum import Enum
from dataclasses import dataclass
from typing import Optional


class UpdatePriority(Enum):
    CRITICAL = 1   # Stock out, price drops
    HIGH = 2       # General updates
    NORMAL = 3     # Description changes
    LOW = 4        # Bulk imports


@dataclass
class PrioritizedUpdate:
    """Update with priority for queue ordering."""
    
    product_id: str
    operation: str  # index, delete
    document: Optional[dict]
    priority: UpdatePriority
    timestamp: float


class UpdateClassifier:
    """
    Classifies updates by priority based on what changed.
    """
    
    def classify(self, event: CDCEvent) -> UpdatePriority:
        """Determine priority of update."""
        
        if event.is_delete:
            return UpdatePriority.HIGH
        
        before = event.before or {}
        after = event.after or {}
        
        # Stock went to zero - critical!
        if before.get("stock_quantity", 0) > 0 and after.get("stock_quantity", 0) == 0:
            return UpdatePriority.CRITICAL
        
        # Stock restored - critical!
        if before.get("stock_quantity", 0) == 0 and after.get("stock_quantity", 0) > 0:
            return UpdatePriority.CRITICAL
        
        # Price changed
        if before.get("price") != after.get("price"):
            return UpdatePriority.HIGH
        
        # Sale price changed
        if before.get("sale_price") != after.get("sale_price"):
            return UpdatePriority.HIGH
        
        # Only metadata changed (description, images, etc.)
        return UpdatePriority.NORMAL
    
    def is_significant_change(self, event: CDCEvent) -> bool:
        """Check if change affects search results."""
        
        if event.is_delete:
            return True
        
        before = event.before or {}
        after = event.after or {}
        
        # Fields that affect search
        significant_fields = {
            "name", "description", "brand", "category",
            "price", "sale_price", "stock_quantity",
            "rating", "review_count"
        }
        
        for field in significant_fields:
            if before.get(field) != after.get(field):
                return True
        
        return False


class PriorityIndexingPipeline(ProductIndexingPipeline):
    """
    Indexing pipeline with priority queues.
    
    Critical updates processed immediately.
    Lower priority updates batched more aggressively.
    
    Batches flush out of order, so an older NORMAL update for a product
    can land after a newer CRITICAL one; the external version set in
    _event_to_operation makes ES reject the stale write.
    """
    
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.classifier = UpdateClassifier()
        
        # Separate batches by priority
        self._priority_batches = {
            UpdatePriority.CRITICAL: [],
            UpdatePriority.HIGH: [],
            UpdatePriority.NORMAL: [],
            UpdatePriority.LOW: [],
        }
        
        # Different batch sizes per priority
        self._batch_sizes = {
            UpdatePriority.CRITICAL: 10,   # Small batches, fast flush
            UpdatePriority.HIGH: 100,
            UpdatePriority.NORMAL: 500,
            UpdatePriority.LOW: 1000,      # Large batches for bulk
        }
    
    async def _process_message(self, message):
        """Process with priority classification."""
        
        event = CDCEvent.from_kafka_message(message.value)
        self.metrics.events_received += 1
        
        # Skip non-significant changes
        if not self.classifier.is_significant_change(event):
            return
        
        # Classify priority
        priority = self.classifier.classify(event)
        
        # Transform and add to appropriate batch
        operation = await self._event_to_operation(event)
        
        if operation:
            async with self._batch_lock:
                self._priority_batches[priority].append(operation)
                
                # Check if this priority batch should flush
                if len(self._priority_batches[priority]) >= self._batch_sizes[priority]:
                    await self._flush_priority_batch(priority)
    
    async def _flush_priority_batch(self, priority: UpdatePriority):
        """Flush a specific priority batch."""
        
        batch = self._priority_batches[priority]
        if not batch:
            return
        
        self._priority_batches[priority] = []
        
        logger.info(f"Flushing {len(batch)} {priority.name} priority updates")
        await self._bulk_index_with_retry(batch)
Quick check2 questions · +5 XP each

Building the indexing pipeline

  1. Q1/2 The pipeline flushes a batch at 500 documents OR after 5 seconds. What is the 5-second timeout for?

    Answer & explanation

    Answer: D

    Size-based batching makes bulk requests efficient under load. At low traffic a batch might never fill, so the time limit caps how stale an update can get.

  2. Q2/2 A product's stock drops to zero. In the priority pipeline, how is that update handled compared with a description edit?

    Answer & explanation

    Answer: B

    Showing out-of-stock items as available hurts users, so stock-to-zero is CRITICAL with tiny batches. Description edits are NORMAL and wait in bigger batches. stock_quantity is a significant field, so it isn't skipped.

Part III · Stage 3 of 6 · 7 min

Part III: Bulk Indexing Strategies#

Chapter 4: Initial Load and Bulk Imports#

Your turn · checkpoint 4not answered

A million products arrive at once

A vendor import adds 1 million products. The CDC pipeline indexes about 100 products a second. A separate bulk indexer could instead send up to 5,000 a second, in batches of 1,000. How long does each path take? And why not simply send the import as fast as the cluster will take it?

Think it through, or jot it down. Then compare.

One strong answer

  • Through CDC: 1,000,000 ÷ 100 = 10,000 s, about 3 hours of users searching for products that don't exist yet.
  • Bulk path: 1,000,000 ÷ 5,000 = 200 s, about 1,000 bulk requests.
  • Why cap it: at full speed the import would overload Elasticsearch, slow down live searches and starve the normal CDC updates (prices, stock). A rate limit and a separate job protect all three.

4.1 The Bulk Import Challenge#

Bulk import scenario
Situation
  • 1 million new products from vendor import
  • Normal indexing: ~100 products/sec from CDC
  • Time to index: 1M / 100 = 10,000 seconds ≈ 3 hours
  • Users searching for products that don't exist yet!
Challenges
  • Don't overwhelm Elasticsearch cluster
  • Don't starve normal CDC updates
  • Don't cause search latency spikes
  • Handle failures gracefully

4.2 Bulk Indexing Implementation#

pythonBulkIndexJob · 295 lines
# pipeline/bulk_indexer.py

"""
Bulk indexing for initial loads and large imports.

Strategies:
1. Rate limiting to protect cluster
2. Parallel workers with coordination
3. Progress tracking and resumability
4. Separate from real-time pipeline
"""

import asyncio
import logging
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Optional, AsyncIterator
import hashlib
from elasticsearch.helpers import async_bulk

logger = logging.getLogger(__name__)


@dataclass
class BulkIndexJob:
    """Represents a bulk indexing job."""
    
    job_id: str
    source: str  # "postgres", "csv", "api"
    query: Optional[str]  # SQL query for postgres source
    total_documents: int
    indexed_documents: int = 0
    failed_documents: int = 0
    status: str = "pending"  # pending, running, completed, failed
    started_at: Optional[datetime] = None
    completed_at: Optional[datetime] = None
    last_processed_id: Optional[str] = None  # For resumability


class BulkIndexer:
    """
    Handles bulk indexing with rate limiting and progress tracking.
    """
    
    def __init__(
        self,
        es_client,
        db_pool,
        transformer,
        job_store,
        max_rate_per_second: int = 5000,
        batch_size: int = 1000,
        parallel_workers: int = 4,
    ):
        self.es = es_client
        self.db = db_pool
        self.transformer = transformer
        self.jobs = job_store
        
        self.max_rate = max_rate_per_second
        self.batch_size = batch_size
        self.parallel_workers = parallel_workers
        
        # Rate limiter
        self._rate_limiter = asyncio.Semaphore(parallel_workers)
        self._tokens = max_rate_per_second
        self._last_refill = datetime.now(timezone.utc)
    
    async def create_job(
        self,
        source: str,
        query: str = None,
        total: int = None
    ) -> BulkIndexJob:
        """Create a new bulk indexing job."""
        
        job_id = hashlib.md5(
            f"{source}:{query}:{datetime.now(timezone.utc).isoformat()}".encode()
        ).hexdigest()[:12]
        
        # Count total if not provided
        if total is None and query:
            count_query = f"SELECT COUNT(*) FROM ({query}) AS subq"
            total = await self.db.fetchval(count_query)
        
        job = BulkIndexJob(
            job_id=job_id,
            source=source,
            query=query,
            total_documents=total or 0
        )
        
        await self.jobs.save(job)
        return job
    
    async def run_job(self, job: BulkIndexJob):
        """Execute a bulk indexing job."""
        
        job.status = "running"
        job.started_at = datetime.now(timezone.utc)
        await self.jobs.save(job)
        
        logger.info(f"Starting bulk index job {job.job_id}: {job.total_documents} documents")
        
        try:
            # Stream documents and index in batches
            batch = []
            
            async for document in self._stream_documents(job):
                batch.append(document)
                
                if len(batch) >= self.batch_size:
                    await self._index_batch_with_rate_limit(batch)
                    job.indexed_documents += len(batch)
                    job.last_processed_id = batch[-1].get("product_id")
                    await self.jobs.save(job)
                    batch = []
                    
                    # Log progress
                    progress = (job.indexed_documents / job.total_documents) * 100
                    if job.indexed_documents % 10000 == 0:
                        logger.info(
                            f"Job {job.job_id}: {job.indexed_documents}/{job.total_documents} "
                            f"({progress:.1f}%)"
                        )
            
            # Final batch
            if batch:
                await self._index_batch_with_rate_limit(batch)
                job.indexed_documents += len(batch)
            
            job.status = "completed"
            job.completed_at = datetime.now(timezone.utc)
            
            duration = (job.completed_at - job.started_at).total_seconds()
            rate = job.indexed_documents / duration
            
            logger.info(
                f"Job {job.job_id} completed: {job.indexed_documents} documents "
                f"in {duration:.1f}s ({rate:.0f} docs/sec)"
            )
            
        except Exception as e:
            logger.error(f"Job {job.job_id} failed: {e}", exc_info=True)
            job.status = "failed"
            raise
        
        finally:
            await self.jobs.save(job)
    
    async def resume_job(self, job: BulkIndexJob):
        """Resume a failed or interrupted job."""
        
        if job.status not in ("failed", "running"):
            raise ValueError(f"Cannot resume job with status {job.status}")
        
        logger.info(
            f"Resuming job {job.job_id} from {job.last_processed_id}, "
            f"{job.indexed_documents}/{job.total_documents} done"
        )
        
        await self.run_job(job)
    
    async def _stream_documents(
        self,
        job: BulkIndexJob
    ) -> AsyncIterator[dict]:
        """Stream documents from source."""
        
        if job.source == "postgres":
            async for doc in self._stream_from_postgres(job):
                yield doc
        elif job.source == "csv":
            async for doc in self._stream_from_csv(job):
                yield doc
        else:
            raise ValueError(f"Unknown source: {job.source}")
    
    async def _stream_from_postgres(
        self,
        job: BulkIndexJob
    ) -> AsyncIterator[dict]:
        """Stream documents from PostgreSQL using cursor."""
        
        query = job.query
        
        # Add resume condition if needed
        if job.last_processed_id:
            # Assume query is ordered by id
            query = f"""
                SELECT * FROM ({query}) AS subq 
                WHERE id > {job.last_processed_id}
                ORDER BY id
            """
        
        # Use server-side cursor for memory efficiency
        async with self.db.transaction():
            async for row in self.db.cursor(query, prefetch=self.batch_size):
                document = await self.transformer.transform(dict(row))
                if document:
                    yield document
    
    async def _index_batch_with_rate_limit(self, batch: list):
        """Index batch with rate limiting to protect cluster."""
        
        # Acquire rate limit tokens
        await self._acquire_tokens(len(batch))
        
        # Index batch
        async with self._rate_limiter:
            operations = [
                {
                    "_op_type": "index",
                    "_index": "products",
                    "_id": doc["product_id"],
                    "_source": doc
                }
                for doc in batch
            ]
            
            # async_bulk understands the _op_type/_index/_id dicts
            _, errors = await async_bulk(
                self.es, operations, raise_on_error=False
            )
            
            if errors:
                logger.warning(f"Batch had {len(errors)} errors")
    
    async def _acquire_tokens(self, count: int):
        """Token bucket rate limiting."""
        
        while True:
            now = datetime.now(timezone.utc)
            elapsed = (now - self._last_refill).total_seconds()
            
            # Refill tokens
            self._tokens = min(
                self.max_rate,
                self._tokens + elapsed * self.max_rate
            )
            self._last_refill = now
            
            if self._tokens >= count:
                self._tokens -= count
                return
            
            # Wait for more tokens
            wait_time = (count - self._tokens) / self.max_rate
            await asyncio.sleep(wait_time)


# =============================================================================
# Bulk Index API
# =============================================================================

async def reindex_all_products(bulk_indexer: BulkIndexer):
    """Full reindex of all products."""
    
    job = await bulk_indexer.create_job(
        source="postgres",
        query="""
            SELECT p.*, 
                   array_agg(DISTINCT pv.color) as colors,
                   array_agg(DISTINCT pv.size) as sizes
            FROM products p
            LEFT JOIN product_variants pv ON p.id = pv.product_id
            WHERE p.deleted_at IS NULL
            GROUP BY p.id
            ORDER BY p.id
        """
    )
    
    await bulk_indexer.run_job(job)
    return job


async def index_vendor_import(
    bulk_indexer: BulkIndexer,
    vendor_id: str,
    import_batch_id: str
):
    """Index products from a vendor import."""
    
    job = await bulk_indexer.create_job(
        source="postgres",
        query=f"""
            SELECT * FROM products
            WHERE vendor_id = '{vendor_id}'
              AND import_batch_id = '{import_batch_id}'
            ORDER BY id
        """
    )
    
    await bulk_indexer.run_job(job)
    return job
WorksheetImports and a full reindex (rates are assumptions)
Value
Vendor import (the lesson's example) 1 million products
Through the CDC pipeline (~100 products/s, the lesson's figure) 10,000 s ≈ 2.8 hours
Bulk indexer cap (max_rate_per_second default, assumed safe for the cluster) 5,000 docs/s → 200 s ≈ 3.3 minutes
Bulk requests at 1,000 docs each 1,000 requests, 5 a second
Full reindex of Day 1's 50M-product catalog at the same cap 50M ÷ 5,000 = 10,000 s ≈ 2.8 hours
Live CDC updates during that reindex (assumed 100/s) ~1 million, each needed in the new index too

Conclusion: a vendor import is minutes on the rate-limited bulk path and hours through CDC, so imports get their own path. A full reindex still takes hours even at the cap, with about a million live updates arriving meanwhile: that's why the reindexer writes to both indexes and switches the alias only after the counts match.

Quick check3 questions · +5 XP each

Bulk indexing

  1. Q1/3 A vendor uploads 1M products and the normal CDC path indexes about 100 products/sec. Roughly how long until they're all searchable?

    Answer & explanation

    Answer: C

    1,000,000 / 100 per second = 10,000 seconds, a bit under 3 hours. That's why large imports get their own bulk path instead of the normal CDC trickle.

  2. Q2/3 Why does the bulk indexer use a token-bucket rate limit and run separately from the real-time pipeline?

    Answer & explanation

    Answer: A

    The stated goals: don't overwhelm Elasticsearch, don't starve normal CDC updates, don't cause search latency spikes. A separate, rate-limited job meets all three. It doesn't skip transformation or jump ahead of live updates.

  3. Q3/3 A 6-hour bulk job crashes at 70%. What lets it resume instead of starting over?

    Answer & explanation

    Answer: C

    Progress is saved after every batch, and the query is ordered by id, so the job continues with WHERE id > last_processed_id. Re-sending a document is harmless because indexing by ID overwrites it.

Part IV · Stage 4 of 6 · 7 min

Part IV: Zero-Downtime Reindexing#

Chapter 5: The Reindexing Problem#

5.1 Why Reindex?#

Scenarios requiring reindex
1. Mapping changes
  • Adding new fields that existing docs must be indexed into (the mapping itself can gain fields in place)
  • Changing field types
  • Modifying analyzers
2. Analysis changes
  • Adding new index-time synonyms
  • Changing tokenizer
  • Adding new filters
3. Shard restructuring
  • Changing shard count beyond what _split/_shrink allow
  • Rebalancing data
4. Data fixes
  • Fixing corrupted documents
  • Re-enriching with new logic

Problem: You can't modify mappings of existing fields. You must create a new index and migrate data. But search must keep working during migration!

Your turn · checkpoint 5not answered

Change the analyzer without stopping search

You need a new analyzer on name, so the existing index can't be changed in place. Search must keep answering throughout, and no price or stock update may be lost during the migration. Describe the steps, and say exactly when users start reading the new index.

Think it through, or jot it down. Then compare.

One strong answer

  • Indirection first: the app reads through a products alias, never a real index name.
  • Build v2 with the new mapping; from then on the pipeline writes every update to both v1 and v2.
  • Copy existing documents from v1 into v2 without overwriting the newer ones that dual-writes already put there.
  • Verify the counts match, then move the alias from v1 to v2 in one atomic remove+add. That's the moment readers switch; before it they never see a half-copied index.
  • Delete v1 only after a wait, and roll back by dropping v2 if anything fails.

5.2 Index Alias Pattern#

Index alias pattern
  • Instead of: Application → "products" index
  • Use: Application → "products" alias → "products_v1" index
Benefits
  • Alias is a pointer, can be changed atomically
  • Can point to multiple indexes (for migration)
  • Application doesn't know actual index name
  • Zero-downtime switches
Migration flow
  1. Before
    • "products" alias → "products_v1" (current)
  2. During migration
    • "products" alias → "products_v1" (current, still serving reads)
    • "products_write" alias → "products_v2" (new, receiving updates)
  3. After switch
    • "products" alias → "products_v2" (new)
    • "products_v1" → (deleted after verification)
ExplainerZero-downtime reindex with an alias switch

The app searches the products alias, which points at products_v1. products_v2 is created with the new mapping, CDC writes to both indexes (products_write points at v2), the old documents are bulk-copied with op_type create, and once counts match within 0.1% one atomic call moves the alias to v2. Searches never stop; v1 is deleted afterwards.

5.3 Zero-Downtime Reindex Implementation#

pythonReindexPlan · 323 lines
# pipeline/reindex.py

"""
Zero-downtime reindexing with index aliases.

Process:
1. Create new index with updated mapping
2. Set up dual-write (CDC writes to both)
3. Bulk copy existing data
4. Verify counts match
5. Atomic alias switch
6. Clean up old index
"""

import asyncio
import logging
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Optional

logger = logging.getLogger(__name__)


@dataclass
class ReindexPlan:
    """Plan for reindex operation."""
    
    plan_id: str
    old_index: str
    new_index: str
    alias: str
    new_mapping: dict
    new_settings: dict
    status: str = "planned"
    started_at: Optional[datetime] = None
    completed_at: Optional[datetime] = None
    
    # Progress tracking
    documents_copied: int = 0
    documents_total: int = 0


class ZeroDowntimeReindexer:
    """
    Handles zero-downtime reindexing.
    """
    
    def __init__(
        self,
        es_client,
        indexing_pipeline,
        bulk_indexer,
    ):
        self.es = es_client
        self.pipeline = indexing_pipeline
        self.bulk = bulk_indexer
    
    async def create_plan(
        self,
        alias: str,
        new_mapping: dict,
        new_settings: dict = None
    ) -> ReindexPlan:
        """Create a reindex plan."""
        
        # Get current index behind alias
        alias_info = await self.es.indices.get_alias(name=alias)
        old_index = list(alias_info.keys())[0]
        
        # Generate new index name
        version = int(old_index.split("_v")[-1]) if "_v" in old_index else 1
        new_index = f"{alias}_v{version + 1}"
        
        # Get document count
        count = await self.es.count(index=old_index)
        
        return ReindexPlan(
            plan_id=f"reindex_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}",
            old_index=old_index,
            new_index=new_index,
            alias=alias,
            new_mapping=new_mapping,
            new_settings=new_settings or {},
            documents_total=count["count"]
        )
    
    async def execute(self, plan: ReindexPlan) -> ReindexPlan:
        """
        Execute reindex plan with zero downtime.
        """
        
        plan.status = "running"
        plan.started_at = datetime.now(timezone.utc)
        
        try:
            # Step 1: Create new index
            logger.info(f"Creating new index: {plan.new_index}")
            await self._create_new_index(plan)
            
            # Step 2: Set up dual write
            logger.info("Setting up dual write")
            await self._setup_dual_write(plan)
            
            # Step 3: Copy existing data
            logger.info(f"Copying {plan.documents_total} documents")
            await self._copy_data(plan)
            
            # Step 4: Verify counts
            logger.info("Verifying document counts")
            await self._verify_counts(plan)
            
            # Step 5: Atomic alias switch
            logger.info("Switching alias")
            await self._switch_alias(plan)
            
            # Step 6: Clean up
            logger.info("Cleaning up old index")
            await self._cleanup(plan)
            
            plan.status = "completed"
            plan.completed_at = datetime.now(timezone.utc)
            
            duration = (plan.completed_at - plan.started_at).total_seconds()
            logger.info(
                f"Reindex completed in {duration:.1f}s: "
                f"{plan.documents_copied} documents migrated"
            )
            
        except Exception as e:
            plan.status = "failed"
            logger.error(f"Reindex failed: {e}", exc_info=True)
            
            # Attempt rollback
            await self._rollback(plan)
            raise
        
        return plan
    
    async def _create_new_index(self, plan: ReindexPlan):
        """Create new index with updated mapping."""
        
        body = {
            "mappings": plan.new_mapping,
            "settings": plan.new_settings
        }
        
        await self.es.indices.create(index=plan.new_index, body=body)
    
    async def _setup_dual_write(self, plan: ReindexPlan):
        """Configure pipeline to write to both indexes."""
        
        # Add write alias pointing to new index
        await self.es.indices.put_alias(
            index=plan.new_index,
            name=f"{plan.alias}_write"
        )
        
        # Configure indexing pipeline to use write alias
        # This ensures new updates go to both old and new index
        self.pipeline.set_write_indexes([plan.old_index, plan.new_index])
    
    async def _copy_data(self, plan: ReindexPlan):
        """Copy data from old index to new index."""
        
        # Use Elasticsearch's reindex API with scroll
        body = {
            "source": {
                "index": plan.old_index,
                "size": 1000  # Batch size
            },
            "dest": {
                "index": plan.new_index,
                # Don't overwrite newer docs already dual-written
                "op_type": "create"
            },
            "conflicts": "proceed"
        }
        
        # Start reindex task
        task = await self.es.reindex(
            body=body,
            wait_for_completion=False,
            refresh=True
        )
        
        task_id = task["task"]
        
        # Monitor progress
        while True:
            status = await self.es.tasks.get(task_id=task_id)
            
            if status["completed"]:
                plan.documents_copied = status["task"]["status"]["created"]
                break
            
            plan.documents_copied = status["task"]["status"].get("created", 0)
            progress = (plan.documents_copied / plan.documents_total) * 100
            logger.info(f"Reindex progress: {progress:.1f}%")
            
            await asyncio.sleep(5)
    
    async def _verify_counts(self, plan: ReindexPlan):
        """Verify new index has all documents."""
        
        # Refresh to ensure all documents are searchable
        await self.es.indices.refresh(index=plan.new_index)
        
        old_count = (await self.es.count(index=plan.old_index))["count"]
        new_count = (await self.es.count(index=plan.new_index))["count"]
        
        # Allow small difference due to concurrent writes
        diff_pct = abs(old_count - new_count) / old_count * 100
        
        if diff_pct > 0.1:  # More than 0.1% difference
            raise ValueError(
                f"Count mismatch: old={old_count}, new={new_count} "
                f"({diff_pct:.2f}% difference)"
            )
        
        logger.info(f"Count verified: old={old_count}, new={new_count}")
    
    async def _switch_alias(self, plan: ReindexPlan):
        """Atomic alias switch."""
        
        # Atomic operation: remove from old, add to new
        await self.es.indices.update_aliases(body={
            "actions": [
                {
                    "remove": {
                        "index": plan.old_index,
                        "alias": plan.alias
                    }
                },
                {
                    "add": {
                        "index": plan.new_index,
                        "alias": plan.alias
                    }
                }
            ]
        })
        
        # Remove write alias
        await self.es.indices.delete_alias(
            index=plan.new_index,
            name=f"{plan.alias}_write"
        )
        
        # Update pipeline to write only to new index
        self.pipeline.set_write_indexes([plan.new_index])
    
    async def _cleanup(self, plan: ReindexPlan):
        """Clean up old index after successful migration."""
        
        # Wait a bit to ensure no in-flight requests
        await asyncio.sleep(30)
        
        # Delete old index
        await self.es.indices.delete(index=plan.old_index)
        
        logger.info(f"Deleted old index: {plan.old_index}")
    
    async def _rollback(self, plan: ReindexPlan):
        """Rollback on failure."""
        
        logger.warning("Rolling back reindex")
        
        try:
            # Remove write alias if it exists
            try:
                await self.es.indices.delete_alias(
                    index=plan.new_index,
                    name=f"{plan.alias}_write"
                )
            except:
                pass
            
            # Delete new index
            if await self.es.indices.exists(index=plan.new_index):
                await self.es.indices.delete(index=plan.new_index)
            
            # Reset pipeline to only write to old index
            self.pipeline.set_write_indexes([plan.old_index])
            
            logger.info("Rollback completed")
            
        except Exception as e:
            logger.error(f"Rollback failed: {e}")


# =============================================================================
# Usage Example
# =============================================================================

async def add_new_field_to_products():
    """Example: Adding a new 'brand_id' field to products."""
    
    # New mapping with additional field
    new_mapping = {
        "properties": {
            # ... existing fields ...
            "brand_id": {
                "type": "keyword"
            },
            "brand_popularity": {
                "type": "float"
            }
        }
    }
    
    reindexer = ZeroDowntimeReindexer(es, pipeline, bulk_indexer)
    
    # Create plan
    plan = await reindexer.create_plan(
        alias="products",
        new_mapping=new_mapping
    )
    
    print(f"Reindex plan: {plan.old_index} → {plan.new_index}")
    print(f"Documents to migrate: {plan.documents_total}")
    
    # Execute
    await reindexer.execute(plan)
Quick check2 questions · +5 XP each

Zero-downtime reindexing

  1. Q1/2 You need to change how an existing field is analyzed. Why can't you just update the mapping in place?

    Answer & explanation

    Answer: B

    The inverted index already holds tokens from the old analyzer, so existing fields can't be re-mapped in place. You create a new index, migrate the data, and use an alias so search keeps working.

  2. Q2/2 During a v1-to-v2 migration, what keeps searches working the whole time?

    Answer & explanation

    Answer: D

    Reads stay on v1 through the products alias while updates go to both indexes and old data is copied. Once counts check out, the alias moves to v2 in one atomic remove+add. Pausing CDC makes results stale; reading v2 early shows a half-copied index.

Part V · Stage 5 of 6 · 5 min

Part V: Monitoring and Troubleshooting#

Chapter 6: Indexing Observability#

6.1 Key Metrics#

pythonIndexingHealthMetrics · 155 lines
# monitoring/indexing_metrics.py

"""
Metrics for monitoring indexing pipeline health.
"""

from dataclasses import dataclass
from datetime import datetime
from typing import Dict, List


@dataclass
class IndexingHealthMetrics:
    """Core metrics for indexing health."""
    
    # Lag metrics
    kafka_consumer_lag: int  # Messages behind
    indexing_lag_seconds: float  # Time behind real-time
    
    # Throughput metrics
    events_per_second: float
    documents_per_second: float
    
    # Error metrics
    error_rate: float
    dlq_size: int
    
    # Resource metrics
    batch_queue_depth: int
    es_bulk_queue_size: int
    
    def is_healthy(self) -> bool:
        """Check if indexing is healthy."""
        return (
            self.indexing_lag_seconds < 60 and  # Less than 1 minute behind
            self.error_rate < 0.01 and           # Less than 1% errors
            self.dlq_size < 1000                  # DLQ not growing
        )


class IndexingMonitor:
    """
    Monitors indexing pipeline health.
    """
    
    def __init__(
        self,
        kafka_admin,
        es_client,
        pipeline,
        alerter
    ):
        self.kafka = kafka_admin
        self.es = es_client
        self.pipeline = pipeline
        self.alerter = alerter
        
        # Alert thresholds
        self.lag_warning_seconds = 60
        self.lag_critical_seconds = 300
        self.error_rate_warning = 0.01
        self.error_rate_critical = 0.05
    
    async def collect_metrics(self) -> IndexingHealthMetrics:
        """Collect current metrics."""
        
        # Get Kafka consumer lag
        lag = await self._get_consumer_lag()
        
        # Get pipeline metrics
        pipeline_metrics = self.pipeline.metrics
        
        # Calculate rates
        events_per_second = self._calculate_rate(
            pipeline_metrics.events_received
        )
        
        # Get ES metrics
        es_stats = await self.es.nodes.stats(metric="thread_pool")
        # The bulk pool is called "write" since ES 6.3
        bulk_queue = es_stats["nodes"][list(es_stats["nodes"].keys())[0]]["thread_pool"]["write"]["queue"]
        
        return IndexingHealthMetrics(
            kafka_consumer_lag=lag,
            indexing_lag_seconds=pipeline_metrics.lag_seconds,
            events_per_second=events_per_second,
            documents_per_second=self._calculate_rate(
                pipeline_metrics.documents_indexed
            ),
            error_rate=pipeline_metrics.errors / max(1, pipeline_metrics.events_received),
            dlq_size=await self._get_dlq_size(),
            batch_queue_depth=len(self.pipeline._batch),
            es_bulk_queue_size=bulk_queue,
        )
    
    async def check_health(self):
        """Check health and alert if needed."""
        
        metrics = await self.collect_metrics()
        
        # Check lag
        if metrics.indexing_lag_seconds > self.lag_critical_seconds:
            await self.alerter.critical(
                "Indexing lag critical",
                f"Lag: {metrics.indexing_lag_seconds:.0f}s"
            )
        elif metrics.indexing_lag_seconds > self.lag_warning_seconds:
            await self.alerter.warning(
                "Indexing lag warning",
                f"Lag: {metrics.indexing_lag_seconds:.0f}s"
            )
        
        # Check error rate
        if metrics.error_rate > self.error_rate_critical:
            await self.alerter.critical(
                "Indexing error rate critical",
                f"Error rate: {metrics.error_rate:.2%}"
            )
        elif metrics.error_rate > self.error_rate_warning:
            await self.alerter.warning(
                "Indexing error rate elevated",
                f"Error rate: {metrics.error_rate:.2%}"
            )
        
        return metrics
    
    async def _get_consumer_lag(self) -> int:
        """Get Kafka consumer lag."""
        
        consumer_group = self.pipeline.config.consumer_group
        
        # Get consumer group offsets
        offsets = await self.kafka.list_consumer_group_offsets(consumer_group)
        
        total_lag = 0
        for tp, offset_meta in offsets.items():
            # Get end offset for partition
            end_offsets = await self.kafka.list_offsets({tp: -1})
            end_offset = end_offsets[tp].offset
            
            lag = end_offset - offset_meta.offset
            total_lag += lag
        
        return total_lag
    
    async def _get_dlq_size(self) -> int:
        """Get dead letter queue size."""
        
        dlq_topic = "dlq.products"
        
        offsets = await self.kafka.list_offsets({
            (dlq_topic, 0): -1  # Get end offset
        })
        
        return offsets[(dlq_topic, 0)].offset
Indexing pipeline dashboard · Indexing health
Latency
  • Indexing lag: 45s
  • Kafka consumer lag: 1,234 messages
  • ES bulk latency: 120ms p99
Throughput
  • CDC events/sec: 850/sec
  • Docs indexed/sec: 820/sec
  • Bulk requests/sec: 12/sec
Errors
  • Error rate: 0.2%
  • DLQ size: 47 messages
  • ES rejections: 0/sec
Resources
  • Pipeline batch: 234 pending
  • ES bulk queue: 45 pending
  • Memory usage: 2.1 GB

6.2 Troubleshooting Common Issues#

python134 lines
# troubleshooting/runbooks.py

"""
Runbooks for common indexing issues.
"""

RUNBOOKS = {
    "high_indexing_lag": """
RUNBOOK: High Indexing Lag

SYMPTOMS:
- Indexing lag > 60 seconds
- Search results stale
- Kafka consumer lag growing

DIAGNOSIS:
1. Check Elasticsearch cluster health:
   GET /_cluster/health
   
2. Check for bulk rejections:
   GET /_cat/thread_pool/write?v
   
3. Check indexing pipeline logs:
   kubectl logs -l app=indexing-pipeline
   
4. Check Kafka consumer lag:
   kafka-consumer-groups.sh --bootstrap-server kafka:9092 --describe --group product-indexer

COMMON CAUSES:

1. Elasticsearch overloaded:
   - Check cluster CPU/memory
   - Check bulk queue size
   - Solution: Scale data nodes or reduce indexing rate

2. Slow document transformation:
   - Check enrichment query latency
   - Solution: Add caching, optimize queries

3. Network issues:
   - Check ES connection errors
   - Solution: Check network, increase timeouts

4. Kafka partition imbalance:
   - Check partition assignment
   - Solution: Rebalance consumers

RESOLUTION:
1. If ES overloaded: Reduce batch size, add rate limiting
2. If transformation slow: Enable caching, skip enrichment temporarily
3. If Kafka issues: Restart consumers, check broker health
    """,
    
    "indexing_errors": """
RUNBOOK: High Indexing Error Rate

SYMPTOMS:
- Error rate > 1%
- DLQ growing
- Documents missing from search

DIAGNOSIS:
1. Check error types in logs:
   grep -i error indexing-pipeline.log | tail -100
   
2. Sample DLQ messages:
   kafka-console-consumer.sh --bootstrap-server kafka:9092 --topic dlq.products --from-beginning --max-messages 10
   
3. Check ES mapping conflicts:
   GET /products/_mapping

COMMON CAUSES:

1. Mapping conflicts:
   - New field has wrong type
   - Solution: Fix source data or update mapping

2. Document too large:
   - Document exceeds http.max_content_length
   - Solution: Truncate fields, increase limit

3. Invalid data:
   - Null values in required fields
   - Solution: Add validation in transformer

RESOLUTION:
1. Fix root cause
2. Replay DLQ messages:
   kafka-console-consumer.sh --bootstrap-server kafka:9092 --topic dlq.products | 
   kafka-console-producer.sh --bootstrap-server kafka:9092 --topic ecommerce.public.products
    """,
    
    "stale_data": """
RUNBOOK: Stale Data in Search Results

SYMPTOMS:
- Recently updated products show old data
- Customer complaints about wrong prices

DIAGNOSIS:
1. Check indexing lag:
   GET /metrics/indexing_lag
   
2. Verify document in ES:
   GET /products/_doc/{product_id}
   
3. Check CDC events:
   kafka-console-consumer.sh --bootstrap-server kafka:9092 --topic ecommerce.public.products 
   --partition 0 --offset latest --max-messages 10

4. Check PostgreSQL for recent changes:
   SELECT * FROM products WHERE id = {id}

COMMON CAUSES:

1. Indexing lag:
   - Pipeline behind
   - Solution: See "high_indexing_lag" runbook

2. CDC not capturing changes:
   - Replication slot behind
   - Solution: Check Debezium connector status

3. Cache serving stale data:
   - Search result cache not invalidated
   - Solution: Reduce cache TTL, flush cache

RESOLUTION:
1. For individual document: make CDC re-emit the row
   UPDATE products SET updated_at = now() WHERE id = {id}
   
2. For many documents: Trigger bulk reindex
    """
}
Quick check2 questions · +5 XP each

Indexing observability

  1. Q1/2 Customers report wrong prices in search. Indexing lag is 5 seconds and the ES document already has the new price. Per the runbook, what's the likely cause?

    Answer & explanation

    Answer: A

    If the indexed document is correct and lag is low, the pipeline is fine. What's left is a cache serving results from before the update; the fix is a shorter TTL or a flush. The other causes would show up as high lag or missing updates.

  2. Q2/2 Indexing lag passes 5 minutes and the ES bulk thread-pool queue is full. What's the first move from the runbook?

    Answer & explanation

    Answer: B

    A full bulk queue means Elasticsearch is overloaded, so pushing harder makes it worse. Reduce indexing load or add capacity. More partitions or a re-snapshot just send more work to a cluster that can't keep up.

Part VI · Stage 6 of 6 · 5 min

Part VI: Real-World Patterns#

Chapter 7: Production Patterns#

7.1 Multi-Table CDC#

pythonMultiTableIndexer · 85 lines
# pipeline/multi_table_cdc.py

"""
Handling CDC from multiple related tables.

Challenge:
- products, product_variants, product_images are separate tables
- Change to any table should update the search document
- Need to denormalize into single document
"""

class MultiTableIndexer:
    """
    Handles CDC events from multiple tables.
    
    When any related table changes, re-indexes the parent product.
    """
    
    def __init__(self, es_client, db_pool, transformer):
        self.es = es_client
        self.db = db_pool
        self.transformer = transformer
        
        # Map table to parent relationship
        self.table_relationships = {
            "products": {"type": "parent", "id_field": "id"},
            "product_variants": {"type": "child", "parent_field": "product_id"},
            "product_images": {"type": "child", "parent_field": "product_id"},
            "product_tags": {"type": "child", "parent_field": "product_id"},
        }
    
    async def process_event(self, event: CDCEvent):
        """Process CDC event and determine what to reindex."""
        
        table = event.table
        relationship = self.table_relationships.get(table)
        
        if not relationship:
            return  # Unknown table
        
        # Determine product ID to reindex
        if relationship["type"] == "parent":
            product_id = event.document_id
        else:
            # Get parent ID from child record
            state = event.after or event.before
            product_id = str(state.get(relationship["parent_field"]))
        
        if not product_id:
            return
        
        # Fetch and reindex the complete product
        await self._reindex_product(product_id)
    
    async def _reindex_product(self, product_id: str):
        """Fetch full product data and reindex."""
        
        # Fetch complete product with all related data
        row = await self.db.fetchrow("""
            SELECT p.*,
                   COALESCE(json_agg(DISTINCT pv.*) FILTER (WHERE pv.id IS NOT NULL), '[]') as variants,
                   COALESCE(json_agg(DISTINCT pi.*) FILTER (WHERE pi.id IS NOT NULL), '[]') as images,
                   COALESCE(array_agg(DISTINCT t.name) FILTER (WHERE t.id IS NOT NULL), '{}') as tags
            FROM products p
            LEFT JOIN product_variants pv ON p.id = pv.product_id
            LEFT JOIN product_images pi ON p.id = pi.product_id
            LEFT JOIN product_tags pt ON p.id = pt.product_id
            LEFT JOIN tags t ON pt.tag_id = t.id
            WHERE p.id = $1
            GROUP BY p.id
        """, int(product_id))
        
        if not row:
            # Product deleted - remove from index
            await self.es.delete(index="products", id=product_id, ignore=[404])
            return
        
        # Transform and index
        document = await self.transformer.transform(dict(row))
        
        await self.es.index(
            index="products",
            id=product_id,
            document=document
        )

7.2 Handling Deletes Correctly#

pythonDeleteHandler · 83 lines
# pipeline/delete_handler.py
from datetime import datetime, timezone


"""
Proper handling of deleted documents.

Challenges:
- Soft deletes vs hard deletes
- Cascading deletes
- Tombstone events
"""

class DeleteHandler:
    """
    Handles document deletion in search index.
    """
    
    def __init__(self, es_client, config):
        self.es = es_client
        
        # Soft delete: Keep in index but mark as deleted
        # Hard delete: Remove from index
        self.soft_delete_enabled = config.get("soft_delete", False)
    
    async def handle_delete(self, event: CDCEvent):
        """Process delete event."""
        
        product_id = event.document_id
        
        if self.soft_delete_enabled:
            await self._soft_delete(product_id)
        else:
            await self._hard_delete(product_id)
    
    async def _hard_delete(self, product_id: str):
        """Remove document from index."""
        
        await self.es.delete(
            index="products",
            id=product_id,
            ignore=[404]  # Ignore if already deleted
        )
    
    async def _soft_delete(self, product_id: str):
        """Mark document as deleted but keep in index."""
        
        # Update document to mark as deleted
        await self.es.update(
            index="products",
            id=product_id,
            body={
                "doc": {
                    "is_deleted": True,
                    "deleted_at": datetime.now(timezone.utc).isoformat(),
                    "in_stock": False,  # Also mark out of stock
                }
            },
            ignore=[404]
        )
    
    async def purge_deleted(self, older_than_days: int = 30):
        """Permanently remove soft-deleted documents."""
        
        await self.es.delete_by_query(
            index="products",
            body={
                "query": {
                    "bool": {
                        "must": [
                            {"term": {"is_deleted": True}},
                            {
                                "range": {
                                    "deleted_at": {
                                        "lt": f"now-{older_than_days}d"
                                    }
                                }
                            }
                        ]
                    }
                }
            }
        )
Quick check2 questions · +5 XP each

Production patterns

  1. Q1/2 A row in product_variants changes (a new size is added). How does the multi-table indexer update search?

    Answer & explanation

    Answer: C

    The search document is denormalized, so a change to any child table means rebuilding the parent product's document. That's why the indexer maps each table to its parent key.

  2. Q2/2 Why might you soft-delete documents in the search index rather than removing them right away?

    Answer & explanation

    Answer: D

    Soft delete sets is_deleted: true and in_stock: false, so the document drops out of normal results but stays recoverable until purge_deleted removes it. Soft deletes make the index bigger until the purge, not smaller.

Summary#

What We Learned Today#

Day 2 summary: indexing pipeline
The problem
  • Dual-write is dangerous (inconsistency, failures)
  • Search index can get out of sync
  • Need reliable, ordered updates
  • Must handle bulk imports too
Change data capture
  • Read from database WAL (transaction log)
  • Debezium connector for PostgreSQL
  • Publish changes to Kafka
  • Maintains order, handles failures
  • Decouples app from search
Indexing pipeline
  • Consume CDC events from Kafka
  • Transform to search documents
  • Batch for efficiency
  • Handle errors with DLQ
  • Monitor lag and health
Bulk indexing
  • Rate limiting to protect cluster
  • Progress tracking
  • Resumability
  • Separate from real-time pipeline
Zero-downtime reindex
  • Index alias pattern
  • Create new index
  • Dual write during migration
  • Atomic alias switch
  • Clean up old index
Monitoring
  • Indexing lag (seconds behind)
  • Consumer lag (messages behind)
  • Error rate
  • DLQ size
  • ES bulk queue
◆ Key takeaways

Key Takeaways#

Indexing pipeline key takeaways
  1. Never dual-write — Use CDC instead — it's safer and more reliable
  2. Kafka provides durability — Events persisted, can replay, maintains order
  3. Batch for efficiency — Don't index one at a time — use bulk API
  4. Monitor lag — Know how far behind you are at all times
  5. Plan for reindex — Use aliases from day one — you WILL need to reindex

Default pattern: PostgreSQL → Debezium → Kafka → Indexing Pipeline → Elasticsearch

▲ Interview tip

Interview Tip#

Tomorrow's Preview#

Day 3: Query Processing & Relevance — "Finding the needle and ranking the haystack"

We'll cover:

  • How BM25 scoring works
  • Query vs Filter performance
  • Boosting strategies for business rules
  • Function score queries
  • Handling "no results" gracefully
Preview: the relevance problem

User searches: "apple"

Results returned:

# Result Score
1 Apple iPhone 14 Pro 8.5
2 Fresh Red Apples 8.3
3 Apple Watch Series 8 8.1
4 Apple Pie Recipe Book 7.9

Is this the right order? How do we know? How do we tune it?

Tomorrow: Day 3 — Query Processing & Relevance: Finding the needle and ranking the haystack

this lesson in shorts · 3

play them all →

One idea from this lesson in about a minute each, made for your phone.

End of Day 02 · Indexing Pipeline
Finish Day 2Stages clear as you read them (0/6 so far). Skipping ahead doesn't count: marking complete unlocks after ~8 more min of reading.

Go deeper · reading trail

all reading →

Real write-ups from the teams that built these systems. Each opens on the publisher's site in a new tab.

YelpBlog post · ~19 min

Fast Order Search Using Yelp's Data Pipeline and Elasticsearch

Dmitriy Kunitskiy · 2018
What
How Yelp made tens of millions of orders searchable without loading MySQL.
Key idea
Its real-time data pipeline streams changes from MySQL into Elasticsearch, so search reads a denormalised copy while MySQL stays the source of truth.
Why here
The CDC-driven indexing pipeline of this lesson, including keeping the index in sync and backfilling.
taught here: Change data capture to the index
ElasticBlog post · ~9 min

Changing Mapping with Zero Downtime

Clinton Gormley · 2013
What
The classic recipe for changing an Elasticsearch mapping on a live index.
Key idea
Point the application at an alias, build a new index with the new mapping, reindex into it, then swap the alias atomically.
Why here
The reindexing problem of this lesson; the post's editor note points to the newer Reindex API for the same flow.
taught here: Reindexing with aliases
GitHubBlog post · ~15 min

The technology behind GitHub's new code search

Timothy Clem · 2023
What
How GitHub built Blackbird, a search engine in Rust for all public code.
Key idea
Kafka events trigger crawlers, content is sharded by Git blob ID so duplicate files are indexed once, and ngram indexes support substring search.
Why here
An indexing pipeline far beyond Elasticsearch defaults: dedupe before you index and choose the shard key for even load.
taught here: Indexing at scale

New lessons by email

New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.

Discussion · …

Public: everyone reading this lesson sees comments. Be kind and keep it on the material.

Sign in to ask a question or answer one. Reading stays open to everyone.
loading the discussion…