<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mike Vu</title>
    <description>The latest articles on DEV Community by Mike Vu (@mikevu).</description>
    <link>https://dev.to/mikevu</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3940235%2F59d061e5-cbf4-40bf-a87e-10406a6d93b0.jpg</url>
      <title>DEV Community: Mike Vu</title>
      <link>https://dev.to/mikevu</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mikevu"/>
    <language>en</language>
    <item>
      <title>Exactly-once doesn’t come for free: Kafka, Flink and ClickHouse</title>
      <dc:creator>Mike Vu</dc:creator>
      <pubDate>Wed, 28 Oct 2026 15:13:32 +0000</pubDate>
      <link>https://dev.to/mikevu/exactly-once-doesnt-come-for-free-kafka-flink-and-clickhouse-5clo</link>
      <guid>https://dev.to/mikevu/exactly-once-doesnt-come-for-free-kafka-flink-and-clickhouse-5clo</guid>
      <description>&lt;p&gt;"Exactly-once" is an easy phrase to put in a design doc. Flink has exactly-once checkpoints, Kafka has idempotent producers, ClickHouse has tables that deduplicate themselves. Put the three together and the data feels safe.&lt;/p&gt;

&lt;p&gt;This post walks through an event analytics pipeline I started and never finished: webhooks from GitHub and Jira hit a gateway, go through Kafka, get aggregated by Flink and land in ClickHouse. Rereading the code for this post, I found that &lt;strong&gt;every stage had a place where data could be lost or counted twice&lt;/strong&gt;, even though the design doc said otherwise. Each one is a fairly common mistake, so here they are with the fixes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyfsbefveqdnurxc85uet.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyfsbefveqdnurxc85uet.png" alt="Vertical diagram of the pipeline: webhook, gateway, Kafka, Flink, the batching sink, ClickHouse ReplacingMergeTree and the materialized view. The gateway and the sink are marked as places where data can be lost, the materialized view as a place where data is counted twice, and Flink as correct." width="800" height="998"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Three of the stages could lose or double-count data even though each tool advertises exactly-once.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;One principle runs through all of it: &lt;strong&gt;end-to-end exactly-once is almost always at-least-once plus an idempotent destination.&lt;/strong&gt; The question at each stage is: if this step runs again, does the result stay the same?&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 1: the gateway claims the key before the work is done
&lt;/h2&gt;

&lt;p&gt;Webhook providers retry when they don't get a success response, so the gateway needs to deduplicate. The first version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;idempotencyStore&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;claim&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;idempotencyKey&lt;/span&gt;&lt;span class="o"&gt;()))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;   &lt;span class="c1"&gt;// Redis SET NX&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;duplicate&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;publisher&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;publish&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// async send to Kafka, not awaited&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;accepted&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;          &lt;span class="c1"&gt;// 2xx right away&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The problem is the order. The key is claimed &lt;strong&gt;before&lt;/strong&gt; the event is actually in Kafka, and the gateway answers 2xx without waiting for Kafka to acknowledge. If Kafka fails at that moment:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The provider got a 2xx, so it doesn't retry.&lt;/li&gt;
&lt;li&gt;If it does retry, the retry is treated as a duplicate, because the key is already in Redis.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The provider's retry, the cheapest recovery there is, gets wasted. What's left is the dead letter queue (DLQ), which usually lives on the same Kafka cluster that just failed.&lt;/p&gt;

&lt;p&gt;Fixes, depending on how strict you need to be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Wait for Kafka's acknowledgement before answering 2xx.&lt;/strong&gt; This is the most important change. On failure, answer 5xx so the provider retries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only mark the key "done" after a successful send&lt;/strong&gt;, or use two states: &lt;code&gt;pending&lt;/code&gt; when starting (with a short TTL), &lt;code&gt;done&lt;/code&gt; once Kafka acknowledges, and delete the key on failure.&lt;/li&gt;
&lt;li&gt;If an exact deduplication layer exists downstream (here, Flink, see below), the gateway's layer only needs to be an &lt;strong&gt;optimization&lt;/strong&gt;, not a guarantee. Two concurrent deliveries slipping through is fine, because Flink drops the second one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The general lesson for any idempotency key: &lt;strong&gt;a key may only say "done" once the side effect is done.&lt;/strong&gt; Claiming it before the side effect turns a transient error into permanent data loss.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 2: Flink deduplicates with keyed state
&lt;/h2&gt;

