This is an automated email from the ASF dual-hosted git repository. jamesbognar pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/juneau.git
commit 89d90ece43abd4278fee8f0107bbc72122eb2cb2 Author: James Bognar <[email protected]> AuthorDate: Sun Aug 16 18:49:30 2026 -0400 READY-394: Make ReadinessState lifecycle-owned instead of a JVM-global singleton ReadinessState was held as a static singleton shared across every instance in the JVM, so two servers running in the same process (e.g. dual jetty+tomcat deployments, or repeated test bootstraps) could silently publish readiness into each other's state. It is now owned per bean-store lifecycle, and HealthServlet's initialization suppresses the resulting (harmless, by-design) discard-of-return resource warning consistent with WritableBeanStore's own precedent. --- .../microservice/jetty/JettyServerComponent.java | 15 +- ...ponent_ReadinessStateDualStorePublish_Test.java | 178 +++++++++++++++++++++ .../microservice/tomcat/TomcatServerComponent.java | 15 +- .../juneau/rest/server/health/HealthServlet.java | 36 +++++ .../juneau/rest/server/health/ReadinessState.java | 20 ++- 5 files changed, 256 insertions(+), 8 deletions(-) diff --git a/juneau-microservice/juneau-microservice-jetty/src/main/java/org/apache/juneau/microservice/jetty/JettyServerComponent.java b/juneau-microservice/juneau-microservice-jetty/src/main/java/org/apache/juneau/microservice/jetty/JettyServerComponent.java index d63322f897..af58bdbe7b 100644 --- a/juneau-microservice/juneau-microservice-jetty/src/main/java/org/apache/juneau/microservice/jetty/JettyServerComponent.java +++ b/juneau-microservice/juneau-microservice-jetty/src/main/java/org/apache/juneau/microservice/jetty/JettyServerComponent.java @@ -263,7 +263,15 @@ public class JettyServerComponent implements MicroserviceListener { else if (server.get().getStopTimeout() <= 0L) server.get().setStopTimeout(DEFAULT_STOP_TIMEOUT.toMillis()); shutdownSettleDelay.set(coalesce(settings.getShutdownSettleDelay(), cf.get("Jetty/shutdownSettleDelay").asLong().map(Duration::ofMillis).orElse(Duration.ZERO))); - ReadinessState.resolve(store).markReady(); + + // Per-service readiness: reuse an app/test-supplied @Bean ReadinessState if present, else construct + // one, and publish it into this microservice's bean store. shared() is intentionally NOT used here: + // it is one JVM-wide singleton, so falling back to it would leak this service's readiness into every + // other default-configured microservice in the same JVM. The health-probe servlet side of this + // dual-store publish happens below, in the @Rest servlet auto-discovery loop. + var readinessState = store.getBean(ReadinessState.class).orElseGet(ReadinessState::new); + store.addBean(ReadinessState.class, readinessState); + readinessState.markReady(); // Track each servlet pathSpec with its declaring source so we can fail loudly on collisions. var mountedPaths = new LinkedHashMap<String,String>(); @@ -309,6 +317,11 @@ public class JettyServerComponent implements MicroserviceListener { var cls = servlet.getClass(); if (cls.getAnnotation(Rest.class) == null) continue; + if (servlet instanceof HealthServlet hs) + // Dual-store publish (READY-394): this probe's own RestContext bean store is not linked to + // ms.getBeanStore(), so the lifecycle-owned instance must be handed to it explicitly before + // it initializes; HealthServlet.initReadinessState() registers it into its own bean store. + hs.publishReadinessState(readinessState); var pathSpecs = restPathsFor(servlet, store); var source = "@Bean " + cls.getName() + (ine(e.getKey()) ? "[" + e.getKey() + "]" : ""); for (var pathSpec : pathSpecs) diff --git a/juneau-microservice/juneau-microservice-jetty/src/test/java/org/apache/juneau/microservice/jetty/JettyServerComponent_ReadinessStateDualStorePublish_Test.java b/juneau-microservice/juneau-microservice-jetty/src/test/java/org/apache/juneau/microservice/jetty/JettyServerComponent_ReadinessStateDualStorePublish_Test.java new file mode 100644 index 0000000000..32498339c2 --- /dev/null +++ b/juneau-microservice/juneau-microservice-jetty/src/test/java/org/apache/juneau/microservice/jetty/JettyServerComponent_ReadinessStateDualStorePublish_Test.java @@ -0,0 +1,178 @@ +/* + * 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.juneau.microservice.jetty; + +import static org.junit.jupiter.api.Assertions.*; + +import org.apache.juneau.*; +import org.apache.juneau.commons.inject.*; +import org.apache.juneau.microservice.*; +import org.apache.juneau.rest.server.health.*; +import org.eclipse.jetty.ee11.servlet.*; +import org.eclipse.jetty.server.*; +import org.junit.jupiter.api.*; + +/** + * READY-394: verifies that {@link JettyServerComponent} publishes one lifecycle-owned {@link ReadinessState} + * instance into both {@code ms.getBeanStore()} and the auto-mounted {@link HealthServlet}'s own + * {@code RestContext} bean store, instead of every default-configured microservice falling back to the + * JVM-wide {@link ReadinessState#shared()} singleton. + * + * <h5 class='section'>Regression covered:</h5> + * <p> + * Before this fix, {@code JettyServerComponent.onStart/onStop} flipped {@code ReadinessState.resolve(store)} + * without ever registering a bean back into {@code store}, and {@link HealthAggregator} read from the probe + * servlet's own (unrelated) bean store. Both sides therefore always fell back to {@link ReadinessState#shared()} + * on the default (no app {@code @Bean ReadinessState}) path, so stopping any one default-configured + * microservice flipped {@code /readyz} for every other one in the same JVM. + * + * <p> + * These tests deliberately verify the bean-store publish and the exact {@link ReadinessState#resolve(BeanStore)} + * resolution that {@link HealthAggregator#aggregate} performs, rather than round-tripping real HTTP requests + * through the Jetty connector: this module's {@code @Rest(paths={"/healthz","/readyz","/livez"})} multi-path + * auto-mount does not currently route those requests to the matching {@code @RestGet} operation (a pre-existing + * mount/dispatch issue unrelated to READY-394 -- see the READY-394 final report). Testing the resolution + * directly still exercises the exact mechanism the probe depends on, without that unrelated flakiness. + * + * @since 10.0.0 + */ [email protected] +@SuppressWarnings("resource") // Microservice/Server instances are test fixtures managed by the test lifecycle; explicit close is not needed for these assertions. +class JettyServerComponent_ReadinessStateDualStorePublish_Test extends TestBase { + + private static Microservice create(Class<?>... configurations) throws Exception { + var classes = new Class<?>[configurations.length + 1]; + System.arraycopy(configurations, 0, classes, 0, configurations.length); + classes[configurations.length] = JettyConfiguration.class; + return Microservice.create().configurations(classes).build(); + } + + private static Server ephemeralServer() { + var server = new Server(); + var connector = new ServerConnector(server); + connector.setPort(0); + server.addConnector(connector); + var ctx = new ServletContextHandler(); + ctx.setContextPath("/"); + server.setAttribute("ServletContextHandler", ctx); + server.setHandler(ctx); + server.setStopTimeout(0L); + return server; + } + + private static void stopQuietly(Microservice ms) { + try { + ms.stop(); + } catch (@SuppressWarnings("unused") Exception e) { + // Best-effort cleanup; the test has already asserted or failed by this point. + } + } + + private static HealthServlet findHealthServlet(Microservice ms) { + for (var servlet : ms.getBeanStore().getBeansOfType(jakarta.servlet.Servlet.class).values()) + if (servlet instanceof HealthServlet hs) + return hs; + throw new AssertionError("Expected HealthProbeConfiguration to register a HealthServlet bean"); + } + + //----------------------------------------------------------------------------------------------------------------- + // A. Two default-configured microservices in one JVM must not share a ReadinessState instance. + //----------------------------------------------------------------------------------------------------------------- + + @Configuration + static class A_Config { + @Bean Server jettyServer() { return ephemeralServer(); } + } + + @Test + void a01_twoServices_defaultPath_readinessNotSharedAcrossServices() throws Exception { + var msA = create(A_Config.class); + var msB = create(A_Config.class); + var aStopped = false; + try { + msA.start(); + msB.start(); + + var rsA = msA.getBeanStore().getBean(ReadinessState.class) + .orElseThrow(() -> new AssertionError("Expected onStart() to publish a ReadinessState bean into msA's bean store")); + var rsB = msB.getBeanStore().getBean(ReadinessState.class) + .orElseThrow(() -> new AssertionError("Expected onStart() to publish a ReadinessState bean into msB's bean store")); + assertNotSame(rsA, rsB, "Two default-configured microservices in the same JVM must not share one ReadinessState instance"); + assertTrue(rsA.isReady(), "A should be ready after its own start()"); + assertTrue(rsB.isReady(), "B should be ready after its own start()"); + + msA.stop(); + aStopped = true; + assertFalse(rsA.isReady(), "A should be out of service after its own stop()"); + assertTrue(rsB.isReady(), "B must be unaffected by A's stop() -- no cross-service leakage via shared()"); + } finally { + if (! aStopped) + stopQuietly(msA); + stopQuietly(msB); + } + } + + //----------------------------------------------------------------------------------------------------------------- + // B. The health-probe servlet's own RestContext bean store holds the SAME instance as ms.getBeanStore(), + // and resolve() against it reflects flips made through ms.getBeanStore() -- the dual-store publish itself. + //----------------------------------------------------------------------------------------------------------------- + + @Configuration + static class B_Config { + @Bean Server jettyServer() { return ephemeralServer(); } + } + + @Test + void b01_healthServletBeanStore_holdsSameInstance_asMicroserviceBeanStore() throws Exception { + var ms = create(B_Config.class, HealthProbeConfiguration.class); + try { + ms.start(); + var rsFromMsStore = ms.getBeanStore().getBean(ReadinessState.class) + .orElseThrow(() -> new AssertionError("Expected onStart() to publish a ReadinessState bean into ms's bean store")); + + var probeBeanStore = findHealthServlet(ms).getContext().getBeanStore(); + var rsFromProbeStore = probeBeanStore.getBean(ReadinessState.class) + .orElseThrow(() -> new AssertionError("Expected the probe servlet's own RestContext bean store to hold a ReadinessState bean")); + assertSame(rsFromMsStore, rsFromProbeStore, + "The probe servlet's RestContext bean store must hold the identical instance published into ms.getBeanStore()"); + } finally { + stopQuietly(ms); + } + } + + @Test + void b02_probeResolution_reflectsInstanceFlippedViaMicroserviceBeanStore() throws Exception { + var ms = create(B_Config.class, HealthProbeConfiguration.class); + try { + ms.start(); + var probeBeanStore = findHealthServlet(ms).getContext().getBeanStore(); + + // This is exactly what HealthAggregator.aggregate() calls for the READY probe. + assertTrue(ReadinessState.resolve(probeBeanStore).isReady(), "Expected the probe to resolve as ready right after start()"); + + // Flip the SAME per-service instance published into ms.getBeanStore(). If the probe servlet's own + // RestContext bean store didn't also receive this instance, resolve() here would still fall through + // to shared()'s untouched state and stay ready. + ms.getBeanStore().getBean(ReadinessState.class).orElseThrow().markOutOfService(); + + assertFalse(ReadinessState.resolve(probeBeanStore).isReady(), + "Expected the probe's own bean store to resolve the instance flipped via ms.getBeanStore()"); + } finally { + stopQuietly(ms); + } + } +} diff --git a/juneau-microservice/juneau-microservice-tomcat/src/main/java/org/apache/juneau/microservice/tomcat/TomcatServerComponent.java b/juneau-microservice/juneau-microservice-tomcat/src/main/java/org/apache/juneau/microservice/tomcat/TomcatServerComponent.java index 9f4584f03f..48ed5dbdf4 100644 --- a/juneau-microservice/juneau-microservice-tomcat/src/main/java/org/apache/juneau/microservice/tomcat/TomcatServerComponent.java +++ b/juneau-microservice/juneau-microservice-tomcat/src/main/java/org/apache/juneau/microservice/tomcat/TomcatServerComponent.java @@ -243,7 +243,15 @@ public class TomcatServerComponent implements MicroserviceListener { // - Mark readiness ready so a freshly-(re)started service serves traffic. stopTimeout.set(coalesce(settings.getStopTimeout(), cf.get("Tomcat/stopTimeout").asLong().map(Duration::ofMillis).orElse(DEFAULT_STOP_TIMEOUT))); shutdownSettleDelay.set(coalesce(settings.getShutdownSettleDelay(), cf.get("Tomcat/shutdownSettleDelay").asLong().map(Duration::ofMillis).orElse(Duration.ZERO))); - ReadinessState.resolve(store).markReady(); + + // Per-service readiness: reuse an app/test-supplied @Bean ReadinessState if present, else construct + // one, and publish it into this microservice's bean store. shared() is intentionally NOT used here: + // it is one JVM-wide singleton, so falling back to it would leak this service's readiness into every + // other default-configured microservice in the same JVM. The health-probe servlet side of this + // dual-store publish happens below, in the @Rest servlet auto-discovery loop. + var readinessState = store.getBean(ReadinessState.class).orElseGet(ReadinessState::new); + store.addBean(ReadinessState.class, readinessState); + readinessState.markReady(); // Track each servlet pathSpec with its declaring source so we can fail loudly on collisions. var mountedPaths = new LinkedHashMap<String,String>(); @@ -266,6 +274,11 @@ public class TomcatServerComponent implements MicroserviceListener { var cls = servlet.getClass(); if (cls.getAnnotation(Rest.class) == null) continue; + if (servlet instanceof HealthServlet hs) + // Dual-store publish (READY-394): this probe's own RestContext bean store is not linked to + // ms.getBeanStore(), so the lifecycle-owned instance must be handed to it explicitly before + // it initializes; HealthServlet.initReadinessState() registers it into its own bean store. + hs.publishReadinessState(readinessState); var pathSpecs = restPathsFor(servlet, store); var source = "@Bean " + cls.getName() + (ine(e.getKey()) ? "[" + e.getKey() + "]" : ""); for (var pathSpec : pathSpecs) diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/health/HealthServlet.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/health/HealthServlet.java index 559cb3d8cb..44d9af05cb 100644 --- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/health/HealthServlet.java +++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/health/HealthServlet.java @@ -18,6 +18,7 @@ package org.apache.juneau.rest.server.health; import java.util.*; +import org.apache.juneau.commons.inject.*; import org.apache.juneau.rest.server.*; import org.apache.juneau.rest.server.health.HealthAggregator.*; import org.apache.juneau.rest.server.servlet.*; @@ -43,6 +44,41 @@ public class HealthServlet extends BasicRestServlet { private static final long serialVersionUID = 1L; private final transient HealthAggregator aggregator = new HealthAggregator(); + private transient volatile ReadinessState readinessState; + + /** + * Publishes the lifecycle-owned {@link ReadinessState} that this probe should observe. + * + * <p> + * Called by the embedded-server lifecycle component (e.g. {@code JettyServerComponent}, + * {@code TomcatServerComponent}) before the server starts, so the per-service instance that component flips + * on shutdown is registered into this resource's own bean store — see {@link #initReadinessState} + * — closing the gap between the microservice's bean store and this servlet's bean store that + * {@link HealthAggregator} consults. + * + * @param state The per-service readiness state. Ignored if <jk>null</jk>. + * @return This object. + */ + public HealthServlet publishReadinessState(ReadinessState state) { + this.readinessState = state; + return this; + } + + /** + * Registers the {@linkplain #publishReadinessState(ReadinessState) published} readiness state (if any) into + * this resource's own bean store, so {@link HealthAggregator#aggregate} resolves the lifecycle-owned + * instance instead of falling back to {@link ReadinessState#shared()}. + * + * @param beanStore This resource's bean store. + */ + @RestInit + @SuppressWarnings({ + "resource" // addBean returns this; the discarded return is the store the caller already holds + }) + public void initReadinessState(WritableBeanStore beanStore) { + if (readinessState != null) + beanStore.addBean(ReadinessState.class, readinessState); + } /** * Health probe endpoint. diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/health/ReadinessState.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/health/ReadinessState.java index 73d73f3741..213d06692e 100644 --- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/health/ReadinessState.java +++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/health/ReadinessState.java @@ -34,9 +34,15 @@ import org.apache.juneau.commons.inject.*; * <p> * {@link HealthAggregator} and the embedded-server shutdown hooks resolve the state via * {@link #resolve(BeanStore)}: a {@code ReadinessState} bean registered in the relevant {@link BeanStore} wins; - * otherwise the process-wide {@link #shared() shared} instance is used. In the standalone microservice runtime - * the auto-mounted health probe servlet and the server lifecycle component live in <i>different</i> bean stores, - * so they bridge through the shared instance — the server flips it on stop and the probe observes the flip. + * otherwise the process-wide {@link #shared() shared} instance is used. In the standalone Jetty/Tomcat + * microservice runtime, the embedded-server lifecycle component (e.g. {@code JettyServerComponent}) constructs + * one per-service instance on {@code start()} — reusing an app/test-supplied {@code @Bean ReadinessState} + * if one is already registered — and explicitly publishes that <b>same</b> instance into both the + * microservice's bean store and the auto-mounted health-probe servlet's own bean store. The two sides then + * observe each other's flips without ever touching {@link #shared()}, so stopping one default-configured + * microservice does not affect any other microservice's readiness in the same JVM. {@link #shared()} remains + * the last-resort fallback for embeddings that skip that dual-store publish (a plain servlet container, Spring + * Boot without this integration, or unit tests that never register a bean). * * <p> * This class is thread-safe; the flag is {@code volatile}. @@ -56,9 +62,11 @@ public final class ReadinessState { * * <p> * Used as the fallback when no {@code ReadinessState} bean is registered in the relevant {@link BeanStore}. - * In the standalone microservice runtime this is the bridge between the embedded-server lifecycle component - * (which flips it on shutdown) and the auto-mounted health probe servlet (which observes the flip), since the - * two live in separate bean stores. + * This is <b>not</b> the standalone Jetty/Tomcat microservice path: that runtime constructs a per-service + * instance and publishes it into both the microservice's bean store and the health-probe servlet's bean + * store (see the class-level <i>Resolution</i> notes above), so two default-configured microservices in the + * same JVM never share readiness. This shared instance exists only for embeddings that skip that publish + * step. * * @return The shared instance. Never <jk>null</jk>. */
