Eigenstate
Essays / Eigenvalues

One fill, two deliveries, one ledger entry

The consumer wrote the fill, then died before committing its Kafka offset. On restart, the same record came back. The holding stayed at one.

Revised Jason Shen

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
Recorded Kafka / SQLite runs. Each row uses a fresh topic, consumer group and database. Inspect the records.

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. 1 / Database committed

    Holding 1; F1 marker present.

    Both database writes survived.

  2. 2 / Consumer killed

    Kafka’s next offset: 0.

    The group had not recorded progress.

  3. 3 / Replacement reads F1

    Holding 1; duplicate skipped.

    Kafka’s next offset advances to 1.

The same record at partition 0, offset 0 reached both processes. The second process found the committed marker and left the holding unchanged.

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.