&lt;p&gt;In Flink, events are keyed by &lt;code&gt;orgId:source:eventId&lt;/code&gt;, and a &lt;code&gt;ValueState&lt;/code&gt; remembers the keys it has seen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;firstSeen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;firstSeenState&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;firstSeen&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;firstSeenState&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;update&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;eventTime&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;toEpochMilli&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;collect&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// already seen: drop, increment dedup_dropped_total&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This stage is right. The state is part of the checkpoint, so after a restart from a checkpoint, the Kafka offsets and the set of seen keys go back to the same point in time. The state has a &lt;a href="https://nightlies.apache.org/flink/flink-docs-stable/docs/dev/datastream/fault-tolerance/state/#state-time-to-live-ttl" rel="noopener noreferrer"&gt;TTL&lt;/a&gt; so it doesn't grow forever, and the TTL is extended only on &lt;em&gt;write&lt;/em&gt;, not on &lt;em&gt;read&lt;/em&gt; (&lt;code&gt;OnCreateAndWrite&lt;/code&gt;): a duplicate arriving 23 hours later is still caught, and checking a key doesn't keep it alive longer.&lt;/p&gt;

&lt;p&gt;The thing to remember is that the dedup window must cover &lt;strong&gt;the longest gap between two deliveries of the same event&lt;/strong&gt; (say, how long the provider keeps retrying). It has nothing to do with how long Kafka retains data. A few easily misread details of state TTL are in &lt;a href="https://vuminhhoa.com/en/blog/flink-event-time-watermarks-replay" rel="noopener noreferrer"&gt;time in Apache Flink&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 3: a buffering sink that isn't tied to checkpoints
&lt;/h2&gt;

&lt;p&gt;Writing to ClickHouse row by row is far too slow, so the sink batches 1,000 rows. The first version extended &lt;code&gt;RichSinkFunction&lt;/code&gt;, kept a &lt;code&gt;List&lt;/code&gt; as its buffer, and had this comment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Retries exhausted: log and drop the batch. The checkpoint won't advance,&lt;/span&gt;
&lt;span class="c1"&gt;// so the batch will be retried when the job restarts.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The comment is &lt;strong&gt;wrong&lt;/strong&gt;, in a way that's very easy to get wrong:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A Flink checkpoint snapshots operator state. A plain &lt;code&gt;List&lt;/code&gt; in a sink &lt;strong&gt;isn't state&lt;/strong&gt;, and the sink does nothing when a checkpoint happens. The barrier passes through, the checkpoint completes and Kafka offsets move on, while those rows are still only in memory.&lt;/li&gt;
&lt;li&gt;If the job dies now, restarting from the checkpoint resumes &lt;em&gt;after&lt;/em&gt; those rows. They're gone.&lt;/li&gt;
&lt;li&gt;A batch "dropped" after its retries is the same: nothing stops the checkpoint from advancing, so that batch is never written.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One more detail: the comment says batches are flushed every 5 seconds "by a background thread", but the code only checks the time when a new record arrives. On a quiet stream, data sits in the buffer indefinitely.&lt;/p&gt;

&lt;p&gt;The fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ClickHouseSink&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;RichSinkFunction&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Row&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;CheckpointedFunction&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@Override&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;snapshotState&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;FunctionSnapshotContext&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;throws&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;flush&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// drain the buffer before the checkpoint may complete&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;throws&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// bounded retries; when exhausted, THROW instead of dropping,&lt;/span&gt;
        &lt;span class="c1"&gt;// so the checkpoint fails and the job restarts from the previous one&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Flush in &lt;code&gt;snapshotState&lt;/code&gt;&lt;/strong&gt; (from &lt;a href="https://nightlies.apache.org/flink/flink-docs-stable/docs/dev/datastream/fault-tolerance/state/#checkpointedfunction" rel="noopener noreferrer"&gt;&lt;code&gt;CheckpointedFunction&lt;/code&gt;&lt;/a&gt;): a checkpoint only completes once every row before the barrier is in ClickHouse. That's what at-least-once requires.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Throw instead of dropping&lt;/strong&gt;: the job restarts and rewrites from the previous checkpoint. You may get duplicates, but nothing is lost, and the next stage can handle duplicates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time-based flushing needs a real timer&lt;/strong&gt;, plus a lock between the timer thread and the processing thread.&lt;/li&gt;
