Skip to main content

Ordering and Checkpoints: Out-of-Order Arrival and the Bounded Apply

Level 9: Streaming & Change Data Capturehard32 minper-partition orderingpartition key choiceout-of-order detectionLAGlog-position applyINSERT ... ON CONFLICT DO UPDATEtombstone deleteapply watermark checkpoint

State what Kafka does and does not promise about order, catch inverted arrivals with LAG, and write the lsn-ordered, checkpoint-bounded apply that survives shuffled input and a rerun.

What the log actually promises

Kafka guarantees order within a partition. That is the whole promise. There is no global ordering across a topic, and there never will be, because partitions are the unit of parallelism and a total order across them would mean a single consumer.

So ordering is a consequence of the partition key you choose. Key the change events by the row's primary key and every change for that row hashes to the same partition and arrives in log order, forever. Key them by anything else, and the events for one row scatter across partitions whose consumers run at different speeds. Then a later change can be applied before an earlier one, and the row ends up holding a stale value with no error anywhere.

The changelog in this lesson came off a connector keyed by warehouse_id. Orders move between warehouses, so one order's events landed on different partitions, and three keys arrived inverted.

Table
Arrival order is an accident of partitioning. The lsn column is the truth, and the checkpoint is a line drawn on it, not on arrival_seq.
arrival_seqorder_idlsnafter_statusreading
310421207shippedarrived first
510421201packedOUT OF ORDER: lower lsn arrived later
1210431210shippedcheckpoint line: apply stops here, and it arrived 12th
1910421217deliveredbeyond the checkpoint, next run's work
Arrival order is an accident of partitioning. The lsn column is the truth, and the checkpoint is a line drawn on it, not on arrival_seq.

Detecting it before it bites

Walk each key in arrival order and compare each event's lsn to the previous one's. LAG(lsn) OVER (PARTITION BY order_id ORDER BY arrival_seq) gives you that previous value; wherever the current lsn is SMALLER, the stream handed you the past after it handed you the future. That query is worth keeping as a monitor, because it is the only cheap way to notice a bad partition key before someone notices a wrong dashboard.

The fix at the sink is never "hope". Sort by lsn at apply time. Arrival order is an accident; the log position is a fact.

The apply, in three moves

You have built these three before, in sql-l5-cdc-changelog-apply, on a changelog ordered by a plain version number. They are recalled here as a component, not retaught: what is new in this lesson is that the order comes from a log position you do not control, and that the apply is bounded by a checkpoint it has to record and resume from.

  1. Reduce. Keep one event per key, the one with the highest lsn. Same ROW_NUMBER reduce as the previous lesson.
  2. Delete. Keys whose winning event is op = 'd' are gone. Remove them from the target.
  3. Upsert. Every other key writes its after image, inserting when the key is new and updating when it already exists.

Interviewers call this whole thing a MERGE, and on Snowflake, BigQuery, and Athena-on-Iceberg you would literally write MERGE INTO target USING source ON ... WHEN MATCHED THEN UPDATE ... WHEN NOT MATCHED THEN INSERT .... SQLite has no MERGE keyword, so you write the same semantics as INSERT ... ON CONFLICT(order_id) DO UPDATE SET status = excluded.status plus a keyed DELETE for the tombstone branch. That is not a workaround to apologize for in an interview. A merge is an upsert plus a delete branch, and being able to say that sentence is the understanding the question is testing. You have already graded the upsert mechanics in de-l8-change-batch-merge; here they are a component, not the lesson.

One parser detail that costs people ten minutes: when the rows you insert come from a SELECT rather than a VALUES list, SQLite cannot tell the ON of ON CONFLICT from the ON of a join, and it reports near "DO": syntax error. The fix is in SQLite's own documentation: give the SELECT a WHERE clause, even a trivial WHERE true, so the parser has something between the source and the conflict clause. A reduce that already filters WHERE rn = 1 satisfies this without you noticing.

The checkpoint

A consumer that runs forever does not exist. Yours will be restarted, redeployed, and rerun, so it needs to remember how far it got. That memory is a watermark: after applying every event with lsn <= C, record C in a small control table, and next run start from there.

Two properties matter and both are graded here.

  • The bound is on the log position, not on time and not on arrival order. lsn <= 1210 describes a slice that means the same thing on every rerun, on every replay, and on any consumer. The other two bounds cannot even name this slice: lsn 1210 is the twelfth arrival, not the tenth, and it shares a commit millisecond with lsn 1214, so a timestamp bound either drags 1214 in or drops 1210.
  • The apply must be idempotent, because at-least-once delivery guarantees you will be handed the same slice twice. Running the script a second time has to leave orders and apply_watermark exactly as the first run left them. The grader checks this literally, by running your script twice.

That second property is why the watermark is never written with a bare insert: a bare insert stacks a second row on the rerun and the checkpoint stops being a single number. Which write is right depends on whether the row exists yet. An UPDATE is the natural rerun-safe write, but it only rewrites a row that is already there, so it does nothing at all on a control table that starts empty, and the practice's apply_watermark starts empty. A DELETE followed by an INSERT is the shape that is correct on the first run and on every run after it.

Common mistake: applying in arrival order because the events "usually" arrive in order. They usually do. The one time they do not, order 1042 ends at packed when the source says shipped, nothing errors, and the discrepancy is found weeks later by a human who noticed a stuck order.

