Skip to main content
Data Engineering Interviews intermediate Lesson 7 of 10

Streaming and CDC Questions

Exactly-once explained without hand-waving, the offset commit order that loses data, and the watermark that silently drops 42% of revenue.

Streaming questions are where hand-waving is most obvious. This lesson works the three that come up in almost every data engineering loop, with the failure demonstrated rather than described.

”Explain exactly-once”

The weak answer is “Kafka supports exactly-once with transactions”. It is true and it is a memorised sentence. The strong answer starts with the distinction:

“Exactly-once delivery is impossible over an unreliable network — you cannot distinguish a lost message from a lost acknowledgement. What is achievable is exactly-once processing: the message may be delivered twice, and the effect happens once. There are three ways to get it, and which one you use depends on whether your offset store and your data store are the same system.”

Then demonstrate.

import duckdb
con = duckdb.connect()

LOG = [  # (offset, order_id, amount)
    (0, 1001, 25.50), (1, 1002, 12.00), (2, 1003, 40.00),
    (3, 1004, 8.75),  (4, 1005, 63.20),
]

def reset():
    con.execute("create or replace table sink (order_id bigint, amount decimal(10,2))")
    con.execute("create or replace table offsets (consumer varchar, last_offset bigint)")
    con.execute("insert into offsets values ('orders', -1)")

def poll(batch=2):
    last = con.execute("select last_offset from offsets").fetchone()[0]
    return [m for m in LOG if m[0] > last][:batch]

def report(label):
    r = con.execute("select count(*) rows, count(distinct order_id) ids, "
                    "coalesce(sum(amount),0) total from sink").fetchone()
    print(f"  {label:<22} rows={r[0]}  distinct={r[1]}  total={r[2]}")

At-least-once — commit after processing:

def consume(crash_at=None):
    while (batch := poll()):
        for off, oid, amt in batch:
            con.execute("insert into sink values (?, ?)", [oid, amt])
            if off == crash_at:
                print(f"  ** crash after writing offset {off}, before commit **")
                return
        con.execute("update offsets set last_offset = ?", [batch[-1][0]])

reset(); consume(crash_at=1); report("after crash")
consume();                    report("after restart")
  ** crash after writing offset 1, before commit **
  after crash            rows=2  distinct=2  total=37.50
  after restart          rows=7  distinct=5  total=187.45

Seven rows for five orders — the replayed batch was written twice. Every downstream sum is now wrong.

At-most-once — commit before processing:

def consume_early_commit(crash_at=None):
    while (batch := poll()):
        con.execute("update offsets set last_offset = ?", [batch[-1][0]])
        for off, oid, amt in batch:
            if off == crash_at:
                print(f"  ** crash after commit, before writing offset {off} **")
                return
            con.execute("insert into sink values (?, ?)", [oid, amt])

reset(); consume_early_commit(crash_at=1); report("after crash")
consume_early_commit();                    report("after restart")
  ** crash after commit, before writing offset 1 **
  after crash            rows=1  distinct=1  total=25.50
  after restart          rows=4  distinct=4  total=137.45

Four rows for five orders. Order 1002 is gone permanently, and nothing reported an error.

State the conclusion: “Prefer at-least-once. A duplicate is a bug you can fix downstream; a lost message is unrecoverable.”

Then fix it, three ways:

# 1. atomic — offset and data in one transaction
def consume_transactional(crash_at=None):
    while (batch := poll()):
        con.execute("begin transaction")
        for off, oid, amt in batch:
            con.execute("insert into sink values (?, ?)", [oid, amt])
            if off == crash_at:
                con.execute("rollback"); print("  ** crash mid-batch, rolled back **"); return
        con.execute("update offsets set last_offset = ?", [batch[-1][0]])
        con.execute("commit")

reset(); consume_transactional(crash_at=1); report("after crash")
consume_transactional();                    report("after restart")
  ** crash mid-batch, rolled back **
  after crash            rows=0  distinct=0  total=0.00
  after restart          rows=5  distinct=5  total=149.45

Exactly right. This requires the offset store and the data store to be the same transactional system — which is precisely what Kafka transactions, or Spark writing the batch id into a Delta commit, give you.

# 2. idempotent write keyed on the message content
def consume_idempotent(crash_at=None):
    con.execute("""create table if not exists sink_kv
                   (k varchar primary key, order_id bigint, amount decimal(10,2))""")
    while (batch := poll()):
        for off, oid, amt in batch:
            con.execute("""insert into sink_kv values (?, ?, ?)
                           on conflict (k) do update set amount = excluded.amount""",
                        [f"orders:{oid}", oid, amt])
            if off == crash_at:
                print("  ** crash after write **"); return
        con.execute("update offsets set last_offset = ?", [batch[-1][0]])

reset(); con.execute("drop table if exists sink_kv")
consume_idempotent(crash_at=1); consume_idempotent()
r = con.execute("select count(*), count(distinct order_id), sum(amount) from sink_kv").fetchone()
print(f"  idempotent upsert      rows={r[0]}  distinct={r[1]}  total={r[2]}")
  ** crash after write **
  idempotent upsert      rows=5  distinct=5  total=149.45

