You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
State persistence is inconsistent across subsystems. Offsets have a RocksDB path, but subscription registration, push buffers, in-flight deliveries, and A2A tasks remain largely in-memory. A2A documentation also states that InMemoryA2AMessageTransport and the in-memory TaskRegistry are part of the current implementation.
Risks
A Runtime crash, restart, migration, or network partition produces uncontrollable redelivery or state loss.
A2A can bypass the Runtime's reliability, DLQ, audit, and authorization paths.
"At-least-once" cannot be formed into an end-to-end, verifiable contract.
SessionRegistry (Meta prefix-watch + local cache; agents/bindings/sessions)
partial (Meta)
✓
Acceptable — already unified, Meta round-trip is rare (lifecycle only, not per-message)
DeadLetterStore
DeadLetterSink interface (CompletableFuture)
downstream-only
n/a
No — only tracks "已 dead-letter" by retiring the delivery; no durable "DLQ ledger"
TaskStore
absent — a2a/TaskRegistry.java deleted by #5274 revert; only a2a/EventMeshA2ATransport.java (58 lines) remains
n/a
n/a
No — A2A tasks have no state model at all
The #5301 acceptance criterion"Each store has an interface + at least one durable implementation" is met only for OffsetStore. The other five either lack a clean interface (DeliveryStateStore is a ConcurrentHashMap field), have no persistent back-end, or do not exist.
The second criterion"Delivery state is recoverable after a hard restart" is not met — the most concrete gap.
The third"ACK validation binds to delivery ownership (id + epoch + idempotency key)" — the deliveryId already includes bootEpoch + instanceSalt + seq (#5291), but there is no persistent ledger to bind it against on restart.
The fourth"A2A tasks persist through TaskStore and route through the Runtime dispatcher" requires a fresh TaskStore plus integration with EventMeshA2ATransport.
Proposed direction (revised — staged delivery)
Three-tier persistence model
The 6 stores fall into three tiers by access pattern. Forcing them into a single backend is the wrong abstraction; each gets the one that matches its access pattern.
Tier
Stores
Backend
Rationale
local-only (per-instance, hot path)
OffsetStore, DeliveryStateStore
RocksDB + in-process LRU cache
High write rate (per-ACK), per-key independence — no need for cluster sharing, just crash recovery
cluster-shared (cross-instance, lifecycle)
SubscriptionStore, SessionStore, TaskStore
Meta (Nacos/etcd) + local cache
Low write rate, must be visible to peers — Meta is the natural fit
durable-egress (terminal state)
DeadLetterStore
Downstream DLQ topic (existing) + Meta "DLQ ledger" for "which deliveryIds are confirmed dead"
The message body lives in the MQ DLQ topic; we only need to durably mark the transition so the dispatcher can retire the delivery
This is not a single SPI — it is a contract per tier, with the existing MetaStore interface (#5308) acting as the cluster-shared tier's backend. The OffsetStore + MetaBackedOffsetStore pattern (local-first, async dirty-flush to Meta) is the reference design.
Acceptance contract per store
Store
Interface methods (min)
Backend
Atomicity
Restart-recovery
OffsetStore
readOffset, writeOffset (returns boolean), readAllOffsets, readAllTopics, flush, close
tasks survive Runtime restart; A2A SSE streams are rebuilt from getTask
Delivery model (revised)
The single highest-leverage change is making ReliableDispatcher state survive a hard restart. Concretely:
Persist pending to RocksDB on every put/remove/reschedule — bounded LRU cache (e.g. 10K entries) + async batch flush. Throughput target: ≤ 10% regression vs. the in-memory baseline (InMemoryOffsetStore-style).
On restart, UniRuntime.start() calls ReliableDispatcher.recover() — read all persisted pending entries, re-ACK each one with its stored offset and clientId (no re-deliver, the MQ cursor was already advanced past these). The MqAckCallback is not re-run on recovery (broker already considered the message gone).
tick() resumes from the persisted nextAttemptAt clock — so a delivery that was 3 seconds from retry when the JVM died retries 3 seconds after the new instance is up, not on the next 30-second ACK window.
Add DeadLetterStore.recordDeadLetter as a separate gate — when tick() calls dlqSink.deadLetter(...), we only retire after both the downstream DLQ topic write succeeds AND DeadLetterStore.recordDeadLetter succeeds (Meta CAS). The Meta record is the "is this deliveryId confirmed dead" ledger for restart.
Subscription and Session (revised scope)
Both already work via Meta + local cache. The #5301 work here is API alignment, not rewrite:
SubscriptionStore gains a thin interface wrapping ClusterSubscriptionStore (so OffsetStore-style swapping is possible), and the local cache is moved from ClusterSubscriptionStore itself to a generic CachingMetaStore<K,V> helper. No semantic change.
SessionStore already has the right shape; the work is documentation + a SessionStore interface extracted from SessionRegistry (same rationale).
TaskStore (new)
Reintroduce the A2A task state model lost in #5274. Foundations still in tree:
a2a/EventMeshA2ATransport.java (58 lines) — transport contract
a2a/TaskRegistry.java is gone; we rebuild a fresh, Meta-backed implementation
Design constraints (carried over from the original #5259 design, refined):
TaskStore is the only writer to /em/tasks/<taskId>; A2A handlers read through it.
A task-expiry reaper (mirrors SessionRegistry.expireStaleSessions) handles PENDING tasks idle for > N minutes.
Staged delivery (sub-PRs)
The work is too large for a single PR. Proposing four sub-PRs, each independently mergeable:
Sub-PR
Title
Scope
Touches
Approx. PR size
Unblocks
A
feat(state): introduce StatefulStore SPI + 6 store interfaces
Define 6 store interfaces; extract SubscriptionStore / SessionStore from their concrete classes; add StatefulStore factory for InMemory / RocksDB / Meta-backed backends; baseline unit tests for the SPI
~ 12 files (+400 / -200)
small
locks the API surface for B/C/D
B
fix(state): persist ReliableDispatcher.pending to RocksDB (fixes #5294#5295)
Implement RocksDBDeliveryStateStore + bounded LRU; add ReliableDispatcher.recover() called from UniRuntime.start; in-process fault-injection tests (crash mid-delivery, restart, re-ACK); per-#5308ClusterDeliveryFaultTest style
Sub-PR ordering: A → B → C → D (A is a pure refactor, B/C can proceed in parallel after A, D after B+C).
Sub-PR A and B are the minimum needed to consider #5301 materially advanced: A locks the contract, B delivers the headline acceptance criterion ("Delivery state is recoverable after a hard restart") and incidentally closes #5294 / #5295.
Acceptance criteria (revised)
All 6 stores expose a stable interface (Sub-PR A)
StatefulStore SPI with at least one persistent backend for each store (Sub-PR A)
DeliveryStateStore recovers across a hard restart — no orphaned in-flight deliveries (Sub-PR B)
DeadLetterStore.recordDeadLetter is the durable gate before a delivery is retired (Sub-PR C)
TaskStore reintroduced; A2A tasks persist and route through the Runtime dispatcher, not a parallel gateway (Sub-PR C)
ACK validation binds to delivery ownership — deliveryId already encodes bootEpoch + instanceSalt + seq ([Bug] Prevent stale ACKs from matching reused delivery IDs #5291) and is now persisted via DeliveryStateStore, so a stale ACK cannot match a fresh delivery after restart (Sub-PR B)
Cross-store fault-injection tests pass (Sub-PR D)
Open questions for reviewer
Sub-PR A interface names — happy with OffsetStore / SubscriptionStore / DeliveryStateStore / SessionStore / DeadLetterStore / TaskStore? The current code already has OffsetStore and SessionRegistry-shape; only the latter four would gain new interface files.
DeliveryStateStore write throughput — proposing 10K-entry LRU + 100ms batch flush as the default. If the existing ReliableDispatcher baseline is 50K ACKs/s, this should keep ≥ 90% of that; if measured < 90%, fall back to RocksDB write-through (no LRU) and accept the latency.
Backward compatibility — the existing ClusterSubscriptionStore and SessionRegistry are public API. Sub-PR A must keep them as concrete classes; the interfaces are additions. If the project requires breaking changes, follow [Bug] Unify cluster delivery topology and fence stale partition owners #5293's enum-removal pattern and document the migration in a CHANGELOG entry.
Related concrete bugs
The following at-least-once reliability defects in the current develop code are the concrete manifestations of this missing unified state control plane. Landing this issue should fix them together:
Phase 2 — Unified state control plane
Problem
State persistence is inconsistent across subsystems. Offsets have a RocksDB path, but subscription registration, push buffers, in-flight deliveries, and A2A tasks remain largely in-memory. A2A documentation also states that
InMemoryA2AMessageTransportand the in-memoryTaskRegistryare part of the current implementation.Risks
Current state (post #5307 + #5308)
Concrete on-develop assessment as of 2026-08-20 (
b43df620d+7260581):OffsetStoreInMemoryOffsetStore+RocksDBOffsetStore+MetaBackedOffsetStore(local+remote dirty-flush)SubscriptionStoreClusterSubscriptionStore(Meta prefix-watch + local cache)targetsFor(...)cluster view, not a general KV APIDeliveryStateStoreReliableDispatcher.pending=ConcurrentHashMap<String, Delivery>SessionStoreSessionRegistry(Meta prefix-watch + local cache; agents/bindings/sessions)DeadLetterStoreDeadLetterSinkinterface (CompletableFuture)TaskStorea2a/TaskRegistry.javadeleted by #5274 revert; onlya2a/EventMeshA2ATransport.java(58 lines) remainsThe #5301 acceptance criterion "Each store has an interface + at least one durable implementation" is met only for
OffsetStore. The other five either lack a clean interface (DeliveryStateStoreis aConcurrentHashMapfield), have no persistent back-end, or do not exist.The second criterion "Delivery state is recoverable after a hard restart" is not met — the most concrete gap.
The third "ACK validation binds to delivery ownership (id + epoch + idempotency key)" — the
deliveryIdalready includesbootEpoch + instanceSalt + seq(#5291), but there is no persistent ledger to bind it against on restart.The fourth "A2A tasks persist through TaskStore and route through the Runtime dispatcher" requires a fresh
TaskStoreplus integration withEventMeshA2ATransport.Proposed direction (revised — staged delivery)
Three-tier persistence model
The 6 stores fall into three tiers by access pattern. Forcing them into a single backend is the wrong abstraction; each gets the one that matches its access pattern.
OffsetStore,DeliveryStateStoreSubscriptionStore,SessionStore,TaskStoreDeadLetterStoreThis is not a single SPI — it is a contract per tier, with the existing
MetaStoreinterface (#5308) acting as the cluster-shared tier's backend. TheOffsetStore+MetaBackedOffsetStorepattern (local-first, async dirty-flush to Meta) is the reference design.Acceptance contract per store
OffsetStorereadOffset,writeOffset(returnsboolean),readAllOffsets,readAllTopics,flush,closewriteLock(#5289 ✓)DeliveryStateStoreput(Delivery),remove(deliveryId),get(deliveryId),iterate(consumer),count(),flush,closeputis last-writer-wins;removewins on retry-vs-ack race (#5290 pattern)SubscriptionStoreput(topic,clientId,sub),remove(topic,clientId),targetsFor(topic,event),instanceOf(clientId),topics()/em/subs/...(existing)tryAcquireCAS on register raceSessionStoreSessionRegistry)/em/agents/,/em/bindings/,/em/sessions/(existing)heartbeatre-writeDeadLetterStorerecordDeadLetter(deliveryId, dlqOffset)(idempotent),isDeadLettered(deliveryId),flush,close/em/dlq/<deliveryId>=<dlqTopic>:<offset>TaskStorecreateTask,getTask,updateStatus(taskId, status),listByAgent(agentId, status),expireStale(olderThanMs)/em/tasks/<taskId>(new)getTaskDelivery model (revised)
The single highest-leverage change is making
ReliableDispatcherstate survive a hard restart. Concretely:pendingto RocksDB on everyput/remove/reschedule— bounded LRU cache (e.g. 10K entries) + async batch flush. Throughput target: ≤ 10% regression vs. the in-memory baseline (InMemoryOffsetStore-style).UniRuntime.start()callsReliableDispatcher.recover()— read all persisted pending entries, re-ACK each one with its storedoffsetandclientId(no re-deliver, the MQ cursor was already advanced past these). TheMqAckCallbackis not re-run on recovery (broker already considered the message gone).tick()resumes from the persistednextAttemptAtclock — so a delivery that was 3 seconds from retry when the JVM died retries 3 seconds after the new instance is up, not on the next 30-second ACK window.DeadLetterStore.recordDeadLetteras a separate gate — whentick()callsdlqSink.deadLetter(...), we only retire after both the downstream DLQ topic write succeeds ANDDeadLetterStore.recordDeadLettersucceeds (Meta CAS). The Meta record is the "is this deliveryId confirmed dead" ledger for restart.Subscription and Session (revised scope)
Both already work via Meta + local cache. The #5301 work here is API alignment, not rewrite:
SubscriptionStoregains a thin interface wrappingClusterSubscriptionStore(soOffsetStore-style swapping is possible), and the local cache is moved fromClusterSubscriptionStoreitself to a genericCachingMetaStore<K,V>helper. No semantic change.SessionStorealready has the right shape; the work is documentation + aSessionStoreinterface extracted fromSessionRegistry(same rationale).TaskStore (new)
Reintroduce the A2A task state model lost in #5274. Foundations still in tree:
a2a/EventMeshA2ATransport.java(58 lines) — transport contracta2a/TaskRegistry.javais gone; we rebuild a fresh, Meta-backed implementationDesign constraints (carried over from the original #5259 design, refined):
TaskStoreis the only writer to/em/tasks/<taskId>; A2A handlers read through it.TaskRecord = { taskId, agentId, clientId, status, createdAt, updatedAt, input, output? };status ∈ {PENDING, RUNNING, COMPLETED, FAILED, CANCELED}./a2a/tasks/send,/a2a/tasks/sendSubscribe) writeTaskRecordviaTaskStore.createTask; SSE streams (/a2a/tasks/{id}/stream) read status transitions throughTaskStore.getTask.EventMeshA2ATransportis invoked fromSubscriptionManager(mode = a newA2A_DISPATCH), not from a parallelA2AGateway([ISSUE #5259] Add A2A Gateway: REST API, SSE streaming, Task lifecycle, Java SDK and tests #5260 was the parallel path we want to avoid). This is the property the original [Enhancement][A2A] Agent-to-Agent Gateway: REST API, SSE streaming, Task lifecycle, Java SDK #5259 design missed and the cause of Revert: drop A2A Gateway (#5260) and Agent Card Registry (#5246) — migrate to develop #5274.task-expiryreaper (mirrorsSessionRegistry.expireStaleSessions) handles PENDING tasks idle for > N minutes.Staged delivery (sub-PRs)
The work is too large for a single PR. Proposing four sub-PRs, each independently mergeable:
feat(state): introduce StatefulStore SPI + 6 store interfacesSubscriptionStore/SessionStorefrom their concrete classes; addStatefulStorefactory forInMemory/RocksDB/Meta-backedbackends; baseline unit tests for the SPIfix(state): persist ReliableDispatcher.pending to RocksDB(fixes #5294 #5295)RocksDBDeliveryStateStore+ bounded LRU; addReliableDispatcher.recover()called fromUniRuntime.start; in-process fault-injection tests (crash mid-delivery, restart, re-ACK); per-#5308ClusterDeliveryFaultTeststylefeat(state): DeadLetterStore + TaskStore(fixes #5292 fully)DeadLetterStore(Meta-backed ledger, gates retirement); reintroduceTaskStore+TaskRecord;EventMeshA2ATransportwires to Runtime dispatcher;a2amode inSubscriptionManagertest(state): cross-store crash-recovery + split-brain fault injectionSub-PR ordering: A → B → C → D (A is a pure refactor, B/C can proceed in parallel after A, D after B+C).
Sub-PR A and B are the minimum needed to consider #5301 materially advanced: A locks the contract, B delivers the headline acceptance criterion ("Delivery state is recoverable after a hard restart") and incidentally closes #5294 / #5295.
Acceptance criteria (revised)
interface(Sub-PR A)StatefulStoreSPI with at least one persistent backend for each store (Sub-PR A)DeliveryStateStorerecovers across a hard restart — no orphaned in-flight deliveries (Sub-PR B)DeadLetterStore.recordDeadLetteris the durable gate before a delivery is retired (Sub-PR C)TaskStorereintroduced; A2A tasks persist and route through the Runtime dispatcher, not a parallel gateway (Sub-PR C)deliveryIdalready encodesbootEpoch + instanceSalt + seq([Bug] Prevent stale ACKs from matching reused delivery IDs #5291) and is now persisted viaDeliveryStateStore, so a stale ACK cannot match a fresh delivery after restart (Sub-PR B)Open questions for reviewer
OffsetStore/SubscriptionStore/DeliveryStateStore/SessionStore/DeadLetterStore/TaskStore? The current code already hasOffsetStoreandSessionRegistry-shape; only the latter four would gain new interface files.TaskStoreand an A2A dispatch mode. Should it also bring back theA2A GatewayREST surface ([ISSUE #5259] Add A2A Gateway: REST API, SSE streaming, Task lifecycle, Java SDK and tests #5260, reverted by Revert: drop A2A Gateway (#5260) and Agent Card Registry (#5246) — migrate to develop #5274), or landTaskStorefirst and defer the HTTP surface to a follow-up issue?DeliveryStateStorewrite throughput — proposing 10K-entry LRU + 100ms batch flush as the default. If the existing ReliableDispatcher baseline is 50K ACKs/s, this should keep ≥ 90% of that; if measured < 90%, fall back to RocksDB write-through (no LRU) and accept the latency.ClusterSubscriptionStoreandSessionRegistryare public API. Sub-PR A must keep them as concrete classes; the interfaces are additions. If the project requires breaking changes, follow [Bug] Unify cluster delivery topology and fence stale partition owners #5293's enum-removal pattern and document the migration in a CHANGELOG entry.Related concrete bugs
The following at-least-once reliability defects in the current
developcode are the concrete manifestations of this missing unified state control plane. Landing this issue should fix them together:Related
docs/eventmesh-uni-architecture-redesign.md§13.2 (will be updated per sub-PR)