This is an automated email from the ASF dual-hosted git repository.
Aias00 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/shenyu.git
The following commit(s) were added to refs/heads/master by this push:
new 70fba09c3f fix(web): scope chain timing and execution to each
subscription (#7793)
70fba09c3f is described below
commit 70fba09c3f817786b1534708cc77785d7cf9c211
Author: redyyang <[email protected]>
AuthorDate: Thu Oct 22 10:26:57 2026 +0800
fix(web): scope chain timing and execution to each subscription (#7793)
---
.../org/apache/shenyu/plugin/api/ShenyuPlugin.java | 9 +
.../shenyu/web/handler/ShenyuWebHandler.java | 32 ++--
.../web/handler/ShenyuWebHandlerLifecycleTest.java | 210 +++++++++++++++++++++
.../shenyu/web/handler/ShenyuWebHandlerTest.java | 6 +-
4 files changed, 240 insertions(+), 17 deletions(-)
diff --git
a/shenyu-plugin/shenyu-plugin-api/src/main/java/org/apache/shenyu/plugin/api/ShenyuPlugin.java
b/shenyu-plugin/shenyu-plugin-api/src/main/java/org/apache/shenyu/plugin/api/ShenyuPlugin.java
index 008f60bd88..fe770db851 100644
---
a/shenyu-plugin/shenyu-plugin-api/src/main/java/org/apache/shenyu/plugin/api/ShenyuPlugin.java
+++
b/shenyu-plugin/shenyu-plugin-api/src/main/java/org/apache/shenyu/plugin/api/ShenyuPlugin.java
@@ -136,6 +136,10 @@ public interface ShenyuPlugin {
/**
* Plugin before operation.
*
+ * <p>The gateway invokes this immediately before calling {@link
#execute}, during chain
+ * subscription. Together with {@link #after}, it brackets construction of
the plugin's
+ * publisher, not its asynchronous completion.</p>
+ *
* @param exchange context
*/
default void before(ServerWebExchange exchange) {
@@ -145,6 +149,11 @@ public interface ShenyuPlugin {
/**
* Plugin after operation.
*
+ * <p>The gateway invokes this when {@link #execute} returns or throws,
before subscribing
+ * to the returned publisher. These construction-time semantics are
retained for compatibility
+ * with plugin overrides, including WASM callbacks. The gateway chain
timing separately measures
+ * the entire subscription through its terminal signal.</p>
+ *
* @param exchange context
*/
default void after(ServerWebExchange exchange) {
diff --git
a/shenyu-web/src/main/java/org/apache/shenyu/web/handler/ShenyuWebHandler.java
b/shenyu-web/src/main/java/org/apache/shenyu/web/handler/ShenyuWebHandler.java
index 43dc50a19a..12fefc30ca 100644
---
a/shenyu-web/src/main/java/org/apache/shenyu/web/handler/ShenyuWebHandler.java
+++
b/shenyu-web/src/main/java/org/apache/shenyu/web/handler/ShenyuWebHandler.java
@@ -46,6 +46,7 @@ import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
+import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
@@ -94,12 +95,12 @@ public final class ShenyuWebHandler implements WebHandler,
ApplicationListener<P
}
/**
- * Chain before operation.
+ * Start monotonic chain timing for a subscription, before any scheduler
queueing.
*
* @param exchange context
*/
public void before(final ServerWebExchange exchange) {
- exchange.getAttributes().put(Constants.CHAIN_START_TIME,
System.currentTimeMillis());
+ exchange.getAttributes().put(Constants.CHAIN_START_TIME,
System.nanoTime());
}
/**
@@ -108,8 +109,8 @@ public final class ShenyuWebHandler implements WebHandler,
ApplicationListener<P
* <p>It runs with the terminal signal of the request, not while the chain
is assembled, so the
* reported cost is the duration of the request and the trace id of the
gateway span is already
* populated. A long lived response, for example a streamed one, reports
its timing once the
- * stream has ended. The operation is idempotent: a request which is
subscribed again does not
- * report a second time.</p>
+ * stream has ended. Each subscription starts its own timing; repeated
cleanup of the same
+ * subscription is ignored. The cost is reported in milliseconds.</p>
*
* @param exchange context
*/
@@ -119,27 +120,30 @@ public final class ShenyuWebHandler implements
WebHandler, ApplicationListener<P
if (Objects.isNull(startTime)) {
return;
}
- long cost = System.currentTimeMillis() - (long) startTime;
+ long cost = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - (long)
startTime);
LOG.debug("shenyu chain handle uri:{}, traceId:{}, cost:{}",
exchange.getRequest().getPath(), TraceIdUtils.traceId(exchange), cost);
}
/**
* Handle the web server exchange.
*
+ * <p>Timing includes scheduler queueing and asynchronous execution until
completion, failure
+ * or cancellation. Creating the publisher does not start timing. Each
subscription creates
+ * a fresh chain, so sequential subscriptions execute all plugins
again.</p>
+ *
* @param exchange the current server exchange
* @return {@code Mono<Void>} to indicate when request handling is complete
*/
@Override
public Mono<Void> handle(@NonNull final ServerWebExchange exchange) {
- before(exchange);
- Mono<Void> execute = new
DefaultShenyuPluginChain(plugins).execute(exchange);
- if (scheduled) {
- execute = execute.subscribeOn(scheduler);
- }
- // the chain is assembled here but only runs once it is subscribed,
and the tracing span is
- // created at that point as well, so the timing and the trace id are
read with the terminal
- // signal of the request instead of while the chain is assembled.
- return execute.doFinally(signalType -> after(exchange));
+ return Mono.defer(() -> {
+ before(exchange);
+ Mono<Void> execute = new
DefaultShenyuPluginChain(plugins).execute(exchange);
+ if (scheduled) {
+ execute = execute.subscribeOn(scheduler);
+ }
+ return execute.doFinally(signalType -> after(exchange));
+ });
}
/**
diff --git
a/shenyu-web/src/test/java/org/apache/shenyu/web/handler/ShenyuWebHandlerLifecycleTest.java
b/shenyu-web/src/test/java/org/apache/shenyu/web/handler/ShenyuWebHandlerLifecycleTest.java
new file mode 100644
index 0000000000..5d16e21313
--- /dev/null
+++
b/shenyu-web/src/test/java/org/apache/shenyu/web/handler/ShenyuWebHandlerLifecycleTest.java
@@ -0,0 +1,210 @@
+/*
+ * 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.shenyu.web.handler;
+
+import org.apache.shenyu.common.config.ShenyuConfig;
+import org.apache.shenyu.common.constant.Constants;
+import org.apache.shenyu.plugin.api.ShenyuPlugin;
+import org.apache.shenyu.web.loader.ShenyuLoaderService;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
+import org.springframework.mock.web.server.MockServerWebExchange;
+import org.springframework.test.util.ReflectionTestUtils;
+import reactor.core.Disposable;
+import reactor.core.Disposables;
+import reactor.core.publisher.Mono;
+import reactor.core.publisher.Sinks;
+import reactor.core.scheduler.Scheduler;
+import reactor.test.StepVerifier;
+import reactor.test.scheduler.VirtualTimeScheduler;
+
+import java.time.Duration;
+import java.util.Collections;
+import java.util.Objects;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Tests subscription and terminal-signal lifecycle with controlled publishers
and scheduling.
+ */
+class ShenyuWebHandlerLifecycleTest {
+
+ private final ShenyuPlugin plugin = mock(ShenyuPlugin.class);
+
+ private final MockServerWebExchange exchange =
MockServerWebExchange.from(MockServerHttpRequest.get("/lifecycle"));
+
+ private final VirtualTimeScheduler scheduler =
VirtualTimeScheduler.create();
+
+ @AfterEach
+ void tearDown() {
+ scheduler.dispose();
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void testCompletion(final boolean scheduled) {
+ verifyTerminal(scheduled, null, false);
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void testAsynchronousFailure(final boolean scheduled) {
+ verifyTerminal(scheduled, new IllegalStateException("upstream
failed"), false);
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void testCancellation(final boolean scheduled) {
+ verifyTerminal(scheduled, null, true);
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void testSynchronousPluginFailure(final boolean scheduled) {
+ ShenyuWebHandler handler = handler(scheduled);
+ IllegalStateException failure = new IllegalStateException("plugin
failed");
+ when(plugin.execute(any(), any())).thenThrow(failure);
+
+ StepVerifier.create(handler.handle(exchange))
+ .then(() -> scheduler.advanceTimeBy(Duration.ZERO))
+ .expectErrorSatisfies(error -> assertSame(failure, error))
+ .verify(Duration.ofSeconds(5));
+
+ verify(handler).before(exchange);
+ verify(handler).after(exchange);
+ verify(plugin).after(exchange);
+ assertNull(exchange.getAttribute(Constants.CHAIN_START_TIME));
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void testTimingRemainsActiveUntilDelayedCompletion(final boolean
scheduled) {
+ ShenyuWebHandler handler = handler(scheduled);
+ when(plugin.execute(any(),
any())).thenReturn(Mono.delay(Duration.ofSeconds(5), scheduler).then());
+ final Mono<Void> result = handler.handle(exchange);
+ verify(handler, never()).before(exchange);
+ verify(handler, never()).after(exchange);
+ assertNull(exchange.getAttribute(Constants.CHAIN_START_TIME));
+
+ StepVerifier.create(result)
+ .then(() -> scheduler.advanceTimeBy(Duration.ofSeconds(4)))
+ .then(() -> {
+
assertNotNull(exchange.getAttribute(Constants.CHAIN_START_TIME));
+ verify(handler, never()).after(exchange);
+ })
+ .then(() -> scheduler.advanceTimeBy(Duration.ofSeconds(1)))
+ .expectComplete()
+ .verify(Duration.ofSeconds(5));
+
+ verify(handler).before(exchange);
+ verify(handler).after(exchange);
+ assertNull(exchange.getAttribute(Constants.CHAIN_START_TIME));
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void testEachSubscriptionExecutesAFreshChain(final boolean scheduled) {
+ ShenyuWebHandler handler = handler(scheduled);
+ when(plugin.execute(any(), any())).thenReturn(Mono.empty());
+ final Mono<Void> result = handler.handle(exchange);
+ for (int subscription = 0; subscription < 2; subscription++) {
+ StepVerifier.create(result)
+ .then(() -> scheduler.advanceTimeBy(Duration.ZERO))
+ .expectComplete().verify(Duration.ofSeconds(5));
+ assertNull(exchange.getAttribute(Constants.CHAIN_START_TIME));
+ }
+ verify(plugin, times(2)).execute(any(), any());
+ verify(handler, times(2)).before(exchange);
+ verify(handler, times(2)).after(exchange);
+ }
+
+ @Test
+ void testCancellationBeforeScheduledExecution() {
+ ShenyuWebHandler handler = handler(true);
+ Scheduler queuedScheduler = mock(Scheduler.class);
+ Scheduler.Worker worker = mock(Scheduler.Worker.class);
+ when(queuedScheduler.createWorker()).thenReturn(worker);
+
when(worker.schedule(any(Runnable.class))).thenReturn(Disposables.single());
+ ReflectionTestUtils.setField(handler, "scheduler", queuedScheduler);
+ Disposable subscription = handler.handle(exchange).subscribe();
+ assertNotNull(exchange.getAttribute(Constants.CHAIN_START_TIME));
+ verify(plugin, never()).execute(any(), any());
+
+ subscription.dispose();
+ scheduler.advanceTimeBy(Duration.ZERO);
+
+ verify(plugin, never()).execute(any(), any());
+ verify(handler).before(exchange);
+ verify(handler).after(exchange);
+ verify(worker).dispose();
+ assertNull(exchange.getAttribute(Constants.CHAIN_START_TIME));
+ }
+
+ private void verifyTerminal(final boolean scheduled, final Throwable
failure, final boolean cancel) {
+ ShenyuWebHandler handler = handler(scheduled);
+ Sinks.Empty<Void> pending = Sinks.empty();
+ when(plugin.execute(any(), any())).thenReturn(pending.asMono());
+ final Mono<Void> result = handler.handle(exchange);
+ verify(handler, never()).before(exchange);
+ verify(handler, never()).after(exchange);
+ assertNull(exchange.getAttribute(Constants.CHAIN_START_TIME));
+ AtomicReference<Throwable> observedFailure = new AtomicReference<>();
+ Disposable subscription = result.subscribe(ignored -> { },
observedFailure::set);
+ scheduler.advanceTimeBy(Duration.ZERO);
+ verify(handler).before(exchange);
+ verify(handler, never()).after(exchange);
+ assertNotNull(exchange.getAttribute(Constants.CHAIN_START_TIME));
+ // Plugin hooks still bracket publisher construction, including WASM
callbacks.
+ verify(plugin).before(exchange);
+ verify(plugin).after(exchange);
+ if (cancel) {
+ subscription.dispose();
+ } else if (Objects.isNull(failure)) {
+ pending.tryEmitEmpty();
+ } else {
+ pending.tryEmitError(failure);
+ }
+ assertSame(failure, observedFailure.get());
+ verify(handler).after(exchange);
+ assertNull(exchange.getAttribute(Constants.CHAIN_START_TIME));
+ subscription.dispose();
+ verify(handler).after(exchange);
+ }
+
+ private ShenyuWebHandler handler(final boolean scheduled) {
+ ShenyuConfig config = new ShenyuConfig();
+ config.getScheduler().setEnabled(scheduled);
+ config.getScheduler().setType("elastic");
+ ShenyuWebHandler handler = spy(new
ShenyuWebHandler(Collections.singletonList(plugin),
mock(ShenyuLoaderService.class), config));
+ ReflectionTestUtils.setField(handler, "scheduler", scheduler);
+ return handler;
+ }
+}
diff --git
a/shenyu-web/src/test/java/org/apache/shenyu/web/handler/ShenyuWebHandlerTest.java
b/shenyu-web/src/test/java/org/apache/shenyu/web/handler/ShenyuWebHandlerTest.java
index 757084d9dd..502223a629 100644
---
a/shenyu-web/src/test/java/org/apache/shenyu/web/handler/ShenyuWebHandlerTest.java
+++
b/shenyu-web/src/test/java/org/apache/shenyu/web/handler/ShenyuWebHandlerTest.java
@@ -108,7 +108,7 @@ public final class ShenyuWebHandlerTest {
Mono<Void> handle = shenyuWebHandler.handle(exchange);
// the chain only runs once it is subscribed, the tracing span is
created at that point, so
// the chain timing is not read while the chain is assembled.
-
assertNotNull(exchange.getAttributes().get(Constants.CHAIN_START_TIME));
+ assertNull(exchange.getAttributes().get(Constants.CHAIN_START_TIME));
StepVerifier.create(handle).expectSubscription().verifyComplete();
assertNull(exchange.getAttributes().get(Constants.CHAIN_START_TIME));
}
@@ -147,7 +147,7 @@ public final class ShenyuWebHandlerTest {
}
@Test
- public void handleIsIdempotentWhenTheRequestIsSubscribedTwice() {
+ public void handleReportsEachSubscription() {
final ServerWebExchange exchange =
MockServerWebExchange.from(MockServerHttpRequest.get("localhost")
.remoteAddress(new InetSocketAddress(8090))
.build());
@@ -170,7 +170,7 @@ public final class ShenyuWebHandlerTest {
.map(ILoggingEvent::getFormattedMessage)
.filter(message -> message.startsWith("shenyu chain handle"))
.count();
- assertEquals(1, reported, "a request which is subscribed again must
not be reported again");
+ assertEquals(2, reported, "each subscription must have its own chain
timing");
}
@Test