Guarantees & Correctness
This page defines DeltaForge’s data delivery guarantees, ordering model, transaction semantics, failure handling, and operational boundaries. The guarantees below describe current behavior. Where a verification is planned rather than already in place, the Correctness Test Matrix marks it explicitly, so read a guarantee together with its test status.
Delivery Guarantees
Per-sink delivery tiers
| Sink | Delivery guarantee | Dedup mechanism | Consumer action required |
|---|---|---|---|
Kafka (exactly_once: true) | Transactional atomic-batch delivery; at-least-once across restart | Each batch is one Kafka transaction, so read_committed consumers never see a partial batch; a restart can replay an already-committed batch as a new transaction (duplicate) | Set isolation.level=read_committed, and dedup by event ID for cross-restart duplicates |
| Kafka (default) | At-least-once (idempotent producer) | Retries are deduped by rdkafka; crash-replay produces duplicates | Dedup by event ID or idempotency key |
| NATS JetStream | At-least-once + server-side dedup | Nats-Msg-Id header within duplicate_window | Configure duplicate_window on stream |
| Redis Streams | At-least-once + consumer-side dedup | idempotency_key field in XADD payload | Check idempotency_key before processing |
| HTTP/Webhook | At-least-once | Retry on 5xx/timeout; no server-side dedup | Consumer must be idempotent (use event id) |
| S3 (Parquet / JSON Lines) | At-least-once at file granularity, atomic at file boundary | Same file may be re-emitted with a different ULID on retry/replay | Dedup downstream via MERGE INTO or event_id |
Terminology rule: DeltaForge does not claim end-to-end exactly-once delivery for any sink. Every sink is at-least-once; the differences are in what each sink adds on top. Kafka exactly_once: true adds transactional atomic-batch visibility (a read_committed consumer sees a whole batch or none of it) but still delivers at-least-once across a restart, so consumers must dedup to reach exactly-once end to end. NATS and Redis add a dedup mechanism (server-side or consumer-side) whose effectiveness depends on configuration or consumer behavior outside DeltaForge’s control. Calling any of these “exactly-once” without the consumer’s cooperation would be misleading.
What “at-least-once” means
- No data loss: every event from the source is delivered to the sink at least once. Checkpoints are saved only after the sink acknowledges delivery — never before.
- Duplicates on crash recovery: if DeltaForge crashes after delivering a batch but before saving the checkpoint, that batch is replayed on restart. Consumers must handle duplicates (see Consumer Guidance below).
- No silent drops: events are never discarded. On a transient delivery error a sink applies a bounded in-sink retry (exponential backoff, finite attempts). If those attempts are exhausted on a
requiredsink, the checkpoint does not advance and the batch is re-delivered from the source on the next pipeline restart (source replay) - there is no unbounded in-session retry loop. See Error Classification & Retry.
What Kafka transactions guarantee (exactly_once: true)
exactly_once: true gives transactional atomic-batch delivery, not end-to-end exactly-once. Each batch is wrapped in a Kafka transaction (begin_transaction / commit_transaction), so consumers using isolation.level=read_committed see a whole batch or none of it - never a partial batch. If a transaction fails before commit, it is aborted and re-attempted from the same checkpoint position.
The restart boundary is still at-least-once: DeltaForge advances its own checkpoint only after the Kafka transaction commits, so a crash between the commit and the checkpoint persist replays that batch on restart as a new transaction. A read_committed consumer then sees the batch a second time. To reach exactly-once end to end, consumers must dedup on the event id (or idempotency key), which is stable across replays.
The transactional-producer overhead is ~7-11% with properly tuned batch sizes. See the Performance guide for benchmark details.
Ordering Model
Within a source
Events are emitted in the source’s native order:
- MySQL: binlog file + position order.
WriteRowsEventbatches preserve row order within each binlog event. - PostgreSQL: LSN (Log Sequence Number) order. One WAL message per row change.
DeltaForge does not reorder events. The source order is preserved through the pipeline.
Within a batch
All events in a batch maintain their source order. The delivery task processes batches in FIFO order from a bounded channel — no reordering between batches.
Per-primary-key ordering (the core guarantee)
DeltaForge guarantees per-primary-key ordering within a table under non-sharded operation. This means: for any single row identified by its primary key, all changes (INSERT, UPDATE, DELETE) are delivered to the sink in the exact order they occurred in the source database.
For Kafka specifically: the default message key is the serialized primary key, so events for the same row always go to the same partition and arrive in order. With dynamic routing (key template), ordering follows the resolved key — events with the same key are ordered; different keys may land in different partitions.
Cross-table ordering
There is no global ordering across tables. Events from different tables may be interleaved across batches. This is by design — enforcing global ordering would require single-threaded delivery, which would cap throughput.
However, when batch.respect_source_tx: true (the default), all rows from a single database transaction are kept in the same batch (see Transaction Boundaries below). This preserves causal ordering within a transaction.
Ordering under retries
When a batch delivery fails and is retried, the batch is re-delivered as a unit in the same order. No reordering occurs within or across retries. The max_inflight=1 setting (default) ensures strict ordering; with max_inflight > 1, batches are still delivered in FIFO order by the single-threaded delivery task.
Cross-sink ordering
All sinks receive the same batch simultaneously. The relative order of events is identical across all sinks.
Transaction Boundaries
How it works
When batch.respect_source_tx: true (the default), the coordinator checks each event’s tx_end flag before splitting a batch:
- MySQL:
tx_endis set on the last row of each XID (transaction commit) event. - PostgreSQL:
tx_endis set on the COMMIT WAL record.
The batch accumulator will not split a batch at a point that would separate rows from the same transaction. If the batch limit (max_events or max_bytes) is reached mid-transaction, the batch grows beyond the limit to include all remaining rows in that transaction.
What this guarantees
- All rows from one database transaction appear in the same batch.
- A batch is DeltaForge’s unit of delivery accounting: a sink’s checkpoint advances only if the whole batch succeeds. Whether a consumer observes the batch atomically depends on the sink:
- Kafka
exactly_once: true: the batch is one Kafka transaction, so aread_committedconsumer sees all rows from the DB transaction together or not at all - within a single delivery. Across a restart the batch can be replayed as a new transaction, so the consumer may see the same transaction twice (at-least-once); dedup on eventidto collapse it. - Non-transactional sinks (Redis, HTTP, NATS, S3, ClickHouse, Elasticsearch): DeltaForge does not make the batch atomic for the consumer. A sink failure partway through a batch can leave some rows visible downstream, and a retry or restart re-delivers the batch, producing duplicates. These sinks are at-least-once; use their stated dedup mechanism.
- Kafka
- Cross-table transactions: a transaction spanning tables A and B is emitted as a single batch containing events for both tables, tagged with the same
tx_id, and delivered as a unit. This is stronger than “tagged but not grouped” - all events from one DB transaction are in one batch. Consumer-visible atomicity for that batch still follows the per-sink rule above.
Precise transaction semantics
To avoid ambiguity, here is exactly what DeltaForge guarantees about transactions:
- Events from one source transaction are emitted contiguously within a single batch.
- Multi-table transactions preserve commit grouping — all rows from tables A and B in one DB transaction appear in the same batch.
- Within a single sink, the batch is the unit of delivery accounting: the sink’s checkpoint advances only if the whole batch succeeds. Consumer-visible atomicity of that batch holds only for a transactional sink (Kafka
exactly_once: true) and only within one delivery; see What this guarantees. - Across heterogeneous sinks, DeltaForge does not guarantee atomic commit. Kafka may commit a transaction while Redis is still retrying. Each sink’s checkpoint tracks its own progress independently.
- Retries and restarts do not break transaction grouping (a replayed batch contains the same events in the same order), but they can re-deliver a transaction that was already delivered. Delivery is at-least-once; consumers dedup on event
id.
Edge cases
- A single database transaction that exceeds
max_eventsormax_bytesis still kept in one batch. The limits are exceeded rather than the transaction being split. - With
respect_source_tx: false, batches are split purely by size/time limits regardless of transaction boundaries. Cross-table transaction atomicity is not preserved in this mode.
Disconnect inside a transaction (PostgreSQL)
When the replication stream ends inside a transaction (the connection is closed or reset), the source reconnects to the same verified server, resumes at the end of the last transaction it handed on, abandons the cut-off transaction (an internal abort marker, never a CDC event) and receives it again from its beginning:
| Mode | Disconnect inside a transaction |
|---|---|
respect_source_tx: true (default) | The buffered prefix is discarded; the transaction is delivered once, with one commit boundary |
respect_source_tx: false | Rows already delivered for it cannot be recalled; its replay may deliver them again (at-least-once duplicates) |
| Either mode | The checkpoint never advances for the abandoned attempt; a process restart resumes from the durable checkpoint |
Each abandoned transaction logs a warning (source, transaction, last complete position, events abandoned, whether duplicates are possible). Metrics:
| Metric | Meaning |
|---|---|
deltaforge_source_reconnects_total | The source reconnected after a transient stream error |
deltaforge_source_transaction_aborts_total | The source found its stream interrupted inside an open transaction and abandoned it |
deltaforge_source_replayed_events_total | Events the source had already handed on for an abandoned attempt and sends again with the replay. Includes events the coordinator only buffered (never delivered) under respect_source_tx: true: it is not a count of downstream duplicates |
deltaforge_abandoned_tx_total{outcome="discarded"} | The coordinator discarded the abandoned attempt’s buffered prefix (no duplicates) |
deltaforge_abandoned_tx_total{outcome="may_duplicate"} | respect_source_tx: false: part of the abandoned attempt may already have reached sinks and may be delivered again |
respect_source_tx is true unless a pipeline sets it to false: BatchConfig fills every key a pipeline’s batch section omits from its defaults (respect_source_tx: true), and the coordinator applies the same defaults when there is no batch section at all. Keep it true for production. In that mode an open transaction is buffered in memory up to max_tx_events / max_tx_bytes, beyond which the oversized_tx policy applies.
Initial-snapshot anchoring
The initial snapshot and the CDC stream meet at a single anchor so that no committed row is lost across the boundary. The checks below run only when a snapshot runs; CDC-only pipelines (snapshot.mode = never) are unaffected.
- PostgreSQL anchors CDC at the replication slot’s consistent point
C. Every row committed at or beforeCis in the snapshot; every row afterCis in the CDC stream. Rows committed in(C, snapshot-export]fall in both - a bounded at-least-once overlap, not exactly-once. Current-state sinks (withversion_source: source_position) converge via last-writer-wins; append-only sinks receive the overlap twice. A snapshot completed under the older anchor is flagged by the gaugedeltaforge_snapshot_unsafe_anchor = 1until a safe re-snapshot. - MySQL brackets the anchor under a brief
FLUSH TABLES WITH READ LOCK: all snapshot workers open their consistent-snapshot transactions and the binlog position + GTID set are captured while the lock is held, so every worker shares one view that matches the captured position exactly. This closes a real initial-snapshot data-loss window present in earlier versions (independent per-worker snapshots with the position captured afterward). It requiresgtid_mode = ON,binlog_format = ROW, InnoDB tables, and the globalRELOADprivilege; managed MySQL withoutRELOADfails closed rather than snapshotting unsafely (no silent fallback).
Snapshot completion follows the commit policy. A snapshot is complete only once its terminal barrier is committed by the sinks the policy requires; an interrupted snapshot is copied again in full (at-least-once, never resumed table by table); a sink that missed part of a completed snapshot is reported (sink_snapshot_incomplete) and excluded from the resume position rather than silently treated as complete. See Initial Snapshots.
Failure Isolation
Per-sink independence
All sinks deliver concurrently. One sink’s failure does not block other sinks:
- The coordinator dispatches the same batch to all sinks simultaneously.
- Each sink’s delivery result is collected independently.
- Only sinks that delivered successfully get their checkpoints advanced.
- Failed sinks remain at their prior checkpoint position — they will receive the same batch again on retry or restart.
Coordinator-level deadline (defense in depth)
In addition to each sink’s own internal timeout (e.g. send_timeout_secs on the S3/Kafka/Redis/NATS/HTTP sinks), the pipeline supports an outer deadline that the coordinator applies to every send_batch call:
spec:
sink_batch_deadline_secs: 60 # outer bound on any one sink's send_batch
Behavior:
- Each sink’s future is wrapped in
tokio::time::timeout(deadline, ...). If a sink exceeds the deadline, the coordinator gives up on it for that batch, classifies it asSinkError::Backpressure, and continues with the others. - A timed-out required sink causes its checkpoint to stay behind (same as any failure); the source replays from
MIN(checkpoints)on restart. - A timed-out optional sink advances none of its state for that batch; same in-session-loss / on-restart-recovery semantics as documented in Required vs. optional sinks.
- Metric:
deltaforge_coordinator_sink_timeout_total{pipeline,sink}. - Default:
None(no outer bound). Set this for defense-in-depth against a sink that hangs despite its own internal timeout (bug, deadlock, infinite retry loop in a dependency).
The two timeout layers are complementary:
| Layer | Catches | Granularity |
|---|---|---|
send_timeout_secs (sink-internal) | Misbehavior of the sink’s underlying API/library (e.g. object_store retries, rdkafka queue) | Per sink, per batch |
sink_batch_deadline_secs (coordinator) | Misbehavior of the sink itself (deadlock, slow Rust code, ignored cancellation) | All sinks, per batch |
Required vs. optional sinks
Each sink is marked required: true (default) or required: false:
- Required: must succeed for the pipeline to consider the batch delivered. Each sink applies bounded sink-local retries (finite attempts with backoff) within a single
send_batch; if those are exhausted for a required sink, no checkpoint advances for any sink and the pipeline stops. There is no in-session pipeline-level retry of the batch - recovery is by source replay fromMIN(checkpoints)on the next restart. - Optional (best-effort): failures are logged but don’t prevent the pipeline from advancing. The failed sink’s own checkpoint stays at its last-successful position; the source’s MIN-checkpoint reader doesn’t go back, but on the next pipeline restart, the source replays from the failed sink’s stuck position and the failed sink catches up.
What an optional sink failure means in practice
When an optional sink fails for batch B (events at LSN 100):
- Required sinks succeed → their checkpoints advance to LSN 100.
- The optional sink’s checkpoint stays at its prior position (LSN 50, say).
- The pipeline continues to batch B+1 (events at LSN 101+). The source’s in-memory position is now 101, not 50.
- The optional sink receives batch B+1 with events at LSN 101+. It may succeed or fail again: its checkpoint stays at LSN 50 for the rest of the session either way. A later success never moves a sink’s checkpoint past a batch that sink failed (in both batching modes).
- Until restart: if the optional sink stays failing, the source’s in-memory position keeps marching forward. The failed events between the sink’s stuck checkpoint and “now” are not in any retry queue for this session.
- On restart: the source reads
MIN(required_cp, optional_cp) = 50and replays from 50. The optional sink (back from outage, presumably) catches up. The replay is source-level: every sink receives it, so sinks already past LSN 50 see duplicates and dedup or accept (idempotent design).
Before the source delivers anything, every configured sink without a checkpoint (a new pipeline, or a newly added sink) gets one at the position the source starts from. A sink that fails its very first batch is therefore part of MIN(...) and the batch is replayed to it on restart. If that initial checkpoint cannot be persisted, the source does not start (fail closed).
This is the practical reality of required: false:
- ✅ A failed optional sink does not stall the pipeline.
- ✅ A failed optional sink does not lose data permanently — restart-replay recovers it.
- ❌ A failed optional sink does not catch up in-flight — events between failure and restart are only delivered on restart.
- ❌ A long-running pipeline with a chronically failing optional sink accumulates a growing replay gap — the longer between failures and restart, the more events the failed sink will re-deliver after restart.
For the S3 sink specifically (where extended outages are realistic — S3 throttling, regional issues), the operational choice is:
| Goal | Configuration |
|---|---|
| S3 must not miss any event in-flight, accept source backpressure | required: true (default). Slow/down S3 backpressures the source. |
| Kafka must keep flowing during S3 outages, accept replay-on-restart latency for S3 catch-up | required: false on S3, required: true on Kafka. Pipeline keeps moving for Kafka; S3 catches up after the next restart. |
| Truly independent throughput | Run two pipelines with the same source DSN — one for Kafka, one for S3. Each has its own coordinator and backpressure. Or use a Kappa-style architecture (Kafka → second pipeline → S3) with Kafka’s retention as the natural retry buffer. |
Multi-batch retries vs in-batch retries
Two distinct retry layers exist and should not be conflated:
- In-batch retries (within a single
send_batchcall): each sink’s internal retry policy (exponential backoff, finite attempts). These are fast (<1s typical) and apply to transient errors likeQueue fullorConnection timeout. - Pipeline-level retries (across batches): triggered by
required: truefailures. The coordinator does NOT auto-retry the same batch from the source channel — instead, the source-side replay mechanism handles recovery on restart.
required: false skips pipeline-level retries for the failing sink. There is no “background queue” that retries the failed batch later in the same session.
Commit policy
The commit policy determines when checkpoints advance:
| Policy | Behavior |
|---|---|
required (default) | All required: true sinks must acknowledge |
all | Every sink (required and optional) must acknowledge |
quorum | At least N sinks must acknowledge |
The policy is checked before any checkpoint is committed. If the policy isn’t satisfied, no sink advances — this prevents optional sinks from getting ahead of failed required sinks.
Per-sink checkpoints
Each sink maintains its own checkpoint key ({source_id}::sink::{sink_id}). On restart, the source replays from the minimum checkpoint across all sinks. This means:
- A fast sink is never held back by a slow one during normal operation: while the pipeline keeps running, the source’s in-memory read position does not rewind for a lagging sink.
- Recovery is not selective per sink. On restart the source resumes from the
MINcheckpoint across all sinks, so every sink - including ones already ahead - is re-delivered the events from that minimum forward. Sinks that were ahead receive duplicates and must dedup (see On crash recovery). - Adding a new sink lowers the
MINto that sink’s earliest position, so on the next restart the source replays from there and the existing sinks are re-delivered those events too (they dedup). The new sink is not backfilled in isolation.
Fatal errors
Some errors are unrecoverable and stop the pipeline immediately:
- Kafka ProducerFenced: another producer instance started with the same
transactional.id. The broker fences the old producer permanently. - Permanent auth revocation: credentials are invalid and retrying won’t help.
Fatal errors return SinkError::Fatal and are not retried. The pipeline stops and requires operator intervention.
MySQL replay across a schema change
MySQL rows are decoded by column position. Each rows event is decoded with the schema version proven for its position in the binlog - the definition the table had when the rows were written - not with the table’s current definition, so a restart that replays rows written before a DDL decodes them with the old definition and the rows after it with the new one. How a version is proven is described in Schema tracking. The proven version must also agree with the column layout the binlog recorded for those rows (column count, storage types and type metadata; with binlog_row_metadata=FULL also names, signedness, charsets and the primary key); if it does not, the source stops fail-closed.
When no version can be proven, the source stops fail-closed: it emits no event for those rows and does not advance the checkpoint, rather than put values under the wrong columns. With binlog_row_metadata=MINIMAL (the MySQL default) this happens when retained rows predate a DDL the source never observed (for example a checkpoint older than a schema change made while an earlier deployment ran), after a statement that cannot be attributed to specific tables (a versioned-comment DDL, several statements in one event, ALTER DATABASE or DROP DATABASE of a tracked database, among others; the tables it may affect stay unproven until later proof for each exists - usually the table’s next rows, once its definition is captured and proven unchanged since them, or a later DDL of the table that the source proves), and when two DDLs of one table both happen before the source reads the first, with rows between them. The source never chooses a version from the MINIMAL layout alone: two tables or columns with the same storage types cannot be told apart without names.
Recovery: the safe default is to re-snapshot (restart the pipeline once with snapshot mode always), which rebuilds current state without decoding the retained rows. Moving the source position past the DDL instead is a manual, lossy action: it intentionally abandons every retained change between the checkpoint and the new position, for all captured tables (the binlog position is shared by the whole stream), and should only be taken after an operator assessment of that data loss.
Set binlog_row_metadata=FULL to remove most of these stops: without positional proof the source then selects the one recorded definition that exactly matches the rows’ metadata, and records that choice durably before emitting them. Rows whose definition was never recorded (the shape between two DDLs the source did not see separately) still stop the source; FULL lets them through only when that definition was already recorded and is the single exact match.
S3 sink atomicity guarantees
The S3 sink commits at file granularity, not event granularity. Specifically:
- File-level atomicity: a Parquet or JSONL file is only visible at its final S3 key after the multipart-complete call returns success. Readers never see a partial file. Verified by tests (
abandon_all_produces_no_visible_file,drop_pool_without_close_produces_no_visible_file). - At-least-once per file: if
send_batchsucceeds and the process crashes before the source checkpoint is committed, the same events will be re-delivered on restart and produce a new file with a different ULID. The original file is also retained — there is no automatic deduplication. Downstream consumers must dedup viaMERGE INTOorevent_id. - Mid-upload crash: a process killed during a multipart upload leaves orphan parts on S3 (not a visible file). The bucket lifecycle policy
AbortIncompleteMultipartUpload: DaysAfterInitiation=1reclaims them within 24h. This policy is an operational prerequisite — without it, abandoned uploads accumulate storage cost. See deployment. - Per-row DLQ: encoder failures are isolated per row via a slow-path retry on the same writer. The bad row appears in
BatchResult.dlq_failuresand is routed to the DLQ; the rest of the batch is written normally. The fast path (success) costs nothing extra — per-event retry only triggers on encoder errors. ParquetFileWriter and JsonLinesFileWriter both roll back their state on per-call failure so the writer remains safe to reuse during the retry loop.
For exactly-once at the event level (i.e., dedup without consumer cooperation), the planned Iceberg sink in Phase 2 uses atomic snapshot commits to make file appearance and event commit equivalent operations.
Error Classification & Retry
Retry behavior by sink
All sinks use exponential backoff with jitter. The classification determines whether an error is retried:
Kafka:
| Error | Classification | Behavior |
|---|---|---|
| Queue full | Retryable | Backoff, retry (100ms base, 10s max, 3 attempts) |
| Message timeout | Retryable | Backoff, retry |
| Broker connection failure | Retryable | Backoff, retry |
| Authentication failure | Non-retryable | Fail immediately |
| Message too large | Non-retryable | Fail immediately |
| Producer fenced | Fatal | Pipeline stops |
| Transaction commit failure (fatal) | Fatal | Pipeline stops |
NATS:
| Error | Classification | Behavior |
|---|---|---|
| Connection failure | Retryable | Backoff, retry (50ms base, 5s max, 3 attempts) |
| Publish timeout | Retryable | Backoff, retry |
| Authentication failure | Non-retryable | Fail immediately |
| No responders | Non-retryable | Fail immediately |
Redis:
| Error | Classification | Behavior |
|---|---|---|
| Connection failure | Retryable | Backoff, retry (50ms base, 5s max, 3 attempts) |
| Command timeout | Retryable | Backoff, retry |
| NOAUTH / WRONGPASS | Non-retryable | Fail immediately |
| Permission denied | Non-retryable | Fail immediately |
S3:
| Error | Classification | Behavior |
|---|---|---|
| Network timeout / partition | Retryable (in-batch) | object_store retries the part upload with its built-in backoff; if still failing, send_batch returns SinkError::Io |
503 SlowDown (throttling) | Retryable (in-batch) | object_store honors Retry-After; send_batch waits |
send_timeout_secs exceeded | Bounded wait | After send_timeout_secs (default 60s), send_batch returns SinkError::Backpressure. Caps the worst-case latency a single batch can contribute. Coordinator routes per required — block (default) or log+continue. Useful when upstream object_store retries would otherwise stack into multi-minute waits. |
403 AccessDenied / SigV4 mismatch | Non-retryable | SinkError::Io immediately; check credentials and region |
NoSuchBucket | Non-retryable | SinkError::Io; create the bucket or fix the config |
| Encoder failure (e.g. value doesn’t fit Decimal128 precision) | Per-row isolated | Slow-path per-event retry isolates the bad row; it lands in BatchResult.dlq_failures and is routed to the DLQ. The other events in the batch are written normally. |
| Mid-multipart abandon (process killed) | N/A | File never becomes visible at the destination key. Orphan parts cleaned by the bucket’s lifecycle policy (operational prerequisite — see deployment) |
After retry exhaustion
If all retry attempts fail for a retryable error, the error is propagated to the coordinator. The coordinator’s behavior depends on the commit policy:
- Required sink: the batch is not committed and its checkpoint stays put. The coordinator does not re-inject the same batch from the source channel in-session; recovery happens by source replay from
MIN(checkpoints)on the next restart (see Multi-batch retries vs in-batch retries). - Optional sink: the failure is logged, and the pipeline continues with other sinks.
Checkpoint Semantics
When checkpoints are saved
The checkpoint commit follows a strict sequence:
1. Accumulate events from source into a batch
2. Run processors (transform, filter)
3. Deliver batch to ALL sinks concurrently
4. Check commit policy (required/all/quorum)
5. Commit per-sink checkpoints (only for successful sinks)
Key invariant: a checkpoint is saved only after the sink has acknowledged delivery AND the commit policy is satisfied. This is the foundation of at-least-once delivery.
On crash recovery
- DeltaForge reads per-sink checkpoints from the checkpoint store.
- The source resumes from the minimum checkpoint across all sinks.
- Sinks that were already ahead of the minimum position receive duplicate events - they must handle these idempotently (Kafka
read_committedstill sees the replayed batch as a new transaction, so dedup on eventidthere too). - Sinks that were behind receive their missing events.
Checkpoint storage
Checkpoints are stored in SQLite (default) with WAL mode and synchronous=NORMAL for durability. The checkpoint store survives SIGKILL — no graceful shutdown required for checkpoint safety.
Event replay
Event replay re-delivers captured commit units from the durable journal to selected sinks. Its correctness guarantees:
- At-least-once re-delivery. The durable cursor is advanced only after a sink acknowledges an envelope. A crash between the acknowledgement and the cursor persist causes the acknowledged envelope to be delivered again on restart, so dedup-capable sinks must absorb duplicates (the same idempotency key applies).
- No checkpoint mutation during replay. A running job never advances or rewinds the source or per-sink checkpoints; only normal live delivery moves checkpoints after the handoff.
- Pause and handoff. Selected sinks are excluded from live delivery and from commit-policy
evaluation while a job runs, and rejoin the live set only at an acknowledged handoff to a
frozen tail
H; the first live delivery to a restored sink is strictly afterH. - Fail-closed capture. A commit unit that cannot be captured verbatim aborts the pipeline rather than advancing the checkpoint past uncaptured data.
Backpressure
DeltaForge implements end-to-end backpressure without dropping events:
Source → [event channel] → Accumulator → [batch channel (max_inflight)] → Delivery → Sinks
- Sink slow: delivery task blocks waiting for sink acknowledgement.
- Batch channel full: accumulator blocks waiting to enqueue the next batch (bounded by
max_inflight). - Event channel full: source blocks waiting to enqueue the next event.
- Source slows: the database connection idles until the channel has capacity.
No events are dropped at any stage. Backpressure propagates from the slowest sink all the way back to the source connection.
max_inflight controls the pipeline depth: higher values allow overlapping batch building with delivery (better throughput), lower values reduce memory usage and latency.
Consumer Guidance
Idempotency key
Every event has a deterministic idempotency key in the format:
{tenant}|{db}.{table}|{tx_id}|{event_id}
This key is identical across replays — the same source event always produces the same key.
- Kafka with
exactly_once: true: setisolation.level=read_committedon consumers. No application-level dedup needed. - Kafka without
exactly_once: use the event’sidfield (UUID v7) or the idempotency key to detect duplicates. - NATS JetStream: server-side dedup via
Nats-Msg-Idheader. Configureduplicate_windowon the stream to cover your maximum expected downtime (default: 2 minutes). - Redis Streams: check the
idempotency_keyfield in the stream entry before processing. Use a Redis SET or application-level tracking to remember processed keys.
Dedup window
How long should consumers remember processed event IDs? Match your maximum expected DeltaForge downtime:
| Scenario | Recommended window |
|---|---|
| Normal operation (no crashes) | No dedup needed (at-most-once per run) |
| Planned restarts | 5 minutes |
| Unplanned crashes with auto-restart | 15-30 minutes |
| Disaster recovery | Match your RPO |
Correctness Test Matrix
This matrix maps guarantees to their verification. Rows marked Exists have a test in the suite today; rows marked Planned do not yet - treat those guarantees as design intent pending verification, not as tested behavior.
| Guarantee | Test | Type | Status |
|---|---|---|---|
| No data loss (at-least-once) | crash_recovery chaos scenario | Chaos | Exists |
| Kafka transactional atomic-batch delivery | exactly_once chaos scenario + kafka_sink_exactly_once_* | Chaos + Integration | Exists |
| At-least-once across restart (committed row reaches Kafka after outage + restart) | kafka_outage_restart_e2e | Integration | Exists |
| Producer fencing detection | kafka_sink_exactly_once_producer_fencing | Integration | Exists |
| Per-primary-key ordering | Events keyed by PK → same Kafka partition | By design | Verified via Kafka partition assignment |
| Transaction boundary preservation | respect_source_tx + check_and_split coordinator logic | Unit | Exists |
| Per-sink checkpoint independence | test_per_sink_checkpoint_only_advances_on_success | Unit | Exists |
| Per-sink checkpoint legacy fallback | per_sink_proxy_falls_back_to_legacy_key | Unit | Exists |
| Commit policy gate before checkpoint | test_per_sink_checkpoint_only_advances_on_success | Unit | Exists |
| DLQ routes per-event failures | test_dlq_routes_failed_events_and_pipeline_continues | Unit | Exists |
| DLQ all-fail batch | test_dlq_all_events_fail_no_send | Unit | Exists |
| Required sink + un-routable row + no DLQ holds checkpoint | test_required_sink_dlq_failure_no_writer_holds_checkpoint | Unit | Exists |
| Required sink + DLQ cannot persist (full/reject) holds checkpoint | test_required_sink_dlq_full_reject_holds_checkpoint | Unit | Exists |
| Optional sink drop holds its own checkpoint (pipeline continues) | test_optional_sink_dlq_failure_holds_its_checkpoint | Unit | Exists |
| Every configured sink is initialized at the start position before delivery; failure stops startup | sink_checkpoints_are_initialized_only_where_absent, sink_checkpoint_initialization_fails_closed, startup_fails_closed_without_sink_checkpoints, pg_startup_fails_closed_without_sink_checkpoints | Unit / Integration | Exists |
A pipeline reports running only once its capture boundary is fixed and durable; a write after running is delivered; start-up retries, a stop while starting and a stale run never report running; a failure before the stream opens resumes at the persisted boundary | capture_boundary_e2e, a_pipeline_is_starting_until_its_run_publishes_its_boundary, a_stale_run_never_marks_its_replacement_running, ready_returns_503_while_a_pipeline_is_starting | Unit / Integration | Exists |
| A first-batch failure replays after restart (MySQL, PostgreSQL, both batching modes) | *_first_batch_failure_replays_after_restart | Unit / Integration | Exists |
| A failed sink stays held through later successes; restart replays the batch to it (both batching modes) | aligned_/unaligned_optional_failure_replays_after_restart, *_consecutive_failures_hold, *_final_batch_failure_replays_after_idle | Unit | Exists |
| Out-of-range DLQ failure index fails closed | test_out_of_range_dlq_index_holds_checkpoint | Unit | Exists |
DLQ Block overflow is bounded (fails closed, no hang) | dlq::overflow_block_times_out_and_fails_closed | Unit | Exists |
| DLQ overflow (drop_oldest) | dlq::overflow_drop_oldest | Unit | Exists |
| DLQ overflow (reject) | dlq::overflow_reject_drops_new | Unit | Exists |
| DLQ overflow (block) | dlq::overflow_block_waits_for_ack | Unit | Exists |
| DLQ cleanup expired | dlq::cleanup_expired_removes_old_entries | Unit | Exists |
| Partial batch timer flush | test_partial_batch_flushed_by_timer | Unit | Exists |
| Network partition recovery | network_partition chaos scenario | Chaos | Exists |
| Sink outage recovery | sink_outage chaos scenario | Chaos | Exists |
| Schema drift handling | schema_drift chaos scenario | Chaos | Exists |
| MySQL failover detection | failover chaos scenario | Chaos | Exists |
| Postgres failover detection | pg_failover chaos scenario | Chaos | Exists |
| Binlog purge detection | binlog_purge chaos scenario | Chaos | Exists |
| Replication slot drop detection | slot_dropped chaos scenario | Chaos | Exists |
| NATS dedup within window | Verify Nats-Msg-Id prevents duplicates | Integration | Planned |
| Redis idempotency key | Verify consumer-side dedup via key | Integration | Planned |
| Snapshot → CDC handoff | No gaps; engine-specific documented overlap | Integration | Exists |
| Snapshot crash, replacement and policy completion | snapshot_restart_e2e, postgres_snapshot_e2e, s3_ministack_durable_canary | Integration | Exists |
Limitations
These are not guaranteed and are documented honestly:
- No cross-table global ordering — events from different tables may be interleaved. This is by design; enforcing global order would require single-threaded delivery and cap throughput. Use
respect_source_tx: trueto preserve ordering within database transactions. - No stateful stream processing — DeltaForge does not support joins, aggregations, or windowing. For stateful processing, consume DeltaForge’s output with Apache Flink, ksqlDB, or Kafka Streams.
- Dead letter queue - when
journal.enabled: true, poison events (serialization/routing failures) are durably appended to the DLQ before the sink acknowledges, so the pipeline advances without losing the row. A row is only treated as isolated once its DLQ append is confirmed. If there is no DLQ, or the DLQ cannot durably capture the row (overflow drop/reject, or a backend write failure), the affected sink does not acknowledge and its own checkpoint is held (fail closed) so restart replay can recover the row. A required sink in that state also stops the pipeline (commit policy unsatisfied); an optional sink does not stop the pipeline, but its checkpoint still stays behind until the row is recovered. TheBlockoverflow policy waits for space but is bounded - it fails closed rather than hanging. Configure a DLQ with a non-dropping overflow policy so poison rows are isolated and every sink keeps moving. See the DLQ page. - No in-session retry for optional sinks — when
required: falseand a sink fails, the failed batch is not retried in the same session. The failed sink’s checkpoint stays at its prior position; events are re-delivered only on pipeline restart via source replay. For sinks with realistic outage windows (e.g., S3 throttling, cross-region issues), the operator must weigh source backpressure (required: true) against replay-on-restart latency (required: false). See Required vs. optional sinks. - S3 sink — at-least-once at file granularity — duplicate events across a crash boundary appear in two files with different ULIDs. Per-row DLQ isolates encoder failures, but does not provide exactly-once delivery on its own; that requires the Phase 2 Iceberg sink (atomic snapshot commits). Consumers must dedup downstream via
MERGE INTOorevent_id. See S3 sink atomicity guarantees. - S3 sink — lifecycle policy required for production — DeltaForge does not track multipart upload IDs externally. Abandoned multiparts are reclaimed by the bucket’s
AbortIncompleteMultipartUploadlifecycle rule. Without this rule, S3 storage cost accumulates on every aborted batch. - Snapshot duplicates - snapshot rows can be delivered more than once: PostgreSQL’s bounded snapshot-to-stream overlap, and every row of a snapshot copied again after an interruption or upgrade. Consumers should resolve them by source position or idempotency key. See Initial Snapshots.