Interview nuance: "How do you guarantee ordering in Kafka" has a two-word core answer, partition key, and the correct qualifier that order holds within a partition only. The follow-up is always "and what if it is already shuffled by the time you get it", and the answer is that you order on the log position at the sink, because the sink is the last place that can still be made correct.

On a real platform this differs. Here the watermark is a one-row SQLite table you write yourself. A real consumer commits Kafka offsets back to the broker, a Flink job checkpoints its state to durable storage, and a warehouse-side merge job keeps its high-water mark in a control table that looks very much like this one. Delta Lake, Iceberg, and Hudi all give you an atomic MERGE so the delete branch and the upsert branch commit together; in SQLite they are two statements you would wrap in a transaction. The reduce-by-log-position step is identical everywhere, including in Flink, where it is the job's keyed state rather than a SQL window.

Sample data for this example
CREATE TABLE cdc_events (
  lsn          INTEGER,  -- source log position: the true order of the changes
  arrival_seq  INTEGER,  -- the order the SINK received them; an accident of partitioning
  ts_ms        INTEGER,
  op           TEXT,     -- 'c' create, 'u' update, 'd' delete
  order_id     INTEGER,
  after_status TEXT      -- NULL on 'd'
);
-- Order 1042's 'shipped' (lsn 1207) lands at arrival_seq 3, before its 'packed' (lsn 1201) at
-- arrival_seq 5. Orders 1044 and 1045 invert once each too. Every other key arrived in log order.
--
-- Two deliberate anti-coincidences, so the lesson's graded claim ("the bound is on the log
-- position, not on time and not on arrival order") is actually gradeable:
--   * lsn 1210 arrives at seq 12 and lsn 1215 at seq 10, so the checkpoint slice lsn <= 1210 is NOT
--     the same set as arrival_seq <= 10. Bounding on arrival leaves 1043 at 'packed' and applies
--     1045's 'picked' early, and the practice's assertions catch both.
--   * lsn 1214 shares a commit millisecond with lsn 1210, so no ts_ms bound can name the slice
--     either: an inclusive one drags 1214 in and an exclusive one drops 1210.
INSERT INTO cdc_events (lsn, arrival_seq, ts_ms, op, order_id, after_status) VALUES
  (1195,  1, 1767290000000, 'u', 1040, 'picked'),
  (1198,  2, 1767290090000, 'u', 1043, 'packed'),
  (1207,  3, 1767290360000, 'u', 1042, 'shipped'),
  (1202,  4, 1767290210000, 'u', 1044, 'picked'),
  (1201,  5, 1767290180000, 'u', 1042, 'packed'),
  (1203,  6, 1767290240000, 'u', 1041, 'packed'),
  (1206,  7, 1767290330000, 'd', 1041, NULL),
  (1208,  8, 1767290390000, 'u', 1040, 'shipped'),
  (1209,  9, 1767290420000, 'c', 1045, 'placed'),
  (1215, 10, 1767290600000, 'u', 1045, 'picked'),
  (1225, 11, 1767290900000, 'u', 1042, 'returned'),
  (1210, 12, 1767290450000, 'u', 1043, 'shipped'),
  (1221, 13, 1767290780000, 'u', 1044, 'shipped'),
  (1214, 14, 1767290450000, 'u', 1044, 'packed'),
  (1218, 15, 1767290690000, 'u', 1040, 'delivered'),
  (1220, 16, 1767290750000, 'u', 1043, 'delivered'),
  (1228, 17, 1767290990000, 'u', 1045, 'shipped'),
  (1226, 18, 1767290930000, 'u', 1044, 'delivered'),
  (1217, 19, 1767290660000, 'u', 1042, 'delivered'),
  (1223, 20, 1767290840000, 'u', 1045, 'packed');
Worked example (SQL)
-- Walk two keys in ARRIVAL order and compare each lsn to the previous one for that key.
SELECT arrival_seq,
       order_id,
       lsn,
       LAG(lsn) OVER (PARTITION BY order_id ORDER BY arrival_seq) AS prev_lsn_for_key,
       CASE
         WHEN lsn < LAG(lsn) OVER (PARTITION BY order_id ORDER BY arrival_seq)
         THEN 'out of order'
         ELSE ''
       END AS flag
FROM cdc_events
WHERE order_id IN (1042, 1045)
ORDER BY arrival_seq;

Apply

Your turn

The task this lesson builds to.

Write a query that returns every event that arrived out of log order for its key, as (order_id, lsn, arrival_seq), in arrival order, over cdc_events(lsn, arrival_seq, ts_ms, op, order_id, after_status).

An event arrived out of order when the previous event the sink received for that same order carried a HIGHER lsn. The first arrival for a key can never be out of order, because there is nothing before it to compare against.

3 hints and 1 automated check are waiting in the workspace.

Practice

Make it stick

A second problem on the same idea, plus 2 bonus drills.

Write a script that applies the changelog up to the checkpoint lsn <= 1210 to orders, then records the checkpoint in apply_watermark, over cdc_events(lsn, arrival_seq, ts_ms, op, order_id, after_status), orders(order_id PRIMARY KEY, status), and the empty apply_watermark(last_lsn).

Within that slice, the newest event per order in log order wins. If that winning event is op = 'd' the order is removed from orders; otherwise its after_status is written, inserting the order when it is new and updating it when it already exists. Leave apply_watermark holding exactly one row: the highest lsn you applied.

Nothing outside the slice may be touched, and nothing that the changelog never mentions may be touched. Your script will be run twice against the same seed, and the second run must leave orders and apply_watermark exactly as the first run left them.

4 automated checks are waiting in the workspace.