Fault Tolerance and Data Consistency
DataFlow Operator processes messages with at-least-once delivery semantics. When the processor pod crashes or restarts, some messages may be re-read and written again. This document explains the behavior, risks of data desynchronization, and how to configure idempotent sinks to prevent duplicates.
Delivery Semantics
- At-least-once: Each message is delivered at least once. Duplicates are possible on processor restart or crash.
- Exactly-once: Not supported natively. Use idempotent sinks to achieve effectively-once semantics.
Source Behavior on Restart
| Source | State storage | On restart |
|---|---|---|
| Kafka | Consumer group (Kafka) | Resumes from last committed offset. No duplicates if offset was committed after sink write. |
| PostgreSQL | ConfigMap (default); in-memory when checkpointPersistence: false |
By default resumes from last position. Without persistence: re-reads from beginning. |
| PostgreSQL CDC | ConfigMap (lastAckedLSN) |
Resumes logical replication from last acked LSN after sink write. |
| ClickHouse | ConfigMap (default); in-memory when checkpointPersistence: false |
By default resumes from last position. Without persistence: re-reads from beginning. |
| Trino | ConfigMap (default); in-memory when checkpointPersistence: false |
By default resumes from last position. Without persistence: re-reads from beginning. |
| Nessie | ConfigMap when incrementalBySnapshot: true and checkpointPersistence (default) |
Incremental reads along the Iceberg snapshot chain (file-level added-data delta when possible); without incrementalBySnapshot, full scan on every poll (no checkpoint). |
| Iceberg | ConfigMap when incrementalBySnapshot: true and checkpointPersistence (default) |
Same as Nessie; checkpoint store key is iceberg. |
Horizontal scaling (spec.replicas)
- Kafka: you may set
spec.replicas > 1. All pods share one consumer group; parallelism is capped by the topic partition count. - PostgreSQL, PostgreSQL CDC, ClickHouse, Trino, Nessie:
replicasmust be1(or unset). Multiple pods with a shared checkpoint ConfigMap will duplicate data. - DataFlowCron:
replicas > 1is not supported (one processor Job per schedule tick).
Within a single pod, raise spec.transformWorkers for CPU-heavy transforms (ordered emit) and rely on sink double-buffer flush — see Architecture — Pipeline concurrency. Do not use extra replicas for polling sources.
Kafka Source
The Kafka consumer marks offset only after the message is successfully written to the sink (via msg.Ack()). With ackGranularity: message (see below), offsets are also committed to the consumer group immediately after each mark.
If the processor crashes:
- Before sink write: Offset not committed. On restart, message is re-read. No duplicate in sink.
- After sink write, before Ack: Data may be in sink, offset not committed. On restart, re-read → duplicate in sink.
- After Ack: Offset marked (and committed when
ackGranularity: message). On restart, resume from next message. No duplicate.
Nessie source (incremental mode)
When source.config.incrementalBySnapshot: true, the processor reads only new Iceberg snapshots since the last Ack. Within a new snapshot, added data files vs the parent are preferred (empty delta skips the scan; append-only parquet without deletes can use a direct file read). Checkpoint (lastAckedSnapshotID, lastAckedSnapshotSequence) is stored in a ConfigMap when snapshotCheckpoints (default) and spec.checkpointPersistence are enabled. Optional maxRowsPerPoll / maxBytesPerPoll cap emission per poll.
See nessie-incremental-snapshots-design.md.
Polling Sources (PostgreSQL, ClickHouse, Trino)
Checkpoint persistence is enabled by default. The read position (lastReadChangeTime, lastReadOrderByValue) is persisted to ConfigMap df-<name>-checkpoint. On restart, the source resumes from the last committed position after sink Ack, reducing duplicates. Set checkpointPersistence: false in spec to store checkpoint only in memory (lost on pod crash).
Legacy checkpoint keys (lastReadID, lastReadTime) are migrated on load; see the migration table below.
Idempotent sink required
For polling sources, always configure an idempotent sink (UPSERT, ReplacingMergeTree) to handle duplicates safely.
Batch Sink Behavior
PostgreSQL, ClickHouse, and Trino sinks write in batches. The flow is:
- Accumulate messages in a batch
- Execute the batch write (PostgreSQL wraps all statements in a single transaction and commits atomically)
- Call
Ack()for each message in the batch (commits Kafka offset / advances polling checkpoint)
If the processor crashes after a successful batch commit but before Ack:
- Data is already in the sink
- Source offset / checkpoint may not be advanced
- On restart: re-read → duplicate writes to sink (safe with an idempotent sink)
Reduce duplicate window
Prefer a smaller sink batchSize with ackGranularity: batch, or use ackGranularity: message.
With message ack, decide whether to keep bulk writes via collapseBatchOnMessageAck.
Ack Granularity (spec.ackGranularity)
Controls when source offsets / checkpoints are considered committed relative to sink writes. This is the delivery watermark for the source — not the physical size of the sink write.
| Value | Behavior |
|---|---|
batch (default) |
After a successful sink batch flush, all messages in that batch are acked together. Kafka source relies on the consumer auto-commit interval after MarkMessage. |
message |
After a successful sink write, each message is acked individually. For Kafka source, Commit() runs after each mark so the consumer-group offset advances sooner. |
Kafka sink always acks per message on the produce path regardless of ackGranularity.
Trino long-running INSERTs
For large JSON payloads and Iceberg/Nessie tables, keep batchSize low (often 1) and set sink.config.queryTimeoutSeconds to cover the full Trino execution window (including nextUri polling).
Timeouts during nextUri follow can happen after Trino already started processing the INSERT, so retries may produce duplicates.
Decoupling ack and sink batch (spec.collapseBatchOnMessageAck)
Historically, ackGranularity: message also forced batch sinks to flush one row at a time
(MaxBatchSize = 1). That shrinks the duplicate window, but it destroys sink throughput
(N round-trips / N statements instead of one bulk write).
collapseBatchOnMessageAck makes that coupling optional:
| Value | Behavior |
|---|---|
true (default) |
Legacy coupling. With ackGranularity: message, sinks force MaxBatchSize = 1 regardless of sink.config.batchSize. |
false |
Decoupled. Sink keeps sink.config.batchSize (and flush interval). After a successful bulk flush, the processor still runs per-message source ack (mark / Commit() / checkpoint notify). |
| (ignored) | When ackGranularity is batch, this field has no effect on batch size. |
What each knob controls
flowchart LR
subgraph semantic["Delivery semantics"]
AG["ackGranularity\nbatch | message"]
end
subgraph physical["Physical sink IO"]
BS["sink.config.batchSize\n+ flush interval"]
CB["collapseBatchOnMessageAck\ntrue → force size 1"]
end
AG -->|"when to Ack / Commit"| SourceState["Kafka offset /\ncheckpoint progress"]
BS --> Flush["Bulk or single-row write"]
CB -->|"only if ack=message"| Flush
Flush -->|"OnAck after success"| AG
| Knob | Controls | Does not control |
|---|---|---|
ackGranularity |
When source progress is committed (per batch vs per message after a successful write) | How many rows go in one INSERT/Append |
sink.config.batchSize |
Target physical write-batch size (and often flush timer) | Whether Kafka Commit() runs per mark |
collapseBatchOnMessageAck |
Whether message-ack overrides batchSize to 1 |
Checkpoint debounce / checkpointSyncOnAck itself |
Mode matrix
ackGranularity |
collapseBatchOnMessageAck |
Effective sink batch | Source ack after flush | Typical use |
|---|---|---|---|---|
batch |
any | batchSize |
One ack pass for the whole batch | Default / high throughput |
message |
true (default) |
Forced 1 |
Per message (and Kafka Commit per mark) |
Minimal re-read window; low volume |
message |
false |
batchSize |
Per message after bulk success | High throughput + tighter offsets |
Runtime sequence (message + collapseBatchOnMessageAck: false)
- Sink accumulates up to
batchSize(or flush interval). - One bulk
OnFlushwrites the batch (transaction / multi-VALUES / columnar insert — connector-specific). - On success,
AckAfterSuccessfulWritewalks the batch and acks each message. - For Kafka source: each ack →
MarkMessage+Commit()(message granularity). - If
checkpointSyncOnAck: true, each ack may attempt a ConfigMap flush (coalesced bycheckpointSaveInterval).
If step 2 fails, no messages in that batch are acked → at-least-once re-read of the whole batch on restart (use an idempotent sink).
sequenceDiagram
participant Src as Source
participant Sink as Batch sink
participant Ack as Ack / Commit
Src->>Sink: msg 1..N (channel)
Note over Sink: accumulate to batchSize
Sink->>Sink: bulk OnFlush
alt flush OK
loop each message
Sink->>Ack: Ack(msg i)
Ack->>Src: Mark / Commit / checkpoint notify
end
else flush error
Note over Sink,Ack: no acks — full batch may replay
end
Duplicate window and crash semantics
Assume a crash after sink write succeeded but before all source acks finished:
| Mode | Worst-case re-read window (approx.) |
|---|---|
ackGranularity: batch |
Up to one full sink batch |
message + collapse true |
~1 message (write and ack are 1:1) |
message + collapse false |
Up to messages in the in-flight bulk that were written but not yet individually acked (usually small; still ≤ batchSize) |
At-least-once is unchanged in all modes: never ack before a successful sink write.
Idempotent sinks (upsertMode + conflictKey, ReplacingMergeTree, etc.) remain required when duplicates are unacceptable.
Which sinks honor collapse / batchSize
collapseBatchOnMessageAck is applied when building the write loop for batch-oriented sinks:
- PostgreSQL
- ClickHouse
- Trino
- Nessie / Iceberg
Kafka sink does not use this batching helper; produce/ack behavior is separate.
Interaction with checkpointSyncOnAck
- Polling / CDC checkpoints:
Savequeues state in memory;checkpointSyncOnAck: truecallsFlushAfterBatchAckafter ack. The flush is non-blocking (background worker) and coalesced bycheckpointSaveInterval(default30s) so the ack path does not wait on ConfigMap Get+Patch. - With
ackGranularity: messageand collapsefalse, you still get many ack callbacks per bulk flush → more flush attempts, but coalesce + async limit API rate and avoid stalling the pipeline. - Combining high volume +
message+checkpointSyncOnAck: truewithout coalesce awareness can still pressure the API server; preferbatchack for migration/cron unless you need a tight window, or keep sync-on-ack with a sanecheckpointSaveInterval.
Choosing a mode
| Goal | Recommended |
|---|---|
| Default streaming | ackGranularity: batch, tune batchSize |
| Smallest duplicate window, low/moderate rate | ackGranularity: message (collapse default true) |
| High Kafka → ClickHouse/Postgres throughput and faster offset commit | ackGranularity: message + collapseBatchOnMessageAck: false + large batchSize + idempotent sink |
| Migration / cron polling | Often checkpointSyncOnAck: true + idempotent sink; ackGranularity: batch unless you need message-level marks |
Examples
Legacy message-ack (batch collapsed to 1):
spec:
ackGranularity: message
# collapseBatchOnMessageAck: true # default
sink:
type: postgresql
config:
upsertMode: true
conflictKey: material_id
batchSize: 100 # ignored while collapse is true
High throughput: message ack without collapsing the sink batch:
spec:
channelBufferSize: 1000
ackGranularity: message
collapseBatchOnMessageAck: false
sink:
type: clickhouse
config:
batchSize: 1000
batchFlushIntervalSeconds: 5
upsertMode: true
conflictKey: event_id
See also sample dataflow/config/samples/kafka-to-clickhouse-high-volume.yaml and Examples — high volume.
Idempotent Sink Configuration
PostgreSQL Sink
Enable UPSERT mode so that duplicate inserts update existing rows instead of failing. Batch writes run inside an explicit transaction (all-or-nothing per flush).
sink:
type: postgresql
config:
connectionString: "postgres://..."
table: output_table
upsertMode: true
conflictKey: id # Optional; defaults to PRIMARY KEY
# Optional: skip stale replays when a version column exists in the payload
upsertStrategy: ifNewer # always (default) | ifNewer
upsertVersionColumn: updated_at # required when upsertStrategy is ifNewer
Requires the table to have a PRIMARY KEY or UNIQUE constraint on the conflict columns. With upsertStrategy: ifNewer, updates apply only when EXCLUDED.<version> > target.<version>.
ClickHouse Sink
Enable upsertMode for idempotent writes via ReplacingMergeTree (auto-created tables use this engine when upsertMode: true):
sink:
type: clickhouse
config:
connectionString: "clickhouse://..."
table: output_table
upsertMode: true
conflictKey: id
replacingVersionColumn: updated_at # optional version column for ReplacingMergeTree
tableEngine: ReplacingMergeTree # optional; default when upsertMode is true
Or create the table manually:
CREATE TABLE output_table (
id UInt64,
data String,
created_at DateTime DEFAULT now()
) ENGINE = ReplacingMergeTree(created_at)
ORDER BY id;
Duplicates may be visible until background merge; use FINAL or rely on merge for read-time deduplication.
Trino Sink
For Iceberg catalogs, enable MERGE-based upsert:
sink:
type: trino
config:
serverURL: "http://trino:8080"
catalog: iceberg # catalog name must contain "iceberg"
schema: default
table: output_table
upsertMode: true
conflictKey: id
On match, rows are updated; there is no ifNewer version guard for Trino yet.
Kafka Sink
By default the Kafka producer uses requiredAcks: all, idempotent: true, compression: snappy, and async: true (AsyncProducer with flush defaults 100 messages / 100ms) for durable batched produce. You can tune produce behavior in sink config:
| Field | Default | Notes |
|---|---|---|
requiredAcks |
all |
all | local | none; idempotent requires all |
compression |
snappy |
none | gzip | snappy | lz4 | zstd |
idempotent |
true |
When true, maxOpenRequests must be 1 |
async |
true |
false → SyncProducer (historical per-message RTT) |
flushMessages / flushBytes / flushFrequency |
100 / unset / 100ms when async | Sarama produce flush thresholds |
Consumers should still handle potential duplicates (e.g., idempotent processing or deduplication by key) for end-to-end exactly-once semantics.
Best Practices
- Use idempotent sinks for PostgreSQL (UPSERT), ClickHouse (
upsertMode/ ReplacingMergeTree), and Trino Iceberg (MERGE) when using polling sources or when duplicates are possible. - Kafka source: Consumer group stores offset; at-least-once is preserved. Idempotent sink recommended for batch sinks. Use
ackGranularity: messageto shrink the re-read window; addcollapseBatchOnMessageAck: falsewhen you still need a large sinkbatchSize. - batchSize / ackGranularity / collapseBatchOnMessageAck: Treat delivery watermark and physical write size as separate knobs — see Decoupling ack and sink batch.
- Migration / cron workloads: combine
checkpointSyncOnAck: true, idempotent sink, and optionallyupsertStrategy: ifNewerwhen a version column exists. - Trino
queryTimeoutSeconds: Use a timeout large enough for peak load; too low values increase false failures on long INSERTs. - batchFlushIntervalSeconds: Shorter intervals flush more frequently, reducing in-flight data at risk.
- Error sink: Configure
spec.errorsto capture failed messages for replay or analysis.
Graceful Shutdown
On SIGTERM (e.g., pod eviction, node drain):
- The processor receives the signal and cancels the context.
- Sinks flush in-flight batches before exiting.
PreStop: sleep 5gives time for the load balancer to stop routing traffic.
Ensure terminationGracePeriodSeconds is sufficient for large batches to flush (default: 600 seconds).
Checkpoint Persistence
Enabled by default
The checkpointPersistence field in the DataFlow spec defaults to true. You do not need to set it explicitly — checkpoint persistence is enabled for all DataFlows with polling sources.
Checkpoint persistence is enabled by default. The read position (lastReadChangeTime, lastReadOrderByValue) is persisted to ConfigMap df-<name>-checkpoint. On processor restart, polling sources (PostgreSQL, ClickHouse, Trino) resume from the last committed position, reducing duplicates.
Canonical checkpoint JSON per source type:
{
"lastReadChangeTime": "2024-06-01T12:00:00.123456789Z",
"lastReadOrderByValue": 5042
}
Legacy formats are normalized on load:
| Legacy | Canonical |
|---|---|
Trino: {"lastReadID": 100} |
{"lastReadOrderByValue": 100} |
ClickHouse: {"lastReadID": 100, "lastReadTime": "..."} |
composite fields above |
Time-only: {"lastReadChangeTime": "..."} |
unchanged (single-column WHERE until order key appears) |
After restart, Trino/ClickHouse checkpoints that only had lastReadID use order-key filtering (WHERE orderByColumn > N) until the first ack with a timestamp; then tuple filtering (changeTrackingColumn, orderByColumn) > (time, key) applies.
To disable, set checkpointPersistence: false:
apiVersion: dataflow.dataflow.io/v1
kind: DataFlow
metadata:
name: my-dataflow
spec:
checkpointPersistence: false # Disable (default: true)
source:
type: postgresql
# ...
The controller creates the ConfigMap and RBAC (ServiceAccount, Role, RoleBinding) for the processor. Checkpoint is saved with debounce (every 30 seconds by default) and on graceful shutdown.
Sync checkpoint on ack (spec.checkpointSyncOnAck)
By default, pending checkpoints are flushed to the ConfigMap on a debounce timer (checkpointSaveInterval, default 30s) and on graceful shutdown. After a pod crash, polling sources may re-read up to one debounce interval of data.
Set checkpointSyncOnAck: true to request a checkpoint flush after each sink batch ack. Flush is asynchronous (does not block the ack path) and still coalesced, not more often than checkpointSaveInterval. Recommended for migration and cron workloads:
spec:
checkpointSyncOnAck: true
checkpointSaveInterval: 5s
source:
type: postgresql
# ...
sink:
type: postgresql
config:
upsertMode: true
conflictKey: material_id
Checkpoint reset
To re-run a migration or cron job from the beginning without manually editing df-<name>-checkpoint:
spec:
checkpointReset: true # one-shot; cleared by the controller after the next reconcile
Or set an annotation on the DataFlow:
metadata:
annotations:
dataflow.dataflow.io/reset-checkpoint: "true"
The processor clears the persisted checkpoint for the source type on startup, then reads from the beginning.
Strict idempotency (spec.strictIdempotency)
When strictIdempotency: true, admission rejects polling sources paired with a main sink that does not have upsertMode enabled. When false (default), a warning is emitted instead.
Summary Checklist
| Scenario | Recommendation |
|---|---|
| PostgreSQL sink | upsertMode: true + conflictKey; upsertStrategy: ifNewer when version column exists |
| ClickHouse sink | upsertMode: true or manual ReplacingMergeTree + ORDER BY dedup key |
| Trino sink (Iceberg) | upsertMode: true + conflictKey |
| Kafka → batch sink | ackGranularity: message (+ optional collapseBatchOnMessageAck: false for throughput) or smaller batchSize + idempotent sink |
| Kafka source | Idempotent sink; ackGranularity: message for faster offset commit |
| Polling sources | Idempotent sink; checkpointSyncOnAck: true for migration/cron |
| batchSize vs ack | Keep separate: see collapseBatchOnMessageAck — do not assume message ack always means batchSize: 1 |