Design a search indexing pipeline (CDC to Elasticsearch)
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
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
3 API
| Endpoint | What it does |
|---|---|
POST/admin/reindex | Start 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/swap | Atomically point alias products to the new index after validation |
POST/admin/reindex/ids | Re-index specific product ids (repair tool) |
GET/admin/pipeline/lag | Replication 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 tablesPostgresproducts(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 topicsKafkacatalog.productscatalog.pricescatalog.inventorycatalog.sellers; key = product_id (or seller_id); value = before/after rowopsource LSNcommit tsDebezium output. Same key → same partition → per-product order preserved.
Enrichment stateRocksDB (Kafka Streams / Flink state) or Redisproduct_id → latest product rowpricestock flagseller_id; seller_id → ratingLets the indexer build full documents without querying Postgres on every change.
Search indexElasticsearchproducts_v{N}: idtitle (multi-language analysers)brandcategory_pathattrspricein_stockseller_ratingpopularity; _version = source LSNAlias products points to the live version. ~20 shards of ~20 GB each.
Reindex jobsPostgresjob_idtarget_indexsnapshot_lsndocs_donestatusstarted_atTracks backfill progress and the LSN where catch-up begins.
Dead lettersKafka topic + S3change eventerrorattemptsMapping errors (e.g. text in a numeric field) are parked, alerted and replayed after the fix.
5 Architecture
Hover a box to highlight its connections.
6 Request flows
A price change becomes searchable
- 1Seller updates price; catalogue service commits UPDATE prices in Postgres
- 2Debezium reads the change from the replication slot and publishes {before, after, lsn} to catalog.prices with key product_id
- 3Indexer consumes it, updates the price in its state store and builds the full document (or a partial update with price fields)
- 4Indexer adds the action to a bulk batch (flushed every 5 MB or 200 ms) with version = LSN, version_type=external
- 5Elasticsearch applies it if the LSN is higher than the stored version; refresh_interval of 1 s makes it searchable
- 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
- 1Create products_v2 with the new mapping, replicas 0 and refresh_interval -1 for fast loading
- 2Record the current LSN, export a consistent snapshot of the catalogue tables (or use Debezium incremental snapshot)
- 3Backfill job bulk-loads all 100M documents with version = snapshot LSN
- 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
- 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)
- 6Atomically move alias products from v1 to v2; keep v1 for a day for rollback
A product is deleted
- 1Catalogue service deletes the row (or sets is_deleted)
- 2Debezium emits a delete event followed by a Kafka tombstone for the key
- 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
Lessons this design uses
Practice questions
- Users search orders by product name, and the query uses LIKE '%shoe%' on Postgres. It is slow and getting slower. What do you do?
- Product data lives in Postgres and search runs on Elasticsearch. The current code writes to both, and they drift apart. How would you keep them in sync?
- Your product service writes to Postgres and then to Elasticsearch in the same request. Sometimes search shows wrong prices or products that were deleted. Why, and how would you redesign the sync?
- Your order service writes to Postgres and then writes the same order to Elasticsearch so support agents can search it. Sometimes orders are missing or stale in search. Why, and how would you fix it?
- You need to change the analyzer on a 400 million document Elasticsearch index to support Hindi and Hinglish search. You cannot change the analyzer in place. How do you roll it out without downtime?
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.