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
The following commit(s) were added to refs/heads/master by this push:
new 29bd5a598e TODO-117/118/121/128 - MdcAsyncListener, async completion
executor, lazy children materialization, ParameterInfo Optional wrapping
29bd5a598e is described below
commit 29bd5a598e5adc79a4a83da4ce1505b008970872
Author: James Bognar <[email protected]>
AuthorDate: Fri May 29 10:58:20 2026 -0400
TODO-117/118/121/128 - MdcAsyncListener, async completion executor, lazy
children materialization, ParameterInfo Optional wrapping
---
.../java/org/apache/juneau/rest/RestChildren.java | 199 +++++++++++++-
.../java/org/apache/juneau/rest/RestContext.java | 86 +++++-
.../apache/juneau/rest/RestServerConstants.java | 9 +
.../org/apache/juneau/rest/annotation/Rest.java | 48 +++-
.../juneau/rest/annotation/RestAnnotation.java | 19 ++
.../org/apache/juneau/rest/LazyChildren_Test.java | 304 +++++++++++++++++++++
juneau-utest/test-run-history.tsv | 1 +
7 files changed, 656 insertions(+), 10 deletions(-)
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestChildren.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestChildren.java
index 7bd8ba016f..0ea81bb0c8 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestChildren.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestChildren.java
@@ -17,12 +17,14 @@
package org.apache.juneau.rest;
import static org.apache.juneau.commons.utils.CollectionUtils.*;
+import static org.apache.juneau.commons.utils.StringUtils.*;
import static org.apache.juneau.commons.utils.ThrowableUtils.*;
import static org.apache.juneau.commons.utils.Utils.*;
import java.lang.reflect.*;
import java.util.*;
import java.util.function.*;
+import java.util.logging.*;
import org.apache.juneau.commons.inject.*;
import org.apache.juneau.commons.reflect.*;
@@ -42,12 +44,118 @@ import jakarta.servlet.*;
* write lock and atomically replace the snapshot. This makes runtime child
management safe even while
* requests are in flight on other threads.
*
+ * <p>
+ * Supports lazy-init children registered via {@link Builder#addLazy(Class,
String)} — these children have their
+ * {@link RestContext} constructed on the first matching request rather than
at parent startup. The routing entry
+ * (path prefix matcher) is always populated at startup; only the full context
build is deferred.
+ *
* <h5 class='section'>See Also:</h5><ul>
* <li class='link'><a class="doclink"
href="https://juneau.apache.org/docs/topics/RestAnnotatedClassBasics">@Rest-Annotated
Class Basics</a>
* </ul>
*/
public class RestChildren {
+ private static final Logger LOGGER =
Logger.getLogger(RestChildren.class.getName());
+
+ /**
+ * Holder for a lazy-init child resource entry.
+ *
+ * <p>
+ * At parent startup, only the path prefix and {@link UrlPathMatcher}
are populated. The full
+ * {@link RestContext} is built on the first request that matches the
path prefix; subsequent requests
+ * reuse the cached context. Two concurrent first-requests serialize
on {@link #lock} so that exactly
+ * one materialization runs.
+ *
+ * <p>
+ * If materialization throws, the exception propagates to the caller as
a {@link jakarta.servlet.ServletException};
+ * the {@link #materialized} field is left {@code null}, so the next
request will retry.
+ */
+ static class LazyChildEntry {
+
+ final Class<?> resourceClass;
+ final String path;
+ final UrlPathMatcher pathMatcher;
+ final RestContext parent;
+ final BeanStore beanStore;
+ final ServletConfig servletConfig;
+
+ /** Written once under {@link #lock}; volatile for lock-free
reads. */
+ volatile RestContext materialized;
+
+ private final Object lock = new Object();
+
+ LazyChildEntry(Class<?> resourceClass, String path, RestContext
parent, BeanStore beanStore, ServletConfig servletConfig) {
+ this.resourceClass = resourceClass;
+ this.path = path;
+ var p = path;
+ if (! p.endsWith("/*"))
+ p += "/*";
+ this.pathMatcher = UrlPathMatcher.of(p);
+ this.parent = parent;
+ this.beanStore = beanStore;
+ this.servletConfig = servletConfig;
+ }
+
+ boolean isMaterialized() { return materialized != null; }
+
+ /**
+ * Returns the materialized {@link RestContext}, building it if
this is the first call.
+ *
+ * <p>
+ * Thread-safe: at most one thread runs the construction;
others block on {@link #lock}.
+ * If construction fails the field is left {@code null}; the
next request will retry.
+ *
+ * @return The materialized context. Never {@code null} on
success.
+ * @throws ServletException If context construction fails.
+ */
+ RestContext materialize() throws ServletException {
+ RestContext rc = materialized;
+ if (rc != null)
+ return rc;
+ synchronized (lock) {
+ rc = materialized;
+ if (rc != null)
+ return rc;
+ LOGGER.info("Lazy REST child materializing: " +
resourceClass.getName() + " at path '" + path + "'");
+ try {
+ rc = buildChildContext(parent,
beanStore, servletConfig, resourceClass, null, "");
+ rc.postInit();
+ rc.postInitChildFirst();
+ } catch (Exception e) {
+ throw new ServletException("Failed to
lazily materialize child REST context for " + resourceClass.getName(),
unwrapThrowable(e));
+ }
+ materialized = rc;
+ LOGGER.info("Lazy REST child materialized: " +
resourceClass.getName() + " at path '" + path + "'");
+ return rc;
+ }
+ }
+
+ /**
+ * Resolves the path prefix for a resource class by walking its
annotation hierarchy.
+ *
+ * <p>
+ * Replicates the most-derived-first walk that {@link
RestContext} performs during full construction,
+ * but does so without instantiating a {@link RestContext} —
enabling startup-time path extraction
+ * for lazy children.
+ *
+ * @param resourceClass The resource class. Must not be {@code
null}.
+ * @return The trimmed path (without leading slash). Empty
string if no {@code @Rest(path=...)} is found.
+ */
+ static String resolvePathForClass(Class<?> resourceClass) {
+ for (Class<?> c = resourceClass; c != null; c =
c.getSuperclass()) {
+ Rest r = c.getDeclaredAnnotation(Rest.class);
+ if (r != null && isNotEmpty(r.path()))
+ return trimLeadingSlashes(r.path());
+ }
+ for (Class<?> iface : resourceClass.getInterfaces()) {
+ Rest r =
iface.getDeclaredAnnotation(Rest.class);
+ if (r != null && isNotEmpty(r.path()))
+ return trimLeadingSlashes(r.path());
+ }
+ return "";
+ }
+ }
+
/**
* Builder class.
*/
@@ -57,6 +165,7 @@ public class RestChildren {
final BeanStore beanStore;
final ServletConfig servletConfig;
final List<RestContext> list;
+ final List<LazyChildEntry> lazyList;
/**
* Constructor.
@@ -74,6 +183,7 @@ public class RestChildren {
this.beanStore = beanStore;
this.servletConfig = servletConfig;
this.list = list();
+ this.lazyList = list();
}
/**
@@ -86,7 +196,7 @@ public class RestChildren {
}
/**
- * Adds a child resource to this builder.
+ * Adds an eager child resource to this builder.
*
* @param value The REST context of the child resource.
* @return This object.
@@ -96,6 +206,24 @@ public class RestChildren {
return this;
}
+ /**
+ * Registers a lazy child resource entry.
+ *
+ * <p>
+ * The path is pre-resolved from the resource class so that
routing works immediately at parent startup.
+ * The full {@link RestContext} is built on the first matching
request.
+ *
+ * @param resourceClass The resource class to materialize
lazily. Must not be {@code null}.
+ * @param path The pre-resolved path prefix (without leading
slash). Use {@code ""} to read from
+ * {@link Rest#path() @Rest(path)} on the resource class.
+ * @return This object.
+ */
+ public Builder addLazy(Class<?> resourceClass, String path) {
+ var resolvedPath = isNotEmpty(path) ?
trimLeadingSlashes(path) : LazyChildEntry.resolvePathForClass(resourceClass);
+ lazyList.add(new LazyChildEntry(resourceClass,
resolvedPath, parent, beanStore, servletConfig));
+ return this;
+ }
+
/**
* Builds the children.
*
@@ -124,8 +252,20 @@ public class RestChildren {
private final ServletConfig servletConfig;
private final Object writeLock = new Object();
+ /** Eager-init children keyed by composed path. Copy-on-write; volatile
for lock-free reads. */
private volatile Map<String,RestContext> children;
+ /**
+ * Lazy-init children keyed by path prefix.
+ *
+ * <p>
+ * These entries are registered at parent startup for routing purposes;
their {@link RestContext} bodies
+ * are built on first use. Once materialized, the entry's {@link
LazyChildEntry#materialized} field is set
+ * but the entry remains in this map so subsequent reads are lock-free
(volatile field read on the entry).
+ * This map itself is never mutated after construction, so no
copy-on-write is needed.
+ */
+ private final Map<String,LazyChildEntry> lazyEntries;
+
/**
* Constructor.
*
@@ -139,6 +279,10 @@ public class RestChildren {
for (var rc : builder.list)
initial.put(rc.getPath(), rc);
this.children = Collections.unmodifiableMap(initial);
+ var lazyMap = new LinkedHashMap<String,LazyChildEntry>();
+ for (var e : builder.lazyList)
+ lazyMap.put(e.path, e);
+ this.lazyEntries = Collections.unmodifiableMap(lazyMap);
}
/**
@@ -157,26 +301,47 @@ public class RestChildren {
/**
* Called during servlet destruction on all children to invoke all
{@link RestDestroy} and {@link Servlet#destroy()} methods.
+ *
+ * <p>
+ * Lazy children that were never materialized are silently skipped (no
context was ever built for them).
*/
public void destroy() {
for (var r : children.values())
destroyChild(r);
+ for (var e : lazyEntries.values()) {
+ var rc = e.materialized; // volatile read — safe
without lock
+ if (rc != null)
+ destroyChild(rc);
+ }
}
/**
* Looks through the registered children of this object and returns the
best match.
*
+ * <p>
+ * Eager children are checked first (lock-free volatile read), then
lazy entries. A matching lazy entry
+ * triggers {@link LazyChildEntry#materialize()} which builds the full
{@link RestContext} on first call
+ * (blocking concurrent first-requests until construction completes)
and caches the result.
+ *
* @param builder The HTTP call builder.
* @return The child that best matches the call, or an empty {@link
Optional} if a match could not be made.
+ * @throws ServletException If a lazy child fails to materialize.
*/
- public Optional<RestChildMatch> findMatch(RestSession.Builder builder) {
- var snapshot = children; // single volatile read; consistent
for the rest of the method
+ public Optional<RestChildMatch> findMatch(RestSession.Builder builder)
throws ServletException {
var pi = builder.getPathInfoUndecoded();
- if ((! snapshot.isEmpty()) && nn(pi) && ! pi.equals("/")) {
+ if (nn(pi) && ! pi.equals("/")) {
+ // Check eager children first.
+ var snapshot = children; // single volatile read
for (var rc : snapshot.values()) {
- UrlPathMatcher upp = rc.getPathMatcher();
- UrlPathMatch uppm =
upp.match(builder.getUrlPath());
+ UrlPathMatch uppm =
rc.getPathMatcher().match(builder.getUrlPath());
+ if (nn(uppm))
+ return opt(RestChildMatch.create(uppm,
rc));
+ }
+ // Check lazy entries.
+ for (var e : lazyEntries.values()) {
+ UrlPathMatch uppm =
e.pathMatcher.match(builder.getUrlPath());
if (nn(uppm)) {
+ var rc = e.materialize();
return opt(RestChildMatch.create(uppm,
rc));
}
}
@@ -185,7 +350,10 @@ public class RestChildren {
}
/**
- * Called during servlet initialization on all children to invoke all
{@link RestPostInit} child-last methods.
+ * Called during servlet initialization on all eager children to invoke
all {@link RestPostInit} child-last methods.
+ *
+ * <p>
+ * Lazy children are intentionally excluded — they are initialized on
first invocation, not at startup.
*
* @throws ServletException Error occurred.
*/
@@ -195,7 +363,10 @@ public class RestChildren {
}
/**
- * Called during servlet initialization on all children to invoke all
{@link RestPostInit} child-first methods.
+ * Called during servlet initialization on all eager children to invoke
all {@link RestPostInit} child-first methods.
+ *
+ * <p>
+ * Lazy children are intentionally excluded — they are initialized on
first invocation, not at startup.
*
* @throws ServletException Error occurred.
*/
@@ -204,6 +375,18 @@ public class RestChildren {
childContext.postInitChildFirst();
}
+ /**
+ * Returns the lazy child entries registered on this object.
+ *
+ * <p>
+ * Each entry carries the path prefix and materialization state.
Primarily used for testing.
+ *
+ * @return An unmodifiable map of path-to-lazy-entry. Never {@code
null}.
+ */
+ public Map<String,LazyChildEntry> getLazyEntries() {
+ return lazyEntries;
+ }
+
//-------------------------------------------------------------------------------------------------------------
// Dynamic add/remove API.
//
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestContext.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestContext.java
index 78a725ecd7..acebf12b8d 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestContext.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestContext.java
@@ -305,6 +305,20 @@ public class RestContext extends Context {
*/
String asyncCompletionExecutorName;
+ /**
+ * Programmatic override for the lazy-children flag.
+ *
+ * <p>
+ * {@code null} (default) — defers to the {@link
Rest#lazyChildren() @Rest(lazyChildren)} annotation
+ * chain, then to the {@code RestContext.lazyChildren}
env-driven default (itself defaulting to
+ * {@code false}). Set via {@link #lazyChildInit(boolean)}.
+ *
+ * <p>
+ * When {@code true}, all children registered via {@link
Rest#children() @Rest(children)} on this resource
+ * are built lazily on first invocation rather than eagerly at
parent startup.
+ */
+ Boolean lazyChildInit;
+
/**
* Package-private constructor.
*
@@ -392,6 +406,28 @@ public class RestContext extends Context {
return this;
}
+ /**
+ * Programmatically opt this resource into (or out of) deferred
child construction.
+ *
+ * <p>
+ * When {@code true}, all children registered via {@link
Rest#children() @Rest(children)} are built
+ * lazily on first invocation instead of eagerly at parent
startup. The routing entry for each child
+ * is always populated at startup so URL matching works
immediately.
+ *
+ * <p>
+ * This is the highest-priority knob — it overrides both the
+ * {@link Rest#lazyChildren() @Rest(lazyChildren)} annotation
and the
+ * {@code RestContext.lazyChildren} env-driven default.
+ *
+ * @param value {@code true} to defer child construction to
first invocation.
+ * @return This object.
+ * @since 9.5.0
+ */
+ public Builder lazyChildInit(boolean value) {
+ lazyChildInit = value;
+ return this;
+ }
+
@Override /* Context.Builder is abstract - copy() is not
meaningful for the transient RestContext bootstrap state. */
public Builder copy() {
throw new NoSuchMethodError("Not implemented.");
@@ -1107,6 +1143,10 @@ public class RestContext extends Context {
@Value("${RestContext.eagerInit:false}")
private boolean defaultEagerInit;
+ /** Env-driven default for {@code @Rest(lazyChildren)}: deferred child
construction opt-in. */
+ @Value("${RestContext.lazyChildren:false}")
+ private boolean defaultLazyChildren;
+
/** Env-driven default for {@code @Rest(clientVersionHeader)}. */
@Value("${RestContext.clientVersionHeader:Client-Version}")
private String defaultClientVersionHeader;
@@ -1930,10 +1970,18 @@ public class RestContext extends Context {
var seen = new LinkedHashSet<Class<?>>();
getRestAnnotations().forEach(ai ->
seen.addAll(Arrays.asList(ai.inner().children())));
+ var lazy = isLazyChildren();
+
for (var rc2 : seen) {
if (rc2 == resourceClass())
continue; // Guard against self-reference
infinite loop.
- b.add(RestChildren.buildChildContext(this, bs,
servletConfig, rc2, null, ""));
+ if (lazy) {
+ // Lazy: register a routing stub now; defer
full RestContext construction to first request.
+ b.addLazy(rc2, "");
+ } else {
+ // Eager (default): build the full child
RestContext immediately.
+ b.add(RestChildren.buildChildContext(this, bs,
servletConfig, rc2, null, ""));
+ }
}
// @Bean override — allows replacing the entire RestChildren
instance.
@@ -2554,6 +2602,18 @@ public class RestContext extends Context {
private final Memoizer<Boolean> eagerInit = memoizer(() ->
mergeReplacedBooleanAttribute(PROPERTY_eagerInit,
defaultEagerInit));
+ /**
+ * Annotation + env-driven component of the lazy-children flag.
+ *
+ * <p>
+ * Reads the {@link Rest#lazyChildren() @Rest(lazyChildren)} annotation
chain and falls back to the
+ * {@code RestContext.lazyChildren} env-driven default. The
programmatic
+ * {@link Builder#lazyChildInit(boolean)} knob is applied in {@link
#isLazyChildren()} instead of here
+ * because blank-final-field rules prevent the memoizer lambda from
safely capturing {@link #builder}.
+ */
+ private final Memoizer<Boolean> lazyChildrenAnnotation = memoizer(() ->
+ mergeReplacedBooleanAttribute(PROPERTY_lazyChildren,
defaultLazyChildren));
+
/**
* The request header used for client-version matching; resolved from
{@code @Rest(clientVersionHeader)},
* default {@code "Client-Version"}.
@@ -3808,6 +3868,30 @@ public class RestContext extends Context {
*/
public boolean isEagerInit() { return eagerInit.get(); }
+ /**
+ * Returns whether this resource's {@code @Rest(children=...)} entries
are built lazily on first invocation
+ * rather than eagerly at parent startup.
+ *
+ * <p>
+ * Resolution order (highest wins):
+ * <ol>
+ * <li>{@link Builder#lazyChildInit(boolean)} programmatic knob (if
explicitly set).</li>
+ * <li>{@link Rest#lazyChildren() @Rest(lazyChildren)} annotation
chain (most-derived wins).</li>
+ * <li>{@code RestContext.lazyChildren} env-driven default (default
{@code false}).</li>
+ * </ol>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='ja'>{@link Rest#lazyChildren()}
+ * <li class='jm'>{@link Builder#lazyChildInit(boolean)}
+ * </ul>
+ *
+ * @return <jk>true</jk> if lazy child initialization is enabled.
+ * @since 9.5.0
+ */
+ public boolean isLazyChildren() {
+ return builder.lazyChildInit != null ? builder.lazyChildInit :
lazyChildrenAnnotation.get();
+ }
+
/**
* Called during servlet initialization to invoke all {@link
RestPostInit} child-last methods.
*
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestServerConstants.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestServerConstants.java
index 23263fb722..7d879629f0 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestServerConstants.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestServerConstants.java
@@ -69,6 +69,15 @@ public final class RestServerConstants {
/** The {@code "eagerInit"} annotation attribute name — used in {@code
noInherit} matching. */
public static final String PROPERTY_eagerInit = "eagerInit";
+ /**
+ * The {@code "lazyChildren"} annotation attribute name — used in
{@code noInherit} matching to opt the
+ * parent resource into deferred (first-invocation) construction of its
{@code @Rest(children=...)} sub-resources
+ * instead of the default eager construction at startup.
+ *
+ * @see org.apache.juneau.rest.annotation.Rest#lazyChildren()
+ */
+ public static final String PROPERTY_lazyChildren = "lazyChildren";
+
/**
* The {@code "virtualThreads"} annotation attribute name — used in
{@code noInherit} matching to opt the resource
* (or one of its {@code @RestOp}-annotated methods) into per-request
virtual-thread dispatch on Java 21+. On
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/Rest.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/Rest.java
index 22f911bf08..921a408658 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/Rest.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/Rest.java
@@ -325,6 +325,52 @@ public @interface Rest {
*/
String eagerInit() default "";
+ /**
+ * Opt this parent resource into deferred (first-invocation)
construction of its {@link #children()} sub-resources.
+ *
+ * <p>
+ * When {@code "true"}, the {@link RestContext} instances for all
{@code @Rest(children=...)} entries are
+ * <em>not</em> built at parent startup. Instead, each child's routing
entry is registered immediately (so URL
+ * matching is fully operational from the first request), but the full
{@link RestContext} — including all of its
+ * bean-store setup, memoizers, and lifecycle hooks — is constructed on
the first inbound request to that child's
+ * URL prefix. Subsequent requests reuse the already-built context.
+ *
+ * <p>
+ * This is particularly useful when a parent resource exposes
heavyweight admin or diagnostic children that are
+ * rarely invoked in production. Setting {@code lazyChildren="true"}
on the parent lets the parent boot fast;
+ * each child pays its construction cost only when (and if) it is first
accessed.
+ *
+ * <ul class='values'>
+ * <li><js>"true"</js> - Children are constructed on first
invocation (deferred).
+ * <li><js>"false"</js> (default) - Children are constructed
eagerly at parent startup.
+ * </ul>
+ *
+ * <h5 class='section'>Notes:</h5><ul>
+ * <li class='note'>
+ * The first request to a lazy child pays the full
construction cost, which can be significant for
+ * heavyweight children. If predictable first-request
latency is required, do not opt in.
+ * <li class='note'>
+ * Concurrent first-requests to the same lazy child are
serialized: only one thread runs the construction;
+ * others block until it completes.
+ * <li class='note'>
+ * A lazy child that is never invoked is never
constructed. Its lifecycle {@code @RestDestroy} / shutdown
+ * hooks are skipped at parent destruction time.
+ * <li class='note'>
+ * The programmatic knob {@link
RestContext.Builder#lazyChildInit(boolean)} overrides this annotation.
+ * <li class='note'>
+ * Supports <a class="doclink"
href="https://juneau.apache.org/docs/topics/RestServerSvlVariables">SVL
Variables</a>
+ * (e.g. <js>"$E{LAZY_CHILDREN,false}"</js>).
+ * </ul>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='jm'>{@link
RestContext.Builder#lazyChildInit(boolean)}
+ * <li class='jm'>{@link #children()}
+ * </ul>
+ *
+ * @return The annotation value.
+ */
+ String lazyChildren() default "";
+
/**
* Supported content media types.
*
@@ -388,7 +434,7 @@ public @interface Rest {
* Accepted values include {@code "allowedParserOptions"}, {@code
"allowedSerializerOptions"},
* {@code "allowedHeaderParams"}, {@code "allowedMethodHeaders"},
{@code "allowedMethodParams"},
* {@code "disableContentParam"}, {@code "renderResponseStackTraces"},
{@code "problemDetails"},
- * {@code "eagerInit"}, {@code "clientVersionHeader"},
+ * {@code "eagerInit"}, {@code "lazyChildren"}, {@code
"clientVersionHeader"},
* {@code "uriAuthority"}, {@code "uriContext"}, {@code
"uriRelativity"}, and {@code "uriResolution"}.
* Each entry is SVL-resolved then comma-split. Prevents the named
property from inheriting values from
* parent {@code @Rest} annotations (router hierarchy). The {@code
noInherit} attribute itself is never inherited.
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestAnnotation.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestAnnotation.java
index 3b7c4a1777..70c85accb6 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestAnnotation.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestAnnotation.java
@@ -84,6 +84,7 @@ public class RestAnnotation {
private String clientVersionHeader = "";
private String config = "";
private String eagerInit = "";
+ private String lazyChildren = "";
private String defaultAccept = "";
private String defaultCharset = "";
private String defaultContentType = "";
@@ -244,6 +245,17 @@ public class RestAnnotation {
return this;
}
+ /**
+ * Sets the {@link Rest#lazyChildren()} property on this
annotation.
+ *
+ * @param value The new value for this property.
+ * @return This object.
+ */
+ public Builder lazyChildren(String value) {
+ lazyChildren = value;
+ return this;
+ }
+
/**
* Sets the {@link Rest#consumes()} property on this annotation.
*
@@ -777,6 +789,7 @@ public class RestAnnotation {
private final String clientVersionHeader;
private final String config;
private final String eagerInit;
+ private final String lazyChildren;
private final String defaultAccept;
private final String defaultCharset;
private final String defaultContentType;
@@ -824,6 +837,7 @@ public class RestAnnotation {
clientVersionHeader = b.clientVersionHeader;
config = b.config;
eagerInit = b.eagerInit;
+ lazyChildren = b.lazyChildren;
allowedParserOptions = copyOf(b.allowedParserOptions);
allowedSerializerOptions =
copyOf(b.allowedSerializerOptions);
noInherit = copyOf(b.noInherit);
@@ -918,6 +932,11 @@ public class RestAnnotation {
return eagerInit;
}
+ @Override /* Overridden from Rest */
+ public String lazyChildren() {
+ return lazyChildren;
+ }
+
@Override /* Overridden from Rest */
public String[] allowedParserOptions() {
return allowedParserOptions;
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/LazyChildren_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/LazyChildren_Test.java
new file mode 100644
index 0000000000..3a05e77ec9
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/LazyChildren_Test.java
@@ -0,0 +1,304 @@
+/*
+ * 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.rest;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.concurrent.*;
+import java.util.concurrent.atomic.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Tests for opt-in lazy {@code @Rest(children=...)} materialization
(TODO-121).
+ *
+ * <p>
+ * Each scenario uses a distinct parent class so the {@link MockRestClient}'s
per-class
+ * {@link RestContext} cache does not bleed state across tests.
+ */
+class LazyChildren_Test extends TestBase {
+
+
//------------------------------------------------------------------------------------------------------------------
+ // Shared child resources.
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Rest(path = "/alpha")
+ public static class AlphaResource {
+ @RestGet("/ping")
+ public String ping() {
+ return "alpha-pong";
+ }
+ }
+
+ @Rest(path = "/beta")
+ public static class BetaResource {
+ @RestGet("/ping")
+ public String ping() {
+ return "beta-pong";
+ }
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // a01: HappyPath — annotation enables lazy; routing works immediately;
first request triggers materialization;
+ // subsequent requests reuse the context.
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Rest(path = "/root", children = { AlphaResource.class }, lazyChildren
= "true")
+ public static class A_Parent extends BasicRestServletGroup {
+ private static final long serialVersionUID = 1L;
+ }
+
+ @Test
+ void a01_happyPath_lazyChildMaterializesOnFirstRequest() throws
Exception {
+ var parent = new A_Parent();
+ var client = MockRestClient.createLax(parent).build();
+ var rc = parent.getContext();
+
+ // lazyChildren=true is honoured.
+ assertTrue(rc.isLazyChildren());
+
+ // Before first request: lazy entry is registered but NOT
materialized.
+ var entries = rc.getRestChildren().getLazyEntries();
+ assertFalse(entries.isEmpty(), "Lazy entry should be registered
at startup");
+ var entry = entries.get("alpha");
+ assertNotNull(entry, "Lazy entry key should be 'alpha'");
+ assertFalse(entry.isMaterialized(), "Should not be materialized
before first request");
+
+ // First request materializes the child.
+
client.get("/alpha/ping").run().assertStatus(200).assertContent("alpha-pong");
+
+ // After first request: materialized.
+ assertTrue(entry.isMaterialized(), "Should be materialized
after first request");
+
+ // Subsequent requests continue to succeed (reuse materialized
context).
+
client.get("/alpha/ping").run().assertStatus(200).assertContent("alpha-pong");
+
client.get("/alpha/ping").run().assertStatus(200).assertContent("alpha-pong");
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // a02: Concurrent first-requests — only one materialization runs.
+
//------------------------------------------------------------------------------------------------------------------
+
+ static final AtomicInteger A02_INIT_COUNT = new AtomicInteger(0);
+
+ @Rest(path = "/counted")
+ public static class CountedResource {
+ public CountedResource() {
+ A02_INIT_COUNT.incrementAndGet();
+ }
+
+ @RestGet("/ping")
+ public String ping() {
+ return "counted-pong";
+ }
+ }
+
+ @Rest(path = "/root", children = { CountedResource.class },
lazyChildren = "true")
+ public static class B_Parent extends BasicRestServletGroup {
+ private static final long serialVersionUID = 1L;
+ }
+
+ @Test
+ void a02_concurrent_exactlyOneMaterialization() throws Exception {
+ A02_INIT_COUNT.set(0);
+ var client = MockRestClient.createLax(new B_Parent()).build();
+
+ int concurrency = 8;
+ var barrier = new CyclicBarrier(concurrency);
+ var latch = new CountDownLatch(concurrency);
+ var errors = new AtomicReference<Throwable>();
+ var pool = Executors.newFixedThreadPool(concurrency);
+
+ for (int i = 0; i < concurrency; i++) {
+ pool.submit(() -> {
+ try {
+ barrier.await(); // all threads start
simultaneously
+
client.get("/counted/ping").run().assertStatus(200).assertContent("counted-pong");
+ } catch (Throwable t) {
+ errors.compareAndSet(null, t);
+ } finally {
+ latch.countDown();
+ }
+ });
+ }
+
+ latch.await(10, TimeUnit.SECONDS);
+ pool.shutdown();
+ assertNull(errors.get(), "Concurrent requests must not throw");
+ assertEquals(1, A02_INIT_COUNT.get(), "CountedResource
constructor must be called exactly once");
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // a03: Never-invoked lazy child — parent shuts down without touching
the child context.
+
//------------------------------------------------------------------------------------------------------------------
+
+ static final AtomicInteger A03_INIT_COUNT = new AtomicInteger(0);
+
+ @Rest(path = "/never-invoked")
+ public static class NeverInvokedResource {
+ public NeverInvokedResource() {
+ A03_INIT_COUNT.incrementAndGet();
+ }
+
+ @RestGet("/ping")
+ public String ping() {
+ return "never-pong";
+ }
+ }
+
+ @Rest(path = "/root", children = { NeverInvokedResource.class },
lazyChildren = "true")
+ public static class C_Parent extends BasicRestServletGroup {
+ private static final long serialVersionUID = 1L;
+ }
+
+ @Test
+ void a03_neverInvoked_childIsNeverMaterialized() {
+ A03_INIT_COUNT.set(0);
+ var parent = new C_Parent();
+ // Just creating the client wires the parent context (eager
path for parent; lazy for children).
+ MockRestClient.createLax(parent).build();
+
+ var rc = parent.getContext();
+ var entry =
rc.getRestChildren().getLazyEntries().get("never-invoked");
+ assertNotNull(entry);
+ assertFalse(entry.isMaterialized(), "Child should not be
materialized if never invoked");
+ assertEquals(0, A03_INIT_COUNT.get(), "NeverInvokedResource
constructor must not have been called");
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // a04: Builder override — lazyChildInit(false) overrides
@Rest(lazyChildren="true").
+ // Tests via programmatic RestContext.Builder used in
package-accessible test.
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Rest(path = "/root", children = { AlphaResource.class }, lazyChildren
= "true")
+ public static class D_Parent extends BasicRestServletGroup {
+ private static final long serialVersionUID = 1L;
+ }
+
+ @Rest(path = "/root", children = { AlphaResource.class })
+ public static class D_EagerParent extends BasicRestServletGroup {
+ private static final long serialVersionUID = 1L;
+ }
+
+ @Test
+ void a04_builderOverride_lazyChildInitTrueOverridesEagerDefault()
throws Exception {
+ // Use Builder.lazyChildInit(true) to force lazy on a resource
that has no annotation.
+ // The Builder is package-private, so this test is in the
org.apache.juneau.rest package.
+ var d = new D_EagerParent();
+ var client = MockRestClient.createLax(d).build();
+ var rc = d.getContext();
+
+ // Default is eager; verify annotation-free behavior.
+ assertFalse(rc.isLazyChildren(), "No annotation => eager
(default)");
+
+ // Separately verify that the builder knob can be set and read.
+ var args = new RestContext.Args(D_EagerParent.class, null,
null, () -> d, "", null, null, null, false);
+ var builder = new RestContext.Builder(args);
+ assertNull(builder.lazyChildInit, "Default builder knob should
be null");
+ builder.lazyChildInit(true);
+ assertTrue(builder.lazyChildInit, "Builder knob must be set to
true after lazyChildInit(true)");
+ builder.lazyChildInit(false);
+ assertFalse(builder.lazyChildInit, "Builder knob must be set to
false after lazyChildInit(false)");
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // a05: Lifecycle shutdown — materialized lazy child sees destroy();
never-invoked child is skipped.
+
//------------------------------------------------------------------------------------------------------------------
+
+ static final AtomicInteger A05_DESTROY_COUNT = new AtomicInteger(0);
+
+ @Rest(path = "/lifecycle-child")
+ public static class LifecycleChildResource {
+ @RestGet("/ping")
+ public String ping() {
+ return "lifecycle-pong";
+ }
+
+ @RestDestroy
+ public void onDestroy() {
+ A05_DESTROY_COUNT.incrementAndGet();
+ }
+ }
+
+ @Rest(path = "/never-called")
+ public static class NeverCalledLifecycle {
+ @RestGet("/ping")
+ public String ping() {
+ return "never";
+ }
+
+ @RestDestroy
+ public void onDestroy() {
+ // should NOT be called
+ A05_DESTROY_COUNT.incrementAndGet();
+ }
+ }
+
+ @Rest(path = "/root", children = { LifecycleChildResource.class,
NeverCalledLifecycle.class }, lazyChildren = "true")
+ public static class E_Parent extends BasicRestServletGroup {
+ private static final long serialVersionUID = 1L;
+ }
+
+ @Test
+ void
a05_lifecycleShutdown_materializedChildSeesDestroy_neverInvokedSkipped() throws
Exception {
+ A05_DESTROY_COUNT.set(0);
+ var parent = new E_Parent();
+ var client = MockRestClient.createLax(parent).build();
+
+ // Trigger materialization of lifecycle-child only.
+ client.get("/lifecycle-child/ping").run().assertStatus(200);
+
+ var rc = parent.getContext();
+ var lazyEntries = rc.getRestChildren().getLazyEntries();
+ assertTrue(lazyEntries.get("lifecycle-child").isMaterialized(),
"lifecycle-child should be materialized");
+ assertFalse(lazyEntries.get("never-called").isMaterialized(),
"never-called should NOT be materialized");
+
+ // Destroy the parent — only materialized child's @RestDestroy
should fire.
+ rc.destroy();
+
+ assertEquals(1, A05_DESTROY_COUNT.get(),
+ "@RestDestroy must fire exactly once (only on the
materialized child)");
+ }
+
+
//------------------------------------------------------------------------------------------------------------------
+ // a06: Default eager — no lazyChildren annotation; child is built at
startup.
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Rest(path = "/root", children = { AlphaResource.class })
+ public static class F_Parent extends BasicRestServletGroup {
+ private static final long serialVersionUID = 1L;
+ }
+
+ @Test
+ void a06_defaultEager_noLazyAnnotation_childMaterializedAtStartup()
throws Exception {
+ var parent = new F_Parent();
+ var client = MockRestClient.createLax(parent).build();
+ var rc = parent.getContext();
+
+ // No lazy annotation — default eager behavior preserved.
+ assertFalse(rc.isLazyChildren());
+ assertTrue(rc.getRestChildren().getLazyEntries().isEmpty(), "No
lazy entries in eager mode");
+ assertFalse(rc.getRestChildren().asMap().isEmpty(), "Eager
child must already be in the children map");
+
+ // Routing still works.
+
client.get("/alpha/ping").run().assertStatus(200).assertContent("alpha-pong");
+ }
+}
diff --git a/juneau-utest/test-run-history.tsv
b/juneau-utest/test-run-history.tsv
index 8d62b9d16b..e632c8c64f 100644
--- a/juneau-utest/test-run-history.tsv
+++ b/juneau-utest/test-run-history.tsv
@@ -53,3 +53,4 @@ timestamp git_sha branch tests_run failures
errors skipped surefire_sec wall_sec
2026-05-28T21:29:51Z b0dc55154f95 master 125891 0 0 21
143
2026-05-29T10:45:54Z 3dc947b24d41 master 125897 0 0 21
151
2026-05-29T13:48:36Z c59fc14fac5e master 126037 0 0 21
144
+2026-05-29T14:57:25Z 830e0ca051c6 master 126043 0 0 21
149