Design problems
Platform & infrastructureMediumSDE-2 30 min

Design a search indexing pipeline (CDC to Elasticsearch)

Asked at
AmazonFlipkartMeeshoMyntraNykaaSwiggy

The interviewer asks

“Your e-commerce catalogue lives in Postgres: products, prices, stock and sellers in separate tables. Search runs on Elasticsearch. Design the pipeline that keeps the search index in sync with the database: changes should show up in search within seconds, nothing should be missed, and you must be able to rebuild the whole index without downtime.”

What a strong answer looks like

A strong answer rejects dual writes from the application and uses change data capture: Debezium reads the Postgres write-ahead log through a logical replication slot and publishes row changes to Kafka, keyed by product id. An indexer service turns row changes into full search documents (denormalising product, price, stock, seller and category), batches them into Elasticsearch bulk requests, and uses external versioning (the commit LSN) so a late, older change can never overwrite a newer one. Deletes become tombstones. Full rebuilds use a second index built from a snapshot plus the change stream, then an atomic alias swap. The deep parts are ordering and correctness (per-key partitioning, versions, idempotent upserts), the replication-slot disk risk, backfill without losing concurrent changes, and continuous verification that the index matches the database.

Concepts to mention

Change data capture from the WAL (Debezium, logical replication slot)No dual writes from the applicationPartition by primary key for per-entity orderingExternal versioning with LSN to reject stale writesBulk API with per-item error handlingDenormalised documents and enrichment stateSnapshot plus stream catch-up for backfillBlue-green index with atomic alias swapReplication slot WAL retention riskDrift detection and repair

1 Requirements

Functional

  • Any insert, update or delete in catalogue tables is reflected in the search index
  • Search documents are denormalised: product fields + current price + in-stock flag + seller rating + category path
  • Full reindex (new mapping, new analyser, new field) with zero search downtime
  • Backfill a new field for 100M existing documents while live changes continue
  • Detect and repair drift between Postgres and Elasticsearch
  • Pipeline lag and error visibility per table and per index

Non-functional

  • Freshness: p95 under 5 s from DB commit to searchable; price and stock under 2 s during sales
  • No lost changes and no stale overwrites (an old price must never replace a new one)
  • Scale: 100M products, 5k changes/s average, 40k/s peak during a sale price push
  • Full reindex of 100M documents in under 6 hours
  • Pipeline outages delay freshness but never require a manual full rebuild to recover

Out of scope (say it out loud)

  • Query understanding and ranking (covered in product search)
  • Personalised results
  • Indexing user-generated reviews text

2 Estimates

Change rate~5k/s average, 40k/s peakMostly price and stock: 100M SKUs with ~4 changes/day each ≈ 400M/day ≈ 4.6k/s; a sale-start price push changes ~20M SKUs in ~10 minutes ≈ 33k/s
Index size~400 GB primary, ~1.2 TB with replicas100M docs × ~4 KB indexed size; 2 replicas for read throughput and availability
Bulk indexing throughput needed~40k docs/s at peakPartial updates are cheaper than full docs, but each still rewrites the doc in Lucene; ~2-4k docs/s per data node → 15-20 data nodes for indexing headroom
Full reindex~3-4 hours100M docs ÷ ~10k docs/s sustained with replicas off and refresh disabled ≈ 2.8 h, plus enrichment reads
Kafka retention7 days, ~3 TB400M change events/day × ~1 KB × 7 days ≈ 2.8 TB; long enough to replay after any pipeline outage
WAL retained if pipeline stops~50-100 GB/hourAt ~20 MB/s of WAL during busy hours, a stalled replication slot fills disk in hours, not days

3 API

EndpointWhat it does
POST/admin/reindexStart a rebuild into a new index version {target_mapping, source: snapshot}; returns job id
GET/admin/reindex/{job_id}Progress: docs indexed, catch-up lag, ETA
POST/admin/alias/swapAtomically point alias products to the new index after validation
POST/admin/reindex/idsRe-index specific product ids (repair tool)
GET/admin/pipeline/lagReplication slot lag, Kafka consumer lag, indexing error rate per index
POST_bulk (Elasticsearch)Indexer writes batches of index/update/delete actions with version_type=external

4 Data model

Catalogue tablesPostgres
products(id, title, brand, category_id, attrs jsonb, updated_at)prices(product_id, mrp, selling_price)inventory(product_id, warehouse_id, qty)sellers(id, rating)

Source of truth. Logical replication (wal_level=logical) with a publication on these tables. REPLICA IDENTITY set so deletes carry the key.

Change topicsKafka
catalog.productscatalog.pricescatalog.inventorycatalog.sellers; key = product_id (or seller_id); value = before/after rowopsource LSNcommit ts

Debezium output. Same key → same partition → per-product order preserved.

Enrichment stateRocksDB (Kafka Streams / Flink state) or Redis
product_id → latest product rowpricestock flagseller_id; seller_id → rating

