DUTY LOG · 2026-08-20 · PHASE B1–B4 · LOCAL LAB
Ten failure classes hit the same pipeline. Snapshot diff never leaves zero.
A dual-path pipeline: MySQL through Kafka and Flink into Iceberg. Every line below is copied from the real run recorded on 2026-08-20 — it is a log, not a diagram. Open any entry to replay that recovery.
Throughput is a measured end-to-end figure: 55.814s from the first MySQL write to zero Iceberg backlog, including the injected broker outage.
PINNED · FEATURED RUN12:53:51Z · PHASE B3 · BROKER-RESTART · RUN 0B79472B · GIT F27AC1D1Kill the Kafka broker container outright. It's back to running in 5.8s; Flink resumes from checkpoint chk-1 and finishes recovery in 47.688s — reconciliation is 120 rows against 120 rows, snapshot diff 0.
47.688 sDIFF 0
Kill the Kafka broker container outright. It's back to running in 5.8s; Flink resumes from checkpoint chk-1 and finishes recovery in 47.688s — reconciliation is 120 rows against 120 rows, snapshot diff 0.
- T+0.0sINJECT docker compose kill kafka · exit 137 · partitions 0/1/2 lag 3·0·2
- T+5.8sRECOVER container running again · started 12:54:24Z
- RESUME checkpoint chk-1 · da52d7f2ccb2 · connector p1-orders-connector RUNNING
- T+47.7sVERIFY mysql 120 = iceberg 120 · sha256 ab3a445b8ee0… = ab3a445b8ee0… · diff 0 · 10/10 checks
2026-05-27 15:55:18Z · PHASE 2.2 · SMALL-FILE-REWRITE48 small data files get compacted by rewrite_data_files down to 2; manifests shrink from 24 to 1, query-planning latency drops from 54.92ms to 44.57ms, table state unchanged.
—DIFF 0
48 small data files get compacted by rewrite_data_files down to 2; manifests shrink from 24 to 1, query-planning latency drops from 54.92ms to 44.57ms, table state unchanged.
- T+0.0sRUN before-state 48 data files · 24 manifests · table cdc_lab.orders_current
- REWRITE rewrite_data_files + rewrite_manifests maintenance
- VERIFY after-state 2 data files (-46 delta) · 1 manifests · planning latency -10.35ms delta
2026-07-11 03:47:48Z · PHASE 2.1 · EO-RECONCILIATIONFlink is put through 5 internal failure scenarios in a row (task-crash, checkpoint-restore, jobmanager-restart, savepoint-restore, sink-commit-fault), reconciled after each one — every snapshot diff comes back 0.
—DIFF 0
Flink is put through 5 internal failure scenarios in a row (task-crash, checkpoint-restore, jobmanager-restart, savepoint-restore, sink-commit-fault), reconciled after each one — every snapshot diff comes back 0.
- RUN task-crash: one-shot Flink operator exception on event_id=1303 · snapshot_diff_count 0 · passed true
- RUN checkpoint-restore: cancel running job and restore from latest completed checkpoint · snapshot_diff_count 0 · passed true
- RUN jobmanager-restart: restart the Flink JobManager container after a completed checkpoint · snapshot_diff_count 0 · passed true
- RUN savepoint-restore: create a Flink savepoint, cancel the job, and restore from that savepoint · snapshot_diff_count 0 · passed true
- RUN sink-commit-fault: test-only checkpoint-complete fault after event_id=9303 · snapshot_diff_count 0 · passed true
10:21:56Z · SEED 17 · BROKER-PARITYTwo fully independent delivery paths — MySQL binlog and MySQL GTID — each push 1,000 rows into the same Iceberg table; the two snapshot digests match byte-for-byte, row-level diff 0.
—DIFF 0
Two fully independent delivery paths — MySQL binlog and MySQL GTID — each push 1,000 rows into the same Iceberg table; the two snapshot digests match byte-for-byte, row-level diff 0.
- T+0.0sRUN path A (1000 rows, MySQL binlog -> embedded Debezium -> Flink -> Iceberg) and path B (1000 rows, MySQL GTID -> standalone Debezium at-least-once -> Kafka offsets -> Flink checkpoint -> Iceberg keyed upsert) in parallel
- VERIFY iceberg_snapshot(path_A) == iceberg_snapshot(path_B): true · row_level_diff_count 0
11:59:57Z · SEED 211 · SCHEMA-CONTRACTSubmit an incompatible Avro schema (a field removed) to the Schema Registry; registration is rejected with HTTP 409. The main pipeline keeps running — 3 more events land cleanly after the rejection, source-to-Iceberg diff 0.
—DIFF 0
Submit an incompatible Avro schema (a field removed) to the Schema Registry; registration is rejected with HTTP 409. The main pipeline keeps running — 3 more events land cleanly after the rejection, source-to-Iceberg diff 0.
- T+0.0sINJECT incompatible Avro schema (removed field) · candidate_schema_sha256 2e4885c50a…
- DETECT registration rejected · http_status 409
- RECOVER old-schema pipeline continues · connector_running_after_rejection true
- VERIFY iceberg_row_count 3 · post_rejection_source_iceberg_diff_zero true
13:07:00Z · SEED 302 · DUPLICATE-REDELIVERYRewind the consumer offsets to earliest and redeliver 36 events as one batch; the pipeline recognizes all 36 redeliveries as duplicates — final state gains not one extra row.
50.696 sDIFF 0
Rewind the consumer offsets to earliest and redeliver 36 events as one batch; the pipeline recognizes all 36 redeliveries as duplicates — final state gains not one extra row.
- T+0.0sINJECT kafka-consumer-groups.sh --reset-offsets --to-earliest --execute
- DETECT 36 duplicate deliveries · distinct_event_ids 36
- RECOVER restart Path B from rewound committed offsets
- VERIFY snapshot_diff_count 0 · redelivery_job 9fa6b5152416
13:11:03Z · SEED 303 · MIS-KEYINGA mis-keyed record is quarantined into the probe topic — the main path never receives it; the ordering ceiling is proven, not papered over.
24.456 sDIFF 0
A mis-keyed record is quarantined into the probe topic — the main path never receives it; the ordering ceiling is proven, not papered over.
- T+0.0sINJECT mis-keyed batch (wrong partition key) · order_id 9200303
- DETECT audit gate: rejected before main-pipeline admission
- RECOVER audit gate rejects mis-keyed batch; retain PK-keyed main path
- VERIFY non_monotonic_transition_count 1 · snapshot_diff_count 0
13:17:52Z · SEED 304 · POISON-DLQA poison message drops into the DLQ; the main pipeline never stops. Repaired and replayed, the final state matches the source row for row.
52.414 sDIFF 0
A poison message drops into the DLQ; the main pipeline never stops. Repaired and replayed, the final state matches the source row for row.
- T+0.0sINJECT malformed UTF-8 JSON bytes on an Avro order topic · topic broker.cdc_lab.orders
- DETECT connector PAUSED · dlq offset 0 partition 0
- RECOVER decode intended row, re-encode registered Avro, replay with PK key
- VERIFY iceberg_snapshot_row_count 13 · snapshot_diff_count 0
13:52:36Z · SEED 305 · OFFSET-REPLAYRebuild the whole table by timestamp: timestamp diff 0, offset diff 0 — the replay itself is a reconciliation.
38.008 sDIFF 0
Rebuild the whole table by timestamp: timestamp diff 0, offset diff 0 — the replay itself is a reconciliation.
- T+0.0sINJECT consumer group rewound to offset 0 · group p1-b3-replay-offset-zero-305
- RECOVER fresh-table rebuild from offset 0 and chosen timestamp · job 3cb3b30705ac
- VERIFY offset_zero_diff_count 0 · timestamp_diff_count 0 · snapshot_diff_count 0
16:23:53Z · SEED 401 · BROKER-SLOUnder load with all 5 fault classes stacked, throughput holds at 1,791 events/s, end-to-end freshness p50 15.2s / p95 20.6s, final snapshot diff 0.
—DIFF 0
Under load with all 5 fault classes stacked, throughput holds at 1,791 events/s, end-to-end freshness p50 15.2s / p95 20.6s, final snapshot diff 0.
- RUN broker-restart: recovered in 47.688s under 100,000 events
- RUN duplicate-redelivery: recovered in 50.696s under 100,000 events
- RUN mis-keying: recovered in 24.456s under 100,000 events
- RUN poison-dlq: recovered in 52.414s under 100,002 events
- RUN offset-replay: recovered in 38.008s under 100,002 events
- VERIFY sustained 1791.665 events/s · freshness p50 15.2s p95 20.6s · snapshot_diff_count 0
RECONCILIATION · SNAPSHOT DIGEST EQUALITY
Exactly-once ends
at reconciliation.
∀ drill ∈ 10 faults: iceberg_snapshot(path_A) ≡ iceberg_snapshot(path_B)
duplicate-redelivery0snapshot_diff_count<=0PASSordering-miskey0snapshot_diff_count<=0PASSpoison-dlq0snapshot_diff_count<=0PASSbroker-restart0snapshot_diff_count<=0PASSoffset-replay0snapshot_diff_count<=0PASSschema-contract0snapshot_diff_count<=0PASSsmall-file-rewrite0snapshot_diff_count<=0PASSeo-reconciliation0snapshot_diff_count<=0PASSbroker-parity0snapshot_diff_count<=0PASSbroker-slo0snapshot_diff_count<=0PASSNo JavaScript required: every drill below is a static table server-rendered from the same summary, with a link to its full recorded run.
DUPdiff = 0
| INJECT | DETECT | RECOVER | VERIFY | DIFF | hash (sha256) |
|---|---|---|---|---|---|
| — | — | 50.7s | — | 0 | d62685a0bdf3168d… |
ORDdiff = 0
| INJECT | DETECT | RECOVER | VERIFY | DIFF | hash (sha256) |
|---|---|---|---|---|---|
| — | — | 24.5s | — | 0 | 38ca7d6d0ded8fbe… |
POISONdiff = 0
| INJECT | DETECT | RECOVER | VERIFY | DIFF | hash (sha256) |
|---|---|---|---|---|---|
| — | — | 52.4s | — | 0 | 8920a86ab10a09f6… |
RESTARTdiff = 0
| INJECT | DETECT | RECOVER | VERIFY | DIFF | hash (sha256) |
|---|---|---|---|---|---|
| — | — | 47.7s | — | 0 | 78896060dfa5b32a… |
OFFSETdiff = 0
| INJECT | DETECT | RECOVER | VERIFY | DIFF | hash (sha256) |
|---|---|---|---|---|---|
| — | — | 38.0s | — | 0 | 444d5a19ed576f15… |
SCHEMAdiff = 0
| INJECT | DETECT | RECOVER | VERIFY | DIFF | hash (sha256) |
|---|---|---|---|---|---|
| — | — | — | — | 0 | 3c65511960885761… |
SMALLFILEdiff = 0
| INJECT | DETECT | RECOVER | VERIFY | DIFF | hash (sha256) |
|---|---|---|---|---|---|
| — | — | — | — | 0 | 3ed9659a57bdd116… |
RECONdiff = 0
| INJECT | DETECT | RECOVER | VERIFY | DIFF | hash (sha256) |
|---|---|---|---|---|---|
| — | — | — | — | 0 | e6c613bc1dc0be55… |
PARITYdiff = 0
| INJECT | DETECT | RECOVER | VERIFY | DIFF | hash (sha256) |
|---|---|---|---|---|---|
| — | — | — | — | 0 | 48be70bbe064112a… |
SLOdiff = 0
| INJECT | DETECT | RECOVER | VERIFY | DIFF | hash (sha256) |
|---|---|---|---|---|---|
| — | — | — | — | 0 | 5176dd0ac38d9778… |
PATH A · PATH B · 1000 EVENTS
Two delivery paths
must land on the same state.
PATH A
MySQL binlog -> embedded Debezium -> Flink -> Iceberg
1,000 rows5780efc5f0fb5005…PATH B
MySQL GTID -> standalone Debezium at-least-once -> Kafka offsets -> Flink checkpoint -> Iceberg keyed upsert
1,000 rows5780efc5f0fb5005…PASS
CHECKPOINT DURATION · ICEBERG COMMIT LAG
Recovery has
ten different failure shapes.
| Sample | Phase | Checkpoint | Duration | Commit lag | Backpressure |
|---|---|---|---|---|---|
| 0 | baseline | 3 | 55 ms | 0 | 1.000 |
| 1 | baseline | 13 | 44 ms | 0 | 0.000 |
| 2 | baseline | 21 | 40 ms | 0 | 0.000 |
| 3 | backpressure | 27 | 53 ms | 320 | 0.000 |
| 4 | backpressure | 27 | 53 ms | 320 | 0.140 |
| 5 | backpressure | 28 | 19,022 ms | 160 | 0.397 |
| 6 | recovery | 28 | 19,022 ms | 0 | 0.498 |
| 7 | recovery | 39 | 54 ms | 0 | 0.649 |
| 8 | recovery | 52 | 43 ms | 0 | 0.601 |
HOW THIS WAS VERIFIED
Every drill opens
the same raw file.
- What was verified
- The recorded fault drills reconcile recovered snapshots with zero differences in the captured runs.
- Evidence class
- Recorded drill JSON, with historical May artifacts and the July U6 local-Mac reproduction kept separate.
- Boundary
- The replay covers the recorded fault classes and environments. It does not establish every possible failure, a live cluster or general hardware compatibility.
Files, hashes and methods
- index.summary.jsonLucisZhang/portfolio-site ·
acf05ae78859 sha256:c008972eaf6da3e53c1b5419177b608e46b6dd4f31dd1629ca1a0af2308008ee- broker_slo.jsonLucisZhang/exactly-once-drills ·
50d88bb4cc9b sha256:5176dd0ac38d977813693de85fa2a6fc3fcfb9307048cc149b0408237488e200- Result manifestLucisZhang/portfolio-site ·
acf05ae78859 sha256:7867fb1ec1f91787ada2bb9141426b29273324f8d84b3eac6968572085120d32- Generated
- 2026-08-27
Every number on this page is read from the JSON files linked above at build time; the chessboard's summary is generated from the ten full drill records, never typed in.
BOUNDARY
Architecture
I built all of it: the deterministic event generator, the failure harness, the reconciliation checks, the incident runbook, and the recorded-run dashboard.
- Source
Deterministic inserts, updates, and deletes in MySQL.
- CDC
Flink captures and checkpoints the changelog.
- Table
Iceberg stores the current state and change history.
- Failure
The harness triggers one recorded failure type at a time.
- Reconcile
Check source, snapshot, and event-ID sets after recovery.
Results & negatives
Ten ways to break the same pipeline: MySQL CDC on one path, Debezium → Avro contracts → Kafka on the other, both landing in Flink → Iceberg. After every induced failure, source state, table snapshots, and event IDs are reconciled — all ten recoveries came back with zero diffs. Sustained throughput measured at 1,791 events/s in the B4 SLO run.
The task-crash drill fires from a one-shot marker file. A marker left behind by an earlier local run can swallow the next run's induced failure, and the drill then reports a clean recovery it never performed. The note I left in the runbook is to keep the marker path unique per run.
Restoring from a retained checkpoint looks like reading a path and handing it to flink run -s. It is not. The checkpoint the REST API reports can be superseded by a later retained checkpoint during shutdown, so the path has to be resolved after cancellation, not before it.
After I restarted the JobManager container the session job was simply gone, and waiting for a registered worker got me nothing. The TaskManager has to be brought back first; only then does the restore from chk-7 take. Reconciliation still came out at zero snapshot differences, but that ordering belongs to this single-node Compose setup, not to Flink.
Limitations
The U6 result is environment-specific: Apple Silicon macOS with 16 GiB RAM and a Docker VM reporting 10 CPUs and about 7.65 GiB memory.
One captured run does not establish universal hardware compatibility or one-command reproducibility. The historical dashboard proves only the run it captured.