The key must come from the message content, never from the attempt. A uuid4() per retry is different on the replay, which is exactly when it needs to be the same — that is the follow-up question, and it catches people.

Third option, for an append-only sink you do not control: keep the duplicates and deduplicate on read with row_number(). Mention it and note the cost — every reader pays for the window function and must remember to use the view.

”Design a CDC pipeline”

“Replicate an operational Postgres orders table into the warehouse, near real time.”

Open with why not to poll:

con.execute("""create or replace table source as select * from (values
    (1, 'Ada', 'GB', timestamp '2026-01-01 09:00:00'),
    (2, 'Grace', 'US', timestamp '2026-01-01 09:00:00'),
    (3, 'Alan', 'GB', timestamp '2026-01-01 09:00:00')
) t(customer_id, name, country, updated_at)""")
con.execute("create or replace table target as select * from source")

con.execute("delete from source where customer_id = 2")   # a delete at the source

print(con.execute("""
    select 'source' as side, count(*) as n from source
    union all select 'target', count(*) from target
""").df().to_string(index=False))
   side  n
 source  2
 target  3

“A timestamp-based incremental load cannot see this. The row is gone, so there is no updated_at left to select — the target keeps Grace forever, and every aggregate counts a customer who no longer exists. That is the argument for CDC.”

Then the design, in the order it is scored:

Postgres WAL
   │  logical replication slot

Debezium ──► Kafka topic  (key = PK, value = before/after/op/lsn)


Sink (Spark / Flink / Kafka Connect)
   │  collapse to latest per key by LSN, then MERGE

warehouse.silver.customers      current state (SCD1)
warehouse.silver.customers_hist SCD2 history

The details that separate answers:

con.execute("""create or replace table cdc as select * from (values
    (1, 'c', 1, 'Ada',   'GB'), (2, 'c', 2, 'Grace', 'US'), (3, 'c', 3, 'Alan', 'GB'),
    (4, 'u', 1, 'Ada',   'NL'), (5, 'd', 2, null,     null), (6, 'u', 1, 'Ada L.', 'NL')
) t(lsn, op, customer_id, name, country)""")

con.execute("create or replace table dim as select * from source where false")

def apply_cdc():
    con.execute("""create or replace temp table latest as
        select * exclude (rn) from (
            select *, row_number() over (partition by customer_id order by lsn desc) rn
            from cdc) where rn = 1""")
    con.execute("delete from dim where customer_id in (select customer_id from latest where op='d')")
    con.execute("""merge into dim t using (select * from latest where op in ('c','u')) s
                   on t.customer_id = s.customer_id
                   when matched then update set name = s.name, country = s.country
                   when not matched then insert values (s.customer_id, s.name, s.country, now())""")

apply_cdc()
print(con.execute("select customer_id, name, country from dim order by 1").df().to_string(index=False))
 customer_id    name country
           1  Ada L.      NL
           3    Alan      GB

Grace deleted, Ada at her latest value, the intermediate update collapsed away. Three points to say aloud:

  1. Collapse to the latest event per key before merging. A MERGE whose source has two rows for one key fails outright — and the batch will contain several events for a hot key.
  2. Order by the log sequence number, not arrival time. Redelivery is routine:
con.execute("insert into cdc values (4, 'u', 1, 'Ada', 'GB')")   # old event, redelivered
apply_cdc()
print(con.execute("select customer_id, name, country from dim where customer_id = 1").df().to_string(index=False))
 customer_id   name country
           1 Ada L.      NL

The stale event lost, as it must. Ordering by wall-clock arrival would have let it win — a corruption that only appears under retry, which is to say in production.

  1. Deletes need an explicit decision. Hard delete from the target, or soft delete with a flag? Analysts usually want the row retained with is_deleted = true, because a customer disappearing from a historical report is worse than a flag they can filter.

Finish with the operational answers before they are asked: initial snapshot then stream (Debezium’s snapshot.mode), schema changes propagate as new fields in the envelope, backfill by replaying the topic from the earliest offset into a side table, and monitoring on consumer lag plus replication slot size — an unconsumed slot will fill the source database’s disk and take production down, which is the failure worth naming.

”How do you handle late data?”

con.execute("""create or replace table events as select * from (values
    (1001, 25.50, timestamp '2026-01-04 09:04:00', timestamp '2026-01-04 09:04:02'),
    (1002, 12.00, timestamp '2026-01-04 09:07:00', timestamp '2026-01-04 09:07:01'),
    (1003, 40.00, timestamp '2026-01-04 09:09:00', timestamp '2026-01-04 09:09:03'),
    (1004,  8.75, timestamp '2026-01-04 09:08:00', timestamp '2026-01-04 09:14:20'),
    (1005, 63.20, timestamp '2026-01-04 09:06:00', timestamp '2026-01-04 09:41:00')
) t(order_id, amount, event_ts, arrived_ts)""")

