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..05ed35c214 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,920 @@ -/* - * 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.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). + // Issue #5295: use a broker-ACK barrier keyed by POP check key so the broker ACK + // fires only when ALL required deliveries have ACKed (BROADCAST / MULTICAST + // targets are not prematurely ACKed by the first delivery that finishes). + String popCk = f.attributes().get("empopck"); + List targets = subscriptionManager.targetsFor(topic, f); + if (popCk != null && !targets.isEmpty()) { + java.util.concurrent.atomic.AtomicInteger pending = + new java.util.concurrent.atomic.AtomicInteger(targets.size()); + Runnable mqAck = () -> { + if (pending.decrementAndGet() == 0) { + storage.ackPulledMessage(topic, popCk); + } + }; + for (Subscription target : targets) { + dispatcher.deliver(topic, partition, offset, f, target.getClientId(), + channelFor(target.getClientId()), mqAck); + } + } else { + for (Subscription target : targets) { + dispatcher.deliver(topic, partition, offset, f, target.getClientId(), + channelFor(target.getClientId()), null); + } + } + } + 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; + }; + } +}