Skip to content

Delivery guarantees

Every connector delivers at least once. A record that a source read, or a sink was handed, reaches its destination one or more times. The only way it can reach it zero times is a plugin that reports success for a write it did not do.

This page is precise about where the "or more" comes from, how wide the window is, and what each sink does with a duplicate when it arrives.

There is no exactly-once mode. PicoMQ does not implement Kafka transactions, and the systems on the other side of a source have no shared commit to join. The runtime offers a small, well-defined duplicate window instead, and sinks that make duplicates harmless where the destination allows it.

Two cursors, two commit points

SideCursorKept byMoves when
SinkConsumer group offsetThe brokerThe plugin has confirmed the write
SourceState blob, plus whatever the external system tracks itselfThe state store, and the external systemEvery record of the batch has a delivery report

Each side has exactly one point at which its cursor moves, and that point is after the write it covers has been confirmed.

sinkfetchconsumecommit offsetcrash here: batch redeliveredsourcepollproducesave stateackcrash here: batch re-read and re-produced

The duplicate window

SinkSource
OpensPlugin returns from consume()Last delivery report arrives
ClosesOffset commit reaches the brokerState save completes
WidthOne asynchronous commit, millisecondsOne state store write
On a crash inside itDestination has the batch, group does not. Restarted sink is handed the same batchPicoMQ has the batch, plugin's committed state does not. Restarted source re-reads and re-produces it
Bounded toOne batch. The next is not fetched until the commit is issuedOne batch. A later batch cannot save state ahead of an earlier one that failed

Both windows can also open without a crash.

  • A sink plugin that wrote successfully and then returned an error, a network blip after the INSERT committed for instance, is retried and writes again.
  • A source whose state save fails after a successful produce is nacked, re-reads, and produces again.

Each is one batch of duplicates. Each is preferable to advancing a cursor past data whose fate is unknown.

Failure matrix

What failsSinkSource
Runtime killed between write and commitBatch redelivered on restartBatch re-read and re-produced on restart
Runtime killed at any other pointResumes at last committed offset, nothing repeatedResumes at last saved state, nothing repeated
Broker unreachableFetch stalls, no writes, resumes when the broker returnsProduce fails, batch nacked, retried with backoff. Thirty consecutive failures stop the source with Error
Plugin returns an errorSame batch retried up to five times, 200 ms to 5 s backoff, offset unmoved. Fifth failure stops the sink with ErrorA failed poll() is logged and polled again
Plugin reports success for a write it did not makeData lostData lost
Destination unreachablePlugin's own retries, then an error as abovePlugin's own retries inside poll()
Routing fails for a record with no fallbackNot applicableWhole batch nacked and re-read
Topic creation failsNot applicableBatch nacked and re-read
File state store unwritableNot applicableBatch nacked, source marked Error, re-read on next poll
HTTP state store write outcome unknownNot applicableWrite kept pending and retried with an idempotency key before the next batch. Batches rejected until it resolves
HTTP state store rejects the write permanentlyNot applicableProvider latches. Every batch rejected until the runtime restarts
Plugin panicsProcess aborts. Resumes from the committed offset on restartProcess aborts. Resumes from the saved state on restart

The last row is worth stating plainly. Panics do not cross the plugin boundary, so a plugin bug takes the runtime down rather than one connector. That is intended. A half-alive runtime is harder to reason about than a dead one, and it is why the runtime is meant to run under a supervisor.

What a duplicate becomes

The runtime's contribution ends at "at least once". What the destination ends up holding depends on the sink.

batch 100..149delivered twiceupserting sinkid = topic:0:offsetappend-only sinkno id to collide on50 rowssecond pass rewrites the same rows100 rowssecond pass appends again

Sinks that write to stores with primary keys derive a record identity from topic:partition:offset. It is stable across replays, since a redelivered record arrives at the same offset. They upsert on it, and the destination is indistinguishable from one that saw the batch once.

Sinks that write append-only cannot dedupe on the way in, and a replayed batch appears twice. Some of those destinations have their own tools, noted below. Where the destination offers nothing, consumers of it must tolerate a repeated batch.

SinkOn replay
PostgresUpsert on topic:partition:offset, no visible duplicate
ElasticsearchDocument _id from topic:partition:offset, no visible duplicate
MongoDB_id from topic:partition:offset, no visible duplicate
MeilisearchPrimary key from topic:partition:offset, no visible duplicate
SurrealDBRecord id from topic:partition:offset, no visible duplicate
Redshiftid column from topic:partition:offset. Rows repeat unless deduplicated downstream
ClickHouseRows repeat. A ReplacingMergeTree keyed on the pico_* columns collapses them
InfluxDBPoints with identical measurement, tags and timestamp overwrite. Others repeat
DorisRows repeat. Stream Load labels are unique per attempt
Iceberg, DeltaAppended files repeat rows
S3A second object holding the same records
HTTPThe endpoint receives the batch again
QuickwitDocuments repeat
stdoutPrinted again

What is not covered

  • A record that reaches PicoMQ is durable in object storage before the source is acknowledged. Nothing here depends on a node staying up.
  • Ordering within a topic is preserved through both loops. Every topic has one partition and each batch is a contiguous range from one topic.
  • Ordering across topics is not promised. A source fanning one stream out across many topics produces them in whatever order delivery reports arrive.
  • The guarantee stops at the plugin. A Postgres pooler with its own retry, an HTTP endpoint that returns 200 before persisting, an S3 lifecycle rule, none of these are visible to the runtime.

At-least-once is what the connectors give you. What the destination does with it is the destination's contract.