Skip to content

fix(state): persist ReliableDispatcher.pending to DeliveryStateStore (issue #5301 Sub-PR B, fixes #5294 #5295) - #5311

Merged
qqeasonchen merged 4 commits into
apache:developfrom
qqeasonchen:feat/state-stores-spi-b
Aug 27, 2026
Merged

fix(state): persist ReliableDispatcher.pending to DeliveryStateStore (issue #5301 Sub-PR B, fixes #5294 #5295)#5311
qqeasonchen merged 4 commits into
apache:developfrom
qqeasonchen:feat/state-stores-spi-b

Conversation

@qqeasonchen

Copy link
Copy Markdown
Contributor

Sub-PR B: DeliveryStateStore (issue #5301 §DeliveryStateStore)

Makes in-flight delivery state survive a hard JVM restart. Every delivery is now persisted to a DeliveryStateStore on deliver/ack/nack/tick, and on startup the dispatcher's recover() re-ACKs each persisted record against the OffsetStore so no client is left without a progress advance and no delivery becomes an orphan.

What's in this PR

  • DeliveryStateStore — new interface in org.apache.eventmesh.runtime.state:

    • put(Record) / remove(String) / get(String) / iterate(Consumer<Record>) / count() / flush() / close()
    • Record carries (deliveryId, topic, partition, offset, clientId, attempt, nextAttemptAtMs, encodedEvent) — channel and mqAckCallback are runtime references that do NOT survive restart
    • Record.toDelivery() rebuilds a live Delivery for tick / iterate use
  • InMemoryDeliveryStateStore — test contract baseline (ConcurrentHashMap-backed)

  • RocksDBDeliveryStateStore — production; key=deliveryId, value=ASCII line with base64-wrapped event bytes. Mirrors the RocksDBOffsetStore pattern (no extra dependency for the same field count).

  • ReliableDispatcher now depends on a DeliveryStateStore (constructor parameter, default InMemory). The previous in-memory ConcurrentHashMap is gone:

    • tick() iterates the store
    • ack() / nack() remove / put via the store
    • deliver() persists via the store
    • Retry-vs-ack race is guarded at the iterate-then-act layer (snapshot semantics + re-read)
  • recover() — new method: on startup, walk the store, write the stored offset as if the client had ACKed, advance the MQ physical cursor (emmqoffset/emmqpartition), remove the record. Never re-runs the channel — the broker has already either redelivered (Kafka / RocketMQ 4.x PULL) or considered the message gone (RocketMQ 5.x POP). Idempotent.

  • UniIngressService wires dispatcher.recover() into its constructor so a fresh JVM picks up unacked deliveries from a prior process without an explicit boot step.

  • package-info updated: DeliveryStateStore is now a real Javadoc link, not a placeholder.

Tests

  • DeliveryStateStoreTest — in-memory + RocksDB contract harness (put/remove/get/count/iterate, including rocksDbPersistsAcrossClose)

  • DeliveryRecoveryTest — fault injection: crash mid-delivery, fresh dispatcher on the same store, recover() retires all in-flight records and advances the OffsetStore without re-running the channel; idempotency; empty-store no-op; nextAttemptAtMs round-trip

Closes

Acceptance criteria touched

  • DeliveryStateStore recovers across a hard restart — no orphaned in-flight deliveries ✓
  • ACK validation binds to delivery ownership — deliveryId is now persisted via DeliveryStateStore, so a stale ACK cannot match a fresh delivery after restart ✓

Sub-PR ordering

A (PR #5310 ✓ merged) → B (this PR) → C (DeadLetterStore + TaskStore Meta backends) → D (cross-store fault-injection)

…(issue apache#5301 Sub-PR B, fixes apache#5294 apache#5295)

Makes in-flight delivery state survive a hard JVM restart: every delivery is now
persisted to a DeliveryStateStore on deliver/ack/nack/tick, and on startup the
dispatcher's recover() re-ACKs each persisted record against the OffsetStore so
no client is left without a progress advance and no delivery becomes an orphan.

* new DeliveryStateStore interface in ...runtime.state:
  - put/remove/get/iterate/count/flush/close
  - Record carries (deliveryId, topic, partition, offset, clientId, attempt,
    nextAttemptAtMs, encodedEvent) - channel and mqAckCallback are runtime
    references that do NOT survive restart
  - toDelivery() rebuilds a live Delivery for tick/iterate use
* InMemoryDeliveryStateStore (test contract baseline)
* RocksDBDeliveryStateStore (production; key=deliveryId, value=ASCII line with
  base64-wrapped event bytes; mirrors RocksDBOffsetStore's pattern)
* ReliableDispatcher now depends on a DeliveryStateStore (constructor parameter,
  default InMemory). The previous in-memory ConcurrentHashMap is gone; tick()
  iterates the store, ack() removes via the store, deliver() persists via the
  store.
* New recover(): on startup, walk the store, write the stored offset as if the
  client had ACKed, advance the MQ physical cursor, remove the record. Never
  re-runs the channel - the broker has already either redelivered (Kafka /
  RocketMQ 4.x PULL) or considered the message gone (RocketMQ 5.x POP).
  Idempotent.
* UniIngressService wires dispatcher.recover() into its constructor so a fresh
  JVM picks up unacked deliveries from a prior process.
* package-info updated: DeliveryStateStore is now a real link, not a placeholder.

Tests:
* DeliveryStateStoreTest: in-memory + RocksDB contract harness (put/remove/get/
  count/iterate; rocksDbPersistsAcrossClose)
* DeliveryRecoveryTest: crash mid-delivery, fresh dispatcher, recover() retires
  all in-flight records and advances the OffsetStore without re-running the
  channel; idempotency; empty store no-op; nextAttemptAtMs round-trip

Closes apache#5294 (unacked broadcast deliveries lost during cursor recovery) and
apache#5295 (RocketMQ 5 POP broker ACK not gated on distribution completion).
…#5301)

- RocksDBDeliveryStateStore: reorder imports to java.* before org.* (ImportOrder)
- DeliveryStateStore: close cross-line {@link} and {@code} inline tags in Javadoc
- package-info: break line 31 to under 150 chars (LineLength)
- DeliveryStateStoreTest: rename local var aUpdated to aupdated (LocalVariableName)
- DeliveryRecoveryTest: add EmptyLineSeparator between TestChannel field and @OverRide method
apache#5301 Sub-PR B)

- After deliveryId was prefixed to the value, decode's offset/clientId/attempt
  still read the pre-shift ranges, so attempt = parseInt(clientId field) threw
  NumberFormatException and every RocksDB round-trip (contract + persist-across-close)
  failed. partition was also off by one.
- Rewrite decode so each field reads its correct (pK+1, pK+1) range: deliveryId
  [0,p1), topic [p1+1,p2), partition [p2+1,p3), offset [p3+1,p4), clientId
  [p4+1,p5), attempt [p5+1,p6), nextAttemptAtMs [p6+1,p7), event [p7+1,end).
- recover() offset advance is monotonic (writeOffset = max), so recovery is
  order-independent and DeliveryRecoveryTest's 101L assertion holds.
@qqeasonchen
qqeasonchen merged commit b99c167 into apache:develop Aug 27, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant