diff --git a/docs/eventmesh-uni-architecture-redesign.md b/docs/eventmesh-uni-architecture-redesign.md
index f2cdcea752..25460b09ac 100644
--- a/docs/eventmesh-uni-architecture-redesign.md
+++ b/docs/eventmesh-uni-architecture-redesign.md
@@ -4882,7 +4882,106 @@ EventMesh 实例本地自采负载指标(`LoadMeter`):
1. **单测**:`EventMeshFrame` 全 msgType 互转(12 例);`OffsetStore` 两 key 空间共存;`LoadMeter` 指标 + 每 client 画像;`ClusterMembership` 心跳负载;dispatch 管线 Frame 化(ReliableDispatcher/SubscriptionManager/ClusterCoordinator)。
2. **E2E**(真 broker):streaming 多轮 + Mode 2 pub/sub + **普通 pub/sub(RocketMQ5BrokerIntegrationTest 2/2)**全绿,内部全程 EventMeshFrame 往返正确;**LegacyTcpClientIntegrationTest(旧 TCP SDK)全绿**(MeshMessage↔Frame 直接转换)。
-3. **构建**:系统 gradle 8.5 + WEOA Nexus(offline)。
+3. **构建**:系统 gradle 8.5 + WEOA Nexus(offline)。
+
+
+### 19.6 #5299 验收矩阵:单协议路径 + ingress/egress 适配
+
+
+
+
+
+#### 协议状态标签
+
+
+
+
+
+| 标签 | 含义 | 当前协议 |
+
+
+|------|------|----------|
+
+
+| **primary** | ingress/egress 全程经 `FrameAdaptor` SPI,内部全程 `EventMeshFrame` | CloudEvents(HTTP/SSE/WS)、MeshMessage(legacy TCP) |
+
+
+| **beta** | 已有 `FrameAdaptor`,但端到端链路未完全收口 | A2A(JSON-RPC 2.0) |
+
+
+| **legacy** | 仅保留兼容桥,不再作为内部表示 | CloudEvent 作为内部中间表示(已废弃,见 §19.1) |
+
+
+
+
+
+#### 路径验收矩阵
+
+
+
+
+
+| 路径 | ingress | egress | 状态 |
+
+
+|------|---------|--------|------|
+
+
+| HTTP publish / publishBatch / lite publish / lite poll / request / reply | `FrameAdaptors.get("cloudevents").toFrame(...)` | `FrameAdaptor.toCloudEventsJson(...)` | ✅ primary |
+
+
+| ingress 安全链(TokenAuth / Acl / SignatureVerifier) | 直接读 `frame.attributes()` | — | ✅ primary |
+
+
+| legacy TCP ingress | `MeshMessagePackageRouter` → `FrameAdaptors.get("meshmessage").toFrameSilent(pkg)` | — | ✅ primary |
+
+
+| legacy TCP egress | `NettyTcpPushChannel` → `FrameAdaptors.get("meshmessage").fromFrameSilent(frame)` | MeshMessage `Package` | ✅ primary |
+
+
+| A2A gateway | `A2AFrameAdaptor` | A2A JSON-RPC bytes | 🟡 beta |
+
+
+
+
+
+#### 子 PR 落地情况
+
+
+
+
+
+| 子 PR | 内容 | 状态 |
+
+
+|-------|------|------|
+
+
+| A | `UniHttpServer` 8 个 ingress 端点改经 `FrameAdaptor` SPI;`UniIngressService` 新增 `publishBatchFrames` / `publishLiteFrame` / `pollLiteFrames` / `requestFrame` / `replyFrame` 五个 `EventMeshFrame` typed 方法 | ✅ |
+
+
+| B | 安全链(`IngressFilter` / `FilterChain` / `AclFilter` / `TokenAuthFilter` / `SignatureVerifierFilter`)改吃 `EventMeshFrame`;租户 / 签名 / token 直接读 `frame.attributes()`。CloudEvent 重载保留为 `@Deprecated` 桥 | ✅ |
+
+
+| C | TCP egress:`TcpFrameCodec.encodePush` / `TcpPushChannel.deliver` 改吃 `EventMeshFrame`(去掉 `frame.toCloudEvent()` 往返);删除死代码 `CloudEventToPackageBody` / `MeshEventToPackageBody`;`UniTcpServer` 去掉未使用的 `bodyMapper` 构造参数 | ✅ |
+
+
+| D | 本节验收矩阵 + 协议状态标签 | ✅ |
+
+
+
+
+
+#### 随 #5299 删除 / 废弃
+
+
+
+
+
+- `CloudEventToPackageBody`、`MeshEventToPackageBody`:CloudEvent 时代的 TCP egress 编码接口。`NettyTcpPushChannel` 接管 egress 后成为死代码,随 Sub-PR C 删除。
+
+
+- `IngressFilter.check(CloudEvent, FilterContext)`:`@Deprecated` 桥接,仅供尚未迁移的自定义 filter 编译通过;等 A2A(beta)收口后移除。
---
diff --git a/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/http/UniHttpServer.java b/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/http/UniHttpServer.java
index e67bdd540f..1b813a4aba 100644
--- a/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/http/UniHttpServer.java
+++ b/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/http/UniHttpServer.java
@@ -17,6 +17,9 @@
package org.apache.eventmesh.runtime.http;
+import org.apache.eventmesh.common.protocol.ByteTransport;
+import org.apache.eventmesh.common.wire.EventMeshFrame;
+import org.apache.eventmesh.protocol.api.FrameAdaptors;
import org.apache.eventmesh.runtime.admin.UniAdminService;
import org.apache.eventmesh.runtime.ingress.UniIngressService;
import org.apache.eventmesh.runtime.push.BufferedEvent;
@@ -35,10 +38,6 @@
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
-import io.cloudevents.CloudEvent;
-import io.cloudevents.core.provider.EventFormatProvider;
-import io.cloudevents.jackson.JsonFormat;
-
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
@@ -249,10 +248,13 @@ private boolean checkSecurity(HttpExchange exchange, String topic, String client
org.apache.eventmesh.runtime.security.FilterContext ctx =
new org.apache.eventmesh.runtime.security.FilterContext(topic, clientId, tenant, credential,
exchange.getRemoteAddress().getAddress().getHostAddress());
- // For non-publish endpoints there's no CloudEvent body to check; use a minimal stub.
- io.cloudevents.CloudEvent stubEvent = io.cloudevents.core.builder.CloudEventBuilder.v1()
- .withId("security-check").withSource(java.net.URI.create("eventmesh")).withType("security").build();
- org.apache.eventmesh.runtime.security.FilterVerdict verdict = filterChain.check(stubEvent, ctx);
+ // For non-publish endpoints there's no EventMeshFrame to check; build a minimal
+ // security-check frame so the filter chain has *something* to evaluate. The filters
+ // themselves read tenant / credential from the FilterContext, so a frame with empty
+ // attributes is sufficient for the auth/acl decision (#5299 sub-PR B).
+ org.apache.eventmesh.common.wire.EventMeshFrame stubFrame =
+ org.apache.eventmesh.common.wire.EventMeshFrame.event(java.util.Collections.emptyMap(), new byte[0]);
+ org.apache.eventmesh.runtime.security.FilterVerdict verdict = filterChain.check(stubFrame, ctx);
if (!verdict.isAllowed()) {
writeJson(exchange, verdict.getRejectStatus(), error(verdict.getReason()));
return false;
@@ -285,28 +287,34 @@ private void publish(HttpExchange exchange) throws IOException {
return;
}
byte[] body = readAll(exchange);
- CloudEvent event;
+ EventMeshFrame frame;
try {
- event = EventFormatProvider.getInstance().resolveFormat(JsonFormat.CONTENT_TYPE).deserialize(body);
- } catch (RuntimeException e) {
+ // Ingress: structured CloudEvents JSON bytes → internal EventMeshFrame.
+ // (#5299: runtime no longer touches io.cloudevents.CloudEvent directly on the ingress
+ // path; the protocol adaptor owns the conversion.)
+ frame = FrameAdaptors.get("cloudevents").toFrame(new ByteTransport(body));
+ } catch (RuntimeException | org.apache.eventmesh.protocol.api.exception.ProtocolHandleException e) {
writeJson(exchange, 400, error("invalid CloudEvent: " + e.getMessage()));
return;
}
// Security filter chain (§4.5): auth/acl/signature run before the event enters the pipeline.
+ // #5299 sub-PR B: filters now read directly from EventMeshFrame.attributes() — no more
+ // CE bridge. Tenant still comes from the CloudEvent extension ("emtenantid") which the
+ // cloudevents FrameAdaptor round-trips into frame attributes under the same key.
if (filterChain != null) {
String credential = exchange.getRequestHeaders().getFirst("Authorization");
- String tenant = event.getExtension("emtenantid") != null ? event.getExtension("emtenantid").toString() : null;
+ String tenant = frame.attributes().get("emtenantid");
org.apache.eventmesh.runtime.security.FilterContext ctx =
new org.apache.eventmesh.runtime.security.FilterContext(topic, null, tenant, credential,
exchange.getRemoteAddress().getAddress().getHostAddress());
- org.apache.eventmesh.runtime.security.FilterVerdict verdict = filterChain.check(event, ctx);
+ org.apache.eventmesh.runtime.security.FilterVerdict verdict = filterChain.check(frame, ctx);
if (!verdict.isAllowed()) {
writeJson(exchange, verdict.getRejectStatus(), error(verdict.getReason()));
return;
}
}
try {
- ingress.publish(topic, event).get(10, TimeUnit.SECONDS);
+ ingress.publish(topic, frame).get(10, TimeUnit.SECONDS);
writeJson(exchange, 202, ack("accepted"));
} catch (Exception e) {
// §6.6: a RateLimitedException (per-topic token bucket exhausted) is a 429, not a 500 —
@@ -337,12 +345,14 @@ private void publishBatch(HttpExchange exchange) throws IOException {
writeJson(exchange, 400, error("expected a CloudEvent JSON array"));
return;
}
- java.util.List events = new java.util.ArrayList<>(node.size());
+ // Batch ingress: each element is a CloudEvents-JSON object → internal EventMeshFrame.
+ // (#5299)
+ java.util.List frames = new java.util.ArrayList<>(node.size());
for (com.fasterxml.jackson.databind.JsonNode el : node) {
- events.add(EventFormatProvider.getInstance().resolveFormat(JsonFormat.CONTENT_TYPE)
- .deserialize(mapper.writeValueAsBytes(el)));
+ frames.add(FrameAdaptors.get("cloudevents")
+ .toFrame(new ByteTransport(mapper.writeValueAsBytes(el))));
}
- ingress.publishBatch(topic, events).get(30, TimeUnit.SECONDS);
+ ingress.publishBatchFrames(topic, frames).get(30, TimeUnit.SECONDS);
writeJson(exchange, 202, ack("accepted"));
} catch (Exception e) {
if (isRateLimited(e)) {
@@ -484,13 +494,16 @@ private void request(HttpExchange exchange) throws IOException {
String topic = param(exchange.getRequestURI(), "topic");
long timeout = longParam(exchange.getRequestURI(), "timeoutMs", 30_000L);
try {
- CloudEvent event = EventFormatProvider.getInstance().resolveFormat(JsonFormat.CONTENT_TYPE)
- .deserialize(readAll(exchange));
+ // Ingress: structured CloudEvents JSON body → internal EventMeshFrame. (#5299)
+ EventMeshFrame event = FrameAdaptors.get("cloudevents")
+ .toFrame(new ByteTransport(readAll(exchange)));
if (!checkSecurity(exchange, topic, null)) {
return;
}
- CloudEvent reply = ingress.request(topic, event, timeout);
- byte[] replyBytes = EventFormatProvider.getInstance().resolveFormat(JsonFormat.CONTENT_TYPE).serialize(reply);
+ // request-reply: send Frame in, get Frame back, then serialize to CloudEvents JSON
+ // for the response body via the egress adapter.
+ EventMeshFrame reply = ingress.requestFrame(topic, event, timeout);
+ byte[] replyBytes = FrameAdaptors.toCloudEventsJson(reply);
exchange.getResponseHeaders().add("Content-Type", "application/cloudevents+json");
exchange.sendResponseHeaders(200, replyBytes.length);
try (OutputStream os = exchange.getResponseBody()) {
@@ -509,14 +522,15 @@ private void reply(HttpExchange exchange) throws IOException {
try {
JsonNode body = readJson(exchange);
String corrId = text(body, "correlationId");
- CloudEvent replyEvent = EventFormatProvider.getInstance().resolveFormat(JsonFormat.CONTENT_TYPE)
- .deserialize(mapper.writeValueAsBytes(body.get("event")));
+ // Ingress: CloudEvents JSON → internal EventMeshFrame. (#5299)
+ EventMeshFrame replyEvent = FrameAdaptors.get("cloudevents")
+ .toFrame(new ByteTransport(mapper.writeValueAsBytes(body.get("event"))));
// §17.6 reply routing (sticky model - no cross-instance forwarding).
// Cross-instance reply forwarding is REMOVED with the forward path: the client posts
// the reply to the instance it sent the request to (pinned via instanceUrl); a reply
// landing on the wrong instance 404s (unknown correlationId) and the caller retries
// on the correct instance.
- writeJson(exchange, ingress.reply(corrId, replyEvent) ? 200 : 404, ack("ok"));
+ writeJson(exchange, ingress.replyFrame(corrId, replyEvent) ? 200 : 404, ack("ok"));
} catch (Exception e) {
writeJson(exchange, 500, error("reply error: " + e.getMessage()));
}
@@ -615,9 +629,10 @@ private void litePublish(HttpExchange exchange) throws IOException {
return;
}
try {
- CloudEvent event = EventFormatProvider.getInstance().resolveFormat(JsonFormat.CONTENT_TYPE)
- .deserialize(readAll(exchange));
- ingress.publishLite(parent, lite, event).get(10, TimeUnit.SECONDS);
+ // Ingress: structured CloudEvents JSON body → internal EventMeshFrame. (#5299)
+ EventMeshFrame event = FrameAdaptors.get("cloudevents")
+ .toFrame(new ByteTransport(readAll(exchange)));
+ ingress.publishLiteFrame(parent, lite, event).get(10, TimeUnit.SECONDS);
writeJson(exchange, 202, ack("accepted"));
} catch (Exception e) {
writeJson(exchange, 500, error("lite publish failed: " + e.getMessage()));
@@ -646,11 +661,12 @@ private void litePoll(HttpExchange exchange) throws IOException {
try {
int max = intParam(exchange.getRequestURI(), "max", 100);
long timeoutMs = longParam(exchange.getRequestURI(), "timeoutMs", 1000L);
- List events = ingress.pollLite(parent, lite, max, timeoutMs);
+ // Egress: drain EventMeshFrames from the LMQ, serialize each as CloudEvents JSON
+ // via the egress adapter. (#5299)
+ List events = ingress.pollLiteFrames(parent, lite, max, timeoutMs);
com.fasterxml.jackson.databind.node.ArrayNode arr = mapper.createArrayNode();
- for (CloudEvent e : events) {
- arr.add(mapper.readTree(EventFormatProvider.getInstance()
- .resolveFormat(JsonFormat.CONTENT_TYPE).serialize(e)));
+ for (EventMeshFrame e : events) {
+ arr.add(mapper.readTree(FrameAdaptors.toCloudEventsJson(e)));
}
writeJson(exchange, 200, arr);
} catch (NumberFormatException e) {
diff --git a/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/ingress/UniIngressService.java b/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/ingress/UniIngressService.java
index 6d47a7d258..f6e617abba 100644
--- a/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/ingress/UniIngressService.java
+++ b/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/ingress/UniIngressService.java
@@ -1,903 +1,988 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.apache.eventmesh.runtime.ingress;
-
-import org.apache.eventmesh.api.SendCallback;
-import org.apache.eventmesh.api.SendResult;
-import org.apache.eventmesh.api.storage.MeshStoragePlugin;
-import org.apache.eventmesh.runtime.delivery.DeadLetterSink;
-import org.apache.eventmesh.runtime.delivery.PushChannel;
-import org.apache.eventmesh.runtime.delivery.ReliableDispatcher;
-import org.apache.eventmesh.runtime.metrics.UniMetrics;
-import org.apache.eventmesh.runtime.metrics.UniTrace;
-import org.apache.eventmesh.runtime.offset.OffsetStore;
-import org.apache.eventmesh.runtime.push.BufferedEvent;
-import org.apache.eventmesh.runtime.push.LongPollingChannel;
-import org.apache.eventmesh.runtime.push.PushService;
-import org.apache.eventmesh.runtime.ratelimit.RateLimitedException;
-import org.apache.eventmesh.runtime.ratelimit.TokenBucketRateLimiter;
-import org.apache.eventmesh.runtime.subscription.CloudEventFilter;
-import org.apache.eventmesh.runtime.subscription.DistributionMode;
-import org.apache.eventmesh.runtime.subscription.Subscription;
-import org.apache.eventmesh.runtime.subscription.SubscriptionManager;
-
-import java.util.List;
-import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.TimeoutException;
-import java.util.concurrent.atomic.AtomicLong;
-
-import io.cloudevents.CloudEvent;
-import io.cloudevents.core.builder.CloudEventBuilder;
-
-import lombok.extern.slf4j.Slf4j;
-
-/**
- * Facade that wires the uni-architecture layers into the end-to-end CloudEvents-over-MQ
- * flow (§6):
- *
- * publish ─▶ MeshStoragePlugin.send
- * pullLoop ─▶ MeshStoragePlugin.poll ─▶ SubscriptionManager.targetsFor ─▶ ReliableDispatcher.deliver ─▶ PushService
- * poll ◀─ client long-polls PushService
- * ack ─▶ PushService.ack ─▶ ReliableDispatcher.ack ─▶ OffsetStore (offset advances only on ACK)
- *
- *
- * This is the orchestration core; the actual HTTP endpoint wiring (§6 UniIngressHandler)
- * delegates here. Phase-1 thin storage adapter means {@code partition} is {@code -1} and the offset
- * is a per-topic monotonic logical counter until the native storage reimplementation supplies real
- * partition/offset (Phase 1 step 2).
- */
-@Slf4j
-public class UniIngressService {
-
- private final MeshStoragePlugin storage;
- private final OffsetStore offsetStore;
- private final SubscriptionManager subscriptionManager;
- private final ReliableDispatcher dispatcher;
- private final PushService pushService;
- private volatile org.apache.eventmesh.runtime.cluster.ClusterCoordinator cluster;
- private volatile org.apache.eventmesh.runtime.cluster.PartitionOwnership partitionOwnership;
- /** Self-collected load metrics for session-distribution load balancing (§3). Null until wired. */
- private volatile LoadMeter loadMeter;
-
- /** Per-topic poll stats for the {@code poll_idle_ratio} gauge (§13.5.1). */
- private final ConcurrentHashMap pollCount = new ConcurrentHashMap<>();
- private final ConcurrentHashMap pollEmptyCount = new ConcurrentHashMap<>();
-
- /** Connector offset store (remote side) — String key → String offset value (§8.9). */
- private final java.util.concurrent.ConcurrentHashMap connectorOffsets = new java.util.concurrent.ConcurrentHashMap<>();
- private final UniMetrics metrics;
-
- private final ConcurrentHashMap channels = new ConcurrentHashMap<>();
- private final ConcurrentHashMap topicOffsetSeq = new ConcurrentHashMap<>();
- private final ConcurrentHashMap> pendingRequests = new ConcurrentHashMap<>();
- private final AtomicLong requestSeq = new AtomicLong();
- private final ConcurrentHashMap topicLimiters = new ConcurrentHashMap<>();
-
- /**
- * CloudEvents extension carrying the request's correlation id (§17).
- *
- * Named without hyphens because the CloudEvents spec restricts extension attribute names to
- * lower-case ASCII letters and digits — the redesign doc's {@code x-em-correlation-id} spelling
- * is rejected by the SDK's name validation.
- */
- public static final String EXT_CORRELATION_ID = "emcorrelationid";
-
- public UniIngressService(MeshStoragePlugin storage, OffsetStore offsetStore) {
- this(storage, offsetStore, new SubscriptionManager(), new PushService(),
- ReliableDispatcher.DEFAULT_ACK_TIMEOUT_MS, ReliableDispatcher.DEFAULT_MAX_ATTEMPTS,
- System::currentTimeMillis);
- }
-
- /**
- * Test-friendly constructor with an injectable clock and retry parameters.
- */
- public UniIngressService(MeshStoragePlugin storage, OffsetStore offsetStore,
- SubscriptionManager subscriptionManager, PushService pushService,
- long ackTimeoutMs, int maxAttempts, java.util.function.LongSupplier clock) {
- this.storage = storage;
- this.offsetStore = offsetStore;
- this.subscriptionManager = subscriptionManager;
- this.pushService = pushService;
- this.metrics = new UniMetrics();
- this.dispatcher = new ReliableDispatcher(ackTimeoutMs, maxAttempts, clock, offsetStore, deadLetterSink(),
- metrics, ReliableDispatcher.DEFAULT_JITTER_RATIO);
- // Sub-PR B: re-ACK any in-flight deliveries from a previous JVM so they do not become
- // orphans after a crash. Safe on first start (store is empty) and idempotent.
- this.dispatcher.recover();
- }
-
- // ---- connector offset (remote side, §8.9) ----
-
- public String getConnectorOffset(String connectorId) {
- return connectorOffsets.get(connectorId);
- }
-
- public void putConnectorOffset(String connectorId, String offset) {
- connectorOffsets.put(connectorId, offset);
- }
-
- /**
- * Publish a CloudEvent to {@code topic} (persisted to MQ). Completes when the storage plugin
- * acknowledges the write.
- */
- public CompletableFuture publish(String topic, CloudEvent event) {
- if (loadMeter != null && event.getData() != null) {
- loadMeter.recordInflow(event.getData().toBytes().length);
- }
- CompletableFuture future = new CompletableFuture<>();
- TokenBucketRateLimiter limiter = topicLimiters.get(topic);
- if (limiter != null && !limiter.tryAcquire()) {
- metrics.incRateLimited();
- future.completeExceptionally(new RateLimitedException(topic));
- return future;
- }
- try {
- // Ingress boundary: CloudEvent (the public/external format) → EventMeshFrame (the internal
- // wire unit). From here on the event is a Frame internally; egress converts back to the
- // client's protocol (CloudEvents / MeshMessage) at the delivery boundary.
- org.apache.eventmesh.common.wire.EventMeshFrame frame =
- org.apache.eventmesh.common.wire.EventMeshFrame.fromCloudEvent(event);
- storage.send(topic, frame, new SendCallback() {
- @Override
- public void onSuccess(SendResult sendResult) {
- metrics.incPublish();
- UniTrace.end(UniTrace.startPublish(topic, event));
- future.complete(null);
- }
-
- @Override
- public void onException(org.apache.eventmesh.api.exception.OnExceptionContext context) {
- metrics.incPublishFailed();
- future.completeExceptionally(context.getException());
- }
- });
- } catch (Exception e) {
- metrics.incPublishFailed();
- future.completeExceptionally(e);
- }
- return future;
- }
-
- /**
- * Publish an already-internal {@link org.apache.eventmesh.common.wire.EventMeshFrame} (no
- * CloudEvent→Frame boundary conversion — the caller built the frame directly, e.g. the legacy
- * MeshMessage TCP path via {@code MeshMessageFrameCodec}). Completes when storage acks the write.
- */
- public CompletableFuture publish(String topic, org.apache.eventmesh.common.wire.EventMeshFrame frame) {
- if (loadMeter != null && frame.data() != null) {
- loadMeter.recordInflow(frame.data().length);
- }
- CompletableFuture future = new CompletableFuture<>();
- TokenBucketRateLimiter limiter = topicLimiters.get(topic);
- if (limiter != null && !limiter.tryAcquire()) {
- metrics.incRateLimited();
- future.completeExceptionally(new RateLimitedException(topic));
- return future;
- }
- try {
- storage.send(topic, frame, new SendCallback() {
- @Override
- public void onSuccess(SendResult sendResult) {
- metrics.incPublish();
- future.complete(null);
- }
-
- @Override
- public void onException(org.apache.eventmesh.api.exception.OnExceptionContext context) {
- metrics.incPublishFailed();
- future.completeExceptionally(context.getException());
- }
- });
- } catch (Exception e) {
- metrics.incPublishFailed();
- future.completeExceptionally(e);
- }
- return future;
- }
-
- /**
- * Publish a batch of CloudEvents to {@code topic} (§13.7.3). Completes when all are persisted.
- * The storage plugin's own batching (e.g. Kafka producer accumulator) amortises the per-event
- * RTT; this layer fans out the per-event futures and joins them.
- */
- public CompletableFuture publishBatch(String topic, java.util.List events) {
- if (events == null || events.isEmpty()) {
- return CompletableFuture.completedFuture(null);
- }
- CompletableFuture>[] futures = events.stream()
- .map(e -> publish(topic, e))
- .toArray(CompletableFuture[]::new);
- return CompletableFuture.allOf(futures);
- }
-
- /**
- * Register a custom push channel for a client (e.g. a WebHook URL for legacy HTTP webhook-push
- * subscribers, or a {@code TcpPushChannel} for legacy TCP clients). Overrides the default
- * long-polling channel for this {@code clientId} on subsequent dispatches.
- */
- public void registerChannel(String clientId, PushChannel channel) {
- pushService.register(clientId);
- channels.put(clientId, channel);
- }
-
- /**
- * Configure a per-topic rate limit (§6.6). Subsequent publishes above {@code permitsPerSecond}
- * (burst {@code capacity}) fail the publish future with {@link RateLimitedException}.
- */
- public void setTopicRateLimit(String topic, long capacity, double permitsPerSecond) {
- topicLimiters.put(topic, new TokenBucketRateLimiter(capacity, permitsPerSecond));
- }
-
- /**
- * Register a subscription. The subscriber retrieves events via {@link #poll}.
- *
- * @return the subscription id
- */
- public String subscribe(String topic, String clientId, DistributionMode mode, CloudEventFilter filter) {
- pushService.register(clientId);
- String subId = subscriptionManager.subscribe(topic, clientId, mode, filter, event -> {
- // No-op: the reliability layer (ReliableDispatcher) owns delivery, not this fire-and-
- // forget callback. Kept to satisfy the SubscriptionManager handler contract.
- });
- // §13.2: when clustered, also register cluster-wide so other instances can route events
- // for this subscriber here (via ClusterCoordinator → HttpForwarder /internal/forward).
- // Without this the HTTP /events/subscribe path is local-only and a publish on a peer
- // never reaches this subscriber.
- if (cluster != null) {
- cluster.subscribe(topic, clientId, mode, null);
- }
- return subId;
- }
-
- /**
- * Remove a subscription.
- */
- public boolean unsubscribe(String subscriptionId) {
- // Resolve topic + clientId before removing so a clustered subscription can be deregistered
- // cluster-wide (the coordinator's unsubscribe is keyed by topic+clientId, not subId).
- org.apache.eventmesh.runtime.subscription.Subscription sub = subscriptionManager.getSubscription(subscriptionId);
- boolean removed = subscriptionManager.unsubscribe(subscriptionId);
- if (removed && cluster != null && sub != null) {
- cluster.unsubscribe(sub.getTopic(), sub.getClientId());
- }
- return removed;
- }
-
- /** Remove one client's subscription to one topic (HTTP /events/unsubscribe with {clientId, topic}). */
- public boolean unsubscribe(String topic, String clientId) {
- boolean removed = subscriptionManager.unsubscribe(topic, clientId);
- if (removed && cluster != null) {
- cluster.unsubscribe(topic, clientId);
- }
- return removed;
- }
-
- /** Remove ALL subscriptions for a client (HTTP /events/unsubscribe with {clientId} only).
- * Propagates cluster-wide + frees the PushService buffer. */
- public int unsubscribeByClient(String clientId) {
- java.util.Set topics = subscriptionManager.topicsForClient(clientId);
- int removed = subscriptionManager.unsubscribeByClient(clientId);
- if (cluster != null) {
- for (String topic : topics) {
- cluster.unsubscribe(topic, clientId);
- }
- }
- pushService.removeClient(clientId);
- return removed;
- }
-
- /**
- * Pump: pull a batch from storage, route to each target subscriber via the reliability layer.
- *
- * @return number of events pulled
- */
- public int pullAndDispatch(String topic, int maxEvents, long timeoutMs) {
- pollCount.merge(topic, 1L, Long::sum);
- // Multi-instance (§13.2.3): poll only the partitions this instance owns. Single-instance
- // (no ownership) or unknown partition count → poll the whole topic (partition -1).
- java.util.List owned = partitionOwnership == null ? null : partitionOwnership.ownedPartitions(topic);
- int total;
- if (owned == null) {
- total = pullAndDispatchPartition(topic, -1, maxEvents, timeoutMs);
- } else if (owned.isEmpty()) {
- total = 0; // owns none -> do not poll (avoids duplicate with the real owners)
- } else {
- total = 0;
- for (int p : owned) {
- total += pullAndDispatchPartition(topic, p, maxEvents, timeoutMs);
- }
- }
- if (total == 0) {
- pollEmptyCount.merge(topic, 1L, Long::sum);
- }
- return total;
- }
-
- private int pullAndDispatchPartition(String topic, int partition, int maxEvents, long timeoutMs) {
- // Pull EventMeshFrames (internal wire); dispatch each as a Frame through the internal
- // pipeline (filter/dispatcher/egress all carry Frame now; egress converts to the client's
- // protocol at the wire boundary).
- List frames = storage.poll(topic, partition, -1, maxEvents, timeoutMs);
- if (frames == null || frames.isEmpty()) {
- return 0;
- }
- long start = System.nanoTime();
- for (org.apache.eventmesh.common.wire.EventMeshFrame f : frames) {
- if (isExpired(f)) {
- // §13.3.4 TTL: drop expired events instead of dispatching.
- log.debug("dropping expired event {} on topic {} (emttl elapsed)", f.attributes().get("id"), topic);
- continue;
- }
- io.opentelemetry.api.trace.Span dispatchSpan = UniTrace.startDispatch(topic, f);
- if (cluster != null) {
- // Multi-instance: route via the cluster coordinator (local vs cross-instance forward).
- cluster.dispatch(topic, f);
- } else {
- // develop's OffsetExtensions (CE-extension-carried MQ offset) is superseded by the
- // Frame architecture: the POP check key rides in frame attributes (empopck) and the
- // deferred broker ACK fires on client ACK — same at-least-once goal, Frame-native.
- long offset = nextOffset(topic);
- // P2 fix: if the frame carries a POP check key (RocketMQ 5.x deferred ACK), build a
- // callback that ACKs the broker on client ACK (restoring at-least-once).
- String popCk = f.attributes().get("empopck");
- Runnable mqAck = (popCk != null) ? () -> storage.ackPulledMessage(topic, popCk) : null;
- for (Subscription target : subscriptionManager.targetsFor(topic, f)) {
- dispatcher.deliver(topic, partition, offset, f, target.getClientId(),
- channelFor(target.getClientId()), mqAck);
- }
- }
- UniTrace.end(dispatchSpan);
- }
- metrics.addDispatchLatencyNanos(System.nanoTime() - start);
- metrics.incDispatched(frames.size());
- return frames.size();
- }
-
- /**
- * TTL expiry check (§13.3.4): an event with an {@code emttl} attribute (ms) and a {@code time}
- * is expired when {@code now > time + emttl}. Events without either field never expire. Reads the
- * attributes off the internal EventMeshFrame (emttl/time are preserved in its KV section).
- */
- private boolean isExpired(org.apache.eventmesh.common.wire.EventMeshFrame event) {
- String ttl = event.attributes().get("emttl");
- String time = event.attributes().get("time");
- if (ttl == null || time == null) {
- return false;
- }
- try {
- long ttlMs = Long.parseLong(ttl);
- long eventTime = java.time.OffsetDateTime.parse(time).toInstant().toEpochMilli();
- return System.currentTimeMillis() > eventTime + ttlMs;
- } catch (NumberFormatException | java.time.format.DateTimeParseException e) {
- return false;
- }
- }
-
- /**
- * Local delivery for one subscriber — handed to the reliability layer + the subscriber's push
- * channel. Exposed so a {@link org.apache.eventmesh.runtime.cluster.ClusterCoordinator} can route
- * same-instance targets here while forwarding remote ones.
- */
- public boolean deliverLocal(String topic, String clientId, org.apache.eventmesh.common.wire.EventMeshFrame event) {
- long offset = nextOffset(topic);
- dispatcher.deliver(topic, -1, offset, event, clientId, channelFor(clientId));
- return true;
- }
-
- /**
- * Enable multi-instance coordination: when set, {@link #pullAndDispatch} routes each event
- * through the cluster coordinator (local targets via {@link #deliverLocal}, remote via forward).
- */
- public void withCluster(org.apache.eventmesh.runtime.cluster.ClusterCoordinator cluster) {
- this.cluster = cluster;
- }
-
- /**
- * Topics this instance should pull and partition-assign: local active topics UNION cluster-wide
- * topics (topics with a remote subscriber discovered via the Meta watch). Without the cluster
- * half, an instance with no local subscriber for a topic never pulls it, so messages on its
- * partitions can't be forwarded to the remote subscriber (multi-instance message loss). Returns
- * local-only when not clustered. Both {@code UniRuntime.pullLoop} and {@code PartitionOwnership}'s
- * topic source use this so the pull set and the assignment set stay consistent (otherwise an
- * unassigned cluster topic would degrade to poll-all and duplicate).
- */
- public java.util.Set activeTopicsClustered() {
- java.util.Set topics = new java.util.HashSet<>(getSubscriptionManager().activeTopics());
- if (cluster != null) {
- topics.addAll(cluster.subscriptionTopics());
- }
- return topics;
- }
-
- // ===================== Lite Topic (RIP-83, 5.x-only) =====================
- // Lite topic ops are exposed only when the storage plugin implements LiteTopicCapable; otherwise
- // they fail fast (UnsupportedOperationException). The HTTP layer (/events/lite/*) delegates here.
-
- /**
- * Publish one CloudEvent to a lite topic (parentTopic, liteTopic). The storage plugin routes it
- * into the lite topic's LMQ. Requires a {@link org.apache.eventmesh.api.storage.LiteTopicCapable}
- * storage (the 5.x plugin); 4.x/kafka/standalone storages throw.
- */
- public CompletableFuture publishLite(String parentTopic, String liteTopic, CloudEvent event) {
- CompletableFuture future = new CompletableFuture<>();
- if (!(storage instanceof org.apache.eventmesh.api.storage.LiteTopicCapable)) {
- future.completeExceptionally(new UnsupportedOperationException("storage does not support lite topic"));
- return future;
- }
- try {
- // Boundary: CloudEvent → EventMeshFrame (internal wire unit).
- org.apache.eventmesh.common.wire.EventMeshFrame frame =
- org.apache.eventmesh.common.wire.EventMeshFrame.fromCloudEvent(event);
- ((org.apache.eventmesh.api.storage.LiteTopicCapable) storage).sendLite(parentTopic, liteTopic, frame,
- new SendCallback() {
- @Override
- public void onSuccess(SendResult sendResult) {
- future.complete(null);
- }
-
- @Override
- public void onException(org.apache.eventmesh.api.exception.OnExceptionContext context) {
- future.completeExceptionally(context.getException());
- }
- });
- } catch (Exception e) {
- future.completeExceptionally(e);
- }
- return future;
- }
-
- /**
- * Pull a batch of CloudEvents from a lite topic (direct pull from the LMQ; no deliveryId / no
- * EventMesh reliability layer — the lite consumer self-manages offset in the storage plugin).
- * Frames pulled from the LMQ are decoded back to CloudEvents at this boundary. Empty list if lite
- * is not supported.
- */
- public List pollLite(String parentTopic, String liteTopic, int maxEvents, long timeoutMs) {
- if (!(storage instanceof org.apache.eventmesh.api.storage.LiteTopicCapable)) {
- return java.util.Collections.emptyList();
- }
- java.util.List out = new java.util.ArrayList<>();
- for (org.apache.eventmesh.common.wire.EventMeshFrame f
- : ((org.apache.eventmesh.api.storage.LiteTopicCapable) storage).pullLite(parentTopic, liteTopic, maxEvents, timeoutMs)) {
- out.add(f.toCloudEvent());
- }
- return out;
- }
-
- /**
- * Publish a pre-encoded EventMeshFrame byte payload to a lite topic (the internal streaming wire
- * path — SessionRouter publishes frame bytes). The payload IS an encoded EventMeshFrame; decode
- * to the Frame object the SPI now expects.
- */
- public CompletableFuture publishLiteBytes(String parentTopic, String liteTopic, byte[] payload) {
- if (loadMeter != null) {
- loadMeter.recordInflow(payload.length);
- }
- CompletableFuture future = new CompletableFuture<>();
- if (!(storage instanceof org.apache.eventmesh.api.storage.LiteTopicCapable)) {
- future.completeExceptionally(new UnsupportedOperationException("storage does not support lite topic"));
- return future;
- }
- try {
- org.apache.eventmesh.common.wire.EventMeshFrame frame = org.apache.eventmesh.common.wire.EventMeshFrame.decode(payload);
- ((org.apache.eventmesh.api.storage.LiteTopicCapable) storage).sendLite(parentTopic, liteTopic, frame,
- new SendCallback() {
- @Override
- public void onSuccess(SendResult sendResult) {
- future.complete(null);
- }
-
- @Override
- public void onException(org.apache.eventmesh.api.exception.OnExceptionContext context) {
- future.completeExceptionally(context.getException());
- }
- });
- } catch (Exception e) {
- future.completeExceptionally(e);
- }
- return future;
- }
-
- /**
- * Pull a batch of pre-encoded EventMeshFrame byte payloads from a lite topic (the byte counterpart
- * of {@link #pollLite}, for the internal streaming wire). Each entry is an encoded frame.
- * Empty list if lite is not supported.
- */
- public List pollLiteBytes(String parentTopic, String liteTopic, int maxEvents, long timeoutMs) {
- if (!(storage instanceof org.apache.eventmesh.api.storage.LiteTopicCapable)) {
- return java.util.Collections.emptyList();
- }
- java.util.List out = new java.util.ArrayList<>();
- for (org.apache.eventmesh.common.wire.EventMeshFrame f
- : ((org.apache.eventmesh.api.storage.LiteTopicCapable) storage).pullLite(parentTopic, liteTopic, maxEvents, timeoutMs)) {
- out.add(f.encode());
- }
- return out;
- }
-
- /**
- * Ensure {@code parentTopic} is lite-capable and declare {@code liteTopic} under it. Throws if the
- * storage does not support lite.
- */
- public void createLiteTopic(String parentTopic, String liteTopic) throws Exception {
- if (!(storage instanceof org.apache.eventmesh.api.storage.LiteTopicCapable)) {
- throw new UnsupportedOperationException("storage does not support lite topic");
- }
- ((org.apache.eventmesh.api.storage.LiteTopicCapable) storage).createLiteTopic(parentTopic, liteTopic);
- }
-
- /**
- * Ensure {@code parentTopic} is lite-capable with the given {@code queueCount} and declare
- * {@code liteTopic} under it. Use {@code queueCount=1} for strict in-order delivery (e.g. a
- * streaming-call response channel). Throws if the storage does not support lite.
- */
- public void createLiteTopic(String parentTopic, String liteTopic, int queueCount) throws Exception {
- if (!(storage instanceof org.apache.eventmesh.api.storage.LiteTopicCapable)) {
- throw new UnsupportedOperationException("storage does not support lite topic");
- }
- ((org.apache.eventmesh.api.storage.LiteTopicCapable) storage).createLiteTopic(parentTopic, liteTopic, queueCount);
- }
-
- /**
- * @return true iff the storage plugin implements {@link org.apache.eventmesh.api.storage.LiteTopicCapable}.
- */
- public boolean isLiteCapable() {
- return storage instanceof org.apache.eventmesh.api.storage.LiteTopicCapable;
- }
-
- /**
- * Enable multi-instance partition ownership (§13.2.3): when set, {@link #pullAndDispatch} polls
- * only this instance's owned partitions instead of the whole topic.
- */
- public void withPartitionOwnership(org.apache.eventmesh.runtime.cluster.PartitionOwnership ownership) {
- this.partitionOwnership = ownership;
- }
-
- /** Wire the load meter; ingress/egress points call its record* methods. */
- public void withLoadMeter(LoadMeter loadMeter) {
- this.loadMeter = loadMeter;
- }
-
- /**
- * @return the wired load meter, or null if not configured (single-instance / tests).
- */
- public LoadMeter loadMeter() {
- return loadMeter;
- }
-
- /**
- * Subscriber long-polls its buffered deliveries.
- */
- public List poll(String clientId, int maxEvents, long timeoutMs) {
- return pushService.poll(clientId, maxEvents, timeoutMs);
- }
-
- /**
- * Subscriber acknowledges a delivery — the offset advances only on ACK (at-least-once).
- */
- public boolean ack(String deliveryId) {
- return pushService.ack(deliveryId);
- }
-
- /**
- * Drive retry / DLQ. Call periodically from a scheduler.
- */
- public int dispatcherTick() {
- return dispatcher.tick();
- }
-
- /**
- * Operational metrics counters (publish/dispatch/ack/retry/DLQ). The dispatcher and ingress
- * share one instance.
- */
- public UniMetrics getMetrics() {
- return metrics;
- }
-
- // Accessors for the admin facade (Phase 7.5). The service is the single owner of these
- // collaborators; exposing them avoids reconstructing them out of band.
- public SubscriptionManager getSubscriptionManager() {
- return subscriptionManager;
- }
-
- public PushService getPushService() {
- return pushService;
- }
-
- public ReliableDispatcher getDispatcher() {
- return dispatcher;
- }
-
- /**
- * @return the multi-instance partition ownership (null when clustering is disabled).
- */
- public org.apache.eventmesh.runtime.cluster.PartitionOwnership getPartitionOwnership() {
- return partitionOwnership;
- }
-
- /**
- * Stale-poll cleanup (§13.6.5): evict clients that haven't polled within {@code thresholdMs} —
- * drops their subscriptions and push buffer so zombie subscriptions don't leak.
- *
- * @return number of subscriptions removed
- */
- public int cleanupStaleClients(long thresholdMs) {
- int removed = 0;
- for (String cid : pushService.getStaleClientIds(thresholdMs)) {
- removed += subscriptionManager.unsubscribeByClient(cid);
- pushService.removeClient(cid);
- log.info("evicted stale client {} (no poll within {}ms)", cid, thresholdMs);
- }
- return removed;
- }
-
- /**
- * Register OTel observable gauges backed by live runtime state (§13.5.1 gauges with *).
- * Call once at boot. Gauges read on each OTel collection cycle.
- */
- public void registerRuntimeGauges() {
- metrics.registerGauge("eventmesh_pending_queue_size", "total buffered events across all clients",
- () -> {
- long sum = 0;
- for (String cid : pushService.clientIds()) {
- sum += pushService.pending(cid);
- }
- return sum;
- });
- metrics.registerGauge("eventmesh_slow_consumer_count", "clients in SLOW or STALLED state",
- pushService::slowConsumerCount);
- metrics.registerGauge("eventmesh_active_topics", "topics with active subscribers",
- () -> subscriptionManager.activeTopics().size());
- metrics.registerGauge("eventmesh_active_subscribers", "active subscriptions across all topics",
- () -> {
- int sum = 0;
- for (String t : subscriptionManager.activeTopics()) {
- sum += subscriptionManager.activeSubscriptions(t).size();
- }
- return sum;
- });
-
- // Labelled gauges (§13.5.1) — emit one reading per topic / partition.
- metrics.registerLabelledGauge("eventmesh_poll_idle_ratio",
- "fraction of poll cycles returning no events (per-mille, per topic)",
- () -> {
- java.util.List out = new java.util.ArrayList<>();
- for (String t : pollCount.keySet()) {
- long total = pollCount.getOrDefault(t, 0L);
- long empty = pollEmptyCount.getOrDefault(t, 0L);
- long perMille = total == 0 ? 0 : Math.round((double) empty / total * 1000);
- out.add(new UniMetrics.LabelledLong(
- io.opentelemetry.api.common.Attributes.of(io.opentelemetry.api.common.AttributeKey.stringKey("topic"), t),
- perMille));
- }
- return out;
- });
-
- metrics.registerLabelledGauge("eventmesh_partition_owner",
- "1 for each partition this instance owns (per topic/partition)",
- () -> {
- java.util.List out = new java.util.ArrayList<>();
- if (partitionOwnership != null) {
- for (String t : subscriptionManager.activeTopics()) {
- java.util.List owned = partitionOwnership.ownedPartitions(t);
- if (owned == null) {
- continue;
- }
- for (int p : owned) {
- out.add(new UniMetrics.LabelledLong(
- io.opentelemetry.api.common.Attributes.of(
- io.opentelemetry.api.common.AttributeKey.stringKey("topic"), t,
- io.opentelemetry.api.common.AttributeKey.longKey("partition"), (long) p,
- io.opentelemetry.api.common.AttributeKey.stringKey("instance"), "self"),
- 1L));
- }
- }
- }
- return out;
- });
-
- metrics.registerLabelledGauge("eventmesh_offset_lag",
- "MQ end offset - max ACK offset (per topic/partition) — total consumer lag",
- () -> {
- java.util.List out = new java.util.ArrayList<>();
- if (partitionOwnership == null) {
- return out;
- }
- for (String t : subscriptionManager.activeTopics()) {
- java.util.List owned = partitionOwnership.ownedPartitions(t);
- if (owned == null) {
- continue;
- }
- // Max ACK offset per partition across all clients (key = clientId#partition).
- // Exclude the reserved __mqcursor__ key (MQ physical offset — different
- // magnitude from the logical sequence numbers, mixing them corrupts the gauge).
- java.util.Map maxAckByPart = new java.util.HashMap<>();
- String reservedPrefix =
- org.apache.eventmesh.runtime.delivery.ReliableDispatcher.MQ_CURSOR_CLIENT + "#";
- for (java.util.Map.Entry e : offsetStore.readAllOffsets(t).entrySet()) {
- if (e.getKey().startsWith(reservedPrefix)) {
- continue;
- }
- int sep = e.getKey().lastIndexOf('#');
- if (sep > 0) {
- try {
- int p = Integer.parseInt(e.getKey().substring(sep + 1));
- maxAckByPart.merge(p, e.getValue(), Math::max);
- } catch (NumberFormatException expected) {
- // offset key suffix is not a numeric partition; skip
- }
- }
- }
- for (int p : owned) {
- long end = storage.endOffset(t, p);
- long ack = maxAckByPart.getOrDefault(p, -1L);
- if (end >= 0 && ack >= 0) {
- out.add(new UniMetrics.LabelledLong(
- io.opentelemetry.api.common.Attributes.of(
- io.opentelemetry.api.common.AttributeKey.stringKey("topic"), t,
- io.opentelemetry.api.common.AttributeKey.longKey("partition"), (long) p),
- Math.max(0, end - ack)));
- }
- }
- }
- return out;
- });
-
- metrics.registerLabelledGauge("eventmesh_push_ack_lag",
- "max push offset - max ACK offset (per topic/partition) — in-flight deliveries",
- () -> {
- java.util.List out = new java.util.ArrayList<>();
- for (String t : subscriptionManager.activeTopics()) {
- // Frame architecture: the MQ physical cursor per partition is recorded by the
- // dispatcher on client ACK under the reserved key MQ_CURSOR_CLIENT (frame
- // attribute emmqoffset, stamped by Kafka / RocketMQ-4.x at poll). Lag =
- // storage endOffset (physical watermark) − recorded cursor. Per-subscriber
- // entries hold logical sequence numbers and are excluded.
- java.util.Map cursorByPart = new java.util.HashMap<>();
- String cursorPrefix =
- org.apache.eventmesh.runtime.delivery.ReliableDispatcher.MQ_CURSOR_CLIENT + "#";
- for (java.util.Map.Entry e : offsetStore.readAllOffsets(t).entrySet()) {
- if (!e.getKey().startsWith(cursorPrefix)) {
- continue;
- }
- try {
- int p = Integer.parseInt(e.getKey().substring(cursorPrefix.length()));
- cursorByPart.merge(p, e.getValue(), Math::max);
- } catch (NumberFormatException expected) {
- }
- }
- for (java.util.Map.Entry cursorEntry : cursorByPart.entrySet()) {
- int p = cursorEntry.getKey();
- long end = storage.endOffset(t, p);
- long cursor = cursorEntry.getValue();
- if (end >= 0 && cursor >= 0) {
- out.add(new UniMetrics.LabelledLong(
- io.opentelemetry.api.common.Attributes.of(
- io.opentelemetry.api.common.AttributeKey.stringKey("topic"), t,
- io.opentelemetry.api.common.AttributeKey.longKey("partition"), (long) p),
- Math.max(0, end - cursor)));
- }
- }
- }
- return out;
- });
- }
-
- public OffsetStore getOffsetStore() {
- return offsetStore;
- }
-
- public MeshStoragePlugin getStorage() {
- return storage;
- }
-
- /**
- * Synchronous request-reply (§17). Publishes {@code event}, blocks for the matching reply
- * keyed by the {@code x-em-correlation-id} extension, and returns it. On timeout the future is
- * failed and a late reply is discarded. Request-reply is independent of the at-least-once
- * pub/sub path: it neither retries nor dead-letters.
- *
- * @throws Exception if the request times out, publishing fails, or the reply errors
- */
- public CloudEvent request(String topic, CloudEvent event, long timeoutMs) throws Exception {
- String correlationId = readCorrelationId(event);
- CloudEvent toPublish = event;
- if (correlationId == null) {
- correlationId = "req-" + requestSeq.incrementAndGet();
- toPublish = CloudEventBuilder.from(event).withExtension(EXT_CORRELATION_ID, correlationId).build();
- }
- CompletableFuture future = new CompletableFuture<>();
- pendingRequests.put(correlationId, future);
- try {
- publish(topic, toPublish).get();
- } catch (Exception e) {
- pendingRequests.remove(correlationId);
- throw e;
- }
- try {
- return future.get(timeoutMs, TimeUnit.MILLISECONDS);
- } catch (TimeoutException te) {
- pendingRequests.remove(correlationId);
- throw new TimeoutException("request-reply timed out: " + correlationId);
- } catch (ExecutionException ee) {
- throw ee;
- } finally {
- metrics.incRequestReply();
- }
- }
-
- /**
- * Deliver a reply to a pending request. Returns false if the request was unknown, already
- * replied, or had timed out (late reply discarded).
- */
- public boolean reply(String correlationId, CloudEvent replyEvent) {
- CompletableFuture future = pendingRequests.remove(correlationId);
- if (future == null) {
- log.debug("late/unknown reply for correlationId={} discarded", correlationId);
- return false;
- }
- return future.complete(replyEvent);
- }
-
- private static String readCorrelationId(CloudEvent event) {
- Object value = event.getExtension(EXT_CORRELATION_ID);
- return value == null ? null : value.toString();
- }
-
- private PushChannel channelFor(String clientId) {
- return channels.computeIfAbsent(clientId, id -> new LongPollingChannel(pushService, id));
- }
-
- /** Per-topic monotonic delivery sequence (EventMesh's logical offset for the dispatcher). */
- private long nextOffset(String topic) {
- return topicOffsetSeq.computeIfAbsent(topic, k -> new AtomicLong()).incrementAndGet();
- }
-
- private DeadLetterSink deadLetterSink() {
- return (originalTopic, event, reason, attempts) -> {
- String dlqTopic = originalTopic + "_DLQ";
- // Issue #5292: the dispatcher retires the delivery only once this future reports the
- // DLQ write as durably recorded by the storage plugin.
- java.util.concurrent.CompletableFuture future = new java.util.concurrent.CompletableFuture<>();
- try {
- // event is already an EventMeshFrame (internal); store it directly to the DLQ topic.
- storage.send(dlqTopic, event, new SendCallback() {
- @Override
- public void onSuccess(SendResult sendResult) {
- log.info("event {} dead-lettered to {} after {} attempts: {}",
- event.attributes().get("id"), dlqTopic, attempts, reason);
- future.complete(Boolean.TRUE);
- }
-
- @Override
- public void onException(org.apache.eventmesh.api.exception.OnExceptionContext context) {
- log.error("failed to write DLQ event {} to {}", event.attributes().get("id"), dlqTopic, context.getException());
- future.complete(Boolean.FALSE);
- }
- });
- } catch (Exception e) {
- log.error("failed to send DLQ event {} to {}", event.attributes().get("id"), dlqTopic, e);
- future.complete(Boolean.FALSE);
- }
- return future;
- };
- }
-}
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.runtime.ingress;
+
+import org.apache.eventmesh.api.SendCallback;
+import org.apache.eventmesh.api.SendResult;
+import org.apache.eventmesh.api.storage.MeshStoragePlugin;
+import org.apache.eventmesh.common.wire.EventMeshFrame;
+import org.apache.eventmesh.runtime.delivery.DeadLetterSink;
+import org.apache.eventmesh.runtime.delivery.PushChannel;
+import org.apache.eventmesh.runtime.delivery.ReliableDispatcher;
+import org.apache.eventmesh.runtime.metrics.UniMetrics;
+import org.apache.eventmesh.runtime.metrics.UniTrace;
+import org.apache.eventmesh.runtime.offset.OffsetStore;
+import org.apache.eventmesh.runtime.push.BufferedEvent;
+import org.apache.eventmesh.runtime.push.LongPollingChannel;
+import org.apache.eventmesh.runtime.push.PushService;
+import org.apache.eventmesh.runtime.ratelimit.RateLimitedException;
+import org.apache.eventmesh.runtime.ratelimit.TokenBucketRateLimiter;
+import org.apache.eventmesh.runtime.subscription.CloudEventFilter;
+import org.apache.eventmesh.runtime.subscription.DistributionMode;
+import org.apache.eventmesh.runtime.subscription.Subscription;
+import org.apache.eventmesh.runtime.subscription.SubscriptionManager;
+
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicLong;
+
+import io.cloudevents.CloudEvent;
+import io.cloudevents.core.builder.CloudEventBuilder;
+
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Facade that wires the uni-architecture layers into the end-to-end CloudEvents-over-MQ
+ * flow (§6):
+ *
+ * publish ─▶ MeshStoragePlugin.send
+ * pullLoop ─▶ MeshStoragePlugin.poll ─▶ SubscriptionManager.targetsFor ─▶ ReliableDispatcher.deliver ─▶ PushService
+ * poll ◀─ client long-polls PushService
+ * ack ─▶ PushService.ack ─▶ ReliableDispatcher.ack ─▶ OffsetStore (offset advances only on ACK)
+ *
+ *
+ * This is the orchestration core; the actual HTTP endpoint wiring (§6 UniIngressHandler)
+ * delegates here. Phase-1 thin storage adapter means {@code partition} is {@code -1} and the offset
+ * is a per-topic monotonic logical counter until the native storage reimplementation supplies real
+ * partition/offset (Phase 1 step 2).
+ */
+@Slf4j
+public class UniIngressService {
+
+ private final MeshStoragePlugin storage;
+ private final OffsetStore offsetStore;
+ private final SubscriptionManager subscriptionManager;
+ private final ReliableDispatcher dispatcher;
+ private final PushService pushService;
+ private volatile org.apache.eventmesh.runtime.cluster.ClusterCoordinator cluster;
+ private volatile org.apache.eventmesh.runtime.cluster.PartitionOwnership partitionOwnership;
+ /** Self-collected load metrics for session-distribution load balancing (§3). Null until wired. */
+ private volatile LoadMeter loadMeter;
+
+ /** Per-topic poll stats for the {@code poll_idle_ratio} gauge (§13.5.1). */
+ private final ConcurrentHashMap pollCount = new ConcurrentHashMap<>();
+ private final ConcurrentHashMap pollEmptyCount = new ConcurrentHashMap<>();
+
+ /** Connector offset store (remote side) — String key → String offset value (§8.9). */
+ private final java.util.concurrent.ConcurrentHashMap connectorOffsets = new java.util.concurrent.ConcurrentHashMap<>();
+ private final UniMetrics metrics;
+
+ private final ConcurrentHashMap channels = new ConcurrentHashMap<>();
+ private final ConcurrentHashMap topicOffsetSeq = new ConcurrentHashMap<>();
+ private final ConcurrentHashMap> pendingRequests = new ConcurrentHashMap<>();
+ private final AtomicLong requestSeq = new AtomicLong();
+ private final ConcurrentHashMap topicLimiters = new ConcurrentHashMap<>();
+
+ /**
+ * CloudEvents extension carrying the request's correlation id (§17).
+ *
+ * Named without hyphens because the CloudEvents spec restricts extension attribute names to
+ * lower-case ASCII letters and digits — the redesign doc's {@code x-em-correlation-id} spelling
+ * is rejected by the SDK's name validation.
+ */
+ public static final String EXT_CORRELATION_ID = "emcorrelationid";
+
+ public UniIngressService(MeshStoragePlugin storage, OffsetStore offsetStore) {
+ this(storage, offsetStore, new SubscriptionManager(), new PushService(),
+ ReliableDispatcher.DEFAULT_ACK_TIMEOUT_MS, ReliableDispatcher.DEFAULT_MAX_ATTEMPTS,
+ System::currentTimeMillis);
+ }
+
+ /**
+ * Test-friendly constructor with an injectable clock and retry parameters.
+ */
+ public UniIngressService(MeshStoragePlugin storage, OffsetStore offsetStore,
+ SubscriptionManager subscriptionManager, PushService pushService,
+ long ackTimeoutMs, int maxAttempts, java.util.function.LongSupplier clock) {
+ this.storage = storage;
+ this.offsetStore = offsetStore;
+ this.subscriptionManager = subscriptionManager;
+ this.pushService = pushService;
+ this.metrics = new UniMetrics();
+ this.dispatcher = new ReliableDispatcher(ackTimeoutMs, maxAttempts, clock, offsetStore, deadLetterSink(),
+ metrics, ReliableDispatcher.DEFAULT_JITTER_RATIO);
+ // Sub-PR B: re-ACK any in-flight deliveries from a previous JVM so they do not become
+ // orphans after a crash. Safe on first start (store is empty) and idempotent.
+ this.dispatcher.recover();
+ }
+
+ // ---- connector offset (remote side, §8.9) ----
+
+ public String getConnectorOffset(String connectorId) {
+ return connectorOffsets.get(connectorId);
+ }
+
+ public void putConnectorOffset(String connectorId, String offset) {
+ connectorOffsets.put(connectorId, offset);
+ }
+
+ /**
+ * Publish a CloudEvent to {@code topic} (persisted to MQ). Completes when the storage plugin
+ * acknowledges the write.
+ */
+ public CompletableFuture publish(String topic, CloudEvent event) {
+ if (loadMeter != null && event.getData() != null) {
+ loadMeter.recordInflow(event.getData().toBytes().length);
+ }
+ CompletableFuture future = new CompletableFuture<>();
+ TokenBucketRateLimiter limiter = topicLimiters.get(topic);
+ if (limiter != null && !limiter.tryAcquire()) {
+ metrics.incRateLimited();
+ future.completeExceptionally(new RateLimitedException(topic));
+ return future;
+ }
+ try {
+ // Ingress boundary: CloudEvent (the public/external format) → EventMeshFrame (the internal
+ // wire unit). From here on the event is a Frame internally; egress converts back to the
+ // client's protocol (CloudEvents / MeshMessage) at the delivery boundary.
+ org.apache.eventmesh.common.wire.EventMeshFrame frame =
+ org.apache.eventmesh.common.wire.EventMeshFrame.fromCloudEvent(event);
+ storage.send(topic, frame, new SendCallback() {
+ @Override
+ public void onSuccess(SendResult sendResult) {
+ metrics.incPublish();
+ UniTrace.end(UniTrace.startPublish(topic, event));
+ future.complete(null);
+ }
+
+ @Override
+ public void onException(org.apache.eventmesh.api.exception.OnExceptionContext context) {
+ metrics.incPublishFailed();
+ future.completeExceptionally(context.getException());
+ }
+ });
+ } catch (Exception e) {
+ metrics.incPublishFailed();
+ future.completeExceptionally(e);
+ }
+ return future;
+ }
+
+ /**
+ * Publish an already-internal {@link org.apache.eventmesh.common.wire.EventMeshFrame} (no
+ * CloudEvent→Frame boundary conversion — the caller built the frame directly, e.g. the legacy
+ * MeshMessage TCP path via {@code MeshMessageFrameCodec}). Completes when storage acks the write.
+ */
+ public CompletableFuture publish(String topic, org.apache.eventmesh.common.wire.EventMeshFrame frame) {
+ if (loadMeter != null && frame.data() != null) {
+ loadMeter.recordInflow(frame.data().length);
+ }
+ CompletableFuture future = new CompletableFuture<>();
+ TokenBucketRateLimiter limiter = topicLimiters.get(topic);
+ if (limiter != null && !limiter.tryAcquire()) {
+ metrics.incRateLimited();
+ future.completeExceptionally(new RateLimitedException(topic));
+ return future;
+ }
+ try {
+ storage.send(topic, frame, new SendCallback() {
+ @Override
+ public void onSuccess(SendResult sendResult) {
+ metrics.incPublish();
+ future.complete(null);
+ }
+
+ @Override
+ public void onException(org.apache.eventmesh.api.exception.OnExceptionContext context) {
+ metrics.incPublishFailed();
+ future.completeExceptionally(context.getException());
+ }
+ });
+ } catch (Exception e) {
+ metrics.incPublishFailed();
+ future.completeExceptionally(e);
+ }
+ return future;
+ }
+
+ /**
+ * Publish a batch of CloudEvents to {@code topic} (§13.7.3). Completes when all are persisted.
+ * The storage plugin's own batching (e.g. Kafka producer accumulator) amortises the per-event
+ * RTT; this layer fans out the per-event futures and joins them.
+ */
+ public CompletableFuture publishBatch(String topic, java.util.List events) {
+ if (events == null || events.isEmpty()) {
+ return CompletableFuture.completedFuture(null);
+ }
+ CompletableFuture>[] futures = events.stream()
+ .map(e -> publish(topic, e))
+ .toArray(CompletableFuture[]::new);
+ return CompletableFuture.allOf(futures);
+ }
+
+ /**
+ * Primary ingress entry for a batch of internal EventMeshFrames (#5299). The HTTP path uses
+ * {@code FrameAdaptors.get("cloudevents").toFrame(body)} per event then calls this method; the
+ * CloudEvent-typed {@link #publishBatch(String, java.util.List)} is preserved for binary
+ * compatibility with the TCP bridge and any out-of-tree caller.
+ */
+ public CompletableFuture publishBatchFrames(String topic, java.util.List frames) {
+ if (frames == null || frames.isEmpty()) {
+ return CompletableFuture.completedFuture(null);
+ }
+ CompletableFuture>[] futures = frames.stream()
+ .map(f -> publish(topic, f))
+ .toArray(CompletableFuture[]::new);
+ return CompletableFuture.allOf(futures);
+ }
+
+ /**
+ * Register a custom push channel for a client (e.g. a WebHook URL for legacy HTTP webhook-push
+ * subscribers, or a {@code TcpPushChannel} for legacy TCP clients). Overrides the default
+ * long-polling channel for this {@code clientId} on subsequent dispatches.
+ */
+ public void registerChannel(String clientId, PushChannel channel) {
+ pushService.register(clientId);
+ channels.put(clientId, channel);
+ }
+
+ /**
+ * Configure a per-topic rate limit (§6.6). Subsequent publishes above {@code permitsPerSecond}
+ * (burst {@code capacity}) fail the publish future with {@link RateLimitedException}.
+ */
+ public void setTopicRateLimit(String topic, long capacity, double permitsPerSecond) {
+ topicLimiters.put(topic, new TokenBucketRateLimiter(capacity, permitsPerSecond));
+ }
+
+ /**
+ * Register a subscription. The subscriber retrieves events via {@link #poll}.
+ *
+ * @return the subscription id
+ */
+ public String subscribe(String topic, String clientId, DistributionMode mode, CloudEventFilter filter) {
+ pushService.register(clientId);
+ String subId = subscriptionManager.subscribe(topic, clientId, mode, filter, event -> {
+ // No-op: the reliability layer (ReliableDispatcher) owns delivery, not this fire-and-
+ // forget callback. Kept to satisfy the SubscriptionManager handler contract.
+ });
+ // §13.2: when clustered, also register cluster-wide so other instances can route events
+ // for this subscriber here (via ClusterCoordinator → HttpForwarder /internal/forward).
+ // Without this the HTTP /events/subscribe path is local-only and a publish on a peer
+ // never reaches this subscriber.
+ if (cluster != null) {
+ cluster.subscribe(topic, clientId, mode, null);
+ }
+ return subId;
+ }
+
+ /**
+ * Remove a subscription.
+ */
+ public boolean unsubscribe(String subscriptionId) {
+ // Resolve topic + clientId before removing so a clustered subscription can be deregistered
+ // cluster-wide (the coordinator's unsubscribe is keyed by topic+clientId, not subId).
+ org.apache.eventmesh.runtime.subscription.Subscription sub = subscriptionManager.getSubscription(subscriptionId);
+ boolean removed = subscriptionManager.unsubscribe(subscriptionId);
+ if (removed && cluster != null && sub != null) {
+ cluster.unsubscribe(sub.getTopic(), sub.getClientId());
+ }
+ return removed;
+ }
+
+ /** Remove one client's subscription to one topic (HTTP /events/unsubscribe with {clientId, topic}). */
+ public boolean unsubscribe(String topic, String clientId) {
+ boolean removed = subscriptionManager.unsubscribe(topic, clientId);
+ if (removed && cluster != null) {
+ cluster.unsubscribe(topic, clientId);
+ }
+ return removed;
+ }
+
+ /** Remove ALL subscriptions for a client (HTTP /events/unsubscribe with {clientId} only).
+ * Propagates cluster-wide + frees the PushService buffer. */
+ public int unsubscribeByClient(String clientId) {
+ java.util.Set topics = subscriptionManager.topicsForClient(clientId);
+ int removed = subscriptionManager.unsubscribeByClient(clientId);
+ if (cluster != null) {
+ for (String topic : topics) {
+ cluster.unsubscribe(topic, clientId);
+ }
+ }
+ pushService.removeClient(clientId);
+ return removed;
+ }
+
+ /**
+ * Pump: pull a batch from storage, route to each target subscriber via the reliability layer.
+ *
+ * @return number of events pulled
+ */
+ public int pullAndDispatch(String topic, int maxEvents, long timeoutMs) {
+ pollCount.merge(topic, 1L, Long::sum);
+ // Multi-instance (§13.2.3): poll only the partitions this instance owns. Single-instance
+ // (no ownership) or unknown partition count → poll the whole topic (partition -1).
+ java.util.List owned = partitionOwnership == null ? null : partitionOwnership.ownedPartitions(topic);
+ int total;
+ if (owned == null) {
+ total = pullAndDispatchPartition(topic, -1, maxEvents, timeoutMs);
+ } else if (owned.isEmpty()) {
+ total = 0; // owns none -> do not poll (avoids duplicate with the real owners)
+ } else {
+ total = 0;
+ for (int p : owned) {
+ total += pullAndDispatchPartition(topic, p, maxEvents, timeoutMs);
+ }
+ }
+ if (total == 0) {
+ pollEmptyCount.merge(topic, 1L, Long::sum);
+ }
+ return total;
+ }
+
+ private int pullAndDispatchPartition(String topic, int partition, int maxEvents, long timeoutMs) {
+ // Pull EventMeshFrames (internal wire); dispatch each as a Frame through the internal
+ // pipeline (filter/dispatcher/egress all carry Frame now; egress converts to the client's
+ // protocol at the wire boundary).
+ List frames = storage.poll(topic, partition, -1, maxEvents, timeoutMs);
+ if (frames == null || frames.isEmpty()) {
+ return 0;
+ }
+ long start = System.nanoTime();
+ for (org.apache.eventmesh.common.wire.EventMeshFrame f : frames) {
+ if (isExpired(f)) {
+ // §13.3.4 TTL: drop expired events instead of dispatching.
+ log.debug("dropping expired event {} on topic {} (emttl elapsed)", f.attributes().get("id"), topic);
+ continue;
+ }
+ io.opentelemetry.api.trace.Span dispatchSpan = UniTrace.startDispatch(topic, f);
+ if (cluster != null) {
+ // Multi-instance: route via the cluster coordinator (local vs cross-instance forward).
+ cluster.dispatch(topic, f);
+ } else {
+ // develop's OffsetExtensions (CE-extension-carried MQ offset) is superseded by the
+ // Frame architecture: the POP check key rides in frame attributes (empopck) and the
+ // deferred broker ACK fires on client ACK — same at-least-once goal, Frame-native.
+ long offset = nextOffset(topic);
+ // P2 fix: if the frame carries a POP check key (RocketMQ 5.x deferred ACK), build a
+ // callback that ACKs the broker on client ACK (restoring at-least-once).
+ String popCk = f.attributes().get("empopck");
+ Runnable mqAck = (popCk != null) ? () -> storage.ackPulledMessage(topic, popCk) : null;
+ for (Subscription target : subscriptionManager.targetsFor(topic, f)) {
+ dispatcher.deliver(topic, partition, offset, f, target.getClientId(),
+ channelFor(target.getClientId()), mqAck);
+ }
+ }
+ UniTrace.end(dispatchSpan);
+ }
+ metrics.addDispatchLatencyNanos(System.nanoTime() - start);
+ metrics.incDispatched(frames.size());
+ return frames.size();
+ }
+
+ /**
+ * TTL expiry check (§13.3.4): an event with an {@code emttl} attribute (ms) and a {@code time}
+ * is expired when {@code now > time + emttl}. Events without either field never expire. Reads the
+ * attributes off the internal EventMeshFrame (emttl/time are preserved in its KV section).
+ */
+ private boolean isExpired(org.apache.eventmesh.common.wire.EventMeshFrame event) {
+ String ttl = event.attributes().get("emttl");
+ String time = event.attributes().get("time");
+ if (ttl == null || time == null) {
+ return false;
+ }
+ try {
+ long ttlMs = Long.parseLong(ttl);
+ long eventTime = java.time.OffsetDateTime.parse(time).toInstant().toEpochMilli();
+ return System.currentTimeMillis() > eventTime + ttlMs;
+ } catch (NumberFormatException | java.time.format.DateTimeParseException e) {
+ return false;
+ }
+ }
+
+ /**
+ * Local delivery for one subscriber — handed to the reliability layer + the subscriber's push
+ * channel. Exposed so a {@link org.apache.eventmesh.runtime.cluster.ClusterCoordinator} can route
+ * same-instance targets here while forwarding remote ones.
+ */
+ public boolean deliverLocal(String topic, String clientId, org.apache.eventmesh.common.wire.EventMeshFrame event) {
+ long offset = nextOffset(topic);
+ dispatcher.deliver(topic, -1, offset, event, clientId, channelFor(clientId));
+ return true;
+ }
+
+ /**
+ * Enable multi-instance coordination: when set, {@link #pullAndDispatch} routes each event
+ * through the cluster coordinator (local targets via {@link #deliverLocal}, remote via forward).
+ */
+ public void withCluster(org.apache.eventmesh.runtime.cluster.ClusterCoordinator cluster) {
+ this.cluster = cluster;
+ }
+
+ /**
+ * Topics this instance should pull and partition-assign: local active topics UNION cluster-wide
+ * topics (topics with a remote subscriber discovered via the Meta watch). Without the cluster
+ * half, an instance with no local subscriber for a topic never pulls it, so messages on its
+ * partitions can't be forwarded to the remote subscriber (multi-instance message loss). Returns
+ * local-only when not clustered. Both {@code UniRuntime.pullLoop} and {@code PartitionOwnership}'s
+ * topic source use this so the pull set and the assignment set stay consistent (otherwise an
+ * unassigned cluster topic would degrade to poll-all and duplicate).
+ */
+ public java.util.Set activeTopicsClustered() {
+ java.util.Set topics = new java.util.HashSet<>(getSubscriptionManager().activeTopics());
+ if (cluster != null) {
+ topics.addAll(cluster.subscriptionTopics());
+ }
+ return topics;
+ }
+
+ // ===================== Lite Topic (RIP-83, 5.x-only) =====================
+ // Lite topic ops are exposed only when the storage plugin implements LiteTopicCapable; otherwise
+ // they fail fast (UnsupportedOperationException). The HTTP layer (/events/lite/*) delegates here.
+
+ /**
+ * Publish one CloudEvent to a lite topic (parentTopic, liteTopic). The storage plugin routes it
+ * into the lite topic's LMQ. Requires a {@link org.apache.eventmesh.api.storage.LiteTopicCapable}
+ * storage (the 5.x plugin); 4.x/kafka/standalone storages throw.
+ */
+ public CompletableFuture publishLite(String parentTopic, String liteTopic, CloudEvent event) {
+ CompletableFuture future = new CompletableFuture<>();
+ if (!(storage instanceof org.apache.eventmesh.api.storage.LiteTopicCapable)) {
+ future.completeExceptionally(new UnsupportedOperationException("storage does not support lite topic"));
+ return future;
+ }
+ try {
+ // Boundary: CloudEvent → EventMeshFrame (internal wire unit).
+ org.apache.eventmesh.common.wire.EventMeshFrame frame =
+ org.apache.eventmesh.common.wire.EventMeshFrame.fromCloudEvent(event);
+ ((org.apache.eventmesh.api.storage.LiteTopicCapable) storage).sendLite(parentTopic, liteTopic, frame,
+ new SendCallback() {
+ @Override
+ public void onSuccess(SendResult sendResult) {
+ future.complete(null);
+ }
+
+ @Override
+ public void onException(org.apache.eventmesh.api.exception.OnExceptionContext context) {
+ future.completeExceptionally(context.getException());
+ }
+ });
+ } catch (Exception e) {
+ future.completeExceptionally(e);
+ }
+ return future;
+ }
+
+ /**
+ * Primary lite ingress: publish a single internal EventMeshFrame to a lite topic (LMQ) (#5299).
+ * The HTTP path uses {@code FrameAdaptors.get("cloudevents").toFrame(body)} then calls this
+ * method; no further CloudEvent touches runtime state.
+ */
+ public CompletableFuture publishLiteFrame(String parentTopic, String liteTopic, EventMeshFrame frame) {
+ CompletableFuture future = new CompletableFuture<>();
+ if (!(storage instanceof org.apache.eventmesh.api.storage.LiteTopicCapable)) {
+ future.completeExceptionally(new UnsupportedOperationException("storage does not support lite topic"));
+ return future;
+ }
+ if (loadMeter != null && frame.data() != null) {
+ loadMeter.recordInflow(frame.data().length);
+ }
+ try {
+ ((org.apache.eventmesh.api.storage.LiteTopicCapable) storage).sendLite(parentTopic, liteTopic, frame,
+ new SendCallback() {
+ @Override
+ public void onSuccess(SendResult sendResult) {
+ future.complete(null);
+ }
+
+ @Override
+ public void onException(org.apache.eventmesh.api.exception.OnExceptionContext context) {
+ future.completeExceptionally(context.getException());
+ }
+ });
+ } catch (Exception e) {
+ future.completeExceptionally(e);
+ }
+ return future;
+ }
+
+ /**
+ * Pull a batch of CloudEvents from a lite topic (direct pull from the LMQ; no deliveryId / no
+ * EventMesh reliability layer — the lite consumer self-manages offset in the storage plugin).
+ * Frames pulled from the LMQ are decoded back to CloudEvents at this boundary. Empty list if lite
+ * is not supported.
+ */
+ public List pollLite(String parentTopic, String liteTopic, int maxEvents, long timeoutMs) {
+ if (!(storage instanceof org.apache.eventmesh.api.storage.LiteTopicCapable)) {
+ return java.util.Collections.emptyList();
+ }
+ java.util.List out = new java.util.ArrayList<>();
+ for (org.apache.eventmesh.common.wire.EventMeshFrame f
+ : ((org.apache.eventmesh.api.storage.LiteTopicCapable) storage).pullLite(parentTopic, liteTopic, maxEvents, timeoutMs)) {
+ out.add(f.toCloudEvent());
+ }
+ return out;
+ }
+
+ /**
+ * Primary lite poll: drain a batch of internal EventMeshFrames from the LMQ (#5299). The
+ * egress boundary (CloudEventsFrameAdaptor) converts back to CloudEvents JSON for the HTTP
+ * response body. Returns an empty list if the storage does not support lite topics.
+ */
+ public List pollLiteFrames(String parentTopic, String liteTopic, int maxEvents, long timeoutMs) {
+ if (!(storage instanceof org.apache.eventmesh.api.storage.LiteTopicCapable)) {
+ return java.util.Collections.emptyList();
+ }
+ return ((org.apache.eventmesh.api.storage.LiteTopicCapable) storage)
+ .pullLite(parentTopic, liteTopic, maxEvents, timeoutMs);
+ }
+
+ /**
+ * Publish a pre-encoded EventMeshFrame byte payload to a lite topic (the internal streaming wire
+ * path — SessionRouter publishes frame bytes). The payload IS an encoded EventMeshFrame; decode
+ * to the Frame object the SPI now expects.
+ */
+ public CompletableFuture publishLiteBytes(String parentTopic, String liteTopic, byte[] payload) {
+ if (loadMeter != null) {
+ loadMeter.recordInflow(payload.length);
+ }
+ CompletableFuture future = new CompletableFuture<>();
+ if (!(storage instanceof org.apache.eventmesh.api.storage.LiteTopicCapable)) {
+ future.completeExceptionally(new UnsupportedOperationException("storage does not support lite topic"));
+ return future;
+ }
+ try {
+ org.apache.eventmesh.common.wire.EventMeshFrame frame = org.apache.eventmesh.common.wire.EventMeshFrame.decode(payload);
+ ((org.apache.eventmesh.api.storage.LiteTopicCapable) storage).sendLite(parentTopic, liteTopic, frame,
+ new SendCallback() {
+ @Override
+ public void onSuccess(SendResult sendResult) {
+ future.complete(null);
+ }
+
+ @Override
+ public void onException(org.apache.eventmesh.api.exception.OnExceptionContext context) {
+ future.completeExceptionally(context.getException());
+ }
+ });
+ } catch (Exception e) {
+ future.completeExceptionally(e);
+ }
+ return future;
+ }
+
+ /**
+ * Pull a batch of pre-encoded EventMeshFrame byte payloads from a lite topic (the byte counterpart
+ * of {@link #pollLite}, for the internal streaming wire). Each entry is an encoded frame.
+ * Empty list if lite is not supported.
+ */
+ public List pollLiteBytes(String parentTopic, String liteTopic, int maxEvents, long timeoutMs) {
+ if (!(storage instanceof org.apache.eventmesh.api.storage.LiteTopicCapable)) {
+ return java.util.Collections.emptyList();
+ }
+ java.util.List out = new java.util.ArrayList<>();
+ for (org.apache.eventmesh.common.wire.EventMeshFrame f
+ : ((org.apache.eventmesh.api.storage.LiteTopicCapable) storage).pullLite(parentTopic, liteTopic, maxEvents, timeoutMs)) {
+ out.add(f.encode());
+ }
+ return out;
+ }
+
+ /**
+ * Ensure {@code parentTopic} is lite-capable and declare {@code liteTopic} under it. Throws if the
+ * storage does not support lite.
+ */
+ public void createLiteTopic(String parentTopic, String liteTopic) throws Exception {
+ if (!(storage instanceof org.apache.eventmesh.api.storage.LiteTopicCapable)) {
+ throw new UnsupportedOperationException("storage does not support lite topic");
+ }
+ ((org.apache.eventmesh.api.storage.LiteTopicCapable) storage).createLiteTopic(parentTopic, liteTopic);
+ }
+
+ /**
+ * Ensure {@code parentTopic} is lite-capable with the given {@code queueCount} and declare
+ * {@code liteTopic} under it. Use {@code queueCount=1} for strict in-order delivery (e.g. a
+ * streaming-call response channel). Throws if the storage does not support lite.
+ */
+ public void createLiteTopic(String parentTopic, String liteTopic, int queueCount) throws Exception {
+ if (!(storage instanceof org.apache.eventmesh.api.storage.LiteTopicCapable)) {
+ throw new UnsupportedOperationException("storage does not support lite topic");
+ }
+ ((org.apache.eventmesh.api.storage.LiteTopicCapable) storage).createLiteTopic(parentTopic, liteTopic, queueCount);
+ }
+
+ /**
+ * @return true iff the storage plugin implements {@link org.apache.eventmesh.api.storage.LiteTopicCapable}.
+ */
+ public boolean isLiteCapable() {
+ return storage instanceof org.apache.eventmesh.api.storage.LiteTopicCapable;
+ }
+
+ /**
+ * Enable multi-instance partition ownership (§13.2.3): when set, {@link #pullAndDispatch} polls
+ * only this instance's owned partitions instead of the whole topic.
+ */
+ public void withPartitionOwnership(org.apache.eventmesh.runtime.cluster.PartitionOwnership ownership) {
+ this.partitionOwnership = ownership;
+ }
+
+ /** Wire the load meter; ingress/egress points call its record* methods. */
+ public void withLoadMeter(LoadMeter loadMeter) {
+ this.loadMeter = loadMeter;
+ }
+
+ /**
+ * @return the wired load meter, or null if not configured (single-instance / tests).
+ */
+ public LoadMeter loadMeter() {
+ return loadMeter;
+ }
+
+ /**
+ * Subscriber long-polls its buffered deliveries.
+ */
+ public List poll(String clientId, int maxEvents, long timeoutMs) {
+ return pushService.poll(clientId, maxEvents, timeoutMs);
+ }
+
+ /**
+ * Subscriber acknowledges a delivery — the offset advances only on ACK (at-least-once).
+ */
+ public boolean ack(String deliveryId) {
+ return pushService.ack(deliveryId);
+ }
+
+ /**
+ * Drive retry / DLQ. Call periodically from a scheduler.
+ */
+ public int dispatcherTick() {
+ return dispatcher.tick();
+ }
+
+ /**
+ * Operational metrics counters (publish/dispatch/ack/retry/DLQ). The dispatcher and ingress
+ * share one instance.
+ */
+ public UniMetrics getMetrics() {
+ return metrics;
+ }
+
+ // Accessors for the admin facade (Phase 7.5). The service is the single owner of these
+ // collaborators; exposing them avoids reconstructing them out of band.
+ public SubscriptionManager getSubscriptionManager() {
+ return subscriptionManager;
+ }
+
+ public PushService getPushService() {
+ return pushService;
+ }
+
+ public ReliableDispatcher getDispatcher() {
+ return dispatcher;
+ }
+
+ /**
+ * @return the multi-instance partition ownership (null when clustering is disabled).
+ */
+ public org.apache.eventmesh.runtime.cluster.PartitionOwnership getPartitionOwnership() {
+ return partitionOwnership;
+ }
+
+ /**
+ * Stale-poll cleanup (§13.6.5): evict clients that haven't polled within {@code thresholdMs} —
+ * drops their subscriptions and push buffer so zombie subscriptions don't leak.
+ *
+ * @return number of subscriptions removed
+ */
+ public int cleanupStaleClients(long thresholdMs) {
+ int removed = 0;
+ for (String cid : pushService.getStaleClientIds(thresholdMs)) {
+ removed += subscriptionManager.unsubscribeByClient(cid);
+ pushService.removeClient(cid);
+ log.info("evicted stale client {} (no poll within {}ms)", cid, thresholdMs);
+ }
+ return removed;
+ }
+
+ /**
+ * Register OTel observable gauges backed by live runtime state (§13.5.1 gauges with *).
+ * Call once at boot. Gauges read on each OTel collection cycle.
+ */
+ public void registerRuntimeGauges() {
+ metrics.registerGauge("eventmesh_pending_queue_size", "total buffered events across all clients",
+ () -> {
+ long sum = 0;
+ for (String cid : pushService.clientIds()) {
+ sum += pushService.pending(cid);
+ }
+ return sum;
+ });
+ metrics.registerGauge("eventmesh_slow_consumer_count", "clients in SLOW or STALLED state",
+ pushService::slowConsumerCount);
+ metrics.registerGauge("eventmesh_active_topics", "topics with active subscribers",
+ () -> subscriptionManager.activeTopics().size());
+ metrics.registerGauge("eventmesh_active_subscribers", "active subscriptions across all topics",
+ () -> {
+ int sum = 0;
+ for (String t : subscriptionManager.activeTopics()) {
+ sum += subscriptionManager.activeSubscriptions(t).size();
+ }
+ return sum;
+ });
+
+ // Labelled gauges (§13.5.1) — emit one reading per topic / partition.
+ metrics.registerLabelledGauge("eventmesh_poll_idle_ratio",
+ "fraction of poll cycles returning no events (per-mille, per topic)",
+ () -> {
+ java.util.List out = new java.util.ArrayList<>();
+ for (String t : pollCount.keySet()) {
+ long total = pollCount.getOrDefault(t, 0L);
+ long empty = pollEmptyCount.getOrDefault(t, 0L);
+ long perMille = total == 0 ? 0 : Math.round((double) empty / total * 1000);
+ out.add(new UniMetrics.LabelledLong(
+ io.opentelemetry.api.common.Attributes.of(io.opentelemetry.api.common.AttributeKey.stringKey("topic"), t),
+ perMille));
+ }
+ return out;
+ });
+
+ metrics.registerLabelledGauge("eventmesh_partition_owner",
+ "1 for each partition this instance owns (per topic/partition)",
+ () -> {
+ java.util.List out = new java.util.ArrayList<>();
+ if (partitionOwnership != null) {
+ for (String t : subscriptionManager.activeTopics()) {
+ java.util.List owned = partitionOwnership.ownedPartitions(t);
+ if (owned == null) {
+ continue;
+ }
+ for (int p : owned) {
+ out.add(new UniMetrics.LabelledLong(
+ io.opentelemetry.api.common.Attributes.of(
+ io.opentelemetry.api.common.AttributeKey.stringKey("topic"), t,
+ io.opentelemetry.api.common.AttributeKey.longKey("partition"), (long) p,
+ io.opentelemetry.api.common.AttributeKey.stringKey("instance"), "self"),
+ 1L));
+ }
+ }
+ }
+ return out;
+ });
+
+ metrics.registerLabelledGauge("eventmesh_offset_lag",
+ "MQ end offset - max ACK offset (per topic/partition) — total consumer lag",
+ () -> {
+ java.util.List out = new java.util.ArrayList<>();
+ if (partitionOwnership == null) {
+ return out;
+ }
+ for (String t : subscriptionManager.activeTopics()) {
+ java.util.List owned = partitionOwnership.ownedPartitions(t);
+ if (owned == null) {
+ continue;
+ }
+ // Max ACK offset per partition across all clients (key = clientId#partition).
+ // Exclude the reserved __mqcursor__ key (MQ physical offset — different
+ // magnitude from the logical sequence numbers, mixing them corrupts the gauge).
+ java.util.Map maxAckByPart = new java.util.HashMap<>();
+ String reservedPrefix =
+ org.apache.eventmesh.runtime.delivery.ReliableDispatcher.MQ_CURSOR_CLIENT + "#";
+ for (java.util.Map.Entry e : offsetStore.readAllOffsets(t).entrySet()) {
+ if (e.getKey().startsWith(reservedPrefix)) {
+ continue;
+ }
+ int sep = e.getKey().lastIndexOf('#');
+ if (sep > 0) {
+ try {
+ int p = Integer.parseInt(e.getKey().substring(sep + 1));
+ maxAckByPart.merge(p, e.getValue(), Math::max);
+ } catch (NumberFormatException expected) {
+ // offset key suffix is not a numeric partition; skip
+ }
+ }
+ }
+ for (int p : owned) {
+ long end = storage.endOffset(t, p);
+ long ack = maxAckByPart.getOrDefault(p, -1L);
+ if (end >= 0 && ack >= 0) {
+ out.add(new UniMetrics.LabelledLong(
+ io.opentelemetry.api.common.Attributes.of(
+ io.opentelemetry.api.common.AttributeKey.stringKey("topic"), t,
+ io.opentelemetry.api.common.AttributeKey.longKey("partition"), (long) p),
+ Math.max(0, end - ack)));
+ }
+ }
+ }
+ return out;
+ });
+
+ metrics.registerLabelledGauge("eventmesh_push_ack_lag",
+ "max push offset - max ACK offset (per topic/partition) — in-flight deliveries",
+ () -> {
+ java.util.List out = new java.util.ArrayList<>();
+ for (String t : subscriptionManager.activeTopics()) {
+ // Frame architecture: the MQ physical cursor per partition is recorded by the
+ // dispatcher on client ACK under the reserved key MQ_CURSOR_CLIENT (frame
+ // attribute emmqoffset, stamped by Kafka / RocketMQ-4.x at poll). Lag =
+ // storage endOffset (physical watermark) − recorded cursor. Per-subscriber
+ // entries hold logical sequence numbers and are excluded.
+ java.util.Map cursorByPart = new java.util.HashMap<>();
+ String cursorPrefix =
+ org.apache.eventmesh.runtime.delivery.ReliableDispatcher.MQ_CURSOR_CLIENT + "#";
+ for (java.util.Map.Entry e : offsetStore.readAllOffsets(t).entrySet()) {
+ if (!e.getKey().startsWith(cursorPrefix)) {
+ continue;
+ }
+ try {
+ int p = Integer.parseInt(e.getKey().substring(cursorPrefix.length()));
+ cursorByPart.merge(p, e.getValue(), Math::max);
+ } catch (NumberFormatException expected) {
+ }
+ }
+ for (java.util.Map.Entry cursorEntry : cursorByPart.entrySet()) {
+ int p = cursorEntry.getKey();
+ long end = storage.endOffset(t, p);
+ long cursor = cursorEntry.getValue();
+ if (end >= 0 && cursor >= 0) {
+ out.add(new UniMetrics.LabelledLong(
+ io.opentelemetry.api.common.Attributes.of(
+ io.opentelemetry.api.common.AttributeKey.stringKey("topic"), t,
+ io.opentelemetry.api.common.AttributeKey.longKey("partition"), (long) p),
+ Math.max(0, end - cursor)));
+ }
+ }
+ }
+ return out;
+ });
+ }
+
+ public OffsetStore getOffsetStore() {
+ return offsetStore;
+ }
+
+ public MeshStoragePlugin getStorage() {
+ return storage;
+ }
+
+ /**
+ * Synchronous request-reply (§17). Publishes {@code event}, blocks for the matching reply
+ * keyed by the {@code x-em-correlation-id} extension, and returns it. On timeout the future is
+ * failed and a late reply is discarded. Request-reply is independent of the at-least-once
+ * pub/sub path: it neither retries nor dead-letters.
+ *
+ * @throws Exception if the request times out, publishing fails, or the reply errors
+ */
+ public CloudEvent request(String topic, CloudEvent event, long timeoutMs) throws Exception {
+ String correlationId = readCorrelationId(event);
+ CloudEvent toPublish = event;
+ if (correlationId == null) {
+ correlationId = "req-" + requestSeq.incrementAndGet();
+ toPublish = CloudEventBuilder.from(event).withExtension(EXT_CORRELATION_ID, correlationId).build();
+ }
+ CompletableFuture future = new CompletableFuture<>();
+ pendingRequests.put(correlationId, future);
+ try {
+ publish(topic, toPublish).get();
+ } catch (Exception e) {
+ pendingRequests.remove(correlationId);
+ throw e;
+ }
+ try {
+ return future.get(timeoutMs, TimeUnit.MILLISECONDS);
+ } catch (TimeoutException te) {
+ pendingRequests.remove(correlationId);
+ throw new TimeoutException("request-reply timed out: " + correlationId);
+ } catch (ExecutionException ee) {
+ throw ee;
+ } finally {
+ metrics.incRequestReply();
+ }
+ }
+
+ /**
+ * Deliver a reply to a pending request. Returns false if the request was unknown, already
+ * replied, or had timed out (late reply discarded).
+ */
+ public boolean reply(String correlationId, CloudEvent replyEvent) {
+ CompletableFuture future = pendingRequests.remove(correlationId);
+ if (future == null) {
+ log.debug("late/unknown reply for correlationId={} discarded", correlationId);
+ return false;
+ }
+ return future.complete(replyEvent);
+ }
+
+ /**
+ * Primary request-reply path (#5299, §17): publish a Frame, wait for the matching reply Frame.
+ * Implementation note: this method round-trips through CloudEvent (via {@code
+ * EventMeshFrame.fromCloudEvent / toCloudEvent}) so the existing {@code pendingRequests} map
+ * (typed as {@code CompletableFuture}) stays untouched. A future sub-PR will
+ * re-type {@code pendingRequests} to {@code CompletableFuture} and remove
+ * the round-trip. The HTTP path now uses this method (the previous {@code request(CloudEvent)}
+ * is preserved for binary compat with the TCP bridge).
+ */
+ public EventMeshFrame requestFrame(String topic, EventMeshFrame frame, long timeoutMs) throws Exception {
+ CloudEvent reply = request(topic, frame.toCloudEvent(), timeoutMs);
+ return EventMeshFrame.fromCloudEvent(reply);
+ }
+
+ /**
+ * Primary reply path: complete the pending request future with a Frame (#5299). Round-trips
+ * through CloudEvent for the same reason as {@link #requestFrame(String, EventMeshFrame, long)}.
+ */
+ public boolean replyFrame(String correlationId, EventMeshFrame replyFrame) {
+ return reply(correlationId, replyFrame.toCloudEvent());
+ }
+
+ private static String readCorrelationId(CloudEvent event) {
+ Object value = event.getExtension(EXT_CORRELATION_ID);
+ return value == null ? null : value.toString();
+ }
+
+ private PushChannel channelFor(String clientId) {
+ return channels.computeIfAbsent(clientId, id -> new LongPollingChannel(pushService, id));
+ }
+
+ /** Per-topic monotonic delivery sequence (EventMesh's logical offset for the dispatcher). */
+ private long nextOffset(String topic) {
+ return topicOffsetSeq.computeIfAbsent(topic, k -> new AtomicLong()).incrementAndGet();
+ }
+
+ private DeadLetterSink deadLetterSink() {
+ return (originalTopic, event, reason, attempts) -> {
+ String dlqTopic = originalTopic + "_DLQ";
+ // Issue #5292: the dispatcher retires the delivery only once this future reports the
+ // DLQ write as durably recorded by the storage plugin.
+ java.util.concurrent.CompletableFuture future = new java.util.concurrent.CompletableFuture<>();
+ try {
+ // event is already an EventMeshFrame (internal); store it directly to the DLQ topic.
+ storage.send(dlqTopic, event, new SendCallback() {
+ @Override
+ public void onSuccess(SendResult sendResult) {
+ log.info("event {} dead-lettered to {} after {} attempts: {}",
+ event.attributes().get("id"), dlqTopic, attempts, reason);
+ future.complete(Boolean.TRUE);
+ }
+
+ @Override
+ public void onException(org.apache.eventmesh.api.exception.OnExceptionContext context) {
+ log.error("failed to write DLQ event {} to {}", event.attributes().get("id"), dlqTopic, context.getException());
+ future.complete(Boolean.FALSE);
+ }
+ });
+ } catch (Exception e) {
+ log.error("failed to send DLQ event {} to {}", event.attributes().get("id"), dlqTopic, e);
+ future.complete(Boolean.FALSE);
+ }
+ return future;
+ };
+ }
+}
diff --git a/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/security/AclFilter.java b/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/security/AclFilter.java
index aa7d8ba9e0..30a68887ff 100644
--- a/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/security/AclFilter.java
+++ b/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/security/AclFilter.java
@@ -17,12 +17,12 @@
package org.apache.eventmesh.runtime.security;
+import org.apache.eventmesh.common.wire.EventMeshFrame;
+
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
-import io.cloudevents.CloudEvent;
-
/**
* Topic-level authorization (§13.4.2). Holds an {@link AclRule} set sorted by priority (descending,
* DENY wins ties) and matches each request against it: the first matching rule's effect applies,
@@ -58,13 +58,23 @@ public synchronized void setRules(List newRules) {
this.rules = sorted;
}
+ /**
+ * Tenant / clientId come from {@code ctx} (set by the HTTP handler before the filter chain).
+ * We no longer read them from the event because that path was CloudEvent-specific; sub-PR
+ * B keeps the contract simple by reading the principal from the context and only using the
+ * frame to confirm an event-shaped payload arrived.
+ */
@Override
- public FilterVerdict check(CloudEvent event, FilterContext ctx) {
+ public FilterVerdict check(EventMeshFrame frame, FilterContext ctx) {
String principal = ctx.getTenant() != null ? ctx.getTenant() : ctx.getClientId();
String resource = ctx.getTopic();
if (principal == null || resource == null) {
return FilterVerdict.deny(FilterVerdict.STATUS_FORBIDDEN, "no principal/resource for ACL");
}
+ if (frame != null && !frame.isEvent()) {
+ return FilterVerdict.deny(FilterVerdict.STATUS_FORBIDDEN,
+ "ACL applies to EVENT frames only (got msgType=" + frame.msgType() + ")");
+ }
// action not yet carried in FilterContext — pass null so rule action doesn't restrict (any matches).
for (AclRule rule : rules) {
if (rule.matches(principal, resource, null)) {
diff --git a/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/security/FilterChain.java b/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/security/FilterChain.java
index 270db39057..be9c56f1cd 100644
--- a/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/security/FilterChain.java
+++ b/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/security/FilterChain.java
@@ -17,13 +17,13 @@
package org.apache.eventmesh.runtime.security;
+import org.apache.eventmesh.common.wire.EventMeshFrame;
+
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
-import io.cloudevents.CloudEvent;
-
/**
* Ordered ingress security pipeline (§4.5): AuthFilter → AclFilter → SignatureVerifier, etc.
*
@@ -46,10 +46,22 @@ public FilterChain(IngressFilter... filters) {
/**
* Run every filter; return the first denying verdict, or {@link FilterVerdict#allow()} if all
* pass.
+ *
+ * @deprecated since #5299 — call {@link #check(EventMeshFrame, FilterContext)} instead. Kept
+ * as a bridge for the TCP path until sub-PR C migrates.
+ */
+ @Deprecated
+ public FilterVerdict check(io.cloudevents.CloudEvent event, FilterContext ctx) {
+ return check(EventMeshFrame.fromCloudEvent(event), ctx);
+ }
+
+ /**
+ * Run every filter on the runtime's internal frame; return the first denying verdict, or
+ * {@link FilterVerdict#allow()} if all pass. This is the primary ingress path since #5299.
*/
- public FilterVerdict check(CloudEvent event, FilterContext ctx) {
+ public FilterVerdict check(EventMeshFrame frame, FilterContext ctx) {
for (IngressFilter filter : filters) {
- FilterVerdict verdict = filter.check(event, ctx);
+ FilterVerdict verdict = filter.check(frame, ctx);
if (!verdict.isAllowed()) {
return verdict;
}
diff --git a/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/security/IngressFilter.java b/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/security/IngressFilter.java
index 573b6828e1..e4193f121b 100644
--- a/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/security/IngressFilter.java
+++ b/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/security/IngressFilter.java
@@ -17,18 +17,37 @@
package org.apache.eventmesh.runtime.security;
+import org.apache.eventmesh.common.wire.EventMeshFrame;
+
import io.cloudevents.CloudEvent;
/**
* One stage of the ingress security pipeline (§4.5). Implementations: authentication (who are
* you — {@code TokenAuthFilter}), authorization (what may you do — {@code AclFilter}), signature
* verification ({@code SignatureVerifierFilter}). TLS / mTLS is enforced at the transport, not here.
+ *
+ * Filters operate on the runtime's internal wire format ({@link EventMeshFrame}) since
+ * #5299; the legacy {@code CloudEvent} overload is retained as a bridge for code paths that have
+ * not yet migrated (notably the TCP ingress in sub-PR C). Implementations should override the
+ * {@code EventMeshFrame} variant; the {@code CloudEvent} variant is implemented as a default
+ * that delegates via {@code frame.toCloudEvent()} so existing custom filters keep working.
*/
-@FunctionalInterface
public interface IngressFilter {
/**
* Decide whether {@code event} from {@code ctx} may proceed.
+ *
+ * @deprecated since #5299 — override {@link #check(EventMeshFrame, FilterContext)} instead.
+ * Will be removed once all ingress paths (HTTP, TCP, A2A) emit {@link EventMeshFrame}.
+ */
+ @Deprecated
+ default FilterVerdict check(CloudEvent event, FilterContext ctx) {
+ return check(EventMeshFrame.fromCloudEvent(event), ctx);
+ }
+
+ /**
+ * Decide whether {@code frame} from {@code ctx} may proceed. Default implementation reads
+ * tenant / signature / token directly from {@code frame.attributes()}.
*/
- FilterVerdict check(CloudEvent event, FilterContext ctx);
+ FilterVerdict check(EventMeshFrame frame, FilterContext ctx);
}
diff --git a/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/security/SignatureVerifierFilter.java b/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/security/SignatureVerifierFilter.java
index df73ee864f..34dca2d404 100644
--- a/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/security/SignatureVerifierFilter.java
+++ b/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/security/SignatureVerifierFilter.java
@@ -17,14 +17,15 @@
package org.apache.eventmesh.runtime.security;
+import org.apache.eventmesh.common.wire.EventMeshFrame;
+
import java.nio.charset.StandardCharsets;
import java.util.Locale;
+import java.util.Map;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
-import io.cloudevents.CloudEvent;
-
/**
* Verifies an HMAC-SHA256 signature over a canonical projection of the CloudEvent (§13.4.4), so the
* receiver can detect tampering and assert provenance. The signature travels in the
@@ -43,13 +44,16 @@ public SignatureVerifierFilter(String secret) {
}
@Override
- public FilterVerdict check(CloudEvent event, FilterContext ctx) {
- Object provided = event.getExtension(EXT_SIGNATURE);
- if (!(provided instanceof String)) {
+ public FilterVerdict check(EventMeshFrame frame, FilterContext ctx) {
+ // The signature travels in the frame attributes under the same key the legacy CloudEvent
+ // extension used, so a signed CE-JSON payload becomes a signed frame automatically after
+ // the cloudevents FrameAdaptor round-trip.
+ String provided = frame.attributes().get(EXT_SIGNATURE);
+ if (provided == null) {
return FilterVerdict.deny(FilterVerdict.STATUS_UNAUTHENTICATED, "missing signature");
}
- String expected = sign(canonical(event));
- if (constantTimeEquals(expected, (String) provided)) {
+ String expected = sign(canonical(frame));
+ if (constantTimeEquals(expected, provided)) {
return FilterVerdict.allow();
}
return FilterVerdict.deny(FilterVerdict.STATUS_UNAUTHENTICATED, "signature mismatch");
@@ -57,7 +61,7 @@ public FilterVerdict check(CloudEvent event, FilterContext ctx) {
/**
* Compute the signature over {@code message} — also used by clients/tests to produce the value
- * placed in the {@code emsignature} extension.
+ * placed in the {@code emsignature} extension/attribute.
*/
public String sign(String message) {
try {
@@ -70,11 +74,12 @@ public String sign(String message) {
}
}
- static String canonical(CloudEvent event) {
- Object id = event.getId();
- Object source = event.getSource();
- Object type = event.getType();
- return String.valueOf(id) + "|" + String.valueOf(source) + "|" + String.valueOf(type);
+ static String canonical(EventMeshFrame frame) {
+ // Same projection as before (#5299 sub-PR B): id|source|type, all read from frame
+ // attributes. For non-EVENT frames the canonical string still computes (we may want to
+ // tighten to isEvent() in a follow-up if streaming chunks ever need sigs).
+ Map a = frame.attributes();
+ return a.getOrDefault("id", "") + "|" + a.getOrDefault("source", "") + "|" + a.getOrDefault("type", "");
}
private static String toHex(byte[] bytes) {
diff --git a/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/security/TokenAuthFilter.java b/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/security/TokenAuthFilter.java
index 2f44f44bbb..62ae9b3426 100644
--- a/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/security/TokenAuthFilter.java
+++ b/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/security/TokenAuthFilter.java
@@ -17,11 +17,11 @@
package org.apache.eventmesh.runtime.security;
+import org.apache.eventmesh.common.wire.EventMeshFrame;
+
import java.util.Collections;
import java.util.Set;
-import io.cloudevents.CloudEvent;
-
/**
* Authenticates the caller by a bearer token (§13.4.1). In production this delegates to the
* existing security-plugin ({@code auth-token} / {@code auth-http-basic}); the uni skeleton
@@ -36,7 +36,7 @@ public TokenAuthFilter(Set validTokens) {
}
@Override
- public FilterVerdict check(CloudEvent event, FilterContext ctx) {
+ public FilterVerdict check(EventMeshFrame frame, FilterContext ctx) {
String credential = ctx.getCredential();
if (credential != null && validTokens.contains(credential)) {
return FilterVerdict.allow();
diff --git a/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/transport/tcp/CloudEventToPackageBody.java b/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/transport/tcp/CloudEventToPackageBody.java
deleted file mode 100644
index f92a3682fd..0000000000
--- a/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/transport/tcp/CloudEventToPackageBody.java
+++ /dev/null
@@ -1,31 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.apache.eventmesh.runtime.transport.tcp;
-
-import io.cloudevents.CloudEvent;
-
-/**
- * Adapts a delivered CloudEvent into the legacy TCP {@code Package} body the old client expects
- * (egress direction). Production uses {@code MeshMessageProtocolAdaptor.fromCloudEvent(...)};
- * tests inject a stub (e.g. event → a Map the test can read back).
- */
-@FunctionalInterface
-public interface CloudEventToPackageBody {
-
- Object toBody(CloudEvent event);
-}
diff --git a/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/transport/tcp/MeshEventToPackageBody.java b/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/transport/tcp/MeshEventToPackageBody.java
deleted file mode 100644
index 3c13c620d0..0000000000
--- a/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/transport/tcp/MeshEventToPackageBody.java
+++ /dev/null
@@ -1,50 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.apache.eventmesh.runtime.transport.tcp;
-
-import org.apache.eventmesh.common.protocol.tcp.EventMeshMessage;
-
-import java.nio.charset.StandardCharsets;
-
-import io.cloudevents.CloudEvent;
-
-/**
- * Egress encoder for the TCP push frame: builds the legacy {@code EventMeshMessage} body the client
- * expects (topic ← CloudEvent subject, content ← CloudEvent data). The {@link NettyTcpPushChannel}
- * wraps this body in an {@code ASYNC_MESSAGE_TO_CLIENT} Package; the netty {@code Codec} serializes
- * it onto the wire.
- *
- * This builds the body directly rather than routing through {@code MeshMessageProtocolAdaptor}.
- * fromCloudEvent, because that adaptor's protocol-desc switching requires CloudEvents to carry
- * legacy protocol metadata extensions that push frames don't naturally have; the wire payload a
- * legacy TCP subscriber receives is the {@code EventMeshMessage} anyway.
- */
-public class MeshEventToPackageBody implements CloudEventToPackageBody {
-
- @Override
- public Object toBody(CloudEvent event) {
- EventMeshMessage message = new EventMeshMessage();
- if (event.getSubject() != null) {
- message.setTopic(event.getSubject());
- }
- if (event.getData() != null) {
- message.setBody(new String(event.getData().toBytes(), StandardCharsets.UTF_8));
- }
- return message;
- }
-}
diff --git a/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/transport/tcp/TcpFrameCodec.java b/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/transport/tcp/TcpFrameCodec.java
index b925b8dfa6..dabb615d49 100644
--- a/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/transport/tcp/TcpFrameCodec.java
+++ b/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/transport/tcp/TcpFrameCodec.java
@@ -17,22 +17,23 @@
package org.apache.eventmesh.runtime.transport.tcp;
-import io.cloudevents.CloudEvent;
+import org.apache.eventmesh.common.wire.EventMeshFrame;
/**
- * Encodes a delivered CloudEvent (plus its delivery id) into the TCP {@code Package} wire bytes the
- * legacy client expects, and decodes a client ACK frame back into the delivery id it acknowledges.
+ * Encodes a delivered {@link EventMeshFrame} (plus its delivery id) into the TCP {@code Package}
+ * wire bytes the legacy client expects, and decodes a client ACK frame back into the delivery id it
+ * acknowledges.
*
- * Production implementation reuses the existing {@code Codec} + {@code MeshMessageProtocolAdaptor}
- * (reverse direction) and carries the delivery id in a Package header/extension so the client's ACK
- * frame echoes it. Tests inject a deterministic stub.
+ * Production implementation reuses the existing {@code Codec} + the {@code meshmessage}
+ * {@code FrameAdaptor} (reverse direction) and carries the delivery id in a Package header/extension
+ * so the client's ACK frame echoes it. Tests inject a deterministic stub.
*/
public interface TcpFrameCodec {
/**
- * Encode a push frame for {@code event}, tagged with {@code deliveryId} so the client can ACK it.
+ * Encode a push frame for {@code frame}, tagged with {@code deliveryId} so the client can ACK it.
*/
- byte[] encodePush(String deliveryId, CloudEvent event);
+ byte[] encodePush(String deliveryId, EventMeshFrame frame);
/**
* Extract the delivery id from a client ACK frame, or {@code null} if the frame isn't an ACK.
diff --git a/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/transport/tcp/TcpPushChannel.java b/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/transport/tcp/TcpPushChannel.java
index 5c8836fbad..68e4d489c1 100644
--- a/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/transport/tcp/TcpPushChannel.java
+++ b/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/transport/tcp/TcpPushChannel.java
@@ -21,19 +21,18 @@
import org.apache.eventmesh.runtime.delivery.AckCallback;
import org.apache.eventmesh.runtime.delivery.PushChannel;
-import io.cloudevents.CloudEvent;
-
import lombok.extern.slf4j.Slf4j;
/**
* A legacy TCP client session as a {@link PushChannel} (egress side of the compatibility bridge).
*
* When the new {@code ReliableDispatcher} picks a TCP subscriber as a target, it hands the
- * CloudEvent here; this channel encodes it into the legacy {@code Package} wire format (via
- * {@link TcpFrameCodec}), writes it to the socket ({@code TcpSessionSink}), and parks the ACK
- * callback in {@link TcpAckRegistry} until the client's ACK frame arrives. Reliability (redelivery,
- * DLQ) is therefore shared with every other transport — the TCP client just looks like another
- * push target to the core.
+ * {@link EventMeshFrame} here (the internal wire unit — no CloudEvent intermediary since #5299);
+ * this channel encodes it into the legacy {@code Package} wire format (via {@link TcpFrameCodec}),
+ * writes it to the socket ({@code TcpSessionSink}), and parks the ACK callback in
+ * {@link TcpAckRegistry} until the client's ACK frame arrives. Reliability (redelivery, DLQ) is
+ * therefore shared with every other transport — the TCP client just looks like another push target
+ * to the core.
*/
@Slf4j
public class TcpPushChannel implements PushChannel {
@@ -57,20 +56,12 @@ public TcpPushChannel(TcpFrameCodec codec, TcpSessionSink sink, TcpAckRegistry a
@Override
public void deliver(String deliveryId, EventMeshFrame event, AckCallback callback) {
- // Egress boundary: convert the internal Frame to a CloudEvent for the legacy TCP wire format.
- CloudEvent ce;
- try {
- ce = event.toCloudEvent();
- } catch (RuntimeException e) {
- log.warn("tcp push frame->CloudEvent conversion failed for delivery={}", deliveryId, e);
- callback.nack(e);
- return;
- }
+ // Egress boundary: encode the internal Frame straight onto the legacy TCP wire format.
byte[] frame;
try {
- frame = codec.encodePush(deliveryId, ce);
+ frame = codec.encodePush(deliveryId, event);
} catch (RuntimeException e) {
- log.warn("tcp push encode failed for delivery={}", deliveryId, e);
+ log.warn("tcp push frame->wire encode failed for delivery={}", deliveryId, e);
callback.nack(e);
return;
}
diff --git a/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/transport/tcp/UniTcpServer.java b/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/transport/tcp/UniTcpServer.java
index a9988e5101..8a5d471bc6 100644
--- a/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/transport/tcp/UniTcpServer.java
+++ b/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/transport/tcp/UniTcpServer.java
@@ -60,19 +60,16 @@ public class UniTcpServer {
private final UniIngressService ingress;
private final TcpAckRegistry ackRegistry;
private final PackageRouter router;
- private final CloudEventToPackageBody bodyMapper;
private EventLoopGroup bossGroup;
private EventLoopGroup workerGroup;
private Channel serverChannel;
private final ConcurrentHashMap clientChannels = new ConcurrentHashMap<>();
- public UniTcpServer(UniIngressService ingress, TcpAckRegistry ackRegistry, PackageRouter router,
- CloudEventToPackageBody bodyMapper) {
+ public UniTcpServer(UniIngressService ingress, TcpAckRegistry ackRegistry, PackageRouter router) {
this.ingress = ingress;
this.ackRegistry = ackRegistry;
this.router = router;
- this.bodyMapper = bodyMapper;
}
/**
@@ -93,7 +90,7 @@ protected void initChannel(SocketChannel ch) {
ch.pipeline()
.addLast(new Codec.Encoder())
.addLast(new Codec.Decoder())
- .addLast(new FrameHandler(ingress, ackRegistry, router, clientChannels, bodyMapper));
+ .addLast(new FrameHandler(ingress, ackRegistry, router, clientChannels));
}
});
serverChannel = bootstrap.bind(port).sync().channel();
@@ -119,7 +116,9 @@ public void stop() {
* UNSUBSCRIBE/GOODBYE) are handled here directly because they need channel context (the
* subscriber's clientId comes from the HELLO {@link UserAgent#getGroup()}, not the SUBSCRIBE
* body). Message commands (ASYNC_MESSAGE_TO_SERVER / ASYNC_MESSAGE_TO_CLIENT_ACK) go through the
- * {@link PackageRouter} for CloudEvents translation. Static + package-private so it can be
+ * {@link PackageRouter}, which decodes the MeshMessage straight into the internal
+ * {@link org.apache.eventmesh.common.wire.EventMeshFrame} wire unit — no CloudEvent hop since
+ * #5299. Static + package-private so it can be
* exercised directly via netty {@code EmbeddedChannel} in tests.
*/
static final class FrameHandler extends SimpleChannelInboundHandler {
@@ -131,15 +130,13 @@ static final class FrameHandler extends SimpleChannelInboundHandler {
private final TcpAckRegistry ackRegistry;
private final PackageRouter router;
private final ConcurrentHashMap clientChannels;
- private final CloudEventToPackageBody bodyMapper;
FrameHandler(UniIngressService ingress, TcpAckRegistry ackRegistry, PackageRouter router,
- ConcurrentHashMap clientChannels, CloudEventToPackageBody bodyMapper) {
+ ConcurrentHashMap clientChannels) {
this.ingress = ingress;
this.ackRegistry = ackRegistry;
this.router = router;
this.clientChannels = clientChannels;
- this.bodyMapper = bodyMapper;
}
@Override
diff --git a/eventmesh-runtime/src/test/java/org/apache/eventmesh/runtime/it/LegacyTcpClientIntegrationTest.java b/eventmesh-runtime/src/test/java/org/apache/eventmesh/runtime/it/LegacyTcpClientIntegrationTest.java
index a484bd445c..c2babba48e 100644
--- a/eventmesh-runtime/src/test/java/org/apache/eventmesh/runtime/it/LegacyTcpClientIntegrationTest.java
+++ b/eventmesh-runtime/src/test/java/org/apache/eventmesh/runtime/it/LegacyTcpClientIntegrationTest.java
@@ -33,7 +33,6 @@
import org.apache.eventmesh.common.wire.EventMeshFrame;
import org.apache.eventmesh.runtime.ingress.UniIngressService;
import org.apache.eventmesh.runtime.offset.InMemoryOffsetStore;
-import org.apache.eventmesh.runtime.transport.tcp.MeshEventToPackageBody;
import org.apache.eventmesh.runtime.transport.tcp.MeshMessagePackageRouter;
import org.apache.eventmesh.runtime.transport.tcp.TcpAckRegistry;
import org.apache.eventmesh.runtime.transport.tcp.UniTcpServer;
@@ -159,8 +158,7 @@ private void boot() throws Exception {
}
}, 0, 100, java.util.concurrent.TimeUnit.MILLISECONDS);
- server = new UniTcpServer(ingress, new TcpAckRegistry(), new MeshMessagePackageRouter(),
- new MeshEventToPackageBody());
+ server = new UniTcpServer(ingress, new TcpAckRegistry(), new MeshMessagePackageRouter());
port = server.start(0);
}
diff --git a/eventmesh-runtime/src/test/java/org/apache/eventmesh/runtime/it/LegacyTcpClusterBrokerIntegrationTest.java b/eventmesh-runtime/src/test/java/org/apache/eventmesh/runtime/it/LegacyTcpClusterBrokerIntegrationTest.java
index a466b233bb..b292b1ed4d 100644
--- a/eventmesh-runtime/src/test/java/org/apache/eventmesh/runtime/it/LegacyTcpClusterBrokerIntegrationTest.java
+++ b/eventmesh-runtime/src/test/java/org/apache/eventmesh/runtime/it/LegacyTcpClusterBrokerIntegrationTest.java
@@ -31,7 +31,6 @@
import org.apache.eventmesh.runtime.boot.EventMeshApplication;
import org.apache.eventmesh.runtime.cluster.NacosMetaStore;
import org.apache.eventmesh.runtime.offset.InMemoryOffsetStore;
-import org.apache.eventmesh.runtime.transport.tcp.MeshEventToPackageBody;
import org.apache.eventmesh.runtime.transport.tcp.MeshMessagePackageRouter;
import org.apache.eventmesh.runtime.transport.tcp.TcpAckRegistry;
import org.apache.eventmesh.runtime.transport.tcp.UniTcpServer;
@@ -119,7 +118,7 @@ void oldSdkOverRealBrokerAndNacosMeta() throws Exception {
// 3. TCP server (not auto-booted by EventMeshApplication) on the cluster-enabled ingress.
tcpServer = new UniTcpServer(app.runtime().ingress(), new TcpAckRegistry(),
- new MeshMessagePackageRouter(), new MeshEventToPackageBody());
+ new MeshMessagePackageRouter());
tcpPort = tcpServer.start(0);
// 4. Subscriber: real old SDK. clientId = HELLO UserAgent.group.
diff --git a/eventmesh-runtime/src/test/java/org/apache/eventmesh/runtime/security/EventMeshFrameFilterTest.java b/eventmesh-runtime/src/test/java/org/apache/eventmesh/runtime/security/EventMeshFrameFilterTest.java
new file mode 100644
index 0000000000..4668e46774
--- /dev/null
+++ b/eventmesh-runtime/src/test/java/org/apache/eventmesh/runtime/security/EventMeshFrameFilterTest.java
@@ -0,0 +1,145 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.runtime.security;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.apache.eventmesh.common.wire.EventMeshFrame;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * #5299 sub-PR B: the filter chain now operates on {@link EventMeshFrame} instead of
+ * {@code io.cloudevents.CloudEvent}. These tests exercise the new primary path; the
+ * CloudEvent-based tests in {@link SecurityFilterTest} remain as the legacy bridge contract.
+ */
+class EventMeshFrameFilterTest {
+
+ @Test
+ void tokenAuthReadsCredentialFromContextNotFrame() {
+ TokenAuthFilter auth = new TokenAuthFilter(java.util.Collections.singleton("good-token"));
+
+ assertTrue(auth.check(frame(), ctx("good-token", "tenantA", "orders")).isAllowed());
+ FilterVerdict missing = auth.check(frame(), ctx(null, "tenantA", "orders"));
+ assertFalse(missing.isAllowed());
+ assertEquals(401, missing.getRejectStatus());
+ }
+
+ @Test
+ void aclDeniesFrameWithNonEventMsgType() {
+ // A STREAM_REQ frame should be rejected by the ACL filter even if the principal/resource
+ // are otherwise fine — the filter applies to event ingress only. We craft the frame via
+ // decode() because the 5-arg ctor is package-private; the wire format is documented in
+ // EventMeshFrame (header = magic(1) | ver(1) | msgType(1) | flags(1) | seq(4) | keyCount(2) | dataLen(4)).
+ byte[] streamReqBytes = new byte[] {
+ (byte) 0xEF, 1, EventMeshFrame.TYPE_STREAM_REQ, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
+ };
+ EventMeshFrame streamFrame = EventMeshFrame.decode(streamReqBytes);
+ AclFilter acl = new AclFilter(java.util.List.of(
+ new AclRule("tenantA", "orders", AclRule.Action.ANY, AclRule.Effect.ALLOW, 10)));
+ FilterVerdict denied = acl.check(streamFrame, ctx("tok", "tenantA", "orders"));
+ assertFalse(denied.isAllowed());
+ assertEquals(403, denied.getRejectStatus());
+ }
+
+ @Test
+ void aclAllowsGrantedTopicRejectsForbidden() {
+ AclFilter acl = new AclFilter(java.util.List.of(
+ new AclRule("tenantA", "orders", AclRule.Action.ANY, AclRule.Effect.ALLOW, 10)));
+
+ assertTrue(acl.check(frame(), ctx("tok", "tenantA", "orders")).isAllowed());
+ FilterVerdict forbidden = acl.check(frame(), ctx("tok", "tenantA", "payments"));
+ assertFalse(forbidden.isAllowed());
+ assertEquals(403, forbidden.getRejectStatus());
+ }
+
+ @Test
+ void chainFailsClosedAtFirstDenyingFilter() {
+ TokenAuthFilter auth = new TokenAuthFilter(java.util.Collections.singleton("good-token"));
+ AclFilter acl = new AclFilter(java.util.List.of(
+ new AclRule("tenantA", "orders", AclRule.Action.ANY, AclRule.Effect.ALLOW, 10)));
+ FilterChain chain = new FilterChain(auth, acl);
+
+ // Bad credential → denied by auth (401), acl never consulted.
+ FilterVerdict badCred = chain.check(frame(), ctx("wrong", "tenantA", "orders"));
+ assertFalse(badCred.isAllowed());
+ assertEquals(401, badCred.getRejectStatus());
+
+ // Good credential but forbidden topic → denied by acl (403).
+ FilterVerdict badTopic = chain.check(frame(), ctx("good-token", "tenantA", "payments"));
+ assertFalse(badTopic.isAllowed());
+ assertEquals(403, badTopic.getRejectStatus());
+
+ // Both pass → allow.
+ assertTrue(chain.check(frame(), ctx("good-token", "tenantA", "orders")).isAllowed());
+ }
+
+ @Test
+ void signatureVerifierReadsSignatureFromFrameAttributes() {
+ SignatureVerifierFilter verifier = new SignatureVerifierFilter("shared-secret");
+ EventMeshFrame unsigned = frame();
+ String goodSig = verifier.sign(SignatureVerifierFilter.canonical(unsigned));
+ EventMeshFrame signed = withAttr(unsigned, SignatureVerifierFilter.EXT_SIGNATURE, goodSig);
+
+ assertTrue(verifier.check(signed, ctx("tok", "tenantA", "orders")).isAllowed());
+
+ // Tampered signature (flip last hex char) → reject.
+ String tampered = goodSig.substring(0, goodSig.length() - 1)
+ + (goodSig.charAt(goodSig.length() - 1) == '0' ? '1' : '0');
+ EventMeshFrame bad = withAttr(signed, SignatureVerifierFilter.EXT_SIGNATURE, tampered);
+ assertFalse(verifier.check(bad, ctx("tok", "tenantA", "orders")).isAllowed());
+
+ // Missing signature → reject.
+ assertFalse(verifier.check(unsigned, ctx("tok", "tenantA", "orders")).isAllowed(),
+ "missing signature rejected");
+ }
+
+ @Test
+ void tenantFromFrameAttributesReachesContext() {
+ // The HTTP handler now reads tenant from frame.attributes() and threads it into
+ // FilterContext before the chain runs; this test pins that contract.
+ EventMeshFrame f = withAttr(frame(), "emtenantid", "tenantZ");
+ String tenant = f.attributes().get("emtenantid");
+ assertEquals("tenantZ", tenant);
+ // The downstream AclFilter then uses ctx.tenant — the test in aclAllowsGrantedTopicRejectsForbidden
+ // already exercises the "tenant from ctx" path; here we just document the contract.
+ }
+
+ private static EventMeshFrame frame() {
+ Map attrs = new LinkedHashMap<>();
+ attrs.put("id", "e-1");
+ attrs.put("source", "svc");
+ attrs.put("type", "order.created");
+ return EventMeshFrame.event(attrs, new byte[0]);
+ }
+
+ private static EventMeshFrame withAttr(EventMeshFrame base, String name, String value) {
+ Map attrs = new LinkedHashMap<>(base.attributes());
+ attrs.put(name, value);
+ return EventMeshFrame.event(attrs, base.data());
+ }
+
+ private static FilterContext ctx(String credential, String tenant, String topic) {
+ return new FilterContext(topic, "client-1", tenant, credential, "127.0.0.1");
+ }
+}
diff --git a/eventmesh-runtime/src/test/java/org/apache/eventmesh/runtime/security/SecurityFilterTest.java b/eventmesh-runtime/src/test/java/org/apache/eventmesh/runtime/security/SecurityFilterTest.java
index 3993c0c7e1..116854653a 100644
--- a/eventmesh-runtime/src/test/java/org/apache/eventmesh/runtime/security/SecurityFilterTest.java
+++ b/eventmesh-runtime/src/test/java/org/apache/eventmesh/runtime/security/SecurityFilterTest.java
@@ -21,6 +21,8 @@
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import org.apache.eventmesh.common.wire.EventMeshFrame;
+
import java.net.URI;
import java.util.Collections;
@@ -94,7 +96,7 @@ void signatureVerifierAcceptsValidRejectsTampered() {
SignatureVerifierFilter verifier = new SignatureVerifierFilter("shared-secret");
CloudEvent event = event();
- String goodSig = verifier.sign(SignatureVerifierFilter.canonical(event));
+ String goodSig = verifier.sign(SignatureVerifierFilter.canonical(EventMeshFrame.fromCloudEvent(event)));
CloudEvent signed = CloudEventBuilder.from(event).withExtension(SignatureVerifierFilter.EXT_SIGNATURE, goodSig).build();
assertTrue(verifier.check(signed, ctx("tok", "tenantA", "orders")).isAllowed());
diff --git a/eventmesh-runtime/src/test/java/org/apache/eventmesh/runtime/transport/tcp/MeshMessagePackageRouterTest.java b/eventmesh-runtime/src/test/java/org/apache/eventmesh/runtime/transport/tcp/MeshMessagePackageRouterTest.java
index fee3281050..bfa79a95f2 100644
--- a/eventmesh-runtime/src/test/java/org/apache/eventmesh/runtime/transport/tcp/MeshMessagePackageRouterTest.java
+++ b/eventmesh-runtime/src/test/java/org/apache/eventmesh/runtime/transport/tcp/MeshMessagePackageRouterTest.java
@@ -25,6 +25,8 @@
import org.apache.eventmesh.common.protocol.tcp.EventMeshMessage;
import org.apache.eventmesh.common.protocol.tcp.Header;
import org.apache.eventmesh.common.protocol.tcp.Package;
+import org.apache.eventmesh.common.wire.EventMeshFrame;
+import org.apache.eventmesh.protocol.meshmessage.MeshMessageFrameAdaptor;
import java.net.URI;
import java.nio.charset.StandardCharsets;
@@ -42,14 +44,16 @@
class MeshMessagePackageRouterTest {
@Test
- void egressEncodesCloudEventIntoEventMeshMessageBody() {
+ void egressEncodesFrameIntoEventMeshMessageBody() throws Exception {
CloudEvent event = CloudEventBuilder.v1()
.withId("e-1").withSource(URI.create("svc")).withType("order.created")
.withSubject("orders")
.withData("hello".getBytes(StandardCharsets.UTF_8))
.build();
+ EventMeshFrame frame = EventMeshFrame.fromCloudEvent(event);
- Object body = new MeshEventToPackageBody().toBody(event);
+ // Egress is frame-native since #5299: Frame -> Package(EventMeshMessage), no CloudEvent hop.
+ Object body = ((Package) new MeshMessageFrameAdaptor().fromFrame(frame)).getBody();
assertNotNull(body, "egress body must be produced");
assertTrue(body instanceof EventMeshMessage, "body is the legacy EventMeshMessage");
@@ -70,9 +74,9 @@ void ackFrameRoutesToAckRequest() {
assertEquals("d-99", req.getDeliveryId());
}
- // NOTE: publish ingress (ASYNC_MESSAGE_TO_SERVER → CloudEvent) routes through
- // MeshMessageProtocolAdaptor.toCloudEvent, which is itself covered by eventmesh-protocol-
- // meshmessage's own test suite with real wire packages (body=JSON string + protocol header
- // properties produced by the Codec). The in-JVM object/string asymmetry makes a direct
- // round-trip unit test unrepresentative, so it is intentionally not asserted here.
+ // NOTE: publish ingress (ASYNC_MESSAGE_TO_SERVER → EventMeshFrame) routes through
+ // MeshMessageFrameAdaptor, which is itself covered by eventmesh-protocol-meshmessage's own
+ // test suite with real wire packages (body=JSON string + protocol header properties produced
+ // by the Codec). The in-JVM object/string asymmetry makes a direct round-trip unit test
+ // unrepresentative, so it is intentionally not asserted here.
}
diff --git a/eventmesh-runtime/src/test/java/org/apache/eventmesh/runtime/transport/tcp/TcpCompatibilityBridgeTest.java b/eventmesh-runtime/src/test/java/org/apache/eventmesh/runtime/transport/tcp/TcpCompatibilityBridgeTest.java
index 2e99641bf7..e0d8ae5db3 100644
--- a/eventmesh-runtime/src/test/java/org/apache/eventmesh/runtime/transport/tcp/TcpCompatibilityBridgeTest.java
+++ b/eventmesh-runtime/src/test/java/org/apache/eventmesh/runtime/transport/tcp/TcpCompatibilityBridgeTest.java
@@ -111,8 +111,8 @@ private static CloudEvent event(String id) {
private static final class StubCodec implements TcpFrameCodec {
@Override
- public byte[] encodePush(String deliveryId, CloudEvent event) {
- return ("PUSH:" + deliveryId + ":" + event.getId()).getBytes(StandardCharsets.UTF_8);
+ public byte[] encodePush(String deliveryId, EventMeshFrame frame) {
+ return ("PUSH:" + deliveryId + ":" + frame.attributes().get("id")).getBytes(StandardCharsets.UTF_8);
}
@Override
diff --git a/eventmesh-runtime/src/test/java/org/apache/eventmesh/runtime/transport/tcp/UniTcpServerTest.java b/eventmesh-runtime/src/test/java/org/apache/eventmesh/runtime/transport/tcp/UniTcpServerTest.java
index 425bbf0592..034f9cbe55 100644
--- a/eventmesh-runtime/src/test/java/org/apache/eventmesh/runtime/transport/tcp/UniTcpServerTest.java
+++ b/eventmesh-runtime/src/test/java/org/apache/eventmesh/runtime/transport/tcp/UniTcpServerTest.java
@@ -98,15 +98,8 @@ void fullPushToClientAndAckLoopAdvancesOffset() throws Exception {
}
return null;
};
- // Body mapper: event → a Map the "client" (test) can read back.
- CloudEventToPackageBody bodyMapper = event -> {
- Map b = new HashMap<>();
- b.put("id", event.getId());
- return b;
- };
-
EmbeddedChannel client = new EmbeddedChannel(new UniTcpServer.FrameHandler(
- ingress, new TcpAckRegistry(), router, new ConcurrentHashMap<>(), bodyMapper));
+ ingress, new TcpAckRegistry(), router, new ConcurrentHashMap<>()));
// 1a. client HELLO (carries clientId in UserAgent.group) → server stashes it on the channel.
UserAgent ua = UserAgent.builder().group("c1").host("test").port(1).build();
@@ -149,7 +142,7 @@ void fullPushToClientAndAckLoopAdvancesOffset() throws Exception {
private static UniTcpServer.FrameHandler newHandler(UniIngressService ingress, PackageRouter router) {
return new UniTcpServer.FrameHandler(ingress, new TcpAckRegistry(), router,
- new ConcurrentHashMap<>(), event -> null);
+ new ConcurrentHashMap<>());
}
private static final class InMemoryStorage implements MeshStoragePlugin {