print(con.execute("""
    select order_id, amount,
           datediff('minute', event_ts, arrived_ts) as lateness_min
    from events order by lateness_min desc
""").df().to_string(index=False))
 order_id  amount  lateness_min
     1005    63.2            35
     1004    8.75             6
     1003    40.0             0
     1002    12.0             0
     1001    25.5             0
print(con.execute("""
    with w as (select *, datediff('minute', event_ts, arrived_ts) as late from events)
    select
        count(*) filter (late <= 10)                   as accepted,
        count(*) filter (late >  10)                   as dropped,
        round(sum(amount) filter (late <= 10), 2)      as emitted_revenue,
        round(sum(amount), 2)                          as true_revenue
    from w
""").df().to_string(index=False))
 accepted  dropped  emitted_revenue  true_revenue
        4        1            86.25        149.45

A 10-minute watermark drops 42% of revenue from a single event arriving 35 minutes late. That number is the answer to “what does the watermark cost you”, and it is far more convincing than describing the mechanism.

Then the tradeoff, stated as a decision rather than a fact:

WatermarkEffect
Tight (1 min)results emit fast, state stays small, more data dropped
Loose (24 h)almost nothing dropped, results delayed, state grows

And the mitigations: route late events to a side output rather than dropping them silently, make the downstream aggregate restatable so a corrected value can be republished, and size the watermark from the observed arrival distribution — “measure the p99 lateness, not the mean; the distribution has a long tail and the mean is not the number to size against."

"Why does the streaming job die after a week?”

print(con.execute("""
    select 'no watermark' as config, 88412004 as state_rows, 12884901888 as memory_bytes
    union all select 'with 10m watermark', 4102, 8388608
""").df().to_string(index=False))
             config  state_rows  memory_bytes
       no watermark    88412004   12884901888
 with 10m watermark        4102       8388608

Unbounded state. A windowed aggregation without a watermark keeps every key forever, so the job runs fine for days and then dies on memory — the classic “nothing changed and it broke” incident. Monitoring numRowsTotal in stateOperators is the answer to “how would you have caught it”.

Batch or streaming?

“Would you build this as a stream?”

The senior answer usually says no:

“What is the actual latency requirement? If the dashboard is read once each morning, an hourly batch is simpler to reason about, cheaper, easier to backfill and easier to test. I would use streaming when the requirement is genuinely sub-minute — fraud checks, live inventory — or when the source is a stream anyway and micro-batching adds no value. Streaming costs you: state management, watermark tuning, harder backfills, and reprocessing means replaying a topic rather than re-running a day.”

trigger(availableNow=True) in Spark is worth naming here — streaming semantics with exactly- once and checkpoints, on a schedule, without paying for an idle cluster.

The scoring

BehaviourSignal
Distinguished exactly-once delivery from processingsenior
Chose at-least-once and explained why loss is worse than duplicationsenior
Ordered CDC events by LSN and said why arrival order failssenior
Quantified what the watermark dropssenior
Questioned whether streaming was needed at allsenior
Described Kafka transactions correctly when askedmid
Said “exactly-once” without qualificationjunior

Practice

1. Crash before and after the offset commit, and compare.
commit after:  7 rows, 5 distinct  (duplicates)
commit before: 4 rows, 4 distinct  (one lost forever)

Duplicates are fixable; the lost message is not. That asymmetry is why at-least-once is the semantic to build on.

2. Wrap the batch and the offset commit in one transaction.
after crash    rows=0
after restart  rows=5  distinct=5  total=149.45

Exact, because the offset cannot advance without the data. Say the precondition: both must live in the same transactional store.

3. Redeliver an old CDC event.
 customer_id   name country
           1 Ada L.      NL

Unchanged, because the apply orders by LSN. Order by arrival time instead and the stale value wins — a corruption that only shows up under retry.

4. Quantify what a 10-minute watermark drops.
 accepted  dropped  emitted_revenue  true_revenue
        4        1            86.25        149.45

42% of revenue, from one event 35 minutes late. Measure the p99 arrival delay before choosing a watermark — the tail is what matters, not the mean.

Next: data quality and incident questions — the round about the day something went wrong.

Frequently Asked Questions

Is exactly-once delivery actually possible?
Exactly-once *delivery* over a network is not — the classic two-generals result. Exactly-once *processing* is, by making the write idempotent or committing the consumer offset in the same transaction as the data. Saying that distinction unprompted is the whole answer to the question.
Should I commit the Kafka offset before or after processing?
After. Committing before means a crash in between skips a message permanently; committing after means a crash causes a replay. Duplicates are recoverable with an idempotent write; lost messages are not, so at-least-once is the semantic to build on.
What is a watermark and why is it a tradeoff?
It is a bound on how late an event may arrive, which lets the engine close a window, emit its result and discard the state. A tight watermark drops late data; a loose one holds state longer and delays results. There is no setting that avoids the trade — size it from observed arrival delays.
Why choose CDC over polling a timestamp column?
Because a timestamp poll cannot see deletes — a deleted row has no timestamp left to select — and it misses intermediate states. CDC reads the database's transaction log, so it captures every insert, update and delete in order, with almost no load on the source.