Lets the indexer build full documents without querying Postgres on every change.

Search indexElasticsearch
products_v{N}: idtitle (multi-language analysers)brandcategory_pathattrspricein_stockseller_ratingpopularity; _version = source LSN

Alias products points to the live version. ~20 shards of ~20 GB each.

Reindex jobsPostgres
job_idtarget_indexsnapshot_lsndocs_donestatusstarted_at

Tracks backfill progress and the LSN where catch-up begins.

Dead lettersKafka topic + S3
change eventerrorattempts

Mapping errors (e.g. text in a numeric field) are parked, alerted and replayed after the fix.

5 Architecture

CLIENTSSERVICESASYNCDATAnormal writesWAL via repl. slotrow changesjoin latest rows_bulk, ext. versionread snapshotsnapshot exportbuild products_v2alias: productsCatalogue / sellerappsShoppersCatalogue serviceSearch APIDebezium (KafkaConnect)Kafka change topicsIndexer (enrich +bulk)Backfill / reindexjobPostgres (catalogue)Enrichment statestoreElasticsearch clusterS3 snapshot export
ClientEdge / gatewayService / workerQueue / streamCacheDatabase / storageAI / modelThird partyrequestasync (event / queue)

Hover a box to highlight its connections.

6 Request flows

A price change becomes searchable

  1. 1Seller updates price; catalogue service commits UPDATE prices in Postgres
  2. 2Debezium reads the change from the replication slot and publishes {before, after, lsn} to catalog.prices with key product_id
  3. 3Indexer consumes it, updates the price in its state store and builds the full document (or a partial update with price fields)
  4. 4Indexer adds the action to a bulk batch (flushed every 5 MB or 200 ms) with version = LSN, version_type=external
  5. 5Elasticsearch applies it if the LSN is higher than the stored version; refresh_interval of 1 s makes it searchable
  6. 6Indexer commits Kafka offsets only after the bulk response; item-level failures are retried or sent to the dead-letter topic

Zero-downtime reindex for a new analyser

  1. 1Create products_v2 with the new mapping, replicas 0 and refresh_interval -1 for fast loading
  2. 2Record the current LSN, export a consistent snapshot of the catalogue tables (or use Debezium incremental snapshot)
  3. 3Backfill job bulk-loads all 100M documents with version = snapshot LSN
  4. 4A second indexer consumer group writes the live change stream into products_v2 from the recorded point; external versions make overlap between backfill and live changes safe
  5. 5When catch-up lag is near zero: set replicas to 2, refresh to 1 s, run validation (doc counts, sample queries, golden-query relevance checks)
  6. 6Atomically move alias products from v1 to v2; keep v1 for a day for rollback

A product is deleted

  1. 1Catalogue service deletes the row (or sets is_deleted)
  2. 2Debezium emits a delete event followed by a Kafka tombstone for the key
  3. 3Indexer issues a delete with version = LSN so an older delayed update cannot resurrect the product

7 Deep dives

Why CDC instead of dual writes

The obvious design is for the catalogue service to write to Postgres and then call Elasticsearch. It fails in two ways. If the second call fails, or the process dies between the two, the index silently misses the change. And two concurrent requests can write Postgres in order A then B but reach Elasticsearch as B then A, leaving a stale price forever. Every team that writes to the database directly (bulk scripts, admin tools, other services) must also remember to update search. CDC fixes all three: the write-ahead log already contains every committed change, in commit order, regardless of who wrote it. Debezium connects to a logical replication slot (pgoutput plugin), turns WAL records into row-level events with before and after images and the LSN, and publishes them to Kafka keyed by primary key. The application is unaware of search. The cost is operational: Kafka Connect to run, replication slots to monitor, and schema changes in the source that flow into the pipeline. An outbox table is a good alternative when you want curated domain events rather than raw row changes.

Ordering, versions and idempotent writes

Correctness rests on three rules. First, partition by product id, so all changes for one product go to one Kafka partition and one indexer thread, in commit order. Second, make every write idempotent and version-checked: index with version_type=external and version = the source LSN (monotonic across the whole database). Elasticsearch rejects any write whose version is not higher than the stored one, so replays after a crash, overlap between backfill and live changes, or a delayed retry can never put an older state back. Treat a version conflict response as success. Third, commit Kafka offsets only after the bulk request succeeds for those items. Bulk responses are per item: a 200 for the batch can still contain failures, so inspect every item, retry 429 (queue full) with backoff, and send mapping errors (400) to a dead-letter topic rather than blocking the partition. Deletes must also carry a version. Elasticsearch keeps deleted-document versions only for a limited time (index.gc_deletes, 60 s by default), so for long delays a soft-delete flag in the document is safer than a hard delete.

Denormalisation: building one document from many tables

