blob: 3af13a19d4b8e7c1c4b3a73e5001342a857cc0d3 [file] [view]
# EventMesh control plane
> **Audience:** operators and contributors. What the runtime boot path
> wires from configuration, and how every state store fails when its
> backend goes down.
---
This page documents the control-plane components that the runtime boot path
wires from configuration. For each component, we list the config key, the
default value, the failure behavior when the key is missing or malformed, and
the ingress route(s) that the component gates.
## 1. DeliveryTopology (#5293, #5309)
Selects how the runtime polls / dispatches events. Two modes:
| Mode | Default? | What it does | What it requires |
| --- | --- | --- | --- |
| `LOCAL_STICKY_PULL` | **Yes** | Every runtime instance polls every partition of every subscribed topic. No Meta store dependency. Backward compatible with pre-#5301 deployments. | None |
| `PARTITION_OWNED_PULL` | No | Each instance acquires ownership of a strict subset of partitions via Meta CAS + fencing. No duplicate consumption across instances. | Meta store (Nacos today). Code is intact (see `PartitionOwnership`); the wiring follow-up is tracked in #5309. |
### 1.1 Configuration
* **Key:** `eventmesh.delivery.topology`
* **Default:** `LOCAL_STICKY_PULL` (the value is read in
`EventMeshApplication` via `DeliveryTopology.fromConfig(System.getProperty(...))`).
* **Set via:** JVM `-D` flag (`-Deventmesh.delivery.topology=PARTITION_OWNED_PULL`)
or the `EVENTMESH_DELIVERY_TOPOLOGY` environment variable (the runtime
forwards env-var values into system properties at boot).
* **Documented in `eventmesh.properties`:** the property is listed with a
commented default and an inline explanation of both modes; operators can
uncomment + edit without leaving the config file.
### 1.2 Failure behavior
* **Missing or blank** -> resolved to `LOCAL_STICKY_PULL` (backward
compatible). The startup log emits `delivery topology=LOCAL_STICKY_PULL`.
* **Unknown value** (e.g. `PARTITION_OWNER` typo) -> `IllegalArgumentException`
raised by `DeliveryTopology.fromConfig`. The runtime fails fast at boot;
the operator sees a clear error in `bin/start.sh` output. A typo must not
silently degrade to single-instance mode (see `DeliveryTopologyTest`).
* **`PARTITION_OWNED_PULL` without a Meta store** -> `enableCluster` is
required. The boot path throws if `metaStore` is null when the topology is
`PARTITION_OWNED_PULL`.
### 1.3 Coverage per ingress route
The delivery topology is referenced from a single code path
(`UniRuntime.poll` -> `ownedPartitions(topic)`), which is the consumer of
`UniIngressService.deliver`. Therefore every ingress path is covered by
construction:
| Ingress route | Path through topology |
| --- | --- |
| HTTP `POST /events/publish` | `UniHttpServer` -> `UniIngressService.publish` -> `Frame` -> `Producer.send` -> `poll loop` -> topology-selected partition set |
| A2A `POST /a2a/tasks/send` | `A2AGatewayHttpHandler` -> `A2AGatewayService` -> `UniIngressService` -> same as above |
| Legacy TCP (`UniTcpServer`) | main path is being phased out (see docs/feature/protocols.md #5341); when active, the TCP server feeds the same `UniIngressService` |
| WebSocket push | passive receiver; not gated by topology (the topology decides *which* partitions to *poll*, not the push path) |
| SSE | passive receiver; not gated by topology |
Coverage is verified by `git grep` of `DeliveryTopology` in the runtime
source tree (only `UniRuntime` and `DeliveryTopology` itself should match in
`eventmesh-runtime/src/main/`).
## 2. SecurityGate (#5304)
`SecurityGate` is the per-request control-plane gate that lives in front of
`UniIngressService` and `A2AGatewayService`. It is composed of:
* **FilterChain** (auth / acl): `TokenAuthFilter`, `SignatureVerifierFilter`,
`AclFilter`.
* **QuotaManager** (per-tenant): `TenantQuotaManager`.
* **AuditSink**: pluggable (default is `LoggingAuditSink`).
### 2.1 Configuration
* **Key:** `eventmesh.security.gate.*` (see `eventmesh.properties` section
`eventmesh.security.gate.*` and the per-filter keys).
* **Default:** a no-op gate that allows every request. This is the
backward-compatible default and is documented as such in
`docs/quickstart/configuration.md`.
* **Fail-closed mode:** when `eventmesh.security.gate.failClosed=true`,
any filter chain exception (auth failed, signature invalid, ACL denied)
results in a 401/403 response. The default is fail-open (allowed) so a
misconfigured gate does not silently break a deployment.
### 2.2 Failure behavior
* **Missing or blank** -> no-op gate; every request is allowed. Operators
who need a real gate must set the per-filter keys.
* **Malformed ACL entry** -> logged at WARN, the request is denied (fail
safe in the security context), the deny is counted in the gate's metrics
counter.
* **Audit sink failure** -> logged at ERROR, the request is still allowed
(audit is best-effort; a failed audit must not take down the data path).
### 2.3 Coverage per ingress route
| Ingress route | Gated by SecurityGate? | Where the gate is invoked |
| --- | --- | --- |
| HTTP `POST /events/publish` | **Yes** | `UniHttpServer` -> `RequestContext` -> `SecurityGate.check(ctx, frame)` -> `UniIngressService.publish` |
| HTTP `POST /events/subscribe` | **Yes** | same chain |
| A2A `POST /a2a/tasks/send` | **Yes** | `A2AGatewayHttpHandler` -> gate -> `A2AGatewayService` -> `UniIngressService` |
| Legacy TCP | **Yes** (gated by TCP-level auth, separate from SecurityGate) | `UniTcpServer` -> `TcpIngressBridge` (the TCP path has its own connection-level auth, see docs/feature/protocols.md) |
| Admin HTTP | **Yes** | `UniAdminServer` -> `UniAdminService` (separate gate, configured under `eventmesh.admin.security.*`) |
| WebSocket push | n/a (passive) | push does not invoke the gate; the gate ran at subscribe time |
| SSE | n/a (passive) | same as WS |
| Connector Runtime | **Yes** | `ConnectorScheduler` -> gate (configured separately) |
## 3. Configuration matrix
| Property | Default | Component | Effect of the default |
| --- | --- | --- | --- |
| `eventmesh.delivery.topology` | `LOCAL_STICKY_PULL` | DeliveryTopology | Single-instance / no Meta dependency |
| `eventmesh.security.gate.failClosed` | `false` | SecurityGate | Backward-compatible allow-all |
| `eventmesh.storage.type` | (commented out) | Storage plugin | Must be set to a supported backend |
| `eventmesh.connector.offset.store` | `memory` | Connector | Offsets not persisted (dev-only) |
## 4. References
* Parent: #5296 (Architecture Review, Q2 / 2026-09-07)
* Tracking: #5338
* DeliveryTopology decision: #5293 (closed via PR #5308, implementing `LOCAL_STICKY_PULL`)
* DeliveryTopology wiring follow-up: #5309 (`PARTITION_OWNED_PULL` wiring)
* SecurityGate: #5304
* Architecture: `docs/architecture/overview.md` (section 4, Security gate)
* Configuration: `docs/quickstart/configuration.md`
## State store failure matrix
> Absorbed from the per-backend failure analysis (issue #5339). What happens when each store's backend goes down, and how the
> restart / multi-instance / fencing scenarios are covered by `StateStoreDurabilityTest`.
## 1. Store-to-backend map
| Store | Backend | Tier | Production impl | Test impl |
| --- | --- | --- | --- | --- |
| `OffsetStore` | RocksDB (local) | L1 | `RocksDBOffsetStore` | `InMemoryOffsetStore` (degraded mode only) |
| `SubscriptionStore` | Meta + local cache | L2 | `ClusterSubscriptionStore` (watches `/em/subs/`) | `state.fault.InMemorySubscriptionStore` |
| `SessionStore` | Meta + local cache | L2 | `SessionRegistry` (agents, bindings, sessions) | direct `SessionRegistry` over `InMemoryMetaStore` |
| `DeliveryStateStore` | RocksDB (local, sub-second flush) | L1 | `RocksDBDeliveryStateStore` | `InMemoryDeliveryStateStore` |
| `DeadLetterStore` | Meta CAS | L3 | `MetaBackedDeadLetterStore` (`/em/dlq/`) | direct wrapper over `InMemoryMetaStore` |
| `TaskStore` | Meta CAS | L3 | `MetaBackedTaskStore` (`/em/tasks/`) | direct wrapper over `InMemoryMetaStore` |
## 2. Per-backend failure behavior
### 2.1 RocksDB backend (OffsetStore, DeliveryStateStore)
| Scenario | Behavior | Operator-visible signal | Recovery |
| --- | --- | --- | --- |
| Local disk full | `writeOffset` / `put` returns `false` or throws `IllegalStateException` | `RocksDBOffsetStore.offsetWriteFailures` counter; `ReliableDispatcher.pendingCount` does not drop | Operator frees disk; next write succeeds. The dispatcher keeps the delivery in flight (issue #5290). |
| Process kill -9 (no graceful close) | The RocksDB file is the durability surface; the next process open at the same path sees the last flushed state | n/a (no log emitted on the way down) | The runtime calls `UniRuntime.alignPullOffsetsToAck` + `ReliableDispatcher.recover()` on boot. `StateStoreDurabilityTest#DeliveryStateStoreKillMinusNine` exercises this path. |
| Process restart (graceful) | `flush()` is called by the shutdown hook, then `close()`. Re-open at the same path is a no-op for the persisted entries | n/a | Same as kill -9. |
| Corrupted RocksDB file | `RocksDB.open` throws `RocksDBException` -> `IllegalStateException` at the constructor. The runtime fails fast at boot. | `IllegalStateException("failed to open RocksDB offset store at <path>")` in the boot log. | Operator restores from backup or wipes the data directory (data loss is unavoidable in this case; the runtime fails fast rather than silently losing state). |
### 2.2 Meta backend (SubscriptionStore, SessionStore, DeadLetterStore, TaskStore)
All four Meta-backed stores use the same primitives:
* `MetaStore.putIfAbsent` (CAS) for first-write-wins (DLQ, TaskStore.createTask).
* `MetaStore.tryAcquire(expectedOldValue, newValue)` (CAS) for status updates (TaskStore.updateStatus).
* `MetaStore.put` (last-write-wins) for plain updates (SubscriptionStore.put).
* `MetaStore.get` / `getWithPrefix` for reads.
| Scenario | Behavior | Operator-visible signal | Recovery |
| --- | --- | --- | --- |
| Meta unreachable (network partition) | `put` / `putIfAbsent` / `tryAcquire` throw `RuntimeException` (the production `NacosMetaStore` wraps the Nacos exception) | The exception is caught at the call site; `DeliveryDispatcher` keeps the delivery in flight; `A2AGatewayService` surfaces the error to the A2A caller; `ReliableDispatcher` keeps the in-flight delivery in the ledger | Once Meta heals, the next tick succeeds. Idempotent on the retry: `DeadLetterStore.recordDeadLetter` is idempotent on already-present keys; `TaskStore.createTask` returns `null` for a duplicate taskId (the gateway generates a fresh id and retries). |
| Meta cluster split-brain (two instances write the same key) | First-write-wins (`putIfAbsent`) is correct; `tryAcquire` rejects the stale writer (returns `false`); plain `put` is last-write-wins | The losing writer observes `false` from the CAS call and re-reads | Operator doesn't need to act; the contract is self-healing. `StateStoreDurabilityTest#TaskStoreMetaFailure` exercises the partitioned-write case. |
| Meta returns stale data (clock skew) | `MetaStore.tryAcquire(expectedOldValue=...)` rejects writes whose `expectedOldValue` does not match the current Meta value; the caller re-reads | The call returns `false` | Caller re-reads via `meta.get(key)` and retries with the fresh value. The Meta-backed stores do not carry their own wall-clock dependency; staleness surfaces as a CAS mismatch, not as a time-based inconsistency. |
| Meta restarts (process restart of the Meta server) | All reads return the snapshot from the most recent successful write; writes resume once the new Meta is reachable | The production `NacosMetaStore` reconnects via the Nacos client retry loop; the runtime logs the reconnection at INFO | No operator action needed; the in-process caches (`SessionRegistry.agentCache`, `ClusterSubscriptionStore.cache`) are rebuilt from the next watch event. `StateStoreDurabilityTest#DeadLetterStoreRestart` exercises the wrapper-restart-over-shared-Meta case. |
| Local in-process cache diverges from Meta | The next read through `MetaStore.get` / `getWithPrefix` returns the Meta view; the in-process cache is refreshed on the next watch event | n/a (no log) | Convergence is automatic; the local cache is best-effort. |
## 3. Restart / multi-instance / fencing coverage
Each row maps an issue #5339 acceptance scenario to the test that exercises it.
| # | Scenario | Store | Test |
| --- | --- | --- | --- |
| 1 | Restart: persist offset 100, kill runtime, restart, read offset = 100 | `OffsetStore` (RocksDB) | `StateStoreDurabilityTest#OffsetStoreRestart.offsetSurvivesProcessRestart` |
| 2 | Multi-instance: two `ClusterSubscriptionStore` instances sharing the same Meta; concurrent register / unregister converges | `SubscriptionStore` | `StateStoreDurabilityTest#SubscriptionStoreMultiInstance.twoInstancesConvergeOnSameView` (100-iteration storm, 8 threads) + `removeIsObservedByAllInstances` |
| 3 | Fencing: stale partition owner (instance A) writes after instance B takes over; the write is rejected | `SessionStore` | `StateStoreDurabilityTest#SessionStoreFencing.staleHeartbeatAfterTakeoverIsRejected` |
| 4 | Restart: DLQ ledger survives a wrapper restart (Meta is the durability surface) | `DeadLetterStore` (Meta) | `StateStoreDurabilityTest#DeadLetterStoreRestart.ledgerSurvivesStoreWrapperRestart` + `writeSucceedsAfterMetaHeals` |
| 5 | Cross-store failure: Meta goes down mid-submit; the TaskStore surfaces the failure rather than silently dropping the task | `TaskStore` (Meta) | `StateStoreDurabilityTest#TaskStoreMetaFailure.createTaskFailsWhenMetaIsPartitioned` + `updateStatusFailsWhenMetaIsPartitioned` |
| 6 | Kill -9: in-flight ledger survives abrupt close | `DeliveryStateStore` (RocksDB) | `StateStoreDurabilityTest#DeliveryStateStoreKillMinusNine.inFlightLedgerSurvivesAbruptClose` |
## 4. Acceptance check (for #5339)
* For each of the 6 stores above, a JUnit test exists in `eventmesh-runtime`
(`StateStoreDurabilityTest`).
* The test for each scenario above is referenced in the table in section 3.
* No production-code store is backed by an `InMemory*` implementation; the
in-memory implementations live in `state/fault/` (test-only).
* This document exists (`docs/feature/control-plane.md`).
## 5. References
* Parent: #5296 (Architecture Review, Q3 / 2026-09-07)
* Tracking: #5339
* Tests: `eventmesh-runtime/src/test/java/org/apache/eventmesh/runtime/state/StateStoreDurabilityTest.java`
* Related: #5289 (monotonic offset), #5290 (write failure semantics), #5291 (idempotency), #5292 (DLQ durability), #5301 (state control plane)
* Sub-PRs:
- #5301 Sub-PR A (interfaces + SPI)
- #5301 Sub-PR B (RocksDB-backed delivery + offset stores)
- #5301 Sub-PR C (Meta-backed DLQ + TaskStore)
- #5301 Sub-PR D (A2A Gateway on Runtime)