&lt;li&gt;Best of all, don't write your own: Flink's &lt;a href="https://nightlies.apache.org/flink/flink-docs-stable/docs/connectors/datastream/jdbc/" rel="noopener noreferrer"&gt;official JDBC connector&lt;/a&gt; (&lt;code&gt;JdbcSink&lt;/code&gt; with &lt;code&gt;JdbcExecutionOptions&lt;/code&gt;) and the &lt;code&gt;AsyncSinkBase&lt;/code&gt; foundation for new sinks already handle batching, timers, retries and flushing on checkpoint.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Stage 4: ReplacingMergeTree deduplicates, eventually
&lt;/h2&gt;

&lt;p&gt;An at-least-once sink means ClickHouse will receive duplicates. The tables use &lt;code&gt;ReplacingMergeTree(checkpoint_version)&lt;/code&gt;, with &lt;code&gt;checkpoint_version&lt;/code&gt; set to the window's end time, so recomputing the same window produces exactly the same row with the same version.&lt;/p&gt;

&lt;p&gt;What you need to know: &lt;a href="https://clickhouse.com/docs/reference/engines/table-engines/mergetree-family/replacingmergetree" rel="noopener noreferrer"&gt;&lt;code&gt;ReplacingMergeTree&lt;/code&gt;&lt;/a&gt; deduplicates &lt;strong&gt;when parts merge in the background&lt;/strong&gt;, not on insert. Until then, &lt;code&gt;SELECT&lt;/code&gt; sees both copies. Reads must use &lt;code&gt;FINAL&lt;/code&gt;, or group by key and take the highest version. The project gets this right: the queries on the deployment and incident tables, the two &lt;code&gt;ReplacingMergeTree&lt;/code&gt; tables, all use &lt;code&gt;FINAL&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 5: a materialized view double-counts, and merges can't fix it
&lt;/h2&gt;

&lt;p&gt;This is the hardest one to see. The normalized events table is a &lt;code&gt;ReplacingMergeTree&lt;/code&gt;, and a &lt;strong&gt;materialized view&lt;/strong&gt; feeds hourly rollups from it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;MATERIALIZED&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;mv_employee_metrics_hourly&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;employee_metrics_hourly&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;org_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;employee_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;toStartOfHour&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_time&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;window_end&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;sumState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;toUInt32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pr.merged'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;pr_merged&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;normalized_events&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A ClickHouse &lt;a href="https://clickhouse.com/docs/concepts/features/materialized-views/incremental-materialized-view" rel="noopener noreferrer"&gt;materialized view&lt;/a&gt; is &lt;strong&gt;a trigger on each inserted block&lt;/strong&gt;, not a query over merged data. Insert an event twice and the view sees it twice, so the rollup counts it twice. When &lt;code&gt;ReplacingMergeTree&lt;/code&gt; later collapses the two copies in the source table, the rollup never finds out. The "idempotent" source table &lt;strong&gt;doesn't carry its idempotency&lt;/strong&gt; into the rollup.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fis07dj2te5zga4ey3wvd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fis07dj2te5zga4ey3wvd.png" alt="Table-style diagram of one event over three steps. Insert: the source table has 1 row, the rollup counts 1. A retry inserts it again: 2 rows, the rollup counts 2. A background merge: the source is back to 1 row, but the rollup still counts 2." width="800" height="481"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The view fires on every inserted block, so a merge in the source table never reaches the rollup.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A few ways out, depending on what you need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Aggregate in Flink, have ClickHouse store results.&lt;/strong&gt; Flink already has windows; write each window's result straight into a &lt;code&gt;ReplacingMergeTree&lt;/code&gt; keyed by window. Writing it again produces the same row.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://clickhouse.com/docs/concepts/features/operations/insert/deduplicating-inserts-on-retries" rel="noopener noreferrer"&gt;Deduplicate at insert time&lt;/a&gt;&lt;/strong&gt;: ClickHouse can skip identical inserted blocks (by default for replicated tables; plain tables need &lt;code&gt;non_replicated_deduplication_window&lt;/code&gt; above 0), and extends that to dependent views through &lt;code&gt;deduplicate_blocks_in_dependent_materialized_views&lt;/code&gt; (on by default only since 26.2, so on the 24.3 this pipeline ran it has to be turned on). A retry must resend the exact same block, or carry the same &lt;code&gt;insert_deduplication_token&lt;/code&gt;, for example built from the checkpoint id and subtask.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recompute on a schedule&lt;/strong&gt; from the source with &lt;code&gt;FINAL&lt;/code&gt; (a refreshable materialized view), accepting the delay.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Bonus: zero isn't always neutral
&lt;/h2&gt;