Search wants one flat document per product, but the database has product, price, inventory per warehouse, seller and category in separate tables. When a seller's rating changes, thousands of product documents must change. Options: (a) on each change, query Postgres for the full joined row and index it. Simple and always fresh, but at 40k changes/s it hammers the database, so point it at a read replica and batch lookups. (b) A stream processor (Kafka Streams or Flink) keeps the latest row of each table in local state and joins changes as they arrive, re-emitting the full document; no database load, but the state must be rebuilt on deploy and repartitioning. (c) Partial updates: a price change only updates price fields. For a 1-to-many fan-out (seller rating to 50k products), use a separate path: look up the product ids for that seller and enqueue partial updates at a capped rate, or store seller rating in a separate index and join at query time. Reduce stock to a boolean in_stock per region; indexing every quantity change is wasteful.

Replication slots and backfills: the operational traps

A logical replication slot makes Postgres keep WAL until the consumer confirms it. If Debezium stops for a weekend, WAL piles up and can fill the primary's disk, taking the catalogue down for a search problem. Set max_slot_wal_keep_size (Postgres 13+) so the slot is invalidated before the disk fills, alert when slot lag passes a few GB, and accept that an invalidated slot means a resnapshot. Slots also live on the primary; after a failover, older Postgres versions lose them (Postgres 17 adds failover slots). Backfills are the other trap: dumping the table and then starting the stream loses changes made during the dump. Either record the LSN before the snapshot and replay from it, relying on versions to resolve overlap, or use Debezium incremental snapshots, which interleave chunked table reads with the live stream using watermarks (the DBLog approach from Netflix). Finally, run a drift checker: sample product ids nightly (or compare per-category counts and checksums) between Postgres and Elasticsearch, and re-index mismatches.

8 Trade-offs

Raw CDC vs outbox events

Raw CDC captures everything with no application changes, but couples the index to table structure. An outbox lets the service publish clean 'ProductChanged' events with a stable contract. Large teams often use CDC on the outbox table to get both.

Freshness vs indexing cost

A 1 s refresh_interval gives near-real-time search but creates many small segments and merge load. During a sale price push, a 5 s refresh roughly halves indexing cost. Price shown on the product page should come from the price service anyway, so search can be a little behind.

Full documents vs partial updates

Full documents are simpler to reason about and self-heal any drift for that product. Partial updates are cheaper on the source but leave stale fields if one path is missed. A good default: partial updates for price/stock, full rebuild of the document for everything else.

Kafka in the middle vs direct connector

Debezium can write to Elasticsearch through a sink connector with little code. A custom indexer costs more to build but handles enrichment, versioning, dead letters and multiple target indexes properly. Kafka also lets other consumers (cache invalidation, recommendations) reuse the same change stream.

9 Bottlenecks

Sale-start price push

20M updates in 10 minutes. Increase bulk size, lengthen refresh_interval temporarily, coalesce multiple changes for the same product inside a batch (keep only the latest), and pre-scale indexing nodes before the sale.

Hot fan-out updates

A category rename touches 5M documents. Run it as a throttled background job with its own consumer group so live price updates are not queued behind it.

Single Debezium task

A Postgres connector reads one slot serially. Split tables across multiple publications and connectors if one cannot keep up, and keep large blob columns out of the publication.

10 Failure handling

Elasticsearch cluster degraded

Indexer gets 429s or timeouts; it backs off and stops committing offsets. Kafka holds the changes (7-day retention), and the indexer catches up when the cluster recovers. Search serves slightly stale results.

Debezium down

No events flow; slot lag grows and alerts fire. On restart it resumes from the slot position with no loss. If the slot was invalidated, run an incremental snapshot to resync.

Bad mapping deployed

Documents start failing with mapping errors and go to the dead-letter topic. Roll back the indexer or create a fixed index version, then replay dead letters.

Postgres failover

With failover slots (Postgres 17 or managed equivalents) the slot continues on the new primary. Without them, recreate the slot and run an incremental snapshot, relying on versions to absorb overlap.

11 What impresses

  • Explaining the dual-write race with a concrete stale-price example
  • Using LSN as an external version so replays and overlap are safe
  • Knowing a stalled replication slot can fill the primary's disk, and max_slot_wal_keep_size
  • Describing incremental snapshots with watermarks (DBLog)
  • Handling 1-to-many fan-out updates separately from per-product changes
  • Validating a new index with golden queries before the alias swap

12 Mistakes

  • Writing to Postgres and Elasticsearch from the same request handler
  • Treating a 200 bulk response as full success without checking item errors
  • No versioning, so retries and replays put old data back
  • Reindexing in place with downtime or a broken mapping
  • Dumping the table then starting CDC, losing changes made during the dump
  • Polling updated_at columns every minute, which misses deletes and same-timestamp updates

13 Follow-ups

Q1How would you add a field that needs data from an external service, not Postgres?
Q2How do you keep search and the product page consistent on price?
Q3How do you index 12 Indian languages for the same product?
Q4What happens when a column is renamed in Postgres?
Q5How would you rebuild only the electronics category?
Q6How would you add vector embeddings to the documents?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free