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
| Side | Cursor | Kept by | Moves when |
|---|---|---|---|
| Sink | Consumer group offset | The broker | The plugin has confirmed the write |
| Source | State blob, plus whatever the external system tracks itself | The state store, and the external system | Every 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.
The duplicate window
| Sink | Source | |
|---|---|---|
| Opens | Plugin returns from consume() | Last delivery report arrives |
| Closes | Offset commit reaches the broker | State save completes |
| Width | One asynchronous commit, milliseconds | One state store write |
| On a crash inside it | Destination has the batch, group does not. Restarted sink is handed the same batch | PicoMQ has the batch, plugin's committed state does not. Restarted source re-reads and re-produces it |
| Bounded to | One batch. The next is not fetched until the commit is issued | One 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
INSERTcommitted 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 fails | Sink | Source |
|---|---|---|
| Runtime killed between write and commit | Batch redelivered on restart | Batch re-read and re-produced on restart |
| Runtime killed at any other point | Resumes at last committed offset, nothing repeated | Resumes at last saved state, nothing repeated |
| Broker unreachable | Fetch stalls, no writes, resumes when the broker returns | Produce fails, batch nacked, retried with backoff. Thirty consecutive failures stop the source with Error |
| Plugin returns an error | Same batch retried up to five times, 200 ms to 5 s backoff, offset unmoved. Fifth failure stops the sink with Error | A failed poll() is logged and polled again |
| Plugin reports success for a write it did not make | Data lost | Data lost |
| Destination unreachable | Plugin's own retries, then an error as above | Plugin's own retries inside poll() |
Routing fails for a record with no fallback | Not applicable | Whole batch nacked and re-read |
| Topic creation fails | Not applicable | Batch nacked and re-read |
| File state store unwritable | Not applicable | Batch nacked, source marked Error, re-read on next poll |
| HTTP state store write outcome unknown | Not applicable | Write kept pending and retried with an idempotency key before the next batch. Batches rejected until it resolves |
| HTTP state store rejects the write permanently | Not applicable | Provider latches. Every batch rejected until the runtime restarts |
| Plugin panics | Process aborts. Resumes from the committed offset on restart | Process 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.
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.
| Sink | On replay |
|---|---|
| Postgres | Upsert on topic:partition:offset, no visible duplicate |
| Elasticsearch | Document _id from topic:partition:offset, no visible duplicate |
| MongoDB | _id from topic:partition:offset, no visible duplicate |
| Meilisearch | Primary key from topic:partition:offset, no visible duplicate |
| SurrealDB | Record id from topic:partition:offset, no visible duplicate |
| Redshift | id column from topic:partition:offset. Rows repeat unless deduplicated downstream |
| ClickHouse | Rows repeat. A ReplacingMergeTree keyed on the pico_* columns collapses them |
| InfluxDB | Points with identical measurement, tags and timestamp overwrite. Others repeat |
| Doris | Rows repeat. Stream Load labels are unique per attempt |
| Iceberg, Delta | Appended files repeat rows |
| S3 | A second object holding the same records |
| HTTP | The endpoint receives the batch again |
| Quickwit | Documents repeat |
| stdout | Printed 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.