&lt;p&gt;The same view fills columns that don't have data yet with placeholders:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;sumState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;toUInt32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;review_comments&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="c1"&gt;-- harmless&lt;/span&gt;
&lt;span class="n"&gt;avgState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;toFloat64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;  &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;pr_review_latency_s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- not harmless&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adding 0 to a sum leaves it unchanged. Adding 0 to an &lt;strong&gt;average&lt;/strong&gt; gives every event a sample of zero, dragging the average down. The real values Flink writes into the same table get diluted by thousands of zeros. It's also why &lt;a href="https://clickhouse.com/docs/reference/engines/table-engines/mergetree-family/aggregatingmergetree" rel="noopener noreferrer"&gt;&lt;code&gt;AggregatingMergeTree&lt;/code&gt;&lt;/a&gt; stores an average's &lt;em&gt;state&lt;/em&gt; (sum and count) rather than the average itself: you can't add averages together.&lt;/p&gt;

&lt;p&gt;Every aggregate function has its own &lt;strong&gt;neutral element&lt;/strong&gt;: for &lt;code&gt;sum&lt;/code&gt; it's 0, for &lt;code&gt;avg&lt;/code&gt; it's "no samples". To reserve a column for an average, produce an empty state, for example &lt;code&gt;avgStateIf(toFloat64(0), 0)&lt;/code&gt;, or leave the column out of the view until real data exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;Risk&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gateway&lt;/td&gt;
&lt;td&gt;Key claimed before the send completes&lt;/td&gt;
&lt;td&gt;Wait for Kafka's ack before 2xx; mark done only when done&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flink&lt;/td&gt;
&lt;td&gt;(correct)&lt;/td&gt;
&lt;td&gt;Dedup with keyed state with TTL, inside the checkpoint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sink&lt;/td&gt;
&lt;td&gt;Buffer not tied to checkpoints&lt;/td&gt;
&lt;td&gt;Flush in &lt;code&gt;snapshotState&lt;/code&gt;, throw instead of dropping, or use a built-in sink&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ClickHouse&lt;/td&gt;
&lt;td&gt;Dedup happens at merge time&lt;/td&gt;
&lt;td&gt;Read with &lt;code&gt;FINAL&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Materialized view&lt;/td&gt;
&lt;td&gt;Counts duplicates before any merge&lt;/td&gt;
&lt;td&gt;Aggregate in Flink, or deduplicate inserted blocks&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of these stages used the "wrong" technology. The bugs live in the joints, where each component keeps its own promise but nobody keeps the promise for the whole chain. What I find most useful when designing is to walk every arrow in the diagram and ask: &lt;em&gt;what if this step runs again, or dies halfway?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A similar pipeline may still have gaps I haven't spotted. If you've run into one, I'd love to hear about it. My email is on the &lt;a href="https://vuminhhoa.com/en#contact" rel="noopener noreferrer"&gt;home page&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>kafka</category>
      <category>apacheflink</category>
      <category>clickhouse</category>
      <category>idempotency</category>
    </item>
  </channel>
</rss>
