To count a fill once, the consumer needs to remember that it has already applied it. I put that processed-fill marker and the holding update in one SQLite transaction, then killed the consumer on either side of the commit. Both restarts recovered to one unit. Committing those writes separately left either zero or two.
These are recorded runs with a real local Kafka broker, process kills and database writes. The fill, account and instrument are constructed.
Four crash points, one expected holding
Fill F1 buys 1 unit. The expected final holding is 1.
Marker and holding committed together
Killed before the transaction committed
Neither write survived; restart applied F1.
- After kill
- 0
- After restart
- 1
Killed after the transaction committed
Both writes survived; restart skipped F1.
- After kill
- 1
- After restart
- 1
Marker and holding committed separately
Killed after committing only the marker
Restart skipped a fill that never changed the holding.
- After kill
- 0
- After restart
- 0
Killed after committing only the holding
Restart applied the same fill a second time.
- After kill
- 1
- After restart
- 2
The difference is what the replacement can infer from finding the marker. If the marker and holding commit together, “already processed” means the fill actually changed the holding. Separating those commits lets one fact survive without the other.
Why Kafka delivered the fill again
The database and Kafka recorded different things. SQLite held the position and processed-fill identities. Kafka held the consumer group’s progress through the records it was reading. Finishing the database write did not update that progress.
Kafka stores records in topic partitions. An offset identifies a record’s position within one partition; a group’s committed offset is where its next read should start. Here, F1 was at offset 0. Committing offset 1 would move the group past it.
In each crash case, the consumer died before making that Kafka commit. The replacement joined the same group and received offset 0 again. This is the at-least-once processing window: doing the work before committing consumption progress allows the work to be delivered again after a crash. Kafka’s delivery semantics describe this ordering.
1 / Database committed
Holding 1; F1 marker present.
Both database writes survived.
2 / Consumer killed
Kafka’s next offset: 0.
The group had not recorded progress.
3 / Replacement reads F1
Holding 1; duplicate skipped.
Kafka’s next offset advances to 1.
Every replacement eventually committed offset 1, including the two that left the wrong holding. Advancing the consumption offset showed progress through Kafka; the database’s commit boundary determined whether the ledger was correct.
Keeping the marker and the effect together
The working variant used one SQLite transaction for the identity check, the processed-fill marker and the position update. The marker’s primary key was (source, account, fill_id). Kafka’s offset was not part of that identity.
For a fill not already recorded, the relevant sequence was:
BEGIN IMMEDIATE
check the fill identity
insert its processed marker
add its quantity to the position
COMMIT
commit the Kafka offset
BEGIN IMMEDIATE acquires SQLite’s write transaction before the check. The identity lookup and both writes remain inside it. If the identity already exists, the consumer checks that the payload matches and ends the transaction without increasing the position. A different payload under the same identity raises an error instead of being silently discarded. SQLite’s transaction documentation describes the write-transaction behavior used here.
The transaction leaves two useful restart states: neither write committed, so apply F1; or both committed, so recognize it and skip the update. In the before-commit run, process death rolled back both writes. In the after-commit run above, both survived. The replacement did not need to infer whether the previous process had reached the end of its function.
A fill’s identity can cross Kafka offsets
Redelivery of offset 0 is only one way to see F1 twice. An upstream application can also submit the same business event in two separate producer calls.
The fifth case did exactly that with Kafka producer idempotence enabled. Both sends succeeded: one at offset 0, another at offset 1. The consumer applied the first, recognized the second by its source, account and fill ID, and finished with holding 1 and committed offset 2.
Producer idempotence protects against duplicates arising from the producer’s retries. It does not interpret two intentional application sends as the same trade because their payloads happen to match. The result makes the distinction concrete: these were two Kafka records describing one fill. A deduplication key based only on topic, partition and offset would give them different identities.
Kafka also supports transactional processing across Kafka records and consumption offsets. Extending a guarantee to an external destination requires that destination’s cooperation; enabling Kafka transactions alone does not include an arbitrary SQLite write. Those scopes are described in the same Kafka design account. This experiment uses ordinary explicit consumer commits and a separate database transaction.
The business identity has a lifetime, too. This experiment retains its markers. Deleting F1’s marker while F1 can still be replayed would remove the evidence the consumer uses to skip it. Deciding when that evidence can be retired belongs with the replay policy, including intentional reprocessing from older records.
Inspect or reproduce the runs
The reproduction bundle includes the executed source, process receipts and database exports. The recorded results keep the offsets and holdings behind the figures on this page.
The experiment grew out of the trading replay’s Read F1 again action. That browser example illustrates the unchanged holding; these runs examine the consumer-crash windows behind that behavior. They cover selected local cases, not production reliability.
How the crashes were run
Each crash case used a fresh topic containing F1, buying one unit of TEST for a demonstration account. The group started with committed offset 0. Automatic offset commits and automatic offset storage were disabled, so the consumer explicitly committed its progress after its database work.
At a named checkpoint, the child process reported where it had reached and waited. The parent sent SIGKILL, verified exit code -9, inspected the database and queried the group offset. It then launched a new consumer with the same group. The replacement subscribed normally; the harness did not seek it backward. The separate-commit variants used the same fill, group-offset policy and restart procedure.
Recorded on 10 September 2026 with Apache Kafka 4.0.2, confluent-kafka and librdkafka 2.12.0, Python 3.9.6, SQLite 3.51.0 and Temurin Java 21.0.12.1 on macOS arm64. Kafka ran as one local KRaft broker with one partition per case, replication factor 1 and the classic consumer-group protocol. SQLite used a rollback journal and synchronous=FULL. Each case had its own fresh topic, group and database.
Recorded results include producer acknowledgments, received offsets, checkpoint and exit records, post-crash database snapshots and committed offsets queried from Kafka. The reproduction bundle contains the executed Python source, pinned client requirement, instructions, per-process receipts, final SQL exports and file hashes. The instructions also describe retained local broker logs; the broker and Java distributions are separate downloads whose recorded checksums are included.
What these runs cover, and where Redis would differ
The five cases examine selected consumer-crash windows and a repeated business identity at a new offset. They do not test broker failure, disk loss, concurrent consumers, multiple partitions, outbound order execution, marker expiry or throughput. The database transaction covers the local marker and position; there is no transaction spanning Kafka and SQLite. These results do not establish production reliability or an end-to-end guarantee for a trading system.
Redis Streams was not run here. Its XACK removes a record from a consumer group’s pending list, and XAUTOCLAIM can transfer pending work after the configured idle interval. A separate implementation would still need to relate that acknowledgment to the external database write.