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 
&mdash; see {@link #initReadinessState}
+        * &mdash; 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 &mdash; 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()} &mdash; reusing an 
app/test-supplied {@code @Bean ReadinessState}
+ * if one is already registered &mdash; 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>.
         */

Reply via email to