From 0580920c070203e2683c057de91beacf962c2ec0 Mon Sep 17 00:00:00 2001 From: Holly Cummins Date: Wed, 26 Aug 2026 20:35:04 +0100 Subject: [PATCH 001/254] Run Roq tests on PRs that change docs --- .github/workflows/doc-build.yml | 169 ++++++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) diff --git a/.github/workflows/doc-build.yml b/.github/workflows/doc-build.yml index 0cb5a131e3d66..aac38e59b7a93 100644 --- a/.github/workflows/doc-build.yml +++ b/.github/workflows/doc-build.yml @@ -133,3 +133,172 @@ jobs: path: | build-reports.zip retention-days: 7 + + test-doc-site: + name: "Documentation Site Tests" + timeout-minutes: 60 + runs-on: ubuntu-latest + needs: build-doc + # Only meaningful on PRs: push builds lack a gh-pages baseline for the + # link-scope diff, and the nightly sync in quarkusio.github.io covers smoke testing. + if: github.event_name == 'pull_request' && (github.repository == 'quarkusio/quarkus' || !endsWith(github.ref, '/main')) + steps: + - name: Checkout quarkusio.github.io + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: quarkusio/quarkusio.github.io + # gh-pages is needed by the link-scope step to diff changed HTML files + fetch-depth: 5000 + fetch-tags: false + + - name: Checkout quarkus (for sync-web-site.sh) + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: quarkus-main + + - name: Download documentation artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: documentation + path: documentation-temp + + - name: Sync documentation into site + # Always use the sync script from the checked-out quarkus repo, not the + # one baked into the artifact (which may be stale on rebases). + run: | + rm -f ./documentation-temp/docs/sync-web-site.sh + cp -a ./quarkus-main/docs/sync-web-site.sh ./documentation-temp/docs/ + chmod 755 ./documentation-temp/docs/sync-web-site.sh + ./documentation-temp/docs/sync-web-site.sh main ${PWD} + rm -rf documentation-temp quarkus-main + + - name: Set up JDK + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + java-version: '25' + distribution: temurin + cache: maven + + - name: Cache npm packages + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.npm + key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }} + restore-keys: ${{ runner.os }}-npm- + + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('pom.xml') }} + + # TODO remove this once the plugin is in Roq + - name: Build TOC plugin + run: ./roq-plugin-toc/build.sh + + # TODO: Remove once https://github.com/quarkiverse/quarkus-roq/issues/1178 is fixed and Roq is upgraded + - name: Patch Roq AsciiDoc tag handling + run: | + mvn -B dependency:resolve -q + bash patches/apply-roq-include-fix.sh + + - name: Build with Maven + run: QUARKUS_ROQ_GENERATOR_BATCH=true QUARKUS_PROFILE=only-latest-guides mvn -B package quarkus:run -DskipTests + + - name: Compile tests + run: mvn -B test-compile + + - name: Install Playwright browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: mvn exec:java -e -Dexec.classpathScope=test -Dexec.mainClass=com.microsoft.playwright.CLI -Dexec.args="install --with-deps --only-shell chromium" + + - uses: jbangdev/setup-jbang@main + + - name: Start Java test server + run: | + jbang trust add https://repo1.maven.org/maven2/io/quarkiverse/roq/quarkus-roq-cli/ + jbang roq@quarkiverse/quarkus-roq serve target/roq/ -p 8090 & + + - name: Run smoke tests + run: mvn -B test -Dtest.url=http://localhost:8090 + env: + PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS: 1 + + - name: Determine link checker scope + id: link_scope + run: | + # Compare the built guides against what is currently on gh-pages. + # version/main/ is excluded: it is overwritten daily by the nightly + # sync and will almost always differ regardless of this PR's changes. + git fetch origin gh-pages --filter=blob:none 2>/dev/null || { + echo "mode=full" >> $GITHUB_OUTPUT + echo "Could not fetch gh-pages, running full link check" + exit 0 + } + + git worktree add --detach /tmp/gh-pages origin/gh-pages 2>/dev/null || { + echo "mode=full" >> $GITHUB_OUTPUT + echo "Could not check out gh-pages, running full link check" + exit 0 + } + + git -C /tmp/gh-pages sparse-checkout set --no-cone '*.html' + + RSYNC_OUT=$(rsync -rcn --out-format='%n' \ + --include='*/' --include='*.html' --exclude='*' \ + target/roq/ /tmp/gh-pages/ 2>/dev/null) || { + echo "mode=full" >> $GITHUB_OUTPUT + echo "Site comparison failed, running full link check" + git worktree remove /tmp/gh-pages 2>/dev/null || true + exit 0 + } + + CHANGED=$(echo "$RSYNC_OUT" | grep '\.html$' | grep -v '^version/main/' || true) + + git worktree remove /tmp/gh-pages 2>/dev/null || true + + if [ -z "$CHANGED" ]; then + echo "mode=skip" >> $GITHUB_OUTPUT + echo "No HTML files changed outside version/main/, skipping link crawl" + exit 0 + fi + + COUNT=$(echo "$CHANGED" | wc -l | tr -d ' ') + echo "$COUNT changed HTML file(s)" + echo "$CHANGED" | sed 's|/[^/]*$||' | sort | uniq -c | sort -rn | head -20 + + if [ "$COUNT" -gt 200 ]; then + echo "mode=full" >> $GITHUB_OUTPUT + echo "More than 200 files changed, running full link check" + else + PATHS=$(echo "$CHANGED" | sed 's|index\.html$||; s|^|/|' | tr '\n' ',' | sed 's/,$//') + echo "mode=partial" >> $GITHUB_OUTPUT + echo "paths=$PATHS" >> $GITHUB_OUTPUT + echo "Targeted check: $PATHS" + fi + + - name: Run link crawler + if: steps.link_scope.outputs.mode != 'skip' + run: | + CHANGED_PATHS_ARG="" + if [ "${{ steps.link_scope.outputs.mode }}" = "partial" ]; then + CHANGED_PATHS_ARG="-Dtest.crawl.changed-paths=${{ steps.link_scope.outputs.paths }}" + fi + mvn -B test -Pe2e -Dtest.url=http://localhost:8090 \ + -Dtest.crawl.check-external=false \ + -Dtest.crawl.exclude-paths=/version/2,/version/3,/version/4,/version/5,/version/main \ + $CHANGED_PATHS_ARG + env: + PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS: 1 + + - name: Upload build reports + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: always() + with: + name: "build-reports-${{ github.run_attempt }}-Documentation Site Tests" + path: | + target/*-reports/TEST-*.xml + target/build-report.json + README.md + retention-days: 7 From 1fa59b0224c454f08ba7bb15c0d0b2f9ff70ee5f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:25:04 +0000 Subject: [PATCH 002/254] Bump org.apache.maven.plugins:maven-plugin-plugin from 3.15.2 to 3.16.0 Bumps [org.apache.maven.plugins:maven-plugin-plugin](https://github.com/apache/maven-plugin-tools) from 3.15.2 to 3.16.0. - [Release notes](https://github.com/apache/maven-plugin-tools/releases) - [Commits](https://github.com/apache/maven-plugin-tools/compare/maven-plugin-tools-3.15.2...maven-plugin-tools-3.16.0) --- updated-dependencies: - dependency-name: org.apache.maven.plugins:maven-plugin-plugin dependency-version: 3.16.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- independent-projects/parent/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/independent-projects/parent/pom.xml b/independent-projects/parent/pom.xml index bb4301a8a6aa9..e4e06ea41a90d 100644 --- a/independent-projects/parent/pom.xml +++ b/independent-projects/parent/pom.xml @@ -31,7 +31,7 @@ 3.1.4 3.12.0 3.5.1 - 3.15.2 + 3.16.0 3.3.1 3.5.0 3.6.2 From 94467bf61e711271c0f33a4591dfb18c7a891f58 Mon Sep 17 00:00:00 2001 From: jnbdz <237008+jnbdz@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:27:20 -0400 Subject: [PATCH 003/254] Keep the type arguments of the ingested payload type of messaging methods The Jandex based mediator configuration turned every generic type into its raw class, so a method consuming Message> had Envelope as ingested payload type and a MessageConverter that checks the target for Envelope never matched, unlike with the reflection based SmallRye configuration. The type arguments are now converted to reflective types (parameterized, wildcard and generic array types, which the bytecode recorder already writes); type variables still fall back to their raw bound. Message payloads that already have the raw type keep taking the identity path, since a raw class is assignable to its parameterized type. Fixes #42315 --- .../QuarkusMediatorConfigurationUtil.java | 55 ++++++- .../MediatorConfigurationSupportTest.java | 46 ++++++ .../converters/GenericConverterTest.java | 138 ++++++++++++++++++ 3 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 extensions/smallrye-reactive-messaging/deployment/src/test/java/io/quarkus/smallrye/reactivemessaging/converters/GenericConverterTest.java diff --git a/extensions/smallrye-reactive-messaging/deployment/src/main/java/io/quarkus/smallrye/reactivemessaging/deployment/QuarkusMediatorConfigurationUtil.java b/extensions/smallrye-reactive-messaging/deployment/src/main/java/io/quarkus/smallrye/reactivemessaging/deployment/QuarkusMediatorConfigurationUtil.java index 0f8a6efcb4639..eae5dcd3e5442 100644 --- a/extensions/smallrye-reactive-messaging/deployment/src/main/java/io/quarkus/smallrye/reactivemessaging/deployment/QuarkusMediatorConfigurationUtil.java +++ b/extensions/smallrye-reactive-messaging/deployment/src/main/java/io/quarkus/smallrye/reactivemessaging/deployment/QuarkusMediatorConfigurationUtil.java @@ -34,6 +34,9 @@ import io.quarkus.arc.processor.BeanInfo; import io.quarkus.deployment.recording.RecorderContext; import io.quarkus.runtime.configuration.ConfigurationException; +import io.quarkus.runtime.types.GenericArrayTypeImpl; +import io.quarkus.runtime.types.ParameterizedTypeImpl; +import io.quarkus.runtime.types.WildcardTypeImpl; import io.quarkus.smallrye.reactivemessaging.deployment.items.CustomInvokerBuildItem; import io.quarkus.smallrye.reactivemessaging.runtime.QuarkusMediatorConfiguration; import io.quarkus.smallrye.reactivemessaging.runtime.QuarkusParameterDescriptor; @@ -429,7 +432,7 @@ public Result check(Class target, int index) { public java.lang.reflect.Type getType(int index) { Type t = extract(type, index); if (t != null) { - return load(t.name().toString(), classLoader); + return toReflectiveType(t, classLoader); } return null; } @@ -457,13 +460,61 @@ public java.lang.reflect.Type getType(int index, int subIndex) { if (generic != null) { Type t = extract(generic, subIndex); if (t != null) { - return load(t.name().toString(), classLoader); + return toReflectiveType(t, classLoader); } } return null; } } + /** + * Converts a Jandex type to the reflective type SmallRye Reactive Messaging compares converters and ingested + * payload types against, keeping type arguments (as the reflection based SmallRye configuration does) instead of + * only the raw class. Type variables fall back to their raw bound, and the recorder knows how to write the + * {@code io.quarkus.runtime.types} implementations. + */ + static java.lang.reflect.Type toReflectiveType(Type type, ClassLoader classLoader) { + switch (type.kind()) { + case PARAMETERIZED_TYPE: { + ParameterizedType parameterizedType = type.asParameterizedType(); + Class rawType = load(parameterizedType.name().toString(), classLoader); + List arguments = parameterizedType.arguments(); + java.lang.reflect.Type[] reflectiveArguments = new java.lang.reflect.Type[arguments.size()]; + for (int i = 0; i < arguments.size(); i++) { + reflectiveArguments[i] = toReflectiveType(arguments.get(i), classLoader); + } + java.lang.reflect.Type owner = parameterizedType.owner() != null + ? toReflectiveType(parameterizedType.owner(), classLoader) + : null; + return new ParameterizedTypeImpl(rawType, reflectiveArguments, owner); + } + case ARRAY: { + Type component = type.asArrayType().constituent(); + if (component.kind() == Type.Kind.PARAMETERIZED_TYPE) { + java.lang.reflect.Type reflectiveComponent = toReflectiveType(component, classLoader); + for (int i = 0; i < type.asArrayType().dimensions(); i++) { + reflectiveComponent = new GenericArrayTypeImpl(reflectiveComponent); + } + return reflectiveComponent; + } + return load(type.name().toString(), classLoader); + } + case WILDCARD_TYPE: { + Type upperBound = type.asWildcardType().extendsBound(); + Type lowerBound = type.asWildcardType().superBound(); + if (lowerBound != null) { + return WildcardTypeImpl.withLowerBound(toReflectiveType(lowerBound, classLoader)); + } + if (upperBound != null && !DotName.OBJECT_NAME.equals(upperBound.name())) { + return WildcardTypeImpl.withUpperBound(toReflectiveType(upperBound, classLoader)); + } + return WildcardTypeImpl.defaultInstance(); + } + default: + return load(type.name().toString(), classLoader); + } + } + public static class AlwaysInvalidIndexGenericTypeAssignable implements MediatorConfigurationSupport.GenericTypeAssignable { diff --git a/extensions/smallrye-reactive-messaging/deployment/src/test/java/io/quarkus/smallrye/reactivemessaging/MediatorConfigurationSupportTest.java b/extensions/smallrye-reactive-messaging/deployment/src/test/java/io/quarkus/smallrye/reactivemessaging/MediatorConfigurationSupportTest.java index 74038e612747c..af808f44a8bb8 100644 --- a/extensions/smallrye-reactive-messaging/deployment/src/test/java/io/quarkus/smallrye/reactivemessaging/MediatorConfigurationSupportTest.java +++ b/extensions/smallrye-reactive-messaging/deployment/src/test/java/io/quarkus/smallrye/reactivemessaging/MediatorConfigurationSupportTest.java @@ -5,6 +5,9 @@ import static org.assertj.core.api.Assertions.fail; import java.io.IOException; +import java.lang.reflect.ParameterizedType; +import java.util.List; +import java.util.Map; import java.util.concurrent.CompletionStage; import jakarta.enterprise.inject.spi.DefinitionException; @@ -77,6 +80,37 @@ public Class load(String className) { } } + @Test + public void testGenericIngestedPayloadTypes() { + MediatorConfigurationSupport support = create("subscriberSinkOfMessageOfList"); + MediatorConfigurationSupport.ValidationOutput output = support.validate(Shape.SUBSCRIBER, null); + assertThat(output.getConsumption()).isEqualTo(MediatorConfiguration.Consumption.MESSAGE); + assertThat(output.getIngestedPayloadType()).isInstanceOfSatisfying(ParameterizedType.class, type -> { + assertThat(type.getRawType()).isEqualTo(List.class); + assertThat(type.getActualTypeArguments()).containsExactly(String.class); + }); + + support = create("subscriberSinkOfMessageOfMapCompletionStage"); + output = support.validate(Shape.SUBSCRIBER, null); + assertThat(output.getConsumption()).isEqualTo(MediatorConfiguration.Consumption.MESSAGE); + assertThat(output.getIngestedPayloadType()).isInstanceOfSatisfying(ParameterizedType.class, type -> { + assertThat(type.getRawType()).isEqualTo(Map.class); + assertThat(type.getActualTypeArguments()).containsExactly(String.class, Integer.class); + }); + + support = create("processorMultiOfMessageOfList"); + output = support.validate(Shape.STREAM_TRANSFORMER, null); + assertThat(output.getConsumption()).isEqualTo(MediatorConfiguration.Consumption.STREAM_OF_MESSAGE); + assertThat(output.getIngestedPayloadType()).isInstanceOfSatisfying(ParameterizedType.class, type -> { + assertThat(type.getRawType()).isEqualTo(List.class); + assertThat(type.getActualTypeArguments()).containsExactly(String.class); + }); + + support = create("subscriberSinkOfMessageCompletionStage"); + output = support.validate(Shape.SUBSCRIBER, null); + assertThat(output.getIngestedPayloadType()).isEqualTo(Person.class); + } + @Test public void testPublishers() { MediatorConfigurationSupport support = create("publisherPublisherOfMessage"); @@ -499,6 +533,18 @@ SubscriberBuilder subscriberSubscriberBuilderOfPayload() { return null; } + CompletionStage subscriberSinkOfMessageOfList(Message> p) { + return null; + } + + CompletionStage subscriberSinkOfMessageOfMapCompletionStage(Message> p) { + return null; + } + + Multi processorMultiOfMessageOfList(Multi>> p) { + return null; + } + void subscriberSinkOfMessage(Message p) { // Invalid } diff --git a/extensions/smallrye-reactive-messaging/deployment/src/test/java/io/quarkus/smallrye/reactivemessaging/converters/GenericConverterTest.java b/extensions/smallrye-reactive-messaging/deployment/src/test/java/io/quarkus/smallrye/reactivemessaging/converters/GenericConverterTest.java new file mode 100644 index 0000000000000..9324c2b17c7d1 --- /dev/null +++ b/extensions/smallrye-reactive-messaging/deployment/src/test/java/io/quarkus/smallrye/reactivemessaging/converters/GenericConverterTest.java @@ -0,0 +1,138 @@ +package io.quarkus.smallrye.reactivemessaging.converters; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletionStage; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import org.eclipse.microprofile.reactive.messaging.Incoming; +import org.eclipse.microprofile.reactive.messaging.Message; +import org.eclipse.microprofile.reactive.messaging.Outgoing; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusExtensionTest; +import io.smallrye.mutiny.Multi; +import io.smallrye.reactive.messaging.MessageConverter; + +/** + * A converter must see the full generic ingested payload type, so that {@code Message>} and + * {@code Message>} can be converted differently. + */ +public class GenericConverterTest { + + @RegisterExtension + static final QuarkusExtensionTest config = new QuarkusExtensionTest() + .withApplicationRoot((jar) -> jar + .addClasses(Envelope.class, Order.class, Invoice.class, MyApp.class, OrderConverter.class, + InvoiceConverter.class)); + + @Inject + MyApp app; + + @Test + public void testGenericConverters() { + assertThat(app.orders()).hasSize(2).allSatisfy(order -> assertThat(order.id).startsWith("order-")); + assertThat(app.invoices()).hasSize(2).allSatisfy(invoice -> assertThat(invoice.id).startsWith("invoice-")); + } + + public static class Envelope { + public final T content; + + public Envelope(T content) { + this.content = content; + } + } + + public static class Order { + public final String id; + + public Order(String id) { + this.id = id; + } + } + + public static class Invoice { + public final String id; + + public Invoice(String id) { + this.id = id; + } + } + + @ApplicationScoped + public static class MyApp { + private final List orders = new ArrayList<>(); + private final List invoices = new ArrayList<>(); + + @Outgoing("orders") + public Multi orderIds() { + return Multi.createFrom().items("1", "2"); + } + + @Outgoing("invoices") + public Multi invoiceIds() { + return Multi.createFrom().items("1", "2"); + } + + @Incoming("orders") + public CompletionStage consumeOrder(Message> message) { + orders.add(message.getPayload().content); + return message.ack(); + } + + @Incoming("invoices") + public CompletionStage consumeInvoice(Message> message) { + invoices.add(message.getPayload().content); + return message.ack(); + } + + public List orders() { + return orders; + } + + public List invoices() { + return invoices; + } + } + + static boolean isEnvelopeOf(Type target, Class content) { + return target instanceof ParameterizedType parameterized + && parameterized.getRawType() == Envelope.class + && parameterized.getActualTypeArguments()[0] == content; + } + + @ApplicationScoped + public static class OrderConverter implements MessageConverter { + + @Override + public boolean canConvert(Message in, Type target) { + return in.getPayload() instanceof String && isEnvelopeOf(target, Order.class); + } + + @Override + public Message convert(Message in, Type target) { + return in.withPayload(new Envelope<>(new Order("order-" + in.getPayload()))); + } + } + + @ApplicationScoped + public static class InvoiceConverter implements MessageConverter { + + @Override + public boolean canConvert(Message in, Type target) { + return in.getPayload() instanceof String && isEnvelopeOf(target, Invoice.class); + } + + @Override + public Message convert(Message in, Type target) { + return in.withPayload(new Envelope<>(new Invoice("invoice-" + in.getPayload()))); + } + } +} From 1d8c0df2c59db5288cc41a95a8294487be7733e0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:12:16 +0000 Subject: [PATCH 004/254] Bump io.quarkus:quarkus-platform-bom-maven-plugin Bumps [io.quarkus:quarkus-platform-bom-maven-plugin](https://github.com/quarkusio/quarkus-platform-bom-generator) from 0.0.134 to 0.0.135. - [Release notes](https://github.com/quarkusio/quarkus-platform-bom-generator/releases) - [Commits](https://github.com/quarkusio/quarkus-platform-bom-generator/compare/0.0.134...0.0.135) --- updated-dependencies: - dependency-name: io.quarkus:quarkus-platform-bom-maven-plugin dependency-version: 0.0.135 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index d929a0f6e394f..8e32010698b42 100644 --- a/pom.xml +++ b/pom.xml @@ -55,7 +55,7 @@ jdbc:postgresql:hibernate_orm_test - 0.0.134 + 0.0.135 false false From dce9910101fddf3ea076c7affa2cce8dba354d80 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:19:24 +0000 Subject: [PATCH 005/254] Bump apicurio-registry.version from 3.3.2 to 3.3.3 Bumps `apicurio-registry.version` from 3.3.2 to 3.3.3. Updates `io.apicurio:apicurio-registry-java-sdk` from 3.3.2 to 3.3.3 - [Release notes](https://github.com/apicurio/apicurio-registry/releases) - [Commits](https://github.com/apicurio/apicurio-registry/compare/3.3.2...3.3.3) Updates `io.apicurio:apicurio-registry-avro-serde-kafka` from 3.3.2 to 3.3.3 Updates `io.apicurio:apicurio-registry-jsonschema-serde-kafka` from 3.3.2 to 3.3.3 Updates `io.apicurio:apicurio-registry-serde-common` from 3.3.2 to 3.3.3 - [Release notes](https://github.com/apicurio/apicurio-registry/releases) - [Commits](https://github.com/apicurio/apicurio-registry/compare/3.3.2...3.3.3) Updates `io.apicurio:apicurio-registry-serde-kafka-common` from 3.3.2 to 3.3.3 - [Release notes](https://github.com/apicurio/apicurio-registry/releases) - [Commits](https://github.com/apicurio/apicurio-registry/compare/3.3.2...3.3.3) Updates `io.apicurio:apicurio-registry-serde-common-jsonschema` from 3.3.2 to 3.3.3 --- updated-dependencies: - dependency-name: io.apicurio:apicurio-registry-java-sdk dependency-version: 3.3.3 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: io.apicurio:apicurio-registry-avro-serde-kafka dependency-version: 3.3.3 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: io.apicurio:apicurio-registry-jsonschema-serde-kafka dependency-version: 3.3.3 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: io.apicurio:apicurio-registry-serde-common dependency-version: 3.3.3 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: io.apicurio:apicurio-registry-serde-kafka-common dependency-version: 3.3.3 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: io.apicurio:apicurio-registry-serde-common-jsonschema dependency-version: 3.3.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- bom/application/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bom/application/pom.xml b/bom/application/pom.xml index ba714fc2cf8f6..074f496f5ed17 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -198,7 +198,7 @@ 2.0.1.Final 2.26.1 1.12.2 - 3.3.2 + 3.3.3 2.0.5 3.7.1 From 481626fdf81c572bb3a944d7178573058c7e77dc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:24:05 +0000 Subject: [PATCH 006/254] Bump io.strimzi:strimzi-test-container from 0.116.0 to 0.117.0 Bumps [io.strimzi:strimzi-test-container](https://github.com/strimzi/test-container) from 0.116.0 to 0.117.0. - [Release notes](https://github.com/strimzi/test-container/releases) - [Commits](https://github.com/strimzi/test-container/compare/0.116.0...0.117.0) --- updated-dependencies: - dependency-name: io.strimzi:strimzi-test-container dependency-version: 0.117.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- bom/application/pom.xml | 2 +- bom/test/pom.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bom/application/pom.xml b/bom/application/pom.xml index ba714fc2cf8f6..a5f1d063f9c03 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -146,7 +146,7 @@ 4.2.1 1.11.1 1.1.10.8 - 0.116.0 + 0.117.0 2.13.16 1.4.0 diff --git a/bom/test/pom.xml b/bom/test/pom.xml index fced4fdfac600..fd7480969a792 100644 --- a/bom/test/pom.xml +++ b/bom/test/pom.xml @@ -20,7 +20,7 @@ 2.3.1 1.3.8 - 0.116.0 + 0.117.0 1.5.0-alpha From 030679b6a9bc3d058a3bd47b8bbf62eb9ff4f65b Mon Sep 17 00:00:00 2001 From: Tiziano Basile Date: Mon, 14 Sep 2026 19:13:53 +0200 Subject: [PATCH 007/254] Document dev mode in multi-module Maven projects The multi-module section of the Maven tooling guide only covered CDI bean discovery via the Jandex plugin. It said nothing about running dev mode in such a project, which was the second half of the original request. Split the existing content into a bean-discovery subsection and add a new subsection covering dev mode: starting quarkus:dev on the application module with -pl, building the project first so dependency modules are compiled, that local dependency modules are hot-reloaded like the application's own sources, and that only one Quarkus application runs per dev session. Signed-off-by: Tiziano Basile --- docs/src/main/asciidoc/maven-tooling.adoc | 37 +++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/docs/src/main/asciidoc/maven-tooling.adoc b/docs/src/main/asciidoc/maven-tooling.adoc index 69ec85eace061..8d5114a924f23 100644 --- a/docs/src/main/asciidoc/maven-tooling.adoc +++ b/docs/src/main/asciidoc/maven-tooling.adoc @@ -682,6 +682,11 @@ artifact classifier for the Uber-Jar artifact. There are two ways to attach an U [[multi-module-maven]] === Working with multi-module projects +A multi-module Maven project is typically organized as a parent (aggregator) module with several child modules, for example one or more shared library modules and one or more Quarkus application modules that depend on them. + +[[multi-module-bean-discovery]] +==== Making CDI beans discoverable across modules + By default, Quarkus will not discover CDI beans inside another module. The best way to enable CDI bean discovery for a module in a multi-module project would be to include the `jandex-maven-plugin`, @@ -710,6 +715,38 @@ unless it is the main application module already configured with the quarkus-mav More information on this topic can be found on the xref:cdi-reference.adoc#bean_discovery[Bean Discovery] section of the CDI guide. +[[multi-module-dev-mode]] +==== Running dev mode in a multi-module project + +<> is started on a single Quarkus application module, not on the aggregator. +Before starting it, build the whole project once so that the other modules the application depends on are compiled: + +[source,bash] +---- +./mvnw install -DskipTests +---- + +Then start dev mode on the application module only, selecting it from the root of the project with `-pl`: + +[source,bash] +---- +./mvnw quarkus:dev -pl :my-app +---- + +Alternatively, run it from within the module directory: + +[source,bash] +---- +cd my-app +../mvnw quarkus:dev +---- + +Local modules that the application depends on are treated as hot-reloadable dependencies: when you edit their sources and a change is detected, Quarkus recompiles them and redeploys the application, exactly as it does for the application's own sources. +This is why the project must be built at least once first; otherwise dev mode fails with a `Hot reloadable dependency ... has not been compiled yet` error. + +Dev mode runs a single Quarkus application at a time. +If your project contains several Quarkus application modules, start each one in its own dev mode session, in a separate terminal, and if needed on a different port using `-Dquarkus.http.port`. + [[maven-test-configuration]] === Maven test plugin configuration From 5071f51eca3635736dce05ae85ee1f03b0cf64ba Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:02:37 +0000 Subject: [PATCH 008/254] Bump com.gradle.plugin-publish from 2.2.0 to 2.2.1 in /devtools/gradle Bumps com.gradle.plugin-publish from 2.2.0 to 2.2.1. --- updated-dependencies: - dependency-name: com.gradle.plugin-publish dependency-version: 2.2.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- devtools/gradle/gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devtools/gradle/gradle/libs.versions.toml b/devtools/gradle/gradle/libs.versions.toml index 089fdc33890f6..18b1bec5f2c88 100644 --- a/devtools/gradle/gradle/libs.versions.toml +++ b/devtools/gradle/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -plugin-publish = "2.2.0" +plugin-publish = "2.2.1" kotlin = "2.4.10" smallrye-config = "3.18.2" From b8e091be3c9c2e659051551714689d3996c08042 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:02:39 +0000 Subject: [PATCH 009/254] Bump com.gradle.develocity from 4.5.0 to 4.5.1 in /devtools/gradle Bumps com.gradle.develocity from 4.5.0 to 4.5.1. --- updated-dependencies: - dependency-name: com.gradle.develocity dependency-version: 4.5.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- devtools/gradle/settings.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devtools/gradle/settings.gradle.kts b/devtools/gradle/settings.gradle.kts index 20ff0713b28f1..07c9d494dec47 100644 --- a/devtools/gradle/settings.gradle.kts +++ b/devtools/gradle/settings.gradle.kts @@ -1,5 +1,5 @@ plugins { - id("com.gradle.develocity") version "4.5.0" + id("com.gradle.develocity") version "4.5.1" } develocity { From 004654813880a8bf7c2addb4aad05dfcb41b0fb8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:33:50 +0000 Subject: [PATCH 010/254] Bump oracle-actions/setup-testpilot from 1.0.29 to 1.0.31 Bumps [oracle-actions/setup-testpilot](https://github.com/oracle-actions/setup-testpilot) from 1.0.29 to 1.0.31. - [Release notes](https://github.com/oracle-actions/setup-testpilot/releases) - [Commits](https://github.com/oracle-actions/setup-testpilot/compare/5622b688a27bde714b9b914a5147d6d96aa0af6f...ad55a0d7294112c44a9cc9a38f4b3d293da8fcf0) --- updated-dependencies: - dependency-name: oracle-actions/setup-testpilot dependency-version: 1.0.31 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/ci-actions-incremental.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci-actions-incremental.yml b/.github/workflows/ci-actions-incremental.yml index 5fe67a872cd38..b69d3cad740ac 100644 --- a/.github/workflows/ci-actions-incremental.yml +++ b/.github/workflows/ci-actions-incremental.yml @@ -1830,7 +1830,7 @@ jobs: develocity-token-expiry: 6 - name: Create Oracle Test Pilot Database id: create_database - uses: oracle-actions/setup-testpilot@5622b688a27bde714b9b914a5147d6d96aa0af6f # v1.0.29 + uses: oracle-actions/setup-testpilot@ad55a0d7294112c44a9cc9a38f4b3d293da8fcf0 # v1.0.31 with: oci-service: ${{ matrix.oci-service }} action: create @@ -1865,7 +1865,7 @@ jobs: - name: Delete Oracle Test Pilot Database id: delete_database if: always() && steps.create_database.outcome == 'success' - uses: oracle-actions/setup-testpilot@5622b688a27bde714b9b914a5147d6d96aa0af6f # v1.0.29 + uses: oracle-actions/setup-testpilot@ad55a0d7294112c44a9cc9a38f4b3d293da8fcf0 # v1.0.31 with: oci-service: ${{ matrix.oci-service }} action: delete @@ -1949,7 +1949,7 @@ jobs: develocity-token-expiry: 6 - name: Create Oracle Test Pilot Database id: create_database - uses: oracle-actions/setup-testpilot@5622b688a27bde714b9b914a5147d6d96aa0af6f # v1.0.29 + uses: oracle-actions/setup-testpilot@ad55a0d7294112c44a9cc9a38f4b3d293da8fcf0 # v1.0.31 with: oci-service: ${{ matrix.oci-service }} action: create @@ -1987,7 +1987,7 @@ jobs: - name: Delete Oracle Test Pilot Database id: delete_database if: always() && steps.create_database.outcome == 'success' - uses: oracle-actions/setup-testpilot@5622b688a27bde714b9b914a5147d6d96aa0af6f # v1.0.29 + uses: oracle-actions/setup-testpilot@ad55a0d7294112c44a9cc9a38f4b3d293da8fcf0 # v1.0.31 with: oci-service: ${{ matrix.oci-service }} action: delete From 3ed36a2d170c2dea5862392233ae0968d6c20788 Mon Sep 17 00:00:00 2001 From: Chintu Kumar Date: Thu, 17 Sep 2026 15:33:55 +0530 Subject: [PATCH 011/254] Fix Amazon Lambda dev mode hot reload causing application shutdown After hot replacement in dev mode, the old polling thread attempts to communicate with the replaced MockEventServer instance, causing failures that stop the newly restarted application. The root cause is that the outer catch block calls Application.currentApplication().stop(), but after hot reload, currentApplication() returns the NEW application instance, not the old one that started this poll loop thread. This fix introduces a solution by storing a reference to the Application instance when the poll loop thread starts, then comparing it with currentApplication() before calling stop(). If they differ, we're an old poll loop thread trying to stop a new application - so we just exit gracefully instead. 1. **Application instance tracking**: Store reference to Application when poll loop starts. In the outer catch, check if currentApplication() != ourApplication before calling stop(). This directly detects "am I trying to stop the wrong app?" regardless of exception type or hot reload flags. 2. **Requeue failure handling**: When checkHotReplacement() detects a restart and triggers requeue(), the old MockEventServer is already gone. Catch the IOException and exit the old poll loop gracefully. 3. **PostError failure handling**: If hot replacement completes DURING request processing (after the hot replacement check but before postResponse), postError also fails. In dev mode, catch this and continue instead of propagating to the outer catch which would log ERROR for an expected condition. Fixes #56813 --- .../runtime/AbstractLambdaPollLoop.java | 47 +++++++++++++++++-- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/extensions/amazon-lambda/common-runtime/src/main/java/io/quarkus/amazon/lambda/runtime/AbstractLambdaPollLoop.java b/extensions/amazon-lambda/common-runtime/src/main/java/io/quarkus/amazon/lambda/runtime/AbstractLambdaPollLoop.java index 7574faad3b581..23e62b6b2c598 100644 --- a/extensions/amazon-lambda/common-runtime/src/main/java/io/quarkus/amazon/lambda/runtime/AbstractLambdaPollLoop.java +++ b/extensions/amazon-lambda/common-runtime/src/main/java/io/quarkus/amazon/lambda/runtime/AbstractLambdaPollLoop.java @@ -95,6 +95,11 @@ public void startPollLoop(ValueRegistry valueRegistry, ShutdownContext context) final Thread pollingThread = new Thread(new Runnable() { @Override public void run() { + // Store reference to the application instance this poll loop is serving. + // In dev mode with hot replacement, a restart creates a new Application instance, + // so we can detect if we're an old poll loop thread trying to stop a new application. + final Application ourApplication = Application.currentApplication(); + try { if (!LambdaHotReplacementRecorder.enabled && (launchMode.isDev() || launchMode.isProduction())) { @@ -146,7 +151,15 @@ public void run() { // so we requeue the request as quarkus will restart // and the message will not be processed // FYI: this requeue endpoint is something only the mock event server implements - requeue(baseUrl, requestId); + try { + requeue(baseUrl, requestId); + } catch (IOException e) { + // After hot replacement, the mock event server has been replaced, + // so requeue will fail with 404. This is expected - just exit gracefully. + log.debug( + "Requeue failed after hot replacement (expected), exiting old poll loop", + e); + } return; } } finally { @@ -190,8 +203,21 @@ public void run() { log.error("Failed to run lambda (" + launchMode + ")", e); } - postError(AmazonLambdaApi.invocationError(baseUrl, requestId), - new FunctionError(e.getClass().getName(), e.getMessage())); + try { + postError(AmazonLambdaApi.invocationError(baseUrl, requestId), + new FunctionError(e.getClass().getName(), e.getMessage())); + } catch (Exception postErrorException) { + // postError failed (e.g., after hot replacement when mock server is replaced). + // In dev mode, this is expected after hot reload. Continue to next request + // instead of propagating to outer catch which would log ERROR and potentially stop app. + if (launchMode == LaunchMode.DEVELOPMENT) { + log.debug("postError failed (likely after hot replacement), continuing", + postErrorException); + } else { + // In production, re-throw to trigger proper error handling + throw postErrorException; + } + } continue; } @@ -201,10 +227,21 @@ public void run() { // SocketException here is expected (e.g. with Lambda extension layers) return; } - if (!abortGracefully(e)) - log.error("Error running lambda (" + launchMode + ")", e); + if (abortGracefully(e)) + return; + + log.error("Error running lambda (" + launchMode + ")", e); Application app = Application.currentApplication(); if (app != null) { + // In dev mode with hot replacement, this poll loop thread may outlive its application. + // After hot reload, currentApplication() returns the NEW application instance. + // Don't stop the new application if we're the old poll loop thread. + // Only skip stopping if both ourApplication and app are non-null and different. + if (ourApplication != null && app != ourApplication) { + log.debug("Not stopping application - this poll loop thread belongs to a previous " + + "application instance (hot reload occurred)"); + return; + } try { app.stop(); } catch (Exception ignored) { From 19f762dcb784951153c8819fba6b286baa51a845 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:38:40 +0000 Subject: [PATCH 012/254] Bump org.asynchttpclient:async-http-client in /bom/application Bumps [org.asynchttpclient:async-http-client](https://github.com/AsyncHttpClient/async-http-client) from 2.16.0 to 2.16.1. - [Release notes](https://github.com/AsyncHttpClient/async-http-client/releases) - [Commits](https://github.com/AsyncHttpClient/async-http-client/compare/async-http-client-project-2.16.0...async-http-client-project-2.16.1) --- updated-dependencies: - dependency-name: org.asynchttpclient:async-http-client dependency-version: 2.16.1 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- bom/application/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bom/application/pom.xml b/bom/application/pom.xml index 7b3180bb6083f..d8abae8fee513 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -217,7 +217,7 @@ 0.8.12 1.3.1 4.2.1 - 2.16.0 + 2.16.1 0.16.0 From 96da03e82431ac07d0b8a9303e8c1869d7495935 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:18:45 +0000 Subject: [PATCH 013/254] Bump org.bitbucket.b_c:jose4j from 0.9.6 to 0.9.7 Bumps [org.bitbucket.b_c:jose4j](https://bitbucket.org/b_c/jose4j) from 0.9.6 to 0.9.7. - [Changelog](https://bitbucket.org/b_c/jose4j/src/master/Release Notes.md) - [Commits](https://bitbucket.org/b_c/jose4j/branches/compare/jose4j-0.9.7..jose4j-0.9.6) --- updated-dependencies: - dependency-name: org.bitbucket.b_c:jose4j dependency-version: 0.9.7 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- bom/application/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bom/application/pom.xml b/bom/application/pom.xml index ec9c4bed8a769..97b9a66579b0a 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -210,7 +210,7 @@ 0.16.1 10.9.1 - 0.9.6 + 0.9.7 0.0.17 1.5.0 2.19.0 From cf4b43d04b4b6537d53f880cd01bcd92cc2b8c1f Mon Sep 17 00:00:00 2001 From: Rolfe Dlugy-Hegwer Date: Fri, 18 Sep 2026 18:06:09 -0400 Subject: [PATCH 014/254] Add the missing guide links to the metadata of 19 extensions Their quarkus-extension.yaml has no metadata.guide, or an empty one, so code.quarkus.io, the CLI and quarkus.io/extensions show no guide for them, although a guide names each one in its :extensions: attribute. Point each extension at that guide. The container image extensions link the section of their own builder, as the Podman one now does too. Co-Authored-By: Claude Opus 5 (1M context) --- .../runtime/src/main/resources/META-INF/quarkus-extension.yaml | 1 + .../runtime/src/main/resources/META-INF/quarkus-extension.yaml | 1 + .../runtime/src/main/resources/META-INF/quarkus-extension.yaml | 1 + .../runtime/src/main/resources/META-INF/quarkus-extension.yaml | 1 + .../runtime/src/main/resources/META-INF/quarkus-extension.yaml | 2 +- .../runtime/src/main/resources/META-INF/quarkus-extension.yaml | 1 + .../runtime/src/main/resources/META-INF/quarkus-extension.yaml | 1 + .../runtime/src/main/resources/META-INF/quarkus-extension.yaml | 1 + .../runtime/src/main/resources/META-INF/quarkus-extension.yaml | 1 + .../runtime/src/main/resources/META-INF/quarkus-extension.yaml | 1 + .../runtime/src/main/resources/META-INF/quarkus-extension.yaml | 1 + .../runtime/src/main/resources/META-INF/quarkus-extension.yaml | 1 + .../runtime/src/main/resources/META-INF/quarkus-extension.yaml | 1 + .../runtime/src/main/resources/META-INF/quarkus-extension.yaml | 1 + .../runtime/src/main/resources/META-INF/quarkus-extension.yaml | 1 + .../runtime/src/main/resources/META-INF/quarkus-extension.yaml | 1 + .../runtime/src/main/resources/META-INF/quarkus-extension.yaml | 1 + .../runtime/src/main/resources/META-INF/quarkus-extension.yaml | 1 + .../runtime/src/main/resources/META-INF/quarkus-extension.yaml | 2 +- .../runtime/src/main/resources/META-INF/quarkus-extension.yaml | 1 + 20 files changed, 20 insertions(+), 2 deletions(-) diff --git a/extensions/container-image/container-image-buildpack/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/extensions/container-image/container-image-buildpack/runtime/src/main/resources/META-INF/quarkus-extension.yaml index 8acd3cee5a218..8d67d72b0efa8 100644 --- a/extensions/container-image/container-image-buildpack/runtime/src/main/resources/META-INF/quarkus-extension.yaml +++ b/extensions/container-image/container-image-buildpack/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -8,6 +8,7 @@ metadata: - "cnb" - "container" - "image" + guide: "https://quarkus.io/guides/container-image#buildpack" categories: - "cloud" status: "preview" diff --git a/extensions/container-image/container-image-docker/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/extensions/container-image/container-image-docker/runtime/src/main/resources/META-INF/quarkus-extension.yaml index ab90c8b2f8006..4996f9f537a84 100644 --- a/extensions/container-image/container-image-docker/runtime/src/main/resources/META-INF/quarkus-extension.yaml +++ b/extensions/container-image/container-image-docker/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -6,6 +6,7 @@ metadata: - "docker" - "container" - "image" + guide: "https://quarkus.io/guides/container-image#docker" categories: - "cloud" status: "stable" diff --git a/extensions/container-image/container-image-jib/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/extensions/container-image/container-image-jib/runtime/src/main/resources/META-INF/quarkus-extension.yaml index 422db45490d53..9a35b62c5c539 100644 --- a/extensions/container-image/container-image-jib/runtime/src/main/resources/META-INF/quarkus-extension.yaml +++ b/extensions/container-image/container-image-jib/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -6,6 +6,7 @@ metadata: - "jib" - "container" - "image" + guide: "https://quarkus.io/guides/container-image#jib" categories: - "cloud" status: "preview" \ No newline at end of file diff --git a/extensions/container-image/container-image-openshift/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/extensions/container-image/container-image-openshift/runtime/src/main/resources/META-INF/quarkus-extension.yaml index 9c1ee15c7c8bb..60f66dab2ccfa 100644 --- a/extensions/container-image/container-image-openshift/runtime/src/main/resources/META-INF/quarkus-extension.yaml +++ b/extensions/container-image/container-image-openshift/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -6,6 +6,7 @@ metadata: - "openshift" - "container" - "image" + guide: "https://quarkus.io/guides/container-image#openshift" categories: - "cloud" status: "stable" diff --git a/extensions/container-image/container-image-podman/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/extensions/container-image/container-image-podman/runtime/src/main/resources/META-INF/quarkus-extension.yaml index 2c12e26682476..8b3dc83caabe2 100644 --- a/extensions/container-image/container-image-podman/runtime/src/main/resources/META-INF/quarkus-extension.yaml +++ b/extensions/container-image/container-image-podman/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -6,7 +6,7 @@ metadata: - "podman" - "container" - "image" - guide: "https://quarkus.io/guides/container-image" + guide: "https://quarkus.io/guides/container-image#podman" categories: - "cloud" status: "stable" diff --git a/extensions/grpc/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/extensions/grpc/runtime/src/main/resources/META-INF/quarkus-extension.yaml index ad0bc59f0e4ce..fddede47b319f 100644 --- a/extensions/grpc/runtime/src/main/resources/META-INF/quarkus-extension.yaml +++ b/extensions/grpc/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -4,6 +4,7 @@ name: "gRPC" metadata: keywords: - "gRPC" + guide: "https://quarkus.io/guides/grpc" categories: - "web" - "serialization" diff --git a/extensions/hibernate-reactive/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/extensions/hibernate-reactive/runtime/src/main/resources/META-INF/quarkus-extension.yaml index 5bc89bca41722..4715cbbe50aac 100644 --- a/extensions/hibernate-reactive/runtime/src/main/resources/META-INF/quarkus-extension.yaml +++ b/extensions/hibernate-reactive/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -7,6 +7,7 @@ metadata: - "hibernate" - "reactive" - "database" + guide: "https://quarkus.io/guides/hibernate-reactive" categories: - "data" - "reactive" diff --git a/extensions/http3/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/extensions/http3/runtime/src/main/resources/META-INF/quarkus-extension.yaml index 6c29d0ca4b3e6..ab46ef85064f3 100644 --- a/extensions/http3/runtime/src/main/resources/META-INF/quarkus-extension.yaml +++ b/extensions/http3/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -6,6 +6,7 @@ metadata: - "http3" - "quic" - "h3" + guide: "https://quarkus.io/guides/http3-reference" categories: - "web" status: "experimental" diff --git a/extensions/keycloak-admin-rest-client/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/extensions/keycloak-admin-rest-client/runtime/src/main/resources/META-INF/quarkus-extension.yaml index 4f1bb6106bcf0..081324c4fe99a 100644 --- a/extensions/keycloak-admin-rest-client/runtime/src/main/resources/META-INF/quarkus-extension.yaml +++ b/extensions/keycloak-admin-rest-client/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -11,6 +11,7 @@ metadata: - "reactive" - "rest" - "rest-client" + guide: "https://quarkus.io/guides/security-keycloak-admin-client" categories: - "security" status: "stable" diff --git a/extensions/keycloak-admin-resteasy-client/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/extensions/keycloak-admin-resteasy-client/runtime/src/main/resources/META-INF/quarkus-extension.yaml index 1b165b9eb273b..7b902b3901ab1 100644 --- a/extensions/keycloak-admin-resteasy-client/runtime/src/main/resources/META-INF/quarkus-extension.yaml +++ b/extensions/keycloak-admin-resteasy-client/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -9,6 +9,7 @@ metadata: - "openid-connect" - "resteasy" - "resteasy-client" + guide: "https://quarkus.io/guides/security-keycloak-admin-client" categories: - "security" status: "stable" diff --git a/extensions/liquibase/liquibase-mongodb/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/extensions/liquibase/liquibase-mongodb/runtime/src/main/resources/META-INF/quarkus-extension.yaml index 8cacc679b7975..c47a58bd8ccee 100644 --- a/extensions/liquibase/liquibase-mongodb/runtime/src/main/resources/META-INF/quarkus-extension.yaml +++ b/extensions/liquibase/liquibase-mongodb/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -6,6 +6,7 @@ metadata: - "liquibase" - "mongodb" - "data" + guide: "https://quarkus.io/guides/liquibase-mongodb" categories: - "data" status: "experimental" \ No newline at end of file diff --git a/extensions/observation/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/extensions/observation/runtime/src/main/resources/META-INF/quarkus-extension.yaml index f21f5c95d8309..ac8cac551a640 100644 --- a/extensions/observation/runtime/src/main/resources/META-INF/quarkus-extension.yaml +++ b/extensions/observation/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -8,6 +8,7 @@ metadata: - "tracing" - "metrics" - "monitoring" + guide: "https://quarkus.io/guides/telemetry-micrometer-observation" categories: - "observability" status: "preview" diff --git a/extensions/packaging/jlink/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/extensions/packaging/jlink/runtime/src/main/resources/META-INF/quarkus-extension.yaml index 2132ffb920ecc..b24e2437a0988 100644 --- a/extensions/packaging/jlink/runtime/src/main/resources/META-INF/quarkus-extension.yaml +++ b/extensions/packaging/jlink/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -2,6 +2,7 @@ artifact: ${project.groupId}:${project.artifactId}:${project.version} name: "JLink" metadata: + guide: "https://quarkus.io/guides/jlink" categories: - "packaging" status: "experimental" diff --git a/extensions/panache/hibernate-reactive-panache/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/extensions/panache/hibernate-reactive-panache/runtime/src/main/resources/META-INF/quarkus-extension.yaml index 82a55ed7be4ed..693612d2d70ea 100644 --- a/extensions/panache/hibernate-reactive-panache/runtime/src/main/resources/META-INF/quarkus-extension.yaml +++ b/extensions/panache/hibernate-reactive-panache/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -7,6 +7,7 @@ metadata: - "panache" - "hibernate" - "jpa" + guide: "https://quarkus.io/guides/hibernate-reactive-panache" categories: - "data" status: "preview" \ No newline at end of file diff --git a/extensions/resteasy-classic/resteasy-jackson/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/extensions/resteasy-classic/resteasy-jackson/runtime/src/main/resources/META-INF/quarkus-extension.yaml index f14fdbe54aef9..be6ab2ceae4af 100644 --- a/extensions/resteasy-classic/resteasy-jackson/runtime/src/main/resources/META-INF/quarkus-extension.yaml +++ b/extensions/resteasy-classic/resteasy-jackson/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -11,6 +11,7 @@ metadata: - "jackson" - "rest" - "jakarta-rest" + guide: "https://quarkus.io/guides/resteasy" categories: - "web" - "serialization" diff --git a/extensions/resteasy-reactive/rest-client-jackson/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/extensions/resteasy-reactive/rest-client-jackson/runtime/src/main/resources/META-INF/quarkus-extension.yaml index d2042f6e3637c..ad5b050ab780e 100644 --- a/extensions/resteasy-reactive/rest-client-jackson/runtime/src/main/resources/META-INF/quarkus-extension.yaml +++ b/extensions/resteasy-reactive/rest-client-jackson/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -13,6 +13,7 @@ metadata: - "resteasy-reactive" - "rest" - "jakarta-rest" + guide: "https://quarkus.io/guides/rest-client" categories: - "web" - "serialization" diff --git a/extensions/resteasy-reactive/rest-client-jsonb/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/extensions/resteasy-reactive/rest-client-jsonb/runtime/src/main/resources/META-INF/quarkus-extension.yaml index 0d0b892a7bbbc..956c9aedbe9c1 100644 --- a/extensions/resteasy-reactive/rest-client-jsonb/runtime/src/main/resources/META-INF/quarkus-extension.yaml +++ b/extensions/resteasy-reactive/rest-client-jsonb/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -13,6 +13,7 @@ metadata: - "resteasy-reactive" - "rest" - "jakarta-rest" + guide: "https://quarkus.io/guides/rest-client" categories: - "web" - "serialization" diff --git a/extensions/resteasy-reactive/rest-csrf/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/extensions/resteasy-reactive/rest-csrf/runtime/src/main/resources/META-INF/quarkus-extension.yaml index fc58b23baf487..03f90e933ec5d 100644 --- a/extensions/resteasy-reactive/rest-csrf/runtime/src/main/resources/META-INF/quarkus-extension.yaml +++ b/extensions/resteasy-reactive/rest-csrf/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -4,6 +4,7 @@ name: "REST - Cross-Site Request Forgery Prevention Filter" metadata: keywords: - "csrf" + guide: "https://quarkus.io/guides/security-csrf-prevention" categories: - "security" status: "stable" diff --git a/extensions/schema-registry/apicurio/json-schema/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/extensions/schema-registry/apicurio/json-schema/runtime/src/main/resources/META-INF/quarkus-extension.yaml index 2186e2a2572fd..cdc866393bb93 100644 --- a/extensions/schema-registry/apicurio/json-schema/runtime/src/main/resources/META-INF/quarkus-extension.yaml +++ b/extensions/schema-registry/apicurio/json-schema/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -6,7 +6,7 @@ metadata: - "apicurio" - "json-schema" - "kafka" - guide: "" + guide: "https://quarkus.io/guides/kafka-schema-registry-json-schema" categories: - "serialization" status: "experimental" diff --git a/extensions/spring-tx/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/extensions/spring-tx/runtime/src/main/resources/META-INF/quarkus-extension.yaml index 4464c63ba338a..51fb65efcd245 100644 --- a/extensions/spring-tx/runtime/src/main/resources/META-INF/quarkus-extension.yaml +++ b/extensions/spring-tx/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -7,6 +7,7 @@ metadata: - "spring" - "transaction" - "transactional" + guide: "https://quarkus.io/guides/spring-tx" categories: - "compatibility" status: "preview" From 8b153d3d6076bfaaaf5ac0d5eb5b59add1ab162f Mon Sep 17 00:00:00 2001 From: Matheus Oliveira Date: Thu, 10 Sep 2026 20:16:42 -0300 Subject: [PATCH 015/254] fix: Resolve dev service config dependency chains deeper than one level Dev services could only depend on config produced by a service with no dependencies of its own; a service depending on another dependent service would race its producer instead of waiting for it, since both were started in the same parallel wave. startAll now starts dependent services in successive waves, only starting a service once all of its required dependency config keys are already available, so chains of arbitrary depth resolve in order. Circular or permanently missing dependencies still fall back to the existing "did not become available" handling instead of hanging. * Fix #56323 Signed-off-by: Matheus Oliveira --- .../DevServicesRegistryBuildItem.java | 46 ++++- .../DevServicesRegistryBuildItemTest.java | 172 ++++++++++++++++++ 2 files changed, 211 insertions(+), 7 deletions(-) create mode 100644 core/deployment/src/test/java/io/quarkus/deployment/builditem/DevServicesRegistryBuildItemTest.java diff --git a/core/deployment/src/main/java/io/quarkus/deployment/builditem/DevServicesRegistryBuildItem.java b/core/deployment/src/main/java/io/quarkus/deployment/builditem/DevServicesRegistryBuildItem.java index 7883bc551dd7c..66195e7fb6f2e 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/builditem/DevServicesRegistryBuildItem.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/builditem/DevServicesRegistryBuildItem.java @@ -1,5 +1,6 @@ package io.quarkus.deployment.builditem; +import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashMap; @@ -101,23 +102,54 @@ public DevServicesStartResult startAll(Collection se startSelectedServices(services, customizers, additionalConfigBuildItems, deploymentClassLoader, dr -> !dr.hasDependencies(), config, overrideConfig); - // Now start everything with a dependency - // This won't handle the case where the dependencies also have dependencies, but that can be a follow-on work item if people ask for it - // I think we could implement it by getting the actual dependencies and seeing if any of them are also in the list of things we're starting, and then recursing - startSelectedServices(services, customizers, additionalConfigBuildItems, deploymentClassLoader, - DevServicesResultBuildItem::hasDependencies, config, overrideConfig); + // Now start everything with a dependency, one wave at a time, so that chains of dependencies + // (a service depending on a service which itself depends on another, and so on) are resolved + // regardless of how deep the chain is. Each wave starts the services whose required dependency + // config is already available; that may unblock further services for the next wave. + List pending = services.stream() + .filter(DevServicesResultBuildItem::isStartable) + .filter(DevServicesResultBuildItem::hasDependencies) + .collect(Collectors.toCollection(ArrayList::new)); + while (!pending.isEmpty()) { + List ready = pending.stream() + .filter(dr -> requiredDependenciesAvailable(dr, config)) + .toList(); + + if (ready.isEmpty()) { + // None of the remaining services are ready: their dependencies are missing or circular. + // Start them anyway so they go through the existing "did not become available" handling. + startSelectedServices(pending, customizers, additionalConfigBuildItems, deploymentClassLoader, + dr -> true, config, overrideConfig); + break; + } + + startSelectedServices(ready, customizers, additionalConfigBuildItems, deploymentClassLoader, + dr -> true, config, overrideConfig); + pending.removeAll(ready); + } return new DevServicesStartResult(Collections.unmodifiableMap(config), Collections.unmodifiableMap(overrideConfig)); } + private static boolean requiredDependenciesAvailable(DevServicesResultBuildItem dr, Map config) { + var dependencies = dr.getDependencies(); + if (dependencies == null) { + return true; + } + for (var dependency : dependencies) { + if (!config.containsKey(dependency.requiredConfigKey())) { + return false; + } + } + return true; + } + private void startSelectedServices(Collection services, List customizers, List additionalConfigBuildItems, ClassLoader deploymentClassLoader, Predicate filter, Map config, Map overrideConfig) { - // TODO Note that this does not handle chained dependencies; dependencies can only be one level deep for now - // It would be easy to fix that, but let's wait until we need to CompletableFuture.allOf(services.stream() .filter(DevServicesResultBuildItem::isStartable) .filter(filter) diff --git a/core/deployment/src/test/java/io/quarkus/deployment/builditem/DevServicesRegistryBuildItemTest.java b/core/deployment/src/test/java/io/quarkus/deployment/builditem/DevServicesRegistryBuildItemTest.java new file mode 100644 index 0000000000000..b3748c6f8376d --- /dev/null +++ b/core/deployment/src/test/java/io/quarkus/deployment/builditem/DevServicesRegistryBuildItemTest.java @@ -0,0 +1,172 @@ +package io.quarkus.deployment.builditem; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import io.quarkus.runtime.LaunchMode; + +class DevServicesRegistryBuildItemTest { + + private final DevServicesRegistryBuildItem registry = new DevServicesRegistryBuildItem(UUID.randomUUID(), null, + LaunchMode.TEST); + + @AfterEach + void cleanup() { + registry.closeOwnRunningServices(); + } + + @Test + void shouldResolveDependencyChainsMoreThanOneLevelDeep() { + DevServicesResultBuildItem serviceA = DevServicesResultBuildItem.owned() + .feature("A") + .startable(() -> new RecordingStartable("valueA", 0)) + .configProvider(Map.of("test.a.value", RecordingStartable::getConnectionInfo)) + .build(); + + DevServicesResultBuildItem serviceB = DevServicesResultBuildItem.owned() + .feature("B") + .startable(() -> new RecordingStartable("valueB", 300)) + .dependsOnConfig("test.a.value", RecordingStartable::setInjectedValue) + .configProvider(Map.of("test.b.value", + s -> s.getConnectionInfo() + "-" + s.getInjectedValue())) + .build(); + + DevServicesResultBuildItem serviceC = DevServicesResultBuildItem.owned() + .feature("C") + .startable(() -> new RecordingStartable("valueC", 0)) + .dependsOnConfig("test.b.value", RecordingStartable::setInjectedValue) + .configProvider(Map.of("test.c.value", + s -> s.getConnectionInfo() + "-" + s.getInjectedValue())) + .build(); + + // Services are passed in an order which does not match the dependency chain, on purpose + DevServicesRegistryBuildItem.DevServicesStartResult result = registry.startAll( + List.of(serviceC, serviceA, serviceB), List.of(), List.of(), null); + + assertEquals("valueA", result.configs().get("test.a.value")); + assertEquals("valueB-valueA", result.configs().get("test.b.value")); + assertEquals("valueC-valueB-valueA", result.configs().get("test.c.value")); + } + + @Test + void shouldNotStartAServiceWhoseTransitiveDependencyNeverBecomesAvailable() { + DevServicesResultBuildItem serviceB = DevServicesResultBuildItem.owned() + .feature("B") + .startable(() -> new RecordingStartable("valueB", 0)) + .dependsOnConfig("test.never.available", RecordingStartable::setInjectedValue) + .configProvider(Map.of("test.b.value", RecordingStartable::getConnectionInfo)) + .build(); + + DevServicesResultBuildItem serviceC = DevServicesResultBuildItem.owned() + .feature("C") + .startable(() -> new RecordingStartable("valueC", 0)) + .dependsOnConfig("test.b.value", RecordingStartable::setInjectedValue) + .configProvider(Map.of("test.c.value", RecordingStartable::getConnectionInfo)) + .build(); + + DevServicesRegistryBuildItem.DevServicesStartResult result = registry.startAll( + List.of(serviceB, serviceC), List.of(), List.of(), null); + + assertNull(result.configs().get("test.b.value")); + assertNull(result.configs().get("test.c.value")); + } + + @Test + void shouldNotHangOnACircularDependency() { + DevServicesResultBuildItem serviceX = DevServicesResultBuildItem.owned() + .feature("X") + .startable(() -> new RecordingStartable("valueX", 0)) + .dependsOnConfig("test.y.value", RecordingStartable::setInjectedValue) + .configProvider(Map.of("test.x.value", RecordingStartable::getConnectionInfo)) + .build(); + + DevServicesResultBuildItem serviceY = DevServicesResultBuildItem.owned() + .feature("Y") + .startable(() -> new RecordingStartable("valueY", 0)) + .dependsOnConfig("test.x.value", RecordingStartable::setInjectedValue) + .configProvider(Map.of("test.y.value", RecordingStartable::getConnectionInfo)) + .build(); + + DevServicesRegistryBuildItem.DevServicesStartResult result = registry.startAll( + List.of(serviceX, serviceY), List.of(), List.of(), null); + + assertNull(result.configs().get("test.x.value")); + assertNull(result.configs().get("test.y.value")); + } + + @Test + void shouldNotBlockChainOnAnUnsatisfiedOptionalDependency() { + DevServicesResultBuildItem serviceB = DevServicesResultBuildItem.owned() + .feature("B") + .startable(() -> new RecordingStartable("valueB", 0)) + .dependsOnConfig("test.never.available", RecordingStartable::setInjectedValue, true) + .configProvider(Map.of("test.b.value", RecordingStartable::getConnectionInfo)) + .build(); + + DevServicesResultBuildItem serviceC = DevServicesResultBuildItem.owned() + .feature("C") + .startable(() -> new RecordingStartable("valueC", 0)) + .dependsOnConfig("test.b.value", RecordingStartable::setInjectedValue) + .configProvider(Map.of("test.c.value", + s -> s.getConnectionInfo() + "-" + s.getInjectedValue())) + .build(); + + DevServicesRegistryBuildItem.DevServicesStartResult result = registry.startAll( + List.of(serviceB, serviceC), List.of(), List.of(), null); + + assertEquals("valueB", result.configs().get("test.b.value")); + assertEquals("valueC-valueB", result.configs().get("test.c.value")); + } + + public static class RecordingStartable implements Startable { + + private final String connectionInfo; + private final long startDelayMillis; + private volatile String injectedValue; + + public RecordingStartable(String connectionInfo, long startDelayMillis) { + this.connectionInfo = connectionInfo; + this.startDelayMillis = startDelayMillis; + } + + public void setInjectedValue(String injectedValue) { + this.injectedValue = injectedValue; + } + + public String getInjectedValue() { + return injectedValue; + } + + @Override + public void start() { + if (startDelayMillis > 0) { + try { + Thread.sleep(startDelayMillis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } + + @Override + public String getConnectionInfo() { + return connectionInfo; + } + + @Override + public String getContainerId() { + return null; + } + + @Override + public void close() { + } + } +} From 3269740b2e2b74bbe3ecbe2ecef339f0171ff183 Mon Sep 17 00:00:00 2001 From: Timur Rakhmatullin <174210871+TimurRakhmatullin86@users.noreply.github.com> Date: Fri, 31 Jul 2026 20:12:38 -0700 Subject: [PATCH 016/254] Read the compareAndExchange witness correctly in CurrentContextState.set set() tested success with 'witness == newState', but VarHandle.compareAndExchange returns the value seen before the call, which on a failed swap also equals newState (every caller computes the same target bits). A loser therefore returned true, breaking the once-only guarantee: concurrent destroy() could fire @BeforeDestroyed/ @Destroyed and run bean destruction more than once. Compare the witness to the expected value instead. Signed-off-by: Timur Rakhmatullin <174210871+TimurRakhmatullin86@users.noreply.github.com> --- .../arc/impl/CurrentManagedContext.java | 7 +- ...ntManagedContextConcurrentDestroyTest.java | 138 ++++++++++++++++++ 2 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 independent-projects/arc/tests/src/test/java/io/quarkus/arc/test/contexts/currentmanaged/CurrentManagedContextConcurrentDestroyTest.java diff --git a/independent-projects/arc/runtime/src/main/java/io/quarkus/arc/impl/CurrentManagedContext.java b/independent-projects/arc/runtime/src/main/java/io/quarkus/arc/impl/CurrentManagedContext.java index 469c7c09c81c5..cd8435351c4c0 100644 --- a/independent-projects/arc/runtime/src/main/java/io/quarkus/arc/impl/CurrentManagedContext.java +++ b/independent-projects/arc/runtime/src/main/java/io/quarkus/arc/impl/CurrentManagedContext.java @@ -299,10 +299,13 @@ private boolean set(byte bitMask) { return false; } final byte newState = (byte) (state | bitMask); - state = (byte) STATE_UPDATER.compareAndExchange(this, state, newState); - if (state == newState) { + // compareAndExchange returns the witness (the value before the call); the swap + // succeeded only when that equals the expected value, not newState. + final byte witness = (byte) STATE_UPDATER.compareAndExchange(this, state, newState); + if (witness == state) { return true; } + state = witness; } } diff --git a/independent-projects/arc/tests/src/test/java/io/quarkus/arc/test/contexts/currentmanaged/CurrentManagedContextConcurrentDestroyTest.java b/independent-projects/arc/tests/src/test/java/io/quarkus/arc/test/contexts/currentmanaged/CurrentManagedContextConcurrentDestroyTest.java new file mode 100644 index 0000000000000..ddf619454e3bd --- /dev/null +++ b/independent-projects/arc/tests/src/test/java/io/quarkus/arc/test/contexts/currentmanaged/CurrentManagedContextConcurrentDestroyTest.java @@ -0,0 +1,138 @@ +package io.quarkus.arc.test.contexts.currentmanaged; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.lang.annotation.Annotation; +import java.util.Collections; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.function.Supplier; + +import jakarta.enterprise.context.ContextNotActiveException; +import jakarta.enterprise.context.RequestScoped; + +import org.junit.jupiter.api.Test; + +import io.quarkus.arc.ContextInstanceHandle; +import io.quarkus.arc.CurrentContext; +import io.quarkus.arc.impl.ContextInstances; +import io.quarkus.arc.impl.CurrentManagedContext; +import io.quarkus.arc.impl.CurrentManagedContext.CurrentContextState; + +/** + * Reproduces the once-only violation in {@code CurrentContextState.set(byte)}: under concurrent + * {@code destroy(ContextState)}, the {@code @Destroyed} / beforeDestroyed notifiers must each fire + * exactly once. Fails on the pre-fix {@code compareAndExchange} witness bug; passes with the fix. + */ +public class CurrentManagedContextConcurrentDestroyTest { + + private static final int THREADS = Math.max(4, Runtime.getRuntime().availableProcessors()); + + private static final int ROUNDS = 1000; + + @Test + public void destroyedFiresExactlyOnceUnderConcurrentDestroy() throws Exception { + AtomicInteger destroyedCount = new AtomicInteger(); + AtomicInteger beforeDestroyedCount = new AtomicInteger(); + + // Trivial ContextInstances (no beans); destroy(state) only iterates removeEach, a no-op here. + Supplier instances = () -> new ContextInstances() { + @Override + public ContextInstanceHandle computeIfAbsent(String id, Supplier> supplier) { + return supplier.get(); + } + + @Override + public ContextInstanceHandle getIfPresent(String id) { + return null; + } + + @Override + public ContextInstanceHandle remove(String id) { + return null; + } + + @Override + public Set> getAllPresent() { + return Collections.emptySet(); + } + + @Override + public void removeEach(Consumer> action) { + // no contextual instances to destroy + } + }; + + // destroy(ContextState) never reads the CurrentContext, so a no-op impl is sufficient. + CurrentContext currentContext = new CurrentContext() { + private volatile CurrentContextState state; + + @Override + public CurrentContextState get() { + return state; + } + + @Override + public void set(CurrentContextState state) { + this.state = state; + } + + @Override + public void remove() { + this.state = null; + } + }; + + CurrentManagedContext ctx = new CurrentManagedContext(currentContext, instances, null, + o -> beforeDestroyedCount.incrementAndGet(), o -> destroyedCount.incrementAndGet()) { + @Override + public Class getScope() { + return RequestScoped.class; + } + + @Override + protected ContextNotActiveException notActive() { + return new ContextNotActiveException("test context not active"); + } + }; + + ExecutorService pool = Executors.newFixedThreadPool(THREADS); + try { + for (int round = 0; round < ROUNDS; round++) { + destroyedCount.set(0); + beforeDestroyedCount.set(0); + + // Fresh, valid state each round; initializeState() does not touch the CurrentContext. + CurrentContextState state = ctx.initializeState(); + + CountDownLatch start = new CountDownLatch(1); + Future[] futures = new Future[THREADS]; + for (int t = 0; t < THREADS; t++) { + futures[t] = pool.submit(() -> { + start.await(); + ctx.destroy(state); + return null; + }); + } + + start.countDown(); + for (Future f : futures) { + f.get(10, TimeUnit.SECONDS); + } + + assertEquals(1, destroyedCount.get(), + "@Destroyed notifier fired " + destroyedCount.get() + " times in round " + round); + assertEquals(1, beforeDestroyedCount.get(), + "beforeDestroyed notifier fired " + beforeDestroyedCount.get() + " times in round " + round); + } + } finally { + pool.shutdownNow(); + } + } +} From fb3f684e31a3d2e319fdf97dcb399f4fb986d782 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:14:11 +0000 Subject: [PATCH 017/254] Bump org.codehaus.mojo:templating-maven-plugin from 3.1.0 to 3.1.1 Bumps [org.codehaus.mojo:templating-maven-plugin](https://github.com/mojohaus/templating-maven-plugin) from 3.1.0 to 3.1.1. - [Release notes](https://github.com/mojohaus/templating-maven-plugin/releases) - [Commits](https://github.com/mojohaus/templating-maven-plugin/compare/templating-maven-plugin-3.1.0...templating-maven-plugin-3.1.1) --- updated-dependencies: - dependency-name: org.codehaus.mojo:templating-maven-plugin dependency-version: 3.1.1 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- extensions/kubernetes-client/deployment/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/kubernetes-client/deployment/pom.xml b/extensions/kubernetes-client/deployment/pom.xml index a1d9d24b44a66..d22fe1a8724ca 100644 --- a/extensions/kubernetes-client/deployment/pom.xml +++ b/extensions/kubernetes-client/deployment/pom.xml @@ -72,7 +72,7 @@ org.codehaus.mojo templating-maven-plugin - 3.1.0 + 3.1.1 filtering-java-templates From 0eb90a376735983e8a3ca8dc3af1f2db71147319 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:12:21 +0000 Subject: [PATCH 018/254] Bump org.slf4j:slf4j-api from 2.0.19 to 2.0.20 Bumps org.slf4j:slf4j-api from 2.0.19 to 2.0.20. --- updated-dependencies: - dependency-name: org.slf4j:slf4j-api dependency-version: 2.0.20 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- independent-projects/bootstrap/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/independent-projects/bootstrap/pom.xml b/independent-projects/bootstrap/pom.xml index fa28f1dc8d444..b63e8f1d122b8 100644 --- a/independent-projects/bootstrap/pom.xml +++ b/independent-projects/bootstrap/pom.xml @@ -62,7 +62,7 @@ 1.2.6 3.2.2.Final 1.1.0.Final - 2.0.19 + 2.0.20 23.1.0 2.6.0 2.0 From 3e1be53080f39b1ab84238f769179ae202dd3b4a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:14:17 +0000 Subject: [PATCH 019/254] Bump org.codejive:java-properties from 0.0.7 to 0.0.8 Bumps [org.codejive:java-properties](https://github.com/codejive/java-properties) from 0.0.7 to 0.0.8. - [Release notes](https://github.com/codejive/java-properties/releases) - [Changelog](https://github.com/codejive/java-properties/blob/main/RELEASE.md) - [Commits](https://github.com/codejive/java-properties/compare/v0.0.7...v0.0.8) --- updated-dependencies: - dependency-name: org.codejive:java-properties dependency-version: 0.0.8 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- independent-projects/tools/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/independent-projects/tools/pom.xml b/independent-projects/tools/pom.xml index d9e1105e026f8..0e7a2356931bb 100644 --- a/independent-projects/tools/pom.xml +++ b/independent-projects/tools/pom.xml @@ -61,7 +61,7 @@ 3.6.0 2.0.2 4.3.0 - 0.0.7 + 0.0.8 3.0.6 From d48a8613d4b7ea5872aef7aaf48551d69b7ac586 Mon Sep 17 00:00:00 2001 From: MrRaveen Date: Fri, 25 Sep 2026 21:24:13 +0530 Subject: [PATCH 020/254] Add JSON-B alternatives to the REST client guide --- docs/src/main/asciidoc/rest-client.adoc | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/src/main/asciidoc/rest-client.adoc b/docs/src/main/asciidoc/rest-client.adoc index cf90bcaeff0c8..658d3b132ecd6 100644 --- a/docs/src/main/asciidoc/rest-client.adoc +++ b/docs/src/main/asciidoc/rest-client.adoc @@ -39,10 +39,10 @@ include::{includes}/devtools/create-app.adoc[] This command generates the Maven project with a REST endpoint and imports: -* the `rest-jackson` extension for the REST server support. Use `rest` instead if you do not wish to use Jackson; -* the `rest-client-jackson` extension for the REST client support. Use `rest-client` instead if you do not wish to use Jackson +* the `quarkus-rest-jackson` extension for the REST server support. Use `quarkus-rest-jsonb` if you prefer JSON-B, or `quarkus-rest` if you do not need JSON support; +* the `quarkus-rest-client-jackson` extension for the REST client support. Use `quarkus-rest-client-jsonb` if you prefer JSON-B, or `quarkus-rest-client` if you do not need JSON support. -If you already have your Quarkus project configured, you can add the `rest-client-jackson` extension +If you already have your Quarkus project configured, you can add the `quarkus-rest-client-jackson` extension to your project by running the following command in your project base directory: :add-extension-extensions: rest-client-jackson From 3ff5dfa595ba49827740c6dc08c80e588340040f Mon Sep 17 00:00:00 2001 From: jnbdz <237008+jnbdz@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:23:18 -0400 Subject: [PATCH 021/254] Let quarkus-vertx-utils use Vert.x internals in a jlink image A REST application packaged with quarkus-jlink failed on its first response with NoClassDefFoundError: io/vertx/core/impl/buffer/ VertxByteBufAllocator. quarkus-vertx-utils uses that class and two other internal Vert.x packages, which the Vert.x module descriptor only exports to its own tests. The Vert.x extension now declares these exports to io.quarkus.vertx.utils with ModuleExportBuildItem. For this to reach the generated module, the modular packaging merges such an extra dependency into an automatic module's existing dependency on the same module; adding it as a separate entry shadowed the automatic one, so vertx-utils lost access to the exported Vert.x packages instead. When the requirements are rendered as JVM arguments, modules the JVM does not know are left out, so these exports do not cause "Unknown module" warnings for applications on the class path. --- .../jvm/ResolvedJVMRequirements.java | 14 ++++++++++++-- .../jvm/ResolvedJVMRequirementsTest.java | 11 +++++++++++ .../quarkus/modular/spi/model/ModuleInfo.java | 17 +++++++++++++++-- .../modular/spi/model/ModuleInfoTest.java | 19 +++++++++++++++++++ .../core/deployment/VertxCoreProcessor.java | 7 +++++++ 5 files changed, 64 insertions(+), 4 deletions(-) diff --git a/core/deployment/src/main/java/io/quarkus/deployment/jvm/ResolvedJVMRequirements.java b/core/deployment/src/main/java/io/quarkus/deployment/jvm/ResolvedJVMRequirements.java index af172138d74d4..1b0d6f51c90a7 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/jvm/ResolvedJVMRequirements.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/jvm/ResolvedJVMRequirements.java @@ -1,5 +1,6 @@ package io.quarkus.deployment.jvm; +import java.lang.module.ModuleFinder; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -86,10 +87,14 @@ public List renderAsJvmArguments() { final Collection toExport = modulePackagesToExport(); final List arguments = new ArrayList<>(toOpen.size() + toExport.size() + 1); for (String modulePackage : toOpen) { - arguments.add("--add-opens=" + modulePackage + "=ALL-UNNAMED"); + if (isSystemModule(modulePackage)) { + arguments.add("--add-opens=" + modulePackage + "=ALL-UNNAMED"); + } } for (String modulePackage : toExport) { - arguments.add("--add-exports=" + modulePackage + "=ALL-UNNAMED"); + if (isSystemModule(modulePackage)) { + arguments.add("--add-exports=" + modulePackage + "=ALL-UNNAMED"); + } } if (!enableNativeAccesses.isEmpty()) { arguments.add("--enable-native-access=ALL-UNNAMED"); @@ -97,6 +102,11 @@ public List renderAsJvmArguments() { return arguments; } + // on the class path only the JDK's own modules are named, and the JVM warns about any other module + private static boolean isSystemModule(String modulePackage) { + return ModuleFinder.ofSystem().find(modulePackage.substring(0, modulePackage.indexOf('/'))).isPresent(); + } + private List modulePackagesToOpen() { // a list but we keep it sorted final List modulesToAddOpens = new ArrayList<>(addOpens.size() * 3); diff --git a/core/deployment/src/test/java/io/quarkus/deployment/jvm/ResolvedJVMRequirementsTest.java b/core/deployment/src/test/java/io/quarkus/deployment/jvm/ResolvedJVMRequirementsTest.java index 81ff438f5d779..f8d60fcde26c8 100644 --- a/core/deployment/src/test/java/io/quarkus/deployment/jvm/ResolvedJVMRequirementsTest.java +++ b/core/deployment/src/test/java/io/quarkus/deployment/jvm/ResolvedJVMRequirementsTest.java @@ -41,6 +41,17 @@ public void addExportsAreRenderedAsJvmArguments() throws BuildException { "--add-exports=java.base/java.nio=ALL-UNNAMED"); } + @Test + public void modulesOutsideTheJdkAreNotRenderedAsJvmArguments() throws BuildException { + ResolvedJVMRequirements requirements = new ResolvedJVMRequirements( + List.of(new ModuleOpenBuildItem("io.vertx.core", "io.quarkus.vertx.utils", "io.vertx.core.impl")), + List.of(new ModuleExportBuildItem("io.vertx.core", "io.quarkus.vertx.utils", "io.vertx.core.impl.buffer"), + new ModuleExportBuildItem("java.base", "io.netty.common", "java.nio")), + List.of()); + + assertThat(requirements.renderAsJvmArguments()).containsExactly("--add-exports=java.base/java.nio=ALL-UNNAMED"); + } + @Test public void nativeAccessIsRenderedAsJvmArgument() throws BuildException { ResolvedJVMRequirements requirements = new ResolvedJVMRequirements( diff --git a/extensions/packaging/modular/spi/src/main/java/io/quarkus/modular/spi/model/ModuleInfo.java b/extensions/packaging/modular/spi/src/main/java/io/quarkus/modular/spi/model/ModuleInfo.java index d65babda099ee..deb00120f88dd 100644 --- a/extensions/packaging/modular/spi/src/main/java/io/quarkus/modular/spi/model/ModuleInfo.java +++ b/extensions/packaging/modular/spi/src/main/java/io/quarkus/modular/spi/model/ModuleInfo.java @@ -3,6 +3,7 @@ import static io.smallrye.common.constraint.Assert.checkNotNullParam; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; @@ -152,8 +153,20 @@ public ModuleInfo withMoreDependencies(final List moreDependenci newDeps.add(dep); } } + // a separate entry would shadow the automatic dependency on the same module + Set autoNames = new HashSet<>(); + ArrayList newAutoDeps = new ArrayList<>(autoDependencies.size()); + for (AutoDependencyGroup group : autoDependencies) { + ArrayList groupDeps = new ArrayList<>(group.dependencies().size()); + for (DependencyInfo dep : group.dependencies()) { + autoNames.add(dep.moduleName()); + DependencyInfo more = index2.get(dep.moduleName()); + groupDeps.add(more == null ? dep : DependencyInfo.merge(dep, more)); + } + newAutoDeps.add(new AutoDependencyGroup(group.hostModuleName(), groupDeps)); + } for (DependencyInfo dep : moreDependencies) { - if (index1.containsKey(dep.moduleName())) { + if (index1.containsKey(dep.moduleName()) || autoNames.contains(dep.moduleName())) { // we already added it; skip } else { newDeps.add(dep); @@ -167,7 +180,7 @@ public ModuleInfo withMoreDependencies(final List moreDependenci mainClassName, packages, newDeps, - autoDependencies, + newAutoDeps, uses, provides, generated); diff --git a/extensions/packaging/modular/spi/src/test/java/io/quarkus/modular/spi/model/ModuleInfoTest.java b/extensions/packaging/modular/spi/src/test/java/io/quarkus/modular/spi/model/ModuleInfoTest.java index c4ca9e7f377da..776341257f148 100644 --- a/extensions/packaging/modular/spi/src/test/java/io/quarkus/modular/spi/model/ModuleInfoTest.java +++ b/extensions/packaging/modular/spi/src/test/java/io/quarkus/modular/spi/model/ModuleInfoTest.java @@ -215,6 +215,25 @@ void withMoreDependenciesMergesExistingByModuleName() { assertThat(merged.modifiers().contains(Dependency.Modifier.READ)).isTrue(); } + @Test + void withMoreDependenciesMergesIntoAutomaticDependencies() { + DependencyInfo existing = new DependencyInfo("dep.a", + Dependency.Modifier.Set.of(Dependency.Modifier.LINKED, Dependency.Modifier.READ), Map.of()); + ModuleInfo info = new ModuleInfo( + "test.module", "1.0", ModuleDescriptor.Modifier.Set.of(), ARTIFACT, null, + Map.of(), List.of(), List.of(new AutoDependencyGroup("dep.b", List.of(existing))), Set.of(), Map.of(), + List.of()); + DependencyInfo incoming = new DependencyInfo("dep.a", + Dependency.Modifier.Set.of(Dependency.Modifier.READ), + Map.of("dep.a.impl", PackageAccess.EXPORTED)); + ModuleInfo result = info.withMoreDependencies(List.of(incoming)); + // no separate entry that would hide the automatic one + assertThat(result.dependencies()).isEmpty(); + DependencyInfo merged = result.autoDependencies().get(0).dependencies().get(0); + assertThat(merged.modifiers().contains(Dependency.Modifier.LINKED)).isTrue(); + assertThat(merged.packageAccesses()).containsEntry("dep.a.impl", PackageAccess.EXPORTED); + } + @Test void withMoreDependenciesPreservesOrder() { DependencyInfo dep1 = new DependencyInfo("dep.a", Dependency.Modifier.Set.of(), Map.of()); diff --git a/extensions/vertx/deployment/src/main/java/io/quarkus/vertx/core/deployment/VertxCoreProcessor.java b/extensions/vertx/deployment/src/main/java/io/quarkus/vertx/core/deployment/VertxCoreProcessor.java index 275074fb91002..fb8012479028b 100644 --- a/extensions/vertx/deployment/src/main/java/io/quarkus/vertx/core/deployment/VertxCoreProcessor.java +++ b/extensions/vertx/deployment/src/main/java/io/quarkus/vertx/core/deployment/VertxCoreProcessor.java @@ -43,6 +43,7 @@ import io.quarkus.deployment.builditem.IOThreadDetectorBuildItem; import io.quarkus.deployment.builditem.LaunchModeBuildItem; import io.quarkus.deployment.builditem.LogCategoryBuildItem; +import io.quarkus.deployment.builditem.ModuleExportBuildItem; import io.quarkus.deployment.builditem.ServiceStartBuildItem; import io.quarkus.deployment.builditem.ShutdownContextBuildItem; import io.quarkus.deployment.builditem.ThreadFactoryBuildItem; @@ -82,6 +83,12 @@ class VertxCoreProcessor { "io.vertx.core.impl.btc.BlockedThreadChecker" // Vert.x 4.3+ ); + @BuildStep + ModuleExportBuildItem exportVertxInternalsToVertxUtils() { + return new ModuleExportBuildItem("io.vertx.core", "io.quarkus.vertx.utils", + "io.vertx.core.impl", "io.vertx.core.impl.buffer", "io.vertx.core.buffer.impl"); + } + @BuildStep AdditionalBeanBuildItem registerSafeDuplicatedContextInterceptor() { return new AdditionalBeanBuildItem(SafeVertxContextInterceptor.class.getName()); From f8a6454c709377b9aaef876c21f2afc1ec7612b2 Mon Sep 17 00:00:00 2001 From: Clement Escoffier Date: Wed, 30 Sep 2026 15:02:53 +0200 Subject: [PATCH 022/254] Close REST Client response entity when client disconnects prematurely When the client disconnects and the server-side handler chain is short-circuited, the response writer is skipped. If the response entity is a REST Client InputStream, that connection is not returned. Fixes: https://github.com/quarkusio/quarkus/issues/57020 --- .../ClientDisconnectResponseLeakTest.java | 186 ++++++++++++++++++ .../core/ResteasyReactiveRequestContext.java | 20 ++ 2 files changed, 206 insertions(+) create mode 100644 extensions/resteasy-reactive/rest-client/deployment/src/test/java/io/quarkus/rest/client/reactive/ClientDisconnectResponseLeakTest.java diff --git a/extensions/resteasy-reactive/rest-client/deployment/src/test/java/io/quarkus/rest/client/reactive/ClientDisconnectResponseLeakTest.java b/extensions/resteasy-reactive/rest-client/deployment/src/test/java/io/quarkus/rest/client/reactive/ClientDisconnectResponseLeakTest.java new file mode 100644 index 0000000000000..fb72c82fde3db --- /dev/null +++ b/extensions/resteasy-reactive/rest-client/deployment/src/test/java/io/quarkus/rest/client/reactive/ClientDisconnectResponseLeakTest.java @@ -0,0 +1,186 @@ +package io.quarkus.rest.client.reactive; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.Socket; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Random; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Response; + +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; +import org.eclipse.microprofile.rest.client.inject.RestClient; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusExtensionTest; +import io.quarkus.test.common.http.TestHTTPResource; +import io.smallrye.common.annotation.Blocking; + +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class ClientDisconnectResponseLeakTest { + + private static final int BODY_SIZE = 1024 * 1024; + private static final int UPSTREAM_DELAY_MS = 1000; + + @RegisterExtension + static final QuarkusExtensionTest config = new QuarkusExtensionTest() + .withApplicationRoot((jar) -> jar + .addClasses(UpstreamResource.class, UpstreamClient.class, ProxyResource.class)) + .overrideRuntimeConfigKey("quarkus.rest-client.upstream.url", + "http://localhost:${quarkus.http.test-port:8081}") + .overrideRuntimeConfigKey("quarkus.rest-client.upstream.connect-timeout", "2000") + .overrideRuntimeConfigKey("quarkus.rest-client.upstream.read-timeout", "10000") + .overrideConfigKey("quarkus.rest-client.upstream.connection-pool-size", "2") + .overrideConfigKey("quarkus.rest-client.upstream.connection-ttl", "30000"); + + @TestHTTPResource + URI uri; + + @Test + @Order(1) + @Timeout(30) + void passThrough_connectionReleasedAfterClientDisconnect() throws Exception { + verifyNoLeak("/proxy/pass-through", "/proxy/pass-through"); + } + + @Test + @Order(2) + @Timeout(30) + void direct_connectionReleasedAfterClientDisconnect() throws Exception { + verifyNoLeak("/proxy/direct", "/proxy/pass-through"); + } + + @Test + @Order(3) + @Timeout(30) + void buffered_connectionReleasedAfterClientDisconnect() throws Exception { + verifyNoLeak("/proxy/buffered", "/proxy/buffered"); + } + + private void verifyNoLeak(String disconnectPath, String verifyPath) throws Exception { + for (int i = 0; i < 2; i++) { + disconnectEarly(disconnectPath); + } + + // Poll until the pool recovers and the verification request succeeds + await().atMost(Duration.ofSeconds(15)) + .pollInterval(Duration.ofMillis(500)) + .untilAsserted(() -> { + try (Socket socket = new Socket(uri.getHost(), uri.getPort())) { + socket.setSoTimeout(10_000); + sendGetRequest(socket, verifyPath); + String responseLine = readStatusLine(socket); + assertThat(responseLine) + .as("REST Client connection pool should not be exhausted after client disconnects (%s)", + disconnectPath) + .contains("200"); + } + }); + } + + private void disconnectEarly(String path) throws Exception { + Socket socket = new Socket(uri.getHost(), uri.getPort()); + sendGetRequest(socket, path); + // Wait for the server to accept the request and start the blocking + // REST Client call, then disconnect before the upstream responds. + // 500ms is well within the 1000ms upstream delay. + Thread.sleep(500); + socket.close(); + } + + private void sendGetRequest(Socket socket, String path) throws IOException { + OutputStream out = socket.getOutputStream(); + String request = "GET " + path + " HTTP/1.1\r\n" + + "Host: " + uri.getHost() + ":" + uri.getPort() + "\r\n" + + "Connection: close\r\n" + + "\r\n"; + out.write(request.getBytes(StandardCharsets.UTF_8)); + out.flush(); + } + + private String readStatusLine(Socket socket) throws IOException { + InputStream in = socket.getInputStream(); + StringBuilder sb = new StringBuilder(); + int c; + while ((c = in.read()) != -1) { + sb.append((char) c); + if (c == '\n') { + break; + } + } + return sb.toString(); + } + + @Path("/upstream") + public static class UpstreamResource { + + private static final byte[] BODY; + + static { + BODY = new byte[BODY_SIZE]; + new Random(42).nextBytes(BODY); + } + + @GET + @Path("items") + public byte[] items() throws InterruptedException { + Thread.sleep(UPSTREAM_DELAY_MS); + return BODY; + } + } + + @Path("/upstream") + @RegisterRestClient(configKey = "upstream") + public interface UpstreamClient { + + @GET + @Path("items") + Response items(); + } + + @Path("/proxy") + public static class ProxyResource { + + private final UpstreamClient client; + + public ProxyResource(@RestClient UpstreamClient client) { + this.client = client; + } + + @GET + @Path("pass-through") + @Blocking + public Response passThrough() { + return Response.fromResponse(client.items()).build(); + } + + @GET + @Path("direct") + @Blocking + public Response direct() { + return client.items(); + } + + @GET + @Path("buffered") + @Blocking + public Response buffered() { + Response r = client.items(); + r.bufferEntity(); + return Response.fromResponse(r).build(); + } + } +} diff --git a/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/ResteasyReactiveRequestContext.java b/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/ResteasyReactiveRequestContext.java index 61fcb89ab1f1e..f641316c93082 100644 --- a/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/ResteasyReactiveRequestContext.java +++ b/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/ResteasyReactiveRequestContext.java @@ -438,9 +438,29 @@ public void close() { } catch (IOException e) { log.debug("Failed to close stream", e); } + if (connectionClosed) { + closeResponseEntityIfNeeded(); + } super.close(); } + private void closeResponseEntityIfNeeded() { + Object entity = responseEntity(); + if (entity instanceof AutoCloseable c) { + try { + c.close(); + } catch (Exception e) { + log.debug("Failed to close response entity", e); + } + } else if (result instanceof AutoCloseable c) { + try { + c.close(); + } catch (Exception e) { + log.debug("Failed to close response", e); + } + } + } + public LazyResponse getResponse() { return response; } From a9618029a12f78ff75ff4d29928bc50c283bcfd1 Mon Sep 17 00:00:00 2001 From: jnbdz <237008+jnbdz@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:27:13 -0400 Subject: [PATCH 023/254] Destroy transaction scoped beans created while the transaction was completing The transaction scoped context creates its state on the first lookup of a transaction scoped bean and registers it as a JTA synchronization so the beans are destroyed on completion. When that first lookup happens once the transaction is marked for rollback, typically from a @BeforeDestroyed(TransactionScoped.class) observer, Narayana refuses the synchronization with "ARJUNA016083: Cannot register synchronization because the transaction is in aborted state", the bean is not created and an error is logged. The state is stored with the transaction either way, through the transaction-local resources Narayana keeps until the transaction is gone. So the context only registers the synchronization while the transaction is active, and the notifying transaction manager and user transaction keep the transaction object across commit and rollback and destroy the state that is left on it afterwards. Beans created after the transaction was marked for rollback get their @PreDestroy callbacks as before. Fixes #40882 --- ...ansactionScopedObserverOnRollbackTest.java | 109 ++++++++++++++++++ .../runtime/NotifyingTransactionManager.java | 17 ++- .../jta/runtime/NotifyingUserTransaction.java | 17 ++- .../runtime/TransactionScopedNotifier.java | 9 ++ .../runtime/context/TransactionContext.java | 27 ++++- 5 files changed, 173 insertions(+), 6 deletions(-) create mode 100644 extensions/narayana-jta/deployment/src/test/java/io/quarkus/narayana/observers/TransactionScopedObserverOnRollbackTest.java diff --git a/extensions/narayana-jta/deployment/src/test/java/io/quarkus/narayana/observers/TransactionScopedObserverOnRollbackTest.java b/extensions/narayana-jta/deployment/src/test/java/io/quarkus/narayana/observers/TransactionScopedObserverOnRollbackTest.java new file mode 100644 index 0000000000000..954eaadd9dabc --- /dev/null +++ b/extensions/narayana-jta/deployment/src/test/java/io/quarkus/narayana/observers/TransactionScopedObserverOnRollbackTest.java @@ -0,0 +1,109 @@ +package io.quarkus.narayana.observers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.logging.Level; +import java.util.logging.LogRecord; + +import jakarta.annotation.PreDestroy; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.context.BeforeDestroyed; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; +import jakarta.transaction.SystemException; +import jakarta.transaction.TransactionManager; +import jakarta.transaction.TransactionScoped; +import jakarta.transaction.Transactional; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusExtensionTest; + +public class TransactionScopedObserverOnRollbackTest { + + @RegisterExtension + static final QuarkusExtensionTest config = new QuarkusExtensionTest() + .withApplicationRoot((jar) -> jar + .addClasses(TransactionalBean.class, RollbackObserver.class, LateBean.class)) + .setLogRecordPredicate(record -> record.getLevel().intValue() >= Level.WARNING.intValue()) + .assertLogRecords(records -> assertTrue( + records.stream().noneMatch(record -> record.getMessage().contains("@BeforeDestroyed")), + () -> "Unexpected log records: " + records.stream().map(LogRecord::getMessage).toList())); + + @Inject + TransactionalBean bean; + + @Test + public void observerNotifiedAndDestroyedOnRollback() { + RollbackObserver.NOTIFIED = 0; + RollbackObserver.DESTROYED = 0; + + assertThrows(IllegalStateException.class, bean::fail); + + assertEquals(1, RollbackObserver.NOTIFIED); + assertEquals(1, RollbackObserver.DESTROYED); + } + + @Test + public void beanCreatedAfterSetRollbackOnlyIsDestroyed() throws SystemException { + LateBean.DESTROYED = 0; + + bean.markRollbackOnlyThenUseLateBean(); + + assertEquals(1, LateBean.DESTROYED); + } + + @ApplicationScoped + static class TransactionalBean { + + @Inject + TransactionManager transactionManager; + + @Inject + LateBean lateBean; + + @Transactional + void fail() { + throw new IllegalStateException("Rollback here"); + } + + @Transactional + void markRollbackOnlyThenUseLateBean() throws SystemException { + transactionManager.setRollbackOnly(); + lateBean.touch(); + } + } + + @TransactionScoped + static class RollbackObserver { + + static volatile int NOTIFIED = 0; + static volatile int DESTROYED = 0; + + void beforeDestroyed(@Observes @BeforeDestroyed(TransactionScoped.class) Object event) { + NOTIFIED++; + } + + @PreDestroy + void destroyed() { + DESTROYED++; + } + } + + @TransactionScoped + static class LateBean { + + static volatile int DESTROYED = 0; + + void touch() { + } + + @PreDestroy + void destroyed() { + DESTROYED++; + } + } +} diff --git a/extensions/narayana-jta/runtime/src/main/java/io/quarkus/narayana/jta/runtime/NotifyingTransactionManager.java b/extensions/narayana-jta/runtime/src/main/java/io/quarkus/narayana/jta/runtime/NotifyingTransactionManager.java index 5eb6516145e55..f3eec4089be33 100644 --- a/extensions/narayana-jta/runtime/src/main/java/io/quarkus/narayana/jta/runtime/NotifyingTransactionManager.java +++ b/extensions/narayana-jta/runtime/src/main/java/io/quarkus/narayana/jta/runtime/NotifyingTransactionManager.java @@ -19,6 +19,7 @@ import org.jboss.logging.Logger; import io.quarkus.narayana.jta.runtime.TransactionScopedNotifier.TransactionId; +import io.quarkus.narayana.jta.runtime.context.TransactionContext; /** * A delegating transaction manager which receives an instance of Narayana transaction manager @@ -65,11 +66,16 @@ public void begin() throws NotSupportedException, SystemException { public void commit() throws RollbackException, HeuristicMixedException, HeuristicRollbackException, SecurityException, IllegalStateException, SystemException { TransactionId id = getTransactionId(); + Transaction transaction = currentTransaction(); beforeDestroyed(id); try { delegate.commit(); } finally { - destroyed(id); + try { + TransactionContext.destroyState(transaction); + } finally { + destroyed(id); + } } } @@ -85,6 +91,7 @@ public void commit() throws RollbackException, HeuristicMixedException, Heuristi @Override public void rollback() throws IllegalStateException, SecurityException, SystemException { TransactionId id = getTransactionId(); + Transaction transaction = currentTransaction(); try { beforeDestroyed(id); } catch (Throwable t) { @@ -93,8 +100,12 @@ public void rollback() throws IllegalStateException, SecurityException, SystemEx try { delegate.rollback(); } finally { - //we don't need a catch block here, if this one fails we just let the exception propagate - destroyed(id); + try { + TransactionContext.destroyState(transaction); + } finally { + //we don't need a catch block here, if this one fails we just let the exception propagate + destroyed(id); + } } } diff --git a/extensions/narayana-jta/runtime/src/main/java/io/quarkus/narayana/jta/runtime/NotifyingUserTransaction.java b/extensions/narayana-jta/runtime/src/main/java/io/quarkus/narayana/jta/runtime/NotifyingUserTransaction.java index 787733a12b3a1..7e5bece2f4435 100644 --- a/extensions/narayana-jta/runtime/src/main/java/io/quarkus/narayana/jta/runtime/NotifyingUserTransaction.java +++ b/extensions/narayana-jta/runtime/src/main/java/io/quarkus/narayana/jta/runtime/NotifyingUserTransaction.java @@ -5,10 +5,13 @@ import jakarta.transaction.NotSupportedException; import jakarta.transaction.RollbackException; import jakarta.transaction.SystemException; +import jakarta.transaction.Transaction; import jakarta.transaction.UserTransaction; import org.jboss.logging.Logger; +import io.quarkus.narayana.jta.runtime.context.TransactionContext; + public class NotifyingUserTransaction extends TransactionScopedNotifier implements UserTransaction { private static final Logger LOG = Logger.getLogger(NotifyingUserTransaction.class); @@ -29,17 +32,23 @@ public void begin() throws NotSupportedException, SystemException { public void commit() throws RollbackException, HeuristicMixedException, HeuristicRollbackException, SecurityException, IllegalStateException, SystemException { TransactionId id = getTransactionId(); + Transaction transaction = currentTransaction(); beforeDestroyed(id); try { delegate.commit(); } finally { - destroyed(id); + try { + TransactionContext.destroyState(transaction); + } finally { + destroyed(id); + } } } @Override public void rollback() throws IllegalStateException, SecurityException, SystemException { TransactionId id = getTransactionId(); + Transaction transaction = currentTransaction(); try { beforeDestroyed(id); } catch (Throwable t) { @@ -48,7 +57,11 @@ public void rollback() throws IllegalStateException, SecurityException, SystemEx try { delegate.rollback(); } finally { - destroyed(id); + try { + TransactionContext.destroyState(transaction); + } finally { + destroyed(id); + } } } diff --git a/extensions/narayana-jta/runtime/src/main/java/io/quarkus/narayana/jta/runtime/TransactionScopedNotifier.java b/extensions/narayana-jta/runtime/src/main/java/io/quarkus/narayana/jta/runtime/TransactionScopedNotifier.java index 55238493448c3..6527d627cdb39 100644 --- a/extensions/narayana-jta/runtime/src/main/java/io/quarkus/narayana/jta/runtime/TransactionScopedNotifier.java +++ b/extensions/narayana-jta/runtime/src/main/java/io/quarkus/narayana/jta/runtime/TransactionScopedNotifier.java @@ -7,6 +7,7 @@ import jakarta.enterprise.context.Initialized; import jakarta.enterprise.event.Event; import jakarta.transaction.SystemException; +import jakarta.transaction.Transaction; import jakarta.transaction.TransactionScoped; import com.arjuna.ats.internal.jta.transaction.arjunacore.TransactionImple; @@ -43,6 +44,14 @@ void destroyed(TransactionId transactionId) { destroyed.fire(transactionId); } + Transaction currentTransaction() { + try { + return TransactionImple.getTransaction(); + } catch (Exception e) { + return null; + } + } + TransactionId getTransactionId() throws SystemException { try { return new TransactionId(TransactionImple.getTransaction().toString()); diff --git a/extensions/narayana-jta/runtime/src/main/java/io/quarkus/narayana/jta/runtime/context/TransactionContext.java b/extensions/narayana-jta/runtime/src/main/java/io/quarkus/narayana/jta/runtime/context/TransactionContext.java index faf5c53d5cb0d..b940ac426f05d 100644 --- a/extensions/narayana-jta/runtime/src/main/java/io/quarkus/narayana/jta/runtime/context/TransactionContext.java +++ b/extensions/narayana-jta/runtime/src/main/java/io/quarkus/narayana/jta/runtime/context/TransactionContext.java @@ -184,6 +184,22 @@ public boolean isActive() { } } + /** + * Destroys the beans of the context of a transaction that has completed. The beans are normally destroyed by the + * synchronization the context state registers, but a state created once the transaction was no longer active could + * not register one. + * + * @param transaction the completed transaction, may be {@code null} + */ + public static void destroyState(Transaction transaction) { + if (transaction instanceof com.arjuna.ats.jta.transaction.Transaction narayanaTransaction) { + Object contextState = narayanaTransaction.getTxLocalResource(TRANSACTION_CONTEXT_MARKER); + if (contextState instanceof TransactionContextState transactionContextState) { + transactionContextState.destroy(); + } + } + } + private Transaction getCurrentTransaction() { try { return transactionManager.get().getTransaction(); @@ -202,9 +218,18 @@ private static class TransactionContextState implements ContextState, Synchroniz private final ConcurrentMap, ContextInstanceHandle> mapBeanToInstanceHandle = new ConcurrentHashMap<>(); + /** + * The context state registers itself as a synchronization so that the beans of the context are destroyed + * when the transaction completes. A transaction that is already completing, typically one marked for + * rollback whose {@code @BeforeDestroyed(TransactionScoped.class)} event instantiates a bean for the first + * time, no longer accepts synchronizations. The state is still stored with the transaction, and + * {@link TransactionContext#destroyState(Transaction)} destroys the beans once the transaction has completed. + */ TransactionContextState(Transaction transaction) { try { - transaction.registerSynchronization(this); + if (transaction.getStatus() == Status.STATUS_ACTIVE) { + transaction.registerSynchronization(this); + } } catch (RollbackException | SystemException e) { throw new RuntimeException("Cannot register synchronization", e); } From 839cfa044a356d86084ee21cff64b74123a51863 Mon Sep 17 00:00:00 2001 From: Martin Kouba Date: Fri, 2 Oct 2026 11:04:14 +0200 Subject: [PATCH 024/254] WebSockets Next: fix back-pressure for fragmented messages - replenish demand for non-final data frames so fragmented messages do not exhaust the pending-message window and stall connections - fixes 57079 --- .../asciidoc/websockets-next-reference.adoc | 3 + .../FragmentedMessagesBackpressureTest.java | 221 ++++++++++++++++++ .../runtime/BasicWebSocketConnectorImpl.java | 31 ++- .../websockets/next/runtime/Endpoints.java | 26 ++- 4 files changed, 269 insertions(+), 12 deletions(-) create mode 100644 extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/client/FragmentedMessagesBackpressureTest.java diff --git a/docs/src/main/asciidoc/websockets-next-reference.adoc b/docs/src/main/asciidoc/websockets-next-reference.adoc index 10983726663be..3f9736168dc0c 100644 --- a/docs/src/main/asciidoc/websockets-next-reference.adoc +++ b/docs/src/main/asciidoc/websockets-next-reference.adoc @@ -621,6 +621,9 @@ The limit is controlled by the following configuration property: * `quarkus.websockets-next.server.max-pending-messages` for server endpoints, and * `quarkus.websockets-next.client.max-pending-messages` for client endpoints. +The limit counts complete text and binary messages, including messages received in multiple frames. +Individual fragments do not count as separate pending messages. + When the limit is reached, the underlying WebSocket read stream is paused and no more messages are received until the pending messages are processed. The default value is `256`. diff --git a/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/client/FragmentedMessagesBackpressureTest.java b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/client/FragmentedMessagesBackpressureTest.java new file mode 100644 index 0000000000000..e2aa6f5d98839 --- /dev/null +++ b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/client/FragmentedMessagesBackpressureTest.java @@ -0,0 +1,221 @@ +package io.quarkus.websockets.next.test.client; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.URI; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import jakarta.inject.Inject; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusExtensionTest; +import io.quarkus.test.common.http.TestHTTPResource; +import io.quarkus.websockets.next.BasicWebSocketConnector; +import io.quarkus.websockets.next.OnBinaryMessage; +import io.quarkus.websockets.next.OnTextMessage; +import io.quarkus.websockets.next.WebSocket; +import io.quarkus.websockets.next.WebSocketClient; +import io.quarkus.websockets.next.WebSocketClientConnection; +import io.quarkus.websockets.next.WebSocketConnector; +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.http.HttpServer; +import io.vertx.core.http.WebSocketBase; +import io.vertx.core.http.WebSocketConnectOptions; +import io.vertx.core.http.WebSocketFrame; + +public class FragmentedMessagesBackpressureTest { + + @RegisterExtension + public static final QuarkusExtensionTest test = new QuarkusExtensionTest() + .withApplicationRoot(root -> root.addClasses(Client.class, Server.class)) + .overrideRuntimeConfigKey("quarkus.websockets-next.client.max-pending-messages", "1") + .overrideRuntimeConfigKey("quarkus.websockets-next.server.max-pending-messages", "1"); + + @Inject + Vertx vertx; + + @Inject + WebSocketConnector connector; + + @TestHTTPResource("/fragmented") + URI uri; + + @Test + void basicClientText() throws Exception { + basicClient(false); + } + + @Test + void basicClientBinary() throws Exception { + basicClient(true); + } + + private void basicClient(boolean binary) throws Exception { + BasicWebSocketConnector basicConnector = BasicWebSocketConnector.create(); + HttpServer server = sendingServer(binary); + CountDownLatch received = new CountDownLatch(10); + CountDownLatch pings = new CountDownLatch(10); + try { + basicConnector.baseUri("ws://localhost:" + server.actualPort()).onPing((c, m) -> pings.countDown()); + if (binary) { + basicConnector.onBinaryMessage((c, m) -> { + assertThat(m.toString()).isEqualTo("hello"); + received.countDown(); + }); + } else { + basicConnector.onTextMessage((c, m) -> { + assertThat(m).isEqualTo("hello"); + received.countDown(); + }); + } + WebSocketClientConnection connection = basicConnector.connectAndAwait(); + try { + assertThat(received.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(pings.await(5, TimeUnit.SECONDS)).isTrue(); + } finally { + connection.closeAndAwait(); + } + } finally { + server.close().toCompletionStage().toCompletableFuture().get(5, TimeUnit.SECONDS); + } + } + + @Test + void basicClientIgnoresUnconsumedMessages() throws Exception { + BasicWebSocketConnector basicConnector = BasicWebSocketConnector.create(); + HttpServer server = vertx.createHttpServer().webSocketHandler(ws -> { + sendFragments(ws, true); + sendFragments(ws, false); + }) + .listen(0) + .toCompletionStage() + .toCompletableFuture() + .get(5, TimeUnit.SECONDS); + CountDownLatch received = new CountDownLatch(10); + try { + WebSocketClientConnection connection = basicConnector.baseUri("ws://localhost:" + server.actualPort()) + .onTextMessage((c, m) -> received.countDown()).connectAndAwait(); + try { + assertThat(received.await(5, TimeUnit.SECONDS)).isTrue(); + } finally { + connection.closeAndAwait(); + } + } finally { + server.close().toCompletionStage().toCompletableFuture().get(5, TimeUnit.SECONDS); + } + } + + @Test + void annotatedClientText() throws Exception { + annotatedClient(false); + } + + @Test + void annotatedClientBinary() throws Exception { + annotatedClient(true); + } + + private void annotatedClient(boolean binary) throws Exception { + Client.received = new CountDownLatch(10); + HttpServer server = sendingServer(binary); + try { + WebSocketClientConnection connection = connector.baseUri("ws://localhost:" + server.actualPort()) + .connectAndAwait(); + try { + assertThat(Client.received.await(5, TimeUnit.SECONDS)).isTrue(); + } finally { + connection.closeAndAwait(); + } + } finally { + server.close().toCompletionStage().toCompletableFuture().get(5, TimeUnit.SECONDS); + } + } + + @Test + void annotatedServerText() throws Exception { + annotatedServer(false); + } + + @Test + void annotatedServerBinary() throws Exception { + annotatedServer(true); + } + + private void annotatedServer(boolean binary) throws Exception { + Server.received = new CountDownLatch(10); + var client = vertx.createWebSocketClient(); + try { + var ws = client.connect(new WebSocketConnectOptions().setHost(uri.getHost()) + .setPort(uri.getPort()) + .setURI(uri.getPath())) + .toCompletionStage() + .toCompletableFuture() + .get(5, TimeUnit.SECONDS); + sendFragments(ws, binary); + assertThat(Server.received.await(5, TimeUnit.SECONDS)).isTrue(); + } finally { + client.close().toCompletionStage().toCompletableFuture().get(5, TimeUnit.SECONDS); + } + } + + private HttpServer sendingServer(boolean binary) throws Exception { + return vertx.createHttpServer() + .webSocketHandler(ws -> sendFragments(ws, binary)) + .listen(0) + .toCompletionStage() + .toCompletableFuture().get(5, TimeUnit.SECONDS); + } + + private static void sendFragments(WebSocketBase ws, boolean binary) { + for (int i = 0; i < 10; i++) { + // Even a small message can have more fragments than the pending-message limit + ws.writeFrame(binary ? WebSocketFrame.binaryFrame(Buffer.buffer("he"), false) + : WebSocketFrame.textFrame("he", false)); + ws.writePing(Buffer.buffer("ping")); + ws.writeFrame(WebSocketFrame.continuationFrame(Buffer.buffer("ll"), false)); + ws.writeFrame(WebSocketFrame.continuationFrame(Buffer.buffer("o"), true)); + } + } + + @WebSocketClient(path = "/") + public static class Client { + + static volatile CountDownLatch received; + + @OnTextMessage + void text(String message) { + if (!"hello".equals(message)) { + throw new IllegalStateException(); + } + received.countDown(); + } + + @OnBinaryMessage + void binary(Buffer message) { + text(message.toString()); + } + } + + @WebSocket(path = "/fragmented") + public static class Server { + + static volatile CountDownLatch received; + + @OnTextMessage + void text(String message) { + if (!"hello".equals(message)) { + throw new IllegalStateException(); + } + received.countDown(); + } + + @OnBinaryMessage + void binary(Buffer message) { + text(message.toString()); + } + } +} diff --git a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/BasicWebSocketConnectorImpl.java b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/BasicWebSocketConnectorImpl.java index 71ec99632b8f4..ec8bebdfa3e3d 100644 --- a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/BasicWebSocketConnectorImpl.java +++ b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/BasicWebSocketConnectorImpl.java @@ -219,10 +219,15 @@ public void handle(AsyncResult r) { doExecute(connection, null, (c, ignored) -> openHandler.accept(c)); } - if (textMessageHandler != null) { + if (textMessageHandler != null || backpressure) { ws.textMessageHandler(new Handler() { @Override public void handle(String message) { + if (textMessageHandler == null) { + // No handler set but the stream must be drained to replenish the final frame's credit + fetchOne(ws, connection); + return; + } if (trafficLogger != null) { trafficLogger.textMessageReceived(connection, message); } @@ -232,11 +237,16 @@ public void handle(String message) { }); } - if (binaryMessageHandler != null) { + if (binaryMessageHandler != null || backpressure) { ws.binaryMessageHandler(new Handler() { @Override public void handle(Buffer message) { + if (binaryMessageHandler == null) { + // No handler set but the stream must be drained to replenish the final frame's credit + fetchOne(ws, connection); + return; + } if (trafficLogger != null) { trafficLogger.binaryMessageReceived(connection, message); } @@ -246,12 +256,20 @@ public void handle(Buffer message) { }); } - if (pingMessageHandler != null) { + if (pingMessageHandler != null || backpressure) { + // The frame handler receives every frame, not just pings. It also replenishes demand for non-final + // data frames so that fragmented messages can be assembled before the message callback runs. ws.frameHandler(new Handler() { @Override public void handle(WebSocketFrame frame) { - if (frame.type() == WebSocketFrameType.PING) { + if (backpressure && Endpoints.isNonFinalDataFrame(frame)) { + // Vert.x demand counts frames; only the final frame waits for message processing. + // PING/PONG frames are not replenished here on purpose - Vert.x returns their demand + // itself (see io.vertx.core.http.impl.websocket.WebSocketImplBase#receiveFrame). + fetchOne(ws, connection); + } + if (frame.type() == WebSocketFrameType.PING && pingMessageHandler != null) { doExecute(connection, frame.binaryData(), pingMessageHandler); } } @@ -299,8 +317,9 @@ public void handle(Void event) { }); if (backpressure) { - // All handlers are registered - allow up to maxPendingMessages to be delivered; each processed - // text/binary message then fetches one more, bounding the number of in-flight messages + // All handlers are registered - grant the initial frame demand; non-final data frames immediately + // return their credit while the final frame returns its credit after message processing, which bounds + // the number of in-flight messages even though Vert.x fetch() counts frames LOG.debugf("Back-pressure - fetch %s pending messages: %s", maxPendingMessages, connection); ws.fetch(maxPendingMessages); } diff --git a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/Endpoints.java b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/Endpoints.java index 0b8c03e57c8e2..8bfb15933c71e 100644 --- a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/Endpoints.java +++ b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/Endpoints.java @@ -237,7 +237,7 @@ public void handle(Void event) { }, false); } - pingMessageHandler(connection, endpoint, ws, onOpenContext, m -> { + frameHandler(connection, endpoint, ws, onOpenContext, m -> { endpoint.onPingMessage(m).onComplete(r -> { if (r.succeeded()) { LOG.debugf("@OnPingMessage callback consumed application message: %s", connection); @@ -246,7 +246,7 @@ public void handle(Void event) { "Unable to consume application message in @OnPingMessage callback", connection); } }); - }); + }, backpressure); pongMessageHandler(connection, endpoint, ws, onOpenContext, m -> { endpoint.onPongMessage(m).onComplete(r -> { @@ -322,8 +322,9 @@ public void handle(Void event) { }); if (backpressure) { - // All handlers are registered - allow up to maxPendingMessages to be delivered; each processed - // message then fetches one more, bounding the number of in-flight messages + // All handlers are registered - grant the initial frame demand; non-final data frames immediately + // return their credit while the final frame returns its credit after message processing, which bounds + // the number of in-flight messages even though Vert.x fetch() counts frames LOG.debugf("Back-pressure - fetch %s pending messages: %s", maxPendingMessages, connection); ws.fetch(maxPendingMessages); } @@ -447,11 +448,24 @@ public void handle(Void event) { }); } - private static void pingMessageHandler(WebSocketConnectionBase connection, WebSocketEndpoint endpoint, WebSocketBase ws, - Context context, Consumer pingAction) { + static boolean isNonFinalDataFrame(WebSocketFrame frame) { + return !frame.isFinal() && (frame.type() == WebSocketFrameType.TEXT + || frame.type() == WebSocketFrameType.BINARY || frame.type() == WebSocketFrameType.CONTINUATION); + } + + private static void frameHandler(WebSocketConnectionBase connection, WebSocketEndpoint endpoint, WebSocketBase ws, + Context context, Consumer pingAction, boolean backpressure) { + // The frame handler receives every frame, not just pings. It also replenishes demand for non-final data + // frames so that fragmented messages can be assembled before the message callback runs. ws.frameHandler(new Handler() { @Override public void handle(WebSocketFrame frame) { + if (backpressure && isNonFinalDataFrame(frame)) { + // Vert.x demand counts frames; only the final frame waits for message processing. + // PING/PONG frames are not replenished here on purpose - Vert.x returns their demand itself + // (see io.vertx.core.http.impl.websocket.WebSocketImplBase#receiveFrame). + fetchOne(ws, connection); + } if (frame.type() == WebSocketFrameType.PING) { Context duplicatedContext = ContextSupport.createNewDuplicatedContext(context, connection); duplicatedContext.runOnContext(new Handler() { From 4f7b8e07f25c4c4873379ae05074683b235859f3 Mon Sep 17 00:00:00 2001 From: Arnab Nandy Date: Fri, 2 Oct 2026 16:03:33 +0530 Subject: [PATCH 025/254] Update Jackson customization documentation Signed-off-by: Arnab Nandy --- docs/src/main/asciidoc/rest-json.adoc | 42 ++++++++++------- docs/src/main/asciidoc/rest.adoc | 8 ++-- docs/src/main/asciidoc/resteasy.adoc | 47 +++++++++++-------- .../src/main/asciidoc/writing-extensions.adoc | 2 +- 4 files changed, 56 insertions(+), 43 deletions(-) diff --git a/docs/src/main/asciidoc/rest-json.adoc b/docs/src/main/asciidoc/rest-json.adoc index 74f5e9550b2a2..87100e8f2d554 100644 --- a/docs/src/main/asciidoc/rest-json.adoc +++ b/docs/src/main/asciidoc/rest-json.adoc @@ -205,52 +205,58 @@ in your `application.properties`. If you want to change the format for a single `@JsonFormat` annotation. Also, Quarkus makes it very easy to configure various Jackson settings via CDI beans. -The simplest (and suggested) approach is to define a CDI bean of type `io.quarkus.jackson.ObjectMapperCustomizer` +The simplest (and suggested) approach is to define a CDI bean of type `io.quarkus.jackson.JsonMapperBuilderCustomizer` inside of which any Jackson configuration can be applied. An example where a custom module needs to be registered would look like so: [source,java] ---- -import com.fasterxml.jackson.databind.ObjectMapper; -import io.quarkus.jackson.ObjectMapperCustomizer; +import io.quarkus.jackson.JsonMapperBuilderCustomizer; import jakarta.inject.Singleton; +import tools.jackson.databind.json.JsonMapper; @Singleton -public class RegisterCustomModuleCustomizer implements ObjectMapperCustomizer { +public class RegisterCustomModuleCustomizer implements JsonMapperBuilderCustomizer { - public void customize(ObjectMapper mapper) { - mapper.registerModule(new CustomModule()); + public void customize(JsonMapper.Builder builder) { + builder.addModule(new CustomModule()); } } ---- -Users can even provide their own `ObjectMapper` bean if they so choose. -If this is done, it is very important to manually inject and apply all `io.quarkus.jackson.ObjectMapperCustomizer` beans in the CDI producer that produces `ObjectMapper`. +Users can even provide their own `JsonMapper` bean if they so choose. +If this is done, it is very important to manually inject and apply all `io.quarkus.jackson.JsonMapperBuilderCustomizer` beans in the CDI producer that produces `JsonMapper`. Failure to do so will prevent Jackson specific customizations provided by various extensions from being applied. [source,java] ---- -import com.fasterxml.jackson.databind.ObjectMapper; import io.quarkus.arc.All; -import io.quarkus.jackson.ObjectMapperCustomizer; +import io.quarkus.jackson.JsonFactoryBuilderCustomizer; +import io.quarkus.jackson.JsonMapperBuilderCustomizer; import java.util.List; +import jakarta.enterprise.inject.Produces; import jakarta.inject.Singleton; +import tools.jackson.core.json.JsonFactory; +import tools.jackson.core.json.JsonFactoryBuilder; +import tools.jackson.databind.json.JsonMapper; public class CustomObjectMapper { - // Replaces the CDI producer for ObjectMapper built into Quarkus + // Replaces the CDI producer for JsonMapper built into Quarkus @Singleton @Produces - ObjectMapper objectMapper(@All List customizers) { - ObjectMapper mapper = myObjectMapper(); // Custom `ObjectMapper` + JsonMapper jsonMapper(@All List customizers, + @All List factoryCustomizers) { + JsonFactoryBuilder factoryBuilder = JsonFactory.builder(); + factoryCustomizers.stream().sorted().forEach(customizer -> customizer.customize(factoryBuilder)); - // Apply all ObjectMapperCustomizer beans (incl. Quarkus) - for (ObjectMapperCustomizer customizer : customizers) { - customizer.customize(mapper); - } + JsonMapper.Builder builder = JsonMapper.builder(factoryBuilder.build()); + + // Apply all JsonMapperBuilderCustomizer beans (including Quarkus customizers) + customizers.stream().sorted().forEach(customizer -> customizer.customize(builder)); - return mapper; + return builder.build(); } } ---- diff --git a/docs/src/main/asciidoc/rest.adoc b/docs/src/main/asciidoc/rest.adoc index 8942b52b7c907..bf5fea548518b 100644 --- a/docs/src/main/asciidoc/rest.adoc +++ b/docs/src/main/asciidoc/rest.adoc @@ -1760,7 +1760,7 @@ Setting this property applies reflection-free optimization to deserialization an When enabled, {project-name} generates `StdSerializer` and `StdDeserializer` implementations at build time for each class requiring JSON conversion. The application then processes objects returned by REST endpoints using these generated serializers and deserializers, eliminating reliance on reflection and improving performance. -Developers can further customize JSON processing by implementing the `ObjectMapperCustomizer` interface. This interface allows fine-grained control over the `ObjectMapper`, enabling the registration of custom serializers and deserializers while ensuring compatibility with the reflection-free optimization. If additional configuration is needed, implement an `ObjectMapperCustomizer` bean and register any necessary modules or settings. +Developers can further customize JSON processing by implementing the `JsonMapperBuilderCustomizer` interface. This interface allows fine-grained control over the `JsonMapper.Builder`, enabling the registration of custom serializers and deserializers while ensuring compatibility with the reflection-free optimization. If additional configuration is needed, implement a `JsonMapperBuilderCustomizer` bean and register any necessary modules or settings. [NOTE] ==== @@ -1769,9 +1769,9 @@ Reflection-free serializers are not compatible with Kotlin classes, since they b ===== Completely customized per method serialization/deserialization -There are times when you need to completely customize the serialization/deserialization of a POJO on a per Jakarta REST method basis or on a per Jakarta REST resource basis. For such use cases, you can use the `@io.quarkus.resteasy.reactive.jackson.CustomSerialization` and `@io.quarkus.resteasy.reactive.jackson.CustomDeserialization` annotations in the REST method or in the REST resource at class level. These annotations allow you to fully configure the `com.fasterxml.jackson.databind.ObjectWriter`/`com.fasterxml.jackson.databind.ObjectReader`. +There are times when you need to completely customize the serialization/deserialization of a POJO on a per Jakarta REST method basis or on a per Jakarta REST resource basis. For such use cases, you can use the `@io.quarkus.resteasy.reactive.jackson.CustomSerialization` and `@io.quarkus.resteasy.reactive.jackson.CustomDeserialization` annotations in the REST method or in the REST resource at class level. These annotations allow you to fully configure the `tools.jackson.databind.ObjectWriter`/`tools.jackson.databind.ObjectReader`. -Here is an example use case to customize the `com.fasterxml.jackson.databind.ObjectWriter`: +Here is an example use case to customize the `tools.jackson.databind.ObjectWriter`: [source,java] ---- @@ -1800,7 +1800,7 @@ Essentially what this class does is force Jackson to not include quotes in the f It is important to note that this customization is only performed for the serialization of the Jakarta REST methods that use `@CustomSerialization(UnquotedFields.class)`. -Following the previous example, let's now customize the `com.fasterxml.jackson.databind.ObjectReader` to read JSON requests with unquoted field names: +Following the previous example, let's now customize the `tools.jackson.databind.ObjectReader` to read JSON requests with unquoted field names: [source,java] ---- diff --git a/docs/src/main/asciidoc/resteasy.adoc b/docs/src/main/asciidoc/resteasy.adoc index cfdcbf3ff4304..f20a105b00269 100644 --- a/docs/src/main/asciidoc/resteasy.adoc +++ b/docs/src/main/asciidoc/resteasy.adoc @@ -184,13 +184,13 @@ Additionally, the `ObjectMapper` formats dates and times in ISO-8601 (by disabli To restore Jackson's default behavior, use `quarkus.jackson.write-dates-as-timestamps=true` in your `application.properties`. For custom date format on a single field, use the `@JsonFormat` annotation. -Quarkus simplifies Jackson configuration via CDI beans. Create a CDI bean of type `io.quarkus.jackson.ObjectMapperCustomizer` to apply various Jackson settings. Here's an example for registering a custom module: +Quarkus simplifies Jackson configuration via CDI beans. Create a CDI bean of type `io.quarkus.jackson.JsonMapperBuilderCustomizer` to apply various Jackson settings. Here's an example for registering a custom module: ```java @ApplicationScoped -public class MyObjectMapperCustomizer implements ObjectMapperCustomizer { +public class MyJsonMapperBuilderCustomizer implements JsonMapperBuilderCustomizer { @Override - public void customize(ObjectMapper objectMapper) { + public void customize(JsonMapper.Builder builder) { // Add custom Jackson configuration here } } @@ -200,44 +200,51 @@ This approach is recommended for configuring Jackson settings. [source,java] ---- -import com.fasterxml.jackson.databind.ObjectMapper; -import io.quarkus.jackson.ObjectMapperCustomizer; +import io.quarkus.jackson.JsonMapperBuilderCustomizer; import jakarta.inject.Singleton; +import tools.jackson.databind.json.JsonMapper; @Singleton -public class RegisterCustomModuleCustomizer implements ObjectMapperCustomizer { +public class RegisterCustomModuleCustomizer implements JsonMapperBuilderCustomizer { - public void customize(ObjectMapper mapper) { - mapper.registerModule(new CustomModule()); + public void customize(JsonMapper.Builder builder) { + builder.addModule(new CustomModule()); } } ---- -Users can even provide their own `ObjectMapper` bean if they so choose. -If this is done, it is very important to manually inject and apply all `io.quarkus.jackson.ObjectMapperCustomizer` beans in the CDI producer that produces `ObjectMapper`. +Users can even provide their own `JsonMapper` bean if they so choose. +If this is done, it is very important to manually inject and apply all `io.quarkus.jackson.JsonMapperBuilderCustomizer` beans in the CDI producer that produces `JsonMapper`. Failure to do so will prevent Jackson-specific customizations provided by various extensions from being applied. [source,java] ---- -import com.fasterxml.jackson.databind.ObjectMapper; import io.quarkus.arc.All; -import io.quarkus.jackson.ObjectMapperCustomizer; +import io.quarkus.jackson.JsonFactoryBuilderCustomizer; +import io.quarkus.jackson.JsonMapperBuilderCustomizer; import java.util.List; +import jakarta.enterprise.inject.Produces; import jakarta.inject.Singleton; +import tools.jackson.core.json.JsonFactory; +import tools.jackson.core.json.JsonFactoryBuilder; +import tools.jackson.databind.json.JsonMapper; public class CustomObjectMapper { - // Replaces the CDI producer for ObjectMapper built into Quarkus + // Replaces the CDI producer for JsonMapper built into Quarkus @Singleton - ObjectMapper objectMapper(@All List customizers) { - ObjectMapper mapper = myObjectMapper(); // Custom `ObjectMapper` + @Produces + JsonMapper jsonMapper(@All List customizers, + @All List factoryCustomizers) { + JsonFactoryBuilder factoryBuilder = JsonFactory.builder(); + factoryCustomizers.stream().sorted().forEach(customizer -> customizer.customize(factoryBuilder)); - // Apply all ObjectMapperCustomizer beans (incl. Quarkus) - for (ObjectMapperCustomizer customizer : customizers) { - customizer.customize(mapper); - } + JsonMapper.Builder builder = JsonMapper.builder(factoryBuilder.build()); + + // Apply all JsonMapperBuilderCustomizer beans (including Quarkus customizers) + customizers.stream().sorted().forEach(customizer -> customizer.customize(builder)); - return mapper; + return builder.build(); } } ---- diff --git a/docs/src/main/asciidoc/writing-extensions.adoc b/docs/src/main/asciidoc/writing-extensions.adoc index 11cc75211098c..37419fa7dbe66 100644 --- a/docs/src/main/asciidoc/writing-extensions.adoc +++ b/docs/src/main/asciidoc/writing-extensions.adoc @@ -1997,7 +1997,7 @@ JacksonModuleBuildItem registerJacksonSerDeser() { The Jackson extension will then use the produced build item to register a module within Jackson automatically. If you need more customization capabilities than registering a module, -you can produce a CDI bean that implements `io.quarkus.jackson.ObjectMapperCustomizer` via an `AdditionalBeanBuildItem`. +you can produce a CDI bean that implements `io.quarkus.jackson.JsonMapperBuilderCustomizer` via an `AdditionalBeanBuildItem`. More info about customizing Jackson can be found on the JSON guide xref:rest-json.adoc#json[Configuring JSON support] ==== Customizing JSON-B From 7d127e1659d8ea38d40e993974030ed315866605 Mon Sep 17 00:00:00 2001 From: "David M. Lloyd" Date: Wed, 8 Jul 2026 11:38:43 -0500 Subject: [PATCH 026/254] New service start/stop infrastructure (v6) - Base impl Fixes #53042 --- bom/application/pom.xml | 8 +- build-parent/pom.xml | 22 +- .../io/quarkus/builder/BuildChainBuilder.java | 1 + .../java/io/quarkus/builder/BuildContext.java | 111 ++ .../io/quarkus/builder/BuildStepBuilder.java | 9 + .../java/io/quarkus/builder/StepInfo.java | 16 +- core/deployment/pom.xml | 53 + .../deployment/service/ServiceRegistrar.java | 354 ++++ .../service/impl/CaptureEmitter.java | 22 + .../service/impl/ConfigMappingDetector.java | 56 + .../service/impl/ConfigMappingSerializer.java | 43 + .../service/impl/ConstantDescSerializer.java | 67 + .../deployment/service/impl/Dependency.java | 90 + .../service/impl/LambdaTransliterator.java | 1238 +++++++++++++ .../impl/QuarkusClassLoaderSerializer.java | 29 + .../impl/SerializedCaptureEmitter.java | 732 ++++++++ .../service/impl/SerializedConfigMapping.java | 32 + .../service/impl/SerializedConstant.java | 58 + .../impl/SerializedQuarkusClassLoader.java | 47 + .../service/impl/ServiceBuilderImpl.java | 482 +++++ .../impl/ServiceMetadataBuildItem.java | 103 ++ .../service/impl/ServiceRegistrarImpl.java | 430 +++++ .../impl/ServiceValueRetentionBuildItem.java | 58 + .../impl/StaticServiceMetadataBuildItem.java | 91 + .../service/impl/TransliteratedAction.java | 121 ++ .../quarkus/deployment/ExtensionLoader.java | 53 +- .../quarkus/deployment/QuarkusAugmentor.java | 11 + .../deployment/SnapStartProcessor.java | 20 +- .../deployment/annotations/ExecutionTime.java | 4 + .../deployment/annotations/Record.java | 6 + ...deRecorderConstantDefinitionBuildItem.java | 5 + ...BytecodeRecorderObjectLoaderBuildItem.java | 2 +- .../MainBytecodeRecorderBuildItem.java | 54 +- .../ObjectSubstitutionBuildItem.java | 2 + .../builditem/ShutdownContextBuildItem.java | 3 + .../StaticBytecodeRecorderBuildItem.java | 72 +- .../deployment/proxy/ProxyConfiguration.java | 8 +- .../deployment/proxy/ProxyFactory.java | 2 +- .../recording/BytecodeRecorderImpl.java | 119 +- .../deployment/recording/RecorderContext.java | 5 + .../steps/ConfigGenerationBuildStep.java | 7 +- .../steps/LastShutdownTasksProcessor.java | 55 + .../deployment/steps/MainClassBuildStep.java | 1278 ++++++++++--- .../steps/NativeImageConfigBuildStep.java | 16 +- .../steps/PhaseServicesProcessor.java | 29 + .../steps/ServiceDependencyValidator.java | 368 ++++ .../deployment/steps/ServiceGraphBuilder.java | 615 +++++++ .../deployment/steps/ThreadPoolSetup.java | 16 +- .../impl/LambdaTransliteratorTest.java | 865 +++++++++ .../service/impl/PhaseServicesTest.java | 107 ++ .../impl/ServiceRegistrarImplTest.java | 117 ++ .../steps/ServiceDependencyValidatorTest.java | 334 ++++ core/pom.xml | 1 + .../io/quarkus/core/AsyncStartContext.java | 56 + .../io/quarkus/core/AsyncStopContext.java | 9 + .../quarkus/core/AsyncVoidStartContext.java | 56 + .../src/main/java/io/quarkus/core/Phase.java | 160 ++ .../java/io/quarkus/core/RunnableHandle.java | 38 + .../java/io/quarkus/core/StartContext.java | 40 + .../core/impl/AsyncStartContextImpl.java | 138 ++ .../core/impl/AsyncVoidStartContextImpl.java | 135 ++ .../quarkus/core/impl/JfrServiceMonitor.java | 166 ++ .../quarkus/core/impl/LastShutdownTasks.java | 98 + .../quarkus/core/impl/NoOpServiceMonitor.java | 60 + .../core/impl/NodeShutdownContext.java | 120 ++ .../io/quarkus/core/impl/ServiceGraph.java | 404 ++++ .../io/quarkus/core/impl/ServiceMonitor.java | 57 + .../io/quarkus/core/impl/ServiceNode.java | 913 +++++++++ .../quarkus/core/impl/SortedNullSafeMap.java | 135 ++ .../java/io/quarkus/runtime/Application.java | 4 + .../quarkus/runtime/ObjectSubstitution.java | 2 + .../runtime/QuarkusContextHandler.java | 30 + .../java/io/quarkus/runtime/RuntimeValue.java | 4 + .../quarkus/runtime/ServiceValueSupplier.java | 38 + .../io/quarkus/runtime/ShutdownContext.java | 22 +- .../io/quarkus/runtime/SnapStartRecorder.java | 15 +- .../io/quarkus/runtime/StartupContext.java | 117 +- .../annotations/RecordableConstructor.java | 3 + .../quarkus/runtime/annotations/Recorder.java | 3 + .../runtime/configuration/ConfigLookup.java | 49 + .../init/InitializationTaskRecorder.java | 26 +- .../ssl/SslContextConfigurationRecorder.java | 11 - .../quarkus/core/impl/ServiceGraphTest.java | 380 ++++ core/service-types-maker/pom.xml | 24 + .../servicetypesmaker/MakeServiceTypes.java | 1639 +++++++++++++++++ .../quarkus/core/servicetypesmaker/Util.java | 55 + ...idcClientCredentialsJwtSecretTestCase.java | 2 +- ...odeFlowRuntimeCredentialsProviderTest.java | 2 +- independent-projects/arc/pom.xml | 2 +- independent-projects/qute/pom.xml | 2 +- 90 files changed, 13017 insertions(+), 445 deletions(-) create mode 100644 core/deployment/src/main/java/io/quarkus/core/deployment/service/ServiceRegistrar.java create mode 100644 core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/CaptureEmitter.java create mode 100644 core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/ConfigMappingDetector.java create mode 100644 core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/ConfigMappingSerializer.java create mode 100644 core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/ConstantDescSerializer.java create mode 100644 core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/Dependency.java create mode 100644 core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/LambdaTransliterator.java create mode 100644 core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/QuarkusClassLoaderSerializer.java create mode 100644 core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/SerializedCaptureEmitter.java create mode 100644 core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/SerializedConfigMapping.java create mode 100644 core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/SerializedConstant.java create mode 100644 core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/SerializedQuarkusClassLoader.java create mode 100644 core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/ServiceBuilderImpl.java create mode 100644 core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/ServiceMetadataBuildItem.java create mode 100644 core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/ServiceRegistrarImpl.java create mode 100644 core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/ServiceValueRetentionBuildItem.java create mode 100644 core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/StaticServiceMetadataBuildItem.java create mode 100644 core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/TransliteratedAction.java create mode 100644 core/deployment/src/main/java/io/quarkus/deployment/steps/LastShutdownTasksProcessor.java create mode 100644 core/deployment/src/main/java/io/quarkus/deployment/steps/PhaseServicesProcessor.java create mode 100644 core/deployment/src/main/java/io/quarkus/deployment/steps/ServiceDependencyValidator.java create mode 100644 core/deployment/src/main/java/io/quarkus/deployment/steps/ServiceGraphBuilder.java create mode 100644 core/deployment/src/test/java/io/quarkus/core/deployment/service/impl/LambdaTransliteratorTest.java create mode 100644 core/deployment/src/test/java/io/quarkus/core/deployment/service/impl/PhaseServicesTest.java create mode 100644 core/deployment/src/test/java/io/quarkus/core/deployment/service/impl/ServiceRegistrarImplTest.java create mode 100644 core/deployment/src/test/java/io/quarkus/deployment/steps/ServiceDependencyValidatorTest.java create mode 100644 core/runtime/src/main/java/io/quarkus/core/AsyncStartContext.java create mode 100644 core/runtime/src/main/java/io/quarkus/core/AsyncStopContext.java create mode 100644 core/runtime/src/main/java/io/quarkus/core/AsyncVoidStartContext.java create mode 100644 core/runtime/src/main/java/io/quarkus/core/Phase.java create mode 100644 core/runtime/src/main/java/io/quarkus/core/RunnableHandle.java create mode 100644 core/runtime/src/main/java/io/quarkus/core/StartContext.java create mode 100644 core/runtime/src/main/java/io/quarkus/core/impl/AsyncStartContextImpl.java create mode 100644 core/runtime/src/main/java/io/quarkus/core/impl/AsyncVoidStartContextImpl.java create mode 100644 core/runtime/src/main/java/io/quarkus/core/impl/JfrServiceMonitor.java create mode 100644 core/runtime/src/main/java/io/quarkus/core/impl/LastShutdownTasks.java create mode 100644 core/runtime/src/main/java/io/quarkus/core/impl/NoOpServiceMonitor.java create mode 100644 core/runtime/src/main/java/io/quarkus/core/impl/NodeShutdownContext.java create mode 100644 core/runtime/src/main/java/io/quarkus/core/impl/ServiceGraph.java create mode 100644 core/runtime/src/main/java/io/quarkus/core/impl/ServiceMonitor.java create mode 100644 core/runtime/src/main/java/io/quarkus/core/impl/ServiceNode.java create mode 100644 core/runtime/src/main/java/io/quarkus/core/impl/SortedNullSafeMap.java create mode 100644 core/runtime/src/main/java/io/quarkus/runtime/QuarkusContextHandler.java create mode 100644 core/runtime/src/main/java/io/quarkus/runtime/ServiceValueSupplier.java create mode 100644 core/runtime/src/main/java/io/quarkus/runtime/configuration/ConfigLookup.java delete mode 100644 core/runtime/src/main/java/io/quarkus/runtime/ssl/SslContextConfigurationRecorder.java create mode 100644 core/runtime/src/test/java/io/quarkus/core/impl/ServiceGraphTest.java create mode 100644 core/service-types-maker/pom.xml create mode 100644 core/service-types-maker/src/main/java/io/quarkus/core/servicetypesmaker/MakeServiceTypes.java create mode 100644 core/service-types-maker/src/main/java/io/quarkus/core/servicetypesmaker/Util.java diff --git a/bom/application/pom.xml b/bom/application/pom.xml index 3261a54505b0c..73619efabea2f 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -51,6 +51,7 @@ 4.3.0 1.0.beta1 4.4.0-alpha3 + 1.0.beta6 3.0.0.Beta7 7.0.0 4.6.4 @@ -89,7 +90,7 @@ 2.1.0 25.0.4.1 1.10.1 - 2.3.0 + 2.3.1 3.1.4 2.22.2 2.0.0.Final @@ -4499,6 +4500,11 @@ smallrye-modules ${smallrye-modules.version} + + io.smallrye.serial + smallrye-serial + ${smallrye-serial.version} + io.smallrye smallrye-open-api-core diff --git a/build-parent/pom.xml b/build-parent/pom.xml index 9e6c95be2d36d..088897a0bb4e5 100644 --- a/build-parent/pom.xml +++ b/build-parent/pom.xml @@ -33,7 +33,8 @@ 3.0.0 5.5.0 3.28.2 - 2.1.0 + 2.1.0 + 3.0.beta1 6.0.1 @@ -251,6 +252,11 @@ org.jboss.jdeparser jdeparser + ${jdeparser2.version} + + + io.smallrye.jdeparser + jdeparser ${jdeparser.version} @@ -763,6 +769,13 @@ net.revelc.code impsort-maven-plugin + + + com.github.javaparser + javaparser-core + ${javaparser-core.version} + + true @@ -803,6 +816,13 @@ net.revelc.code impsort-maven-plugin + + + com.github.javaparser + javaparser-core + ${javaparser-core.version} + + true diff --git a/core/builder/src/main/java/io/quarkus/builder/BuildChainBuilder.java b/core/builder/src/main/java/io/quarkus/builder/BuildChainBuilder.java index 5d6b42bb0c33c..f9bdd3526c1f9 100644 --- a/core/builder/src/main/java/io/quarkus/builder/BuildChainBuilder.java +++ b/core/builder/src/main/java/io/quarkus/builder/BuildChainBuilder.java @@ -463,6 +463,7 @@ private StepInfo buildOne(BuildStepBuilder toBuild, Set includ toBuild.getBuildStep(), toBuild.getRealConsumes(), realProduces, + toBuild.getAllProduces(), includedDependencies, dependentStepInfos, ord); mapped.put(toBuild, stepInfo); if (includedDependencies == 0) { diff --git a/core/builder/src/main/java/io/quarkus/builder/BuildContext.java b/core/builder/src/main/java/io/quarkus/builder/BuildContext.java index 094411664c674..b953f4a8547cd 100644 --- a/core/builder/src/main/java/io/quarkus/builder/BuildContext.java +++ b/core/builder/src/main/java/io/quarkus/builder/BuildContext.java @@ -3,9 +3,14 @@ import static io.quarkus.builder.Execution.log; import java.time.LocalTime; +import java.util.ArrayDeque; import java.util.Collections; import java.util.Comparator; +import java.util.Deque; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.concurrent.ExecutorService; import java.util.concurrent.TimeUnit; @@ -198,6 +203,112 @@ public ExecutorService getExecutor() { return execution.getExecutor(); } + /** + * Get the ID of the currently executing build step. + * + * @return the step ID (never {@code null}) + */ + public String getStepId() { + return stepInfo.getBuildStep().getId(); + } + + /** + * Build item types whose production/consumption edges should be excluded + * from the step dependency graph. These items are consumed only by + * {@code MainClassBuildStep} for code generation and do not represent + * runtime ordering dependencies between services. + */ + private static final Set EXCLUDED_EDGE_ITEMS = Set.of( + "io.quarkus.deployment.builditem.StaticBytecodeRecorderBuildItem", + "io.quarkus.deployment.builditem.MainBytecodeRecorderBuildItem"); + + /** + * Get the build step dependency graph as an adjacency map. + * Each key is a build step ID; the corresponding value is the set + * of step IDs that the key step depends on (its dependencies). + *

+ * This traverses the full step graph by inverting the forward + * (dependent) edges stored in each {@link StepInfo}. The result + * is computed on each call and not cached. + * + * @return the dependency graph (step ID → set of dependency step IDs) + */ + public Map> getStepDependencyGraph() { + Map> result = new HashMap<>(); + Set visited = new HashSet<>(); + Deque work = new ArrayDeque<>(execution.getBuildChain().getStartSteps()); + while (!work.isEmpty()) { + StepInfo step = work.poll(); + if (!visited.add(step)) { + continue; + } + String stepId = step.getBuildStep().getId(); + result.computeIfAbsent(stepId, k -> new HashSet<>()); + for (StepInfo dependent : step.getDependents()) { + // skip edges where the only connecting items are recorder build items + if (isRecorderOnlyEdge(step, dependent)) { + work.add(dependent); + continue; + } + String dependentId = dependent.getBuildStep().getId(); + // dependent depends on step → add step as a dependency of dependent + result.computeIfAbsent(dependentId, k -> new HashSet<>()).add(stepId); + work.add(dependent); + } + } + return result; + } + + /** + * Check if the dependency edge between a producer and consumer step + * exists solely because of recorder build item types. If the only + * items that the consumer consumes AND the producer produces are + * excluded item types, the edge is spurious. + */ + private static boolean isRecorderOnlyEdge(StepInfo producer, StepInfo consumer) { + Set produced = producer.getProduces(); + Set consumed = consumer.getConsumes(); + boolean hasRealOverlap = false; + boolean hasExcludedOverlap = false; + for (ItemId p : produced) { + if (consumed.contains(p)) { + if (EXCLUDED_EDGE_ITEMS.contains(p.getType().getName())) { + hasExcludedOverlap = true; + } else { + hasRealOverlap = true; + } + } + } + return hasExcludedOverlap && !hasRealOverlap; + } + + /** + * Get a mapping from build item class name to the set of step IDs that + * produce that build item. Used by the service graph builder to resolve + * {@code afterBuildItem()} declarations to producing steps. + * + * @return a map of build item class name → producing step IDs + */ + public Map> getBuildItemProducers() { + Map> result = new HashMap<>(); + Set visited = new HashSet<>(); + Deque work = new ArrayDeque<>(execution.getBuildChain().getStartSteps()); + while (!work.isEmpty()) { + StepInfo step = work.poll(); + if (!visited.add(step)) { + continue; + } + String stepId = step.getBuildStep().getId(); + for (ItemId produced : step.getAllProduces()) { + result.computeIfAbsent(produced.getType().getName(), k -> new HashSet<>()).add(stepId); + } + for (StepInfo dependent : step.getDependents()) { + work.add(dependent); + } + } + return result; + } + // -- // private void doProduce(ItemId id, BuildItem value) { diff --git a/core/builder/src/main/java/io/quarkus/builder/BuildStepBuilder.java b/core/builder/src/main/java/io/quarkus/builder/BuildStepBuilder.java index fde609236af1c..fe4d0b5a7352d 100644 --- a/core/builder/src/main/java/io/quarkus/builder/BuildStepBuilder.java +++ b/core/builder/src/main/java/io/quarkus/builder/BuildStepBuilder.java @@ -245,6 +245,15 @@ Set getRealProduces() { return map.keySet(); } + /** + * Get all produced item IDs, including order-only items. + * + * @return the full produces key set + */ + Set getAllProduces() { + return produces.keySet(); + } + @Override public String toString() { StringBuilder builder = new StringBuilder(); diff --git a/core/builder/src/main/java/io/quarkus/builder/StepInfo.java b/core/builder/src/main/java/io/quarkus/builder/StepInfo.java index a0db9db8a5897..615fb5b2b7563 100644 --- a/core/builder/src/main/java/io/quarkus/builder/StepInfo.java +++ b/core/builder/src/main/java/io/quarkus/builder/StepInfo.java @@ -8,13 +8,15 @@ final class StepInfo { private final Set dependents; private final Set consumes; private final Set produces; + private final Set allProduces; private final int ordinal; - StepInfo(BuildStep buildStep, Set consumes, Set produces, int dependencies, Set dependents, - int ordinal) { + StepInfo(BuildStep buildStep, Set consumes, Set produces, Set allProduces, + int dependencies, Set dependents, int ordinal) { this.buildStep = buildStep; this.consumes = consumes; this.produces = produces; + this.allProduces = allProduces; this.dependencies = dependencies; this.dependents = dependents; this.ordinal = ordinal; @@ -40,6 +42,16 @@ Set getProduces() { return produces; } + /** + * Get all items this step produces, including order-only items + * (from {@code @Produce} annotations / {@code beforeConsume} calls). + * + * @return the full produces set + */ + Set getAllProduces() { + return allProduces; + } + /** * @return an index in the total topological ordering of all {@link StepInfo}s present in the given {@link Execution}. */ diff --git a/core/deployment/pom.xml b/core/deployment/pom.xml index a09275546ae35..cdd34446cb429 100644 --- a/core/deployment/pom.xml +++ b/core/deployment/pom.xml @@ -66,6 +66,10 @@ io.smallrye.common smallrye-common-version + + io.smallrye.serial + smallrye-serial + org.ow2.asm asm @@ -82,6 +86,10 @@ io.quarkus quarkus-class-change-agent + + io.smallrye.classfile + jdk-classfile-backport + io.quarkus quarkus-bootstrap-core @@ -242,6 +250,51 @@ + + org.codehaus.mojo + exec-maven-plugin + + + ${project.groupId} + quarkus-service-types-maker + ${project.version} + + + + + generation-action-sources + generate-sources + + java + + + true + io.quarkus.core.servicetypesmaker.MakeServiceTypes + + ${project.build.directory}/generated-sources/jdeparser + + + + + + + org.codehaus.mojo + build-helper-maven-plugin + + + add-jdeparser-sources + generate-sources + + add-source + + + + ${project.build.directory}/generated-sources/jdeparser + + + + + maven-compiler-plugin diff --git a/core/deployment/src/main/java/io/quarkus/core/deployment/service/ServiceRegistrar.java b/core/deployment/src/main/java/io/quarkus/core/deployment/service/ServiceRegistrar.java new file mode 100644 index 0000000000000..c44314aac8892 --- /dev/null +++ b/core/deployment/src/main/java/io/quarkus/core/deployment/service/ServiceRegistrar.java @@ -0,0 +1,354 @@ +package io.quarkus.core.deployment.service; + +import java.util.List; +import java.util.function.Supplier; + +import io.quarkus.core.Phase; +import io.quarkus.deployment.recording.BytecodeRecorderImpl; +import io.quarkus.runtime.RuntimeValue; + +/** + * The entry point for defining services from build steps. + *

+ * An {@code ServiceRegistrar} is injected as a parameter into {@code @BuildStep} methods. + * It provides a fluent API for defining services that execute at application startup. + * Services are assigned to a {@link Phase} via {@code atPhase()} on the builder; + * the default phase is {@link Phase#APPLICATION}. + *

Dependency ordering during recorder coexistence

+ * During the transitional period where bytecode recorders and services coexist, + * service execution order is determined by build step ordering (via {@code @Consume}/ + * {@code @Produce} annotations on build items), not by the service dependency graph. + * Service dependencies declared via {@code require()}, {@code after()}, etc. are + * validated against the build step ordering for consistency, but this validation + * may not detect all missing service dependencies. + *

+ * Once bytecode recorders have been fully removed, the bytecode generator will be + * rewritten to use strictly dependency-based ordering, and the build step ordering + * constraints will no longer be required. + *

+ * Until then, extension authors should declare both the build step ordering + * ({@code @Consume}/{@code @Produce}) and the service dependencies ({@code require()}/ + * {@code after()}) to ensure a smooth transition. + */ +public interface ServiceRegistrar { + + /** + * Begin defining a typed service with the given type and no name. + * The default phase is {@link Phase#APPLICATION}; use {@code atPhase()} to change it. + * + * @param serviceType the service type (must not be {@code null}) + * @param the service type + * @return a service builder with zero dependencies + */ + ServiceBuilder0 forService(Class serviceType); + + /** + * Begin defining a named typed service. + * The default phase is {@link Phase#APPLICATION}; use {@code atPhase()} to change it. + * + * @param serviceType the service type (must not be {@code null}) + * @param serviceName the service name (may be {@code null} for an unnamed service) + * @param the service type + * @return a service builder with zero dependencies + */ + ServiceBuilder0 forService(Class serviceType, String serviceName); + + /** + * Begin defining a named typed service with multi-part name. + * The default phase is {@link Phase#APPLICATION}; use {@code atPhase()} to change it. + * + * @param serviceType the service type (must not be {@code null}) + * @param serviceNameParts the service name parts (must not be {@code null}) + * @param the service type + * @return a service builder with zero dependencies + */ + ServiceBuilder0 forService(Class serviceType, List serviceNameParts); + + /** + * Begin defining a void service with the given name. + * Void services produce no value and are identified by name alone. + * The default phase is {@link Phase#APPLICATION}; use {@code atPhase()} to change it. + * + * @param serviceName the service name (must not be {@code null}) + * @return a void service builder with zero dependencies + */ + VoidServiceBuilder0 forService(String serviceName); + + /** + * Begin defining a void service with the given multi-part name. + * Void services produce no value and are identified by name alone. + * The default phase is {@link Phase#APPLICATION}; use {@code atPhase()} to change it. + * + * @param serviceNameParts the service name parts (must not be {@code null}) + * @return a void service builder with zero dependencies + */ + VoidServiceBuilder0 forService(List serviceNameParts); + + /** + * Publish a value produced by the legacy {@code @Recorder} system as a named service, + * making it available as a dependency for ServiceRegistrar-based services via {@code require()}. + *

+ * The {@code recorderValue} must be a recorder proxy (an object returned from a + * {@code @Recorder} method). The phase (static-init vs runtime) is determined + * automatically from the proxy. + *

+ * This is a temporary bridge to allow incremental migration from the recorder + * system to the ServiceRegistrar system. + * + * @param serviceType the service type to register the value under (must not be {@code null}) + * @param recorderValue the recorder-produced proxy value (must not be {@code null}) + * @param the service type + * @deprecated This method exists as a bridge to the deprecated bytecode recorder system, and will be removed + * at the same time. + */ + @Deprecated(since = "4.0") + void aliasRecorderValue(Class serviceType, T recorderValue); + + /** + * Publish a value produced by the legacy {@code @Recorder} system as an unnamed service + * in a specific runtime phase, + * making it available as a dependency for ServiceRegistrar-based services via {@code require()}. + *

+ * The {@code recorderValue} must be a recorder proxy (an object returned from a + * {@code @Recorder} method). The phase (static-init vs runtime) is determined + * automatically from the proxy. + *

+ * This is a temporary bridge to allow incremental migration from the recorder + * system to the ServiceRegistrar system. + * + * @param serviceType the service type to register the value under (must not be {@code null}) + * @param recorderValue the recorder-produced proxy value (must not be {@code null}) + * @param phase the runtime phase for the alias (must not be {@code null}) + * @param the service type + * @deprecated This method exists as a bridge to the deprecated bytecode recorder system, and will be removed + * at the same time. + */ + @Deprecated(since = "4.0") + void aliasRecorderValue(Class serviceType, T recorderValue, Phase phase); + + /** + * Publish a value produced by the legacy {@code @Recorder} system as a named service, + * making it available as a dependency for ServiceRegistrar-based services via {@code require()}. + *

+ * The {@code recorderValue} must be a recorder proxy (an object returned from a + * {@code @Recorder} method). The phase (static-init vs runtime) is determined + * automatically from the proxy. + *

+ * This is a temporary bridge to allow incremental migration from the recorder + * system to the ServiceRegistrar system. + * + * @param serviceType the service type to register the value under (must not be {@code null}) + * @param serviceName the service name (may be {@code null} for an unnamed service) + * @param recorderValue the recorder-produced proxy value (must not be {@code null}) + * @param the service type + * @deprecated This method exists as a bridge to the deprecated bytecode recorder system, and will be removed + * at the same time. + */ + @Deprecated(since = "4.0") + void aliasRecorderValue(Class serviceType, String serviceName, T recorderValue); + + /** + * Publish a value produced by the legacy {@code @Recorder} system as a named service + * in a specific runtime phase, + * making it available as a dependency for ServiceRegistrar-based services via {@code require()}. + *

+ * The {@code recorderValue} must be a recorder proxy (an object returned from a + * {@code @Recorder} method). The phase (static-init vs runtime) is determined + * automatically from the proxy. + *

+ * This is a temporary bridge to allow incremental migration from the recorder + * system to the ServiceRegistrar system. + * + * @param serviceType the service type to register the value under (must not be {@code null}) + * @param serviceName the service name (may be {@code null} for an unnamed service) + * @param recorderValue the recorder-produced proxy value (must not be {@code null}) + * @param phase the runtime phase for the alias (must not be {@code null}) + * @param the service type + * @deprecated This method exists as a bridge to the deprecated bytecode recorder system, and will be removed + * at the same time. + */ + @Deprecated(since = "4.0") + void aliasRecorderValue(Class serviceType, String serviceName, T recorderValue, Phase phase); + + // ── Service → Recorder bridge methods ── + + /** + * Produce a {@link RuntimeValue RuntimeValue<T>} proxy + * for a runtime service, suitable for passing to legacy {@code @Record(RUNTIME_INIT)} + * recorder methods or embedding in legacy build items (e.g. {@code VertxBuildItem}). + *

+ * The returned object extends {@code RuntimeValue} and implements + * {@link BytecodeRecorderImpl.ReturnedProxy ReturnedProxy}. + * At runtime, the recorder-generated code retrieves the wrapper from the startup context; + * the wrapper contains the actual service value. + *

+ * The service must be defined (via {@link #forService(Class)}) before or during the same + * build step. + * + * @param serviceType the service type (must not be {@code null}) + * @param the service type + * @return a {@code RuntimeValue} proxy for use with legacy recorders + * @deprecated This method exists as a bridge to the deprecated bytecode recorder system, and will be removed + * at the same time. + */ + @Deprecated(since = "4.0") + RuntimeValue serviceAsRuntimeValue(Class serviceType); + + /** + * Produce a {@link RuntimeValue RuntimeValue<T>} proxy + * for a named runtime service. + * + * @param serviceType the service type (must not be {@code null}) + * @param serviceName the service name (may be {@code null} for an unnamed service) + * @param the service type + * @return a {@code RuntimeValue} proxy for use with legacy recorders + * @see #serviceAsRuntimeValue(Class) + * @deprecated This method exists as a bridge to the deprecated bytecode recorder system, and will be removed + * at the same time. + */ + @Deprecated(since = "4.0") + RuntimeValue serviceAsRuntimeValue(Class serviceType, String serviceName); + + /** + * Produce a {@link RuntimeValue RuntimeValue<T>} proxy + * for a runtime service with a multi-part name. + * + * @param serviceType the service type (must not be {@code null}) + * @param serviceNameParts the service name parts (must not be {@code null}) + * @param the service type + * @return a {@code RuntimeValue} proxy for use with legacy recorders + * @see #serviceAsRuntimeValue(Class) + * @deprecated This method exists as a bridge to the deprecated bytecode recorder system, and will be removed + * at the same time. + */ + @Deprecated(since = "4.0") + RuntimeValue serviceAsRuntimeValue(Class serviceType, List serviceNameParts); + + /** + * Produce a bare {@code T} recorder proxy for a service defined earlier in this same build step, + * suitable for passing directly as a parameter to a legacy {@code @Record(STATIC_INIT)} or + * {@code @Record(RUNTIME_INIT)} recorder method, or embedding in a legacy build item. + *

+ * The returned proxy implements {@link BytecodeRecorderImpl.ReturnedProxy ReturnedProxy} + * and the service type. When the recorder sees this proxy as a method argument, it generates + * code to load the service value from the startup context. The proxy phase is inferred from + * the service definition, so it is valid for whichever recorder phase matches that service. + *

+ * The service must be defined (its {@code action()}/{@code actionAsync()} called) earlier in the + * same build step; phase resolution is per-build-step. Static-init services are retained + * across the phase boundary automatically, because the downstream consumer and its phase cannot + * be detected here. + * + * @param serviceType the service type (must not be {@code null}; must be proxyable) + * @param the service type + * @return a {@code T} proxy for use with legacy recorders + * @throws IllegalStateException if the service is not defined in this build step + * @deprecated This method exists as a bridge to the deprecated bytecode recorder system, and will be removed + * at the same time. + */ + @Deprecated(since = "4.0") + T getRecorderProxy(Class serviceType); + + /** + * Produce a bare {@code T} recorder proxy for a named service defined earlier in this same build step. + * + * @param serviceType the service type (must not be {@code null}; must be proxyable) + * @param serviceName the service name (may be {@code null} for an unnamed service) + * @param the service type + * @return a {@code T} proxy for use with legacy recorders + * @throws IllegalStateException if the service is not defined in this build step + * @see #getRecorderProxy(Class) + * @deprecated This method exists as a bridge to the deprecated bytecode recorder system, and will be removed + * at the same time. + */ + @Deprecated(since = "4.0") + T getRecorderProxy(Class serviceType, String serviceName); + + // ── Service → Supplier bridge (temporary) ── + + /** + * Produce a {@link Supplier Supplier<T>} proxy + * for a runtime service, suitable for passing to legacy recorder methods or + * embedding in build items that expose {@code Supplier}. + *

+ * At runtime, the generated bytecode loads the service value and wraps it + * in a {@code Supplier}. The returned proxy implements + * {@link BytecodeRecorderImpl.ReturnedProxy ReturnedProxy}. + *

+ * This is a temporary bridge for the recorder coexistence period. Once all + * consumers are converted to use {@code require()}, this method and the + * build items that use it should be removed. + * + * @param serviceType the service type (must not be {@code null}) + * @param the service type + * @return a {@code Supplier} proxy for use with legacy recorders and build items + * @deprecated This method exists as a bridge to the deprecated bytecode recorder system, and will be removed + * at the same time. + */ + @Deprecated(since = "4.0") + Supplier serviceAsRecorderSupplier(Class serviceType); + + /** + * Produce a {@link Supplier Supplier<T>} proxy + * for a named runtime service. + *

+ * This is a temporary bridge for the recorder coexistence period. + * + * @param serviceType the service type (must not be {@code null}) + * @param serviceName the service name (may be {@code null} for an unnamed service) + * @param the service type + * @return a {@code Supplier} proxy for use with legacy recorders and build items + * @see #serviceAsRecorderSupplier(Class) + * @deprecated This method exists as a bridge to the deprecated bytecode recorder system, and will be removed + * at the same time. + */ + @Deprecated(since = "4.0") + Supplier serviceAsRecorderSupplier(Class serviceType, String serviceName); + + // ── Static-init → Recorder bridge methods ── + + /** + * Produce a {@link RuntimeValue RuntimeValue<T>} proxy + * for a static-init service, suitable for passing to legacy + * {@code @Record(STATIC_INIT)} or {@code @Record(RUNTIME_INIT)} recorder methods. + * + * @param serviceType the service type (must not be {@code null}) + * @param the service type + * @return a {@code RuntimeValue} proxy for use with legacy recorders + * @see #serviceAsRuntimeValue(Class) + * @deprecated This method exists as a bridge to the deprecated bytecode recorder system, and will be removed + * at the same time. + */ + @Deprecated(since = "4.0") + RuntimeValue staticInitServiceAsRuntimeValue(Class serviceType); + + /** + * Produce a {@link RuntimeValue RuntimeValue<T>} proxy + * for a named static-init service. + * + * @param serviceType the service type (must not be {@code null}) + * @param serviceName the service name (may be {@code null} for an unnamed service) + * @param the service type + * @return a {@code RuntimeValue} proxy for use with legacy recorders + * @see #staticInitServiceAsRuntimeValue(Class) + * @deprecated This method exists as a bridge to the deprecated bytecode recorder system, and will be removed + * at the same time. + */ + @Deprecated(since = "4.0") + RuntimeValue staticInitServiceAsRuntimeValue(Class serviceType, String serviceName); + + /** + * Produce a {@link RuntimeValue RuntimeValue<T>} proxy + * for a static-init service with a multi-part name. + * + * @param serviceType the service type (must not be {@code null}) + * @param serviceNameParts the service name parts (must not be {@code null}) + * @param the service type + * @return a {@code RuntimeValue} proxy for use with legacy recorders + * @see #staticInitServiceAsRuntimeValue(Class) + * @deprecated This method exists as a bridge to the deprecated bytecode recorder system, and will be removed + * at the same time. + */ + @Deprecated(since = "4.0") + RuntimeValue staticInitServiceAsRuntimeValue(Class serviceType, List serviceNameParts); +} diff --git a/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/CaptureEmitter.java b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/CaptureEmitter.java new file mode 100644 index 0000000000000..51467367a105d --- /dev/null +++ b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/CaptureEmitter.java @@ -0,0 +1,22 @@ +package io.quarkus.core.deployment.service.impl; + +import io.smallrye.classfile.CodeBuilder; + +/** + * Emits bytecode that loads a captured value onto the operand stack. + *

+ * Every captured value in a service action lambda resolves to a {@code CaptureEmitter} + * during extraction. For simple constants this wraps a single {@code ldc} instruction; + * for compound values (collections, maps) it may emit an entire instruction sequence + * that reconstructs the value inline. + */ +@FunctionalInterface +public interface CaptureEmitter { + + /** + * Emit bytecode that pushes this captured value onto the operand stack. + * + * @param code the code builder to emit into (never {@code null}) + */ + void emit(CodeBuilder code); +} diff --git a/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/ConfigMappingDetector.java b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/ConfigMappingDetector.java new file mode 100644 index 0000000000000..749f52959c65e --- /dev/null +++ b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/ConfigMappingDetector.java @@ -0,0 +1,56 @@ +package io.quarkus.core.deployment.service.impl; + +import io.quarkus.runtime.annotations.ConfigRoot; +import io.smallrye.config.ConfigMapping; + +/** + * Utility for detecting {@code @ConfigMapping} + {@code @ConfigRoot} interfaces. + * Shared between {@link ConfigCaptureInterceptor} and {@link ServiceBuilderImpl}. + */ +final class ConfigMappingDetector { + + private ConfigMappingDetector() { + } + + /** + * Find the {@code @ConfigMapping}-annotated interface implemented by the given class. + * Walks the class hierarchy and all implemented interfaces. + * + * @param clazz the class to inspect + * @return the {@code @ConfigMapping}-annotated interface, or {@code null} if none found + */ + static Class findConfigMappingInterface(Class clazz) { + if (clazz.isInterface() && clazz.isAnnotationPresent(ConfigMapping.class)) { + return clazz; + } + for (Class iface : clazz.getInterfaces()) { + if (iface.isAnnotationPresent(ConfigMapping.class)) { + return iface; + } + Class found = findConfigMappingInterface(iface); + if (found != null) { + return found; + } + } + Class superclass = clazz.getSuperclass(); + if (superclass != null && superclass != Object.class) { + return findConfigMappingInterface(superclass); + } + return null; + } + + /** + * Find the {@code @ConfigRoot} annotation on the config mapping interface for the given type. + * Returns {@code null} if the type is not a config mapping or has no {@code @ConfigRoot}. + * + * @param type the type to check + * @return the {@code @ConfigRoot} annotation, or {@code null} + */ + static ConfigRoot findConfigRoot(Class type) { + Class configInterface = findConfigMappingInterface(type); + if (configInterface == null) { + return null; + } + return configInterface.getAnnotation(ConfigRoot.class); + } +} diff --git a/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/ConfigMappingSerializer.java b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/ConfigMappingSerializer.java new file mode 100644 index 0000000000000..21e78ac4298ee --- /dev/null +++ b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/ConfigMappingSerializer.java @@ -0,0 +1,43 @@ +package io.quarkus.core.deployment.service.impl; + +import java.io.IOException; + +import io.smallrye.serial.Serialized; +import io.smallrye.serial.spi.ObjectSerializer; + +/** + * A custom {@link ObjectSerializer} for {@code smallrye-serial} that intercepts + * {@code @ConfigMapping} + {@code @ConfigRoot} config instances, producing a + * {@link SerializedConfigMapping} instead of attempting standard serialization. + *

+ * Config mapping instances are SmallRye Config proxies that are not serializable. + * At runtime, they are resolved directly from SmallRye Config via + * {@code ConfigLookup.getConfigMapping(type)}. + *

+ * Registered at a priority above {@code PRIORITY_REPLACE} to intercept before + * any default serializer attempts to serialize the config proxy. + */ +final class ConfigMappingSerializer implements ObjectSerializer { + + /** + * Singleton instance. + */ + static final ConfigMappingSerializer INSTANCE = new ConfigMappingSerializer(); + + private ConfigMappingSerializer() { + } + + @Override + public Serialized serialize(Context ctxt, Object object) throws IOException { + Class configInterface = ConfigMappingDetector.findConfigMappingInterface(object.getClass()); + if (configInterface != null && ConfigMappingDetector.findConfigRoot(object.getClass()) != null) { + return new SerializedConfigMapping(configInterface.describeConstable().orElseThrow()); + } + return ctxt.next(); + } + + @Override + public int priority() { + return PRIORITY_REPLACE + 2; + } +} diff --git a/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/ConstantDescSerializer.java b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/ConstantDescSerializer.java new file mode 100644 index 0000000000000..1d0c58b4d0604 --- /dev/null +++ b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/ConstantDescSerializer.java @@ -0,0 +1,67 @@ +package io.quarkus.core.deployment.service.impl; + +import java.io.IOException; +import java.lang.constant.ClassDesc; +import java.lang.constant.Constable; +import java.lang.constant.ConstantDesc; + +import io.smallrye.serial.Serialized; +import io.smallrye.serial.spi.ObjectSerializer; + +/** + * A custom {@link ObjectSerializer} for {@code smallrye-serial} that intercepts + * {@link ConstantDesc} and {@link Constable} values, producing a {@link SerializedConstant} + * instead of attempting standard Java serialization. + *

+ * This covers strings, enums, primitive wrappers, and any other type that + * can describe itself as a JVM constant. {@link Class} objects are not intercepted + * because this serializer runs at a priority below {@code ClassSerializer}, which + * handles them first and produces the correct {@code SerializedClass} subtype + * (needed by {@code SerializedRecord} for type descriptors). + *

+ * For {@link Constable} objects whose {@code describeConstable()} returns empty, + * serialization is delegated to the next serializer in the chain. + *

+ * Registered at a priority between {@code PRIORITY_CLASS} and {@code PRIORITY_BASIC}, + * so that {@code ClassSerializer} handles {@code Class} objects first, but constants + * are intercepted before the default string/enum/record serializers. + */ +final class ConstantDescSerializer implements ObjectSerializer { + + /** + * Singleton instance. + */ + static final ConstantDescSerializer INSTANCE = new ConstantDescSerializer(); + + private ConstantDescSerializer() { + } + + @Override + public Serialized serialize(Context ctxt, Object object) throws IOException { + // Boolean, Byte, Short, Character: store as int with the original box type + // (their describeConstable() returns DynamicConstantDesc that loads a reference, + // but primitive slots need an int value) + return switch (object) { + case Boolean b -> + new SerializedConstant(Integer.valueOf(b.booleanValue() ? 1 : 0), ClassDesc.of("java.lang.Boolean")); + case Byte b -> new SerializedConstant(Integer.valueOf(b.intValue()), ClassDesc.of("java.lang.Byte")); + case Short s -> new SerializedConstant(Integer.valueOf(s.intValue()), ClassDesc.of("java.lang.Short")); + case Character c -> new SerializedConstant(Integer.valueOf(c.charValue()), ClassDesc.of("java.lang.Character")); + case ConstantDesc cd -> new SerializedConstant(cd); + case Constable c -> { + var opt = c.describeConstable(); + if (opt.isPresent()) { + yield new SerializedConstant(opt.get()); + } + // Constable but describeConstable() returned empty — delegate to next + yield ctxt.next(); + } + case null, default -> ctxt.next(); + }; + } + + @Override + public int priority() { + return PRIORITY_REPLACE + 1; + } +} diff --git a/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/Dependency.java b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/Dependency.java new file mode 100644 index 0000000000000..6deaf956c0bd4 --- /dev/null +++ b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/Dependency.java @@ -0,0 +1,90 @@ +package io.quarkus.core.deployment.service.impl; + +import java.util.List; + +import io.quarkus.runtime.StartupContext; +import io.smallrye.common.constraint.Assert; + +/** + * A service dependency declaration. + * + * @param type the dependency type + * @param nameParts the parts of the name (must not be {@code null}) + * @param flags bitset of dependency flags (use the {@code FL_*} constants) + */ +public record Dependency(Class type, List nameParts, int flags) { + + /** The dependency value is injected into the action (not just ordering). */ + public static final int FL_INJECTED = 1; + + /** The dependency is optional (absent providers do not cause validation errors). */ + public static final int FL_OPTIONAL = 2; + + /** The dependency consumes all services of the given type as a {@code Map}. */ + public static final int FL_CONSUME_ALL = 4; + + /** + * The dependency is a {@code @ConfigRoot} config mapping resolved directly + * from SmallRye Config at runtime, bypassing the service values map. + */ + public static final int FL_CONFIG_DIRECT = 8; + + public Dependency { + Assert.checkNotNullParam("type", type); + nameParts = List.copyOf(Assert.checkNotNullParam("nameParts", nameParts)); + for (String namePart : nameParts) { + if (namePart.indexOf('/') != -1) { + throw new IllegalArgumentException("Name part must not contain '/'"); + } + } + } + + /** + * {@return {@code true} if this dependency is injected into the action} + */ + public boolean injected() { + return (flags & FL_INJECTED) != 0; + } + + /** + * {@return {@code true} if this dependency is optional} + */ + public boolean optional() { + return (flags & FL_OPTIONAL) != 0; + } + + /** + * {@return {@code true} if this dependency consumes all matching services as a map} + */ + public boolean consumeAll() { + return (flags & FL_CONSUME_ALL) != 0; + } + + /** + * {@return {@code true} if this is a config-direct dependency} + */ + public boolean configDirect() { + return (flags & FL_CONFIG_DIRECT) != 0; + } + + /** + * Get the key used to look up this dependency's value in the {@link StartupContext}. + * This will only be used until bytecode recording has been removed. + * + * @return the key string + */ + public String key() { + return LambdaTransliterator.serviceKey(type, nameParts); + } + + /** + * Get the key prefix for matching services in a {@code consumeAll} dependency. + * For a type {@code Foo.class} with no name parts, this returns {@code "com.example.Foo:"}. + * A service key matches if it starts with this prefix. + * + * @return the key prefix string + */ + public String keyPrefix() { + return type.getName() + ":"; + } +} diff --git a/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/LambdaTransliterator.java b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/LambdaTransliterator.java new file mode 100644 index 0000000000000..d0428847d1ea7 --- /dev/null +++ b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/LambdaTransliterator.java @@ -0,0 +1,1238 @@ +package io.quarkus.core.deployment.service.impl; + +import java.io.IOException; +import java.io.InputStream; +import java.io.Serializable; +import java.lang.constant.ClassDesc; +import java.lang.constant.ConstantDescs; +import java.lang.constant.MethodTypeDesc; +import java.lang.invoke.LambdaMetafactory; +import java.lang.invoke.MethodHandleInfo; +import java.lang.invoke.SerializedLambda; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.UnaryOperator; + +import org.jboss.logging.Logger; + +import io.quarkus.core.impl.ServiceGraph; +import io.quarkus.core.impl.ServiceNode; +import io.quarkus.core.impl.SortedNullSafeMap; +import io.quarkus.runtime.RuntimeValue; +import io.quarkus.runtime.StartupContext; +import io.smallrye.classfile.BootstrapMethodEntry; +import io.smallrye.classfile.ClassFile; +import io.smallrye.classfile.ClassModel; +import io.smallrye.classfile.CodeBuilder; +import io.smallrye.classfile.CodeElement; +import io.smallrye.classfile.CodeModel; +import io.smallrye.classfile.Label; +import io.smallrye.classfile.MethodModel; +import io.smallrye.classfile.Opcode; +import io.smallrye.classfile.TypeKind; +import io.smallrye.classfile.attribute.EnclosingMethodAttribute; +import io.smallrye.classfile.attribute.InnerClassInfo; +import io.smallrye.classfile.attribute.InnerClassesAttribute; +import io.smallrye.classfile.attribute.NestHostAttribute; +import io.smallrye.classfile.attribute.NestMembersAttribute; +import io.smallrye.classfile.components.ClassRemapper; +import io.smallrye.classfile.constantpool.ConstantPoolBuilder; +import io.smallrye.classfile.constantpool.InvokeDynamicEntry; +import io.smallrye.classfile.constantpool.LoadableConstantEntry; +import io.smallrye.classfile.constantpool.MemberRefEntry; +import io.smallrye.classfile.constantpool.MethodHandleEntry; +import io.smallrye.classfile.constantpool.MethodTypeEntry; +import io.smallrye.classfile.extras.constant.ConstantUtils; +import io.smallrye.classfile.extras.constant.ExtraClassDesc; +import io.smallrye.classfile.extras.constant.ExtraConstantDescs; +import io.smallrye.classfile.instruction.IncrementInstruction; +import io.smallrye.classfile.instruction.InvokeDynamicInstruction; +import io.smallrye.classfile.instruction.InvokeInstruction; +import io.smallrye.classfile.instruction.LineNumber; +import io.smallrye.classfile.instruction.LoadInstruction; +import io.smallrye.classfile.instruction.LocalVariable; +import io.smallrye.classfile.instruction.LocalVariableType; +import io.smallrye.classfile.instruction.NewObjectInstruction; +import io.smallrye.classfile.instruction.ReturnInstruction; +import io.smallrye.classfile.instruction.StoreInstruction; +import io.smallrye.classfile.instruction.TypeCheckInstruction; +import io.smallrye.serial.SerialContext; + +/** + * Transliterates serializable lambdas into generated classes for the service + * action system. + *

+ * The transliteration process is split into two phases: + *

    + *
  1. {@link #extract(Serializable, Class, List, List, boolean, boolean, List) extract()} — + * eagerly extracts the lambda metadata and body during build step execution, + * producing a {@link TransliteratedAction.ActionService} data record
  2. + *
  3. {@link #generateConsolidatedClass(String, List) generateConsolidatedClass()} — + * deferred to {@code MainClassBuildStep}, batches multiple actions into a single + * class with static deploy methods to reduce classloading overhead at startup
  4. + *
+ */ +public final class LambdaTransliterator { + + /** Descriptor for {@link ServiceNode}. */ + private static final ClassDesc CD_ServiceNode = ConstantUtils.classDesc(ServiceNode.class); + /** Descriptor for {@link SortedNullSafeMap}. */ + private static final ClassDesc CD_SortedNullSafeMap = ConstantUtils.classDesc(SortedNullSafeMap.class); + /** Descriptor for {@link Map}. */ + private static final ClassDesc CD_Map = ConstantUtils.classDesc(Map.class); + + /** + * Internal names of START context interfaces whose invokeinterface calls + * are rewritten to invokevirtual on ServiceNode during transliteration. + *

+ * Only start context interfaces belong here — the {@code ctx} parameter + * in the action lambda is always a {@code ServiceNode}. {@code AsyncStopContext} + * must NOT be included: it is a callback object passed to the stop handler, + * not the node itself. + */ + private static final Set CONTEXT_INTERFACE_INTERNALS = Set.of( + "io/quarkus/core/StartContext", + "io/quarkus/core/AsyncStartContext", + "io/quarkus/core/AsyncVoidStartContext"); + + /** + * Descriptor strings for start context interfaces that should be remapped + * to {@code ServiceNode} in inner method signatures and invokedynamic + * bootstrap args. + */ + private static final Set CONTEXT_INTERFACE_DESCRIPTORS = Set.of( + "Lio/quarkus/core/StartContext;", + "Lio/quarkus/core/AsyncStartContext;", + "Lio/quarkus/core/AsyncVoidStartContext;"); + + /** + * Remap any context interface parameter types to {@code ServiceNode} + * in a method type descriptor. Since the context interfaces are sealed + * to {@code ServiceNode}, this is always type-safe. + * + * @param mtd the original method type descriptor + * @return the remapped descriptor (unchanged if no context types present) + */ + private static MethodTypeDesc remapContextTypes(MethodTypeDesc mtd) { + boolean changed = false; + ClassDesc[] params = new ClassDesc[mtd.parameterCount()]; + for (int i = 0; i < params.length; i++) { + ClassDesc p = mtd.parameterType(i); + if (CONTEXT_INTERFACE_DESCRIPTORS.contains(p.descriptorString())) { + params[i] = CD_ServiceNode; + changed = true; + } else { + params[i] = p; + } + } + return changed ? MethodTypeDesc.of(mtd.returnType(), params) : mtd; + } + + private LambdaTransliterator() { + } + + /** + * Extract a service action lambda into a {@link TransliteratedAction.ActionService} + * for deferred class generation. + *

+ * This performs all validation and bytecode analysis of the lambda but does + * not generate any class. The returned record carries all data needed for + * later code generation via {@link #generateConsolidatedClass}. + * + * @param lambda the serializable lambda instance (must not be {@code null}) + * @param serviceType the service type produced by this action (must not be {@code null}) + * @param serviceNameParts the service name parts (must not be {@code null}) + * @param dependencies the dependency identities, in order (must not be {@code null}) + * @param beforeKeys service keys that should depend on this service (reverse ordering; + * must not be {@code null}; may be empty) + * @param async {@code true} if this is an async action (uses {@code AsyncStartContext}) + * @param optional {@code true} if this service is declared {@code optional()} (its action returns + * {@code Optional} and an empty result marks the service as valueless rather than a failure) + * @param staticInit {@code true} if this is a static-init service + * @param stepId the producing build step's ID + * @return the extracted action data + * @throws TransliterationException if the lambda cannot be transliterated + */ + public static TransliteratedAction.ActionService extract( + Serializable lambda, + Class serviceType, + List serviceNameParts, + List dependencies, + List beforeKeys, + List afterBuildItemClasses, + boolean async, + boolean optional, + boolean staticInit, + String stepId) { + if (staticInit && async) { + throw new TransliterationException( + "Static-init services do not support async actions"); + } + // extract the SerializedLambda metadata + SerializedLambda sl = extractSerializedLambda(lambda); + + // only static lambda impl methods are supported + if (sl.getImplMethodKind() != MethodHandleInfo.REF_invokeStatic) { + throw new TransliterationException( + "Only static lambda implementations are supported; got method kind " + + sl.getImplMethodKind() + " for " + sl.getImplClass() + "::" + sl.getImplMethodName() + + ". Instance-capturing lambdas and instance method references are not allowed."); + } + + int capturedArgCount = sl.getCapturedArgCount(); + + // resolve captured args to emitters via serialization + CaptureEmitter[] capturedEmitters = new CaptureEmitter[capturedArgCount]; + MethodTypeDesc mtd0 = MethodTypeDesc.ofDescriptor(sl.getImplMethodSignature()); + var emitter = new SerializedCaptureEmitter(); + // single serializer for the loop: enables cross-capture dedup + // when the same object is captured in multiple slots + var serializer = buildSerialContext().createSerializer(); + for (int i = 0; i < capturedArgCount; i++) { + Object captured = sl.getCapturedArg(i); + if (captured == null) { + capturedEmitters[i] = CodeBuilder::aconst_null; + continue; + } + try { + var serialized = serializer.serialize(captured); + capturedEmitters[i] = emitter.emitFor(serialized, mtd0.parameterType(i)); + } catch (IOException e) { + throw new TransliterationException( + "Failed to serialize captured argument " + i + " of type " + + captured.getClass().getName() + + ". Lambda: " + sl.getImplClass().replace('/', '.') + "::" + sl.getImplMethodName(), + e); + } + } + + // load the impl class bytecode + String implClassInternal = sl.getImplClass(); + byte[] implClassBytes = loadClassBytes(implClassInternal); + + // parse and find the impl method + ClassModel classModel = ClassFile.of().parse(implClassBytes); + String implMethodName = sl.getImplMethodName(); + String implMethodSig = sl.getImplMethodSignature(); + MethodModel implMethod = classModel.methods().stream() + .filter(m -> m.methodName().stringValue().equals(implMethodName) + && m.methodType().stringValue().equals(implMethodSig)) + .findFirst() + .orElseThrow(() -> new TransliterationException( + "Lambda implementation method not found: " + + implClassInternal.replace('/', '.') + "::" + implMethodName + implMethodSig)); + + CodeModel originalCode = implMethod.code() + .orElseThrow(() -> new TransliterationException( + "Lambda implementation method has no code: " + implMethodName)); + + // parse the method descriptor to compute capture slot layout + MethodTypeDesc mtd = MethodTypeDesc.ofDescriptor(implMethodSig); + int totalParams = mtd.parameterCount(); + if (capturedArgCount > totalParams) { + throw new TransliterationException( + "Captured arg count (" + capturedArgCount + ") exceeds parameter count (" + totalParams + ")"); + } + + // compute total slot count consumed by captured parameters + int captureSlots = 0; + int[] captureSlotStart = new int[capturedArgCount]; // slot index for each capture + for (int i = 0; i < capturedArgCount; i++) { + captureSlotStart[i] = captureSlots; + ClassDesc paramType = mtd.parameterType(i); + captureSlots += TypeKind.from(paramType).slotSize(); + } + final int captureSlotCount = captureSlots; + + // build slot→captureIndex mapping for fast lookup + int[] slotToCaptureIndex = new int[captureSlotCount]; + Arrays.fill(slotToCaptureIndex, -1); + for (int i = 0; i < capturedArgCount; i++) { + slotToCaptureIndex[captureSlotStart[i]] = i; + } + + // compute the transliterated method descriptor (drops captured params) + ClassDesc[] declaredParamTypes = new ClassDesc[totalParams - capturedArgCount]; + for (int i = capturedArgCount; i < totalParams; i++) { + declaredParamTypes[i - capturedArgCount] = mtd.parameterType(i); + } + MethodTypeDesc actionMethodType = MethodTypeDesc.of(mtd.returnType(), declaredParamTypes); + + String serviceKey = serviceKey(serviceType, serviceNameParts); + + return new TransliteratedAction.ActionService( + serviceKey, + List.copyOf(dependencies), + List.copyOf(beforeKeys), + List.copyOf(afterBuildItemClasses), + async, + optional, + staticInit, + actionMethodType, + originalCode, + captureSlotCount, + slotToCaptureIndex, + capturedEmitters, + implClassInternal, + classModel, + stepId); + } + + /** + * Generate a consolidated class containing deploy methods for multiple + * service actions. + *

+ * Each action at index {@code i} produces: + *

    + *
  • {@code public static void deploy$i(StartupContext)} — the entry point
  • + *
  • For {@link TransliteratedAction.ActionService}: a private static + * {@code action$i} method (the transliterated lambda body) plus inner + * lambda methods prefixed with {@code s$i$}
  • + *
  • For {@link TransliteratedAction.AliasService}: the deploy method + * simply copies a value between startup context keys
  • + *
  • For {@link TransliteratedAction.RuntimeValueWrapper}: the deploy method + * reads the bare service value and stores a {@code RuntimeValue} wrapper + * under a separate key
  • + *
+ * The generated class does not implement any interface; deploy methods are + * invoked directly via {@code invokestatic}. + * + * @param classInternalName the internal name for the generated class (using {@code /} separators) + * @param actions the list of actions to include (must not be empty) + * @return a map of internal class name → class file bytes including the + * consolidated class itself plus any copied anonymous inner classes + */ + public static Map generateConsolidatedClass( + String classInternalName, + List actions) { + ClassDesc generatedClassDesc = ExtraClassDesc.ofInternalName(classInternalName); + + Map classRemapTable = new LinkedHashMap<>(); + int[] anonCounter = { 0 }; + + byte[] consolidatedBytes = ClassFile.of().build(generatedClassDesc, cb -> { + cb.withVersion(ClassFile.JAVA_21_VERSION, 0); + cb.withFlags(ClassFile.ACC_PUBLIC | ClassFile.ACC_FINAL | ClassFile.ACC_SUPER); + + ClassDesc serviceNodeDesc = ConstantUtils.classDesc(ServiceNode.class); + + for (int i = 0; i < actions.size(); i++) { + TransliteratedAction action = actions.get(i); + String deployMethodName = "deploy$" + i; + + if (action instanceof TransliteratedAction.ActionService as) { + String innerPrefix = "s$" + i + "$"; + + // deploy method: loads deps, inlines action body, signals completion + // inner lambdas discovered during inlining are tracked for BFS copy + Set pendingMethods = new LinkedHashSet<>(); + cb.withMethodBody( + deployMethodName, + MethodTypeDesc.of(ConstantDescs.CD_void, serviceNodeDesc), + ClassFile.ACC_PUBLIC | ClassFile.ACC_STATIC, + code -> generateActionDeployBody( + code, as, generatedClassDesc, deployMethodName, actions, + classRemapTable, classInternalName, anonCounter, + innerPrefix, pendingMethods)); + + // BFS: copy inner lambda methods discovered during inlining + UnaryOperator remapper = name -> innerPrefix + name; + Set processedMethods = new HashSet<>(); + while (!pendingMethods.isEmpty()) { + Iterator it = pendingMethods.iterator(); + InnerMethod inner = it.next(); + it.remove(); + String methodKey = inner.name() + inner.descriptor(); + if (!processedMethods.add(methodKey)) { + continue; + } + MethodModel innerMethod = as.enclosingClassModel().methods().stream() + .filter(m -> m.methodName().stringValue().equals(inner.name()) + && m.methodType().stringValue().equals(inner.descriptor())) + .findFirst() + .orElseThrow(() -> new TransliterationException( + "Inner lambda method not found: " + + as.enclosingClassInternal().replace('/', '.') + "::" + + inner.name() + inner.descriptor())); + CodeModel innerCode = innerMethod.code() + .orElseThrow(() -> new TransliterationException( + "Inner lambda method has no code: " + inner.name())); + MethodTypeDesc innerMtd = remapContextTypes( + MethodTypeDesc.ofDescriptor(inner.descriptor())); + cb.withMethodBody( + inner.remappedName(), + innerMtd, + ClassFile.ACC_PRIVATE | ClassFile.ACC_STATIC, + code -> copyMethodBody(innerCode, code, + 0, new int[0], new CaptureEmitter[0], + as.enclosingClassInternal(), generatedClassDesc, + pendingMethods, remapper, + classRemapTable, classInternalName, anonCounter, + inner.remappedName(), innerMtd, + null, -1)); + } + } else if (action instanceof TransliteratedAction.AliasService alias) { + // alias deploy: copies value from recorder key to service key, then completes + cb.withMethodBody( + deployMethodName, + MethodTypeDesc.of(ConstantDescs.CD_void, serviceNodeDesc), + ClassFile.ACC_PUBLIC | ClassFile.ACC_STATIC, + code -> generateAliasDeployBody(code, alias)); + } else if (action instanceof TransliteratedAction.RuntimeValueWrapper rvw) { + // rv wrapper deploy: reads service value, wraps in RuntimeValue, stores, then completes + cb.withMethodBody( + deployMethodName, + MethodTypeDesc.of(ConstantDescs.CD_void, serviceNodeDesc), + ClassFile.ACC_PUBLIC | ClassFile.ACC_STATIC, + code -> generateRuntimeValueWrapperDeployBody(code, rvw)); + } + } + + // emit nest-mate and inner class attributes for any copied anonymous classes + if (!classRemapTable.isEmpty()) { + List innerDescs = classRemapTable.values().stream() + .map(e -> ExtraClassDesc.ofInternalName(e.newInternalName())) + .toList(); + cb.with(NestMembersAttribute.ofSymbols(innerDescs)); + cb.with(InnerClassesAttribute.of(innerDescs.stream() + .map(d -> InnerClassInfo.of(d, + Optional.empty(), Optional.empty(), 0)) + .toList())); + } + }); + + // build the result map: consolidated class + any anonymous class copies + Map result = new LinkedHashMap<>(); + if (!classRemapTable.isEmpty()) { + ClassDesc consolidatedDesc = ExtraClassDesc.ofInternalName(classInternalName); + Map descMap = new HashMap<>(); + for (var entry : classRemapTable.entrySet()) { + descMap.put(ExtraClassDesc.ofInternalName(entry.getKey()), + ExtraClassDesc.ofInternalName(entry.getValue().newInternalName())); + } + // remap remaining references in method descriptors and invokedynamic types + ClassModel consolidatedModel = ClassFile.of().parse(consolidatedBytes); + consolidatedBytes = ClassRemapper.of(descMap).remapClass(ClassFile.of(), consolidatedModel); + for (var entry : classRemapTable.entrySet()) { + result.put(entry.getValue().newInternalName(), + copyInnerClass(entry.getKey(), entry.getValue(), + descMap, consolidatedDesc)); + } + } + result.put(classInternalName, consolidatedBytes); + return result; + } + + /** + * Generate the deploy method body for an {@link TransliteratedAction.ActionService}. + *

+ * The generated code loads dependencies, invokes the transliterated action + * method, and signals completion on the {@link ServiceNode}. The node itself + * serves as the start context (it implements the context interfaces). + *

+ * Slot numbering starts at 0 for the {@code ServiceNode} parameter + * (static method — no {@code this}). + * + * @param code the code builder to emit instructions into + * @param action the action service data + * @param generatedClassDesc the generated class descriptor + * @param deployMethodName the deploy method name (for inner lambda enclosing method references) + * @param allActions all actions in the consolidated class (for consumeAll resolution) + * @param classRemapTable shared map tracking anonymous inner class remappings + * @param consolidatedClassInternalName the internal name of the consolidated class + * @param anonCounter shared counter for anonymous class naming + * @param innerPrefix prefix for inner lambda method names + * @param pendingInnerMethods set to which discovered inner lambda methods are added + */ + private static void generateActionDeployBody( + CodeBuilder code, + TransliteratedAction.ActionService action, + ClassDesc generatedClassDesc, + String deployMethodName, + List allActions, + Map classRemapTable, + String consolidatedClassInternalName, + int[] anonCounter, + String innerPrefix, + Set pendingInnerMethods) { + // slot 0 = ServiceNode (static method — no `this`) + // the ServiceNode is both the method parameter and the start context + ClassDesc serviceNodeDesc = ConstantUtils.classDesc(ServiceNode.class); + ClassDesc objectDesc = ConstantUtils.classDesc(Object.class); + ClassDesc stringDesc = ConstantUtils.classDesc(String.class); + MethodTypeDesc actionMethodType = action.actionMethodType(); + List dependencies = action.dependencies(); + String serviceKey = action.serviceKey(); + boolean async = action.async(); + boolean optional = action.optional(); + + // load injected dependencies via indexed access + // Normal (non-configDirect, non-consumeAll) deps occupy positions in the + // ServiceNode's dependency array. The graphDepIndex tracks positions in + // that array independently of the full dependency list index, since + // configDirect and consumeAll deps are resolved through other mechanisms + // and do not have entries in the ServiceNode's dependency array. + ClassDesc classDesc = ConstantUtils.classDesc(Class.class); + ClassDesc configLookupDesc = ClassDesc.of("io.quarkus.runtime.configuration.ConfigLookup"); + int resultSlot = 1; // first available slot after ServiceNode param + int graphDepIndex = 0; // position in the ServiceNode's dependency array + for (Dependency dep : dependencies) { + if (!dep.injected()) { + continue; + } + if (dep.configDirect()) { + // config-direct dep: resolve directly from SmallRye Config + code.loadConstant(dep.type().describeConstable().orElseThrow()); + code.invokestatic(configLookupDesc, "getConfigMapping", + MethodTypeDesc.of(objectDesc, classDesc)); + code.checkcast(dep.type().describeConstable().orElseThrow()); + } else if (dep.consumeAll()) { + // consumeAll: expanded to multiple consecutive dependency slots by + // ServiceGraphBuilder (one per matching service, sorted by name). + // Build a SortedNullSafeMap from the indexed dependency values. + String prefix = dep.keyPrefix(); + List matches = new ArrayList<>(); + for (TransliteratedAction ta : allActions) { + if (ta instanceof TransliteratedAction.ActionService as) { + String matchKey = as.serviceKey(); + if (matchKey.startsWith(prefix) && matchKey.length() > prefix.length()) { + matches.add(new String[] { matchKey.substring(prefix.length()), matchKey }); + } + } + } + matches.sort(Comparator.comparing(a -> a[0])); + if (matches.isEmpty()) { + code.invokestatic(CD_SortedNullSafeMap, "of", + MethodTypeDesc.of(CD_Map)); + } else if (matches.size() <= 4) { + for (String[] match : matches) { + code.loadConstant(match[0]); // service name + code.aload(0); // ServiceNode + code.loadConstant(graphDepIndex); + code.invokevirtual(serviceNodeDesc, "dependencyValue", + MethodTypeDesc.of(objectDesc, ConstantDescs.CD_int)); + graphDepIndex++; + } + ClassDesc[] paramTypes = new ClassDesc[matches.size() << 1]; + for (int j = 0; j < matches.size(); j++) { + paramTypes[j << 1] = stringDesc; + paramTypes[(j << 1) + 1] = objectDesc; + } + code.invokestatic(CD_SortedNullSafeMap, "of", + MethodTypeDesc.of(CD_Map, paramTypes)); + } else { + code.loadConstant(matches.size() << 1); + code.anewarray(objectDesc); + for (int j = 0; j < matches.size(); j++) { + code.dup(); + code.loadConstant(j << 1); + code.loadConstant(matches.get(j)[0]); // service name + code.aastore(); + code.dup(); + code.loadConstant((j << 1) + 1); + code.aload(0); // ServiceNode + code.loadConstant(graphDepIndex); + code.invokevirtual(serviceNodeDesc, "dependencyValue", + MethodTypeDesc.of(objectDesc, ConstantDescs.CD_int)); + graphDepIndex++; + code.aastore(); + } + code.invokestatic(CD_SortedNullSafeMap, "ofEntries", + MethodTypeDesc.of(CD_Map, objectDesc.arrayType())); + } + } else { + // indexed access: node.dependencyValue(graphDepIndex) + code.aload(0); // ServiceNode + code.loadConstant(graphDepIndex); + code.invokevirtual(serviceNodeDesc, "dependencyValue", + MethodTypeDesc.of(objectDesc, ConstantDescs.CD_int)); + graphDepIndex++; + if (dep.optional()) { + // optional dep: wrap in Optional.ofNullable() + code.invokestatic(ConstantUtils.classDesc(Optional.class), "ofNullable", + MethodTypeDesc.of(ConstantUtils.classDesc(Optional.class), objectDesc)); + } else { + ClassDesc depTypeDesc = dep.type().describeConstable().orElseThrow(); + code.checkcast(depTypeDesc); + } + } + code.astore(resultSlot); + resultSlot++; + } + + // inline the action body directly into this deploy method + // return instructions in the body are replaced with store + goto endLabel + TypeKind returnKind = TypeKind.from(actionMethodType.returnType()); + Label endLabel = code.newLabel(); + int returnSlot = returnKind != TypeKind.VOID ? resultSlot : -1; + + UnaryOperator remapper = name -> innerPrefix + name; + MethodTypeDesc deployMtd = MethodTypeDesc.of(ConstantDescs.CD_void, serviceNodeDesc); + + copyMethodBody(action.originalCode(), code, + action.captureSlotCount(), action.slotToCaptureIndex(), + action.capturedEmitters(), action.enclosingClassInternal(), + generatedClassDesc, pendingInnerMethods, remapper, + classRemapTable, consolidatedClassInternalName, anonCounter, + deployMethodName, deployMtd, + endLabel, returnSlot); + + // bind the label — execution reaches here after any return in the inlined body + code.labelBinding(endLabel); + + // handle result + if (async) { + // async: the action already called startComplete/startFailed on the ServiceNode + } else if (returnKind == TypeKind.VOID) { + // sync void: signal completion + code.aload(0); // ServiceNode + code.invokevirtual(serviceNodeDesc, "startComplete", + MethodTypeDesc.of(ConstantDescs.CD_void)); + } else if (optional) { + // optional sync typed: the action returns Optional; an empty result marks the service as + // valueless (skipped by consumeAll() consumers, empty for request() consumers) rather than failing + code.aload(0); // ServiceNode + code.aload(returnSlot); // Optional + code.invokevirtual(serviceNodeDesc, "startCompleteOptional", + MethodTypeDesc.of(ConstantDescs.CD_void, ConstantUtils.classDesc(Optional.class))); + } else { + // sync typed: null check, then signal completion with value + code.aload(returnSlot); + code.dup(); + code.ifThen(Opcode.IFNULL, b -> { + b.new_(ConstantUtils.classDesc(IllegalStateException.class)); + b.dup(); + b.loadConstant("Service '" + serviceKey + + "' returned null; declare the service optional() (returning Optional) if it may produce" + + " no value at run time, or use a void service if it never produces a value"); + b.invokespecial(ConstantUtils.classDesc(IllegalStateException.class), + ExtraConstantDescs.INIT_NAME, + MethodTypeDesc.of(ConstantDescs.CD_void, stringDesc)); + b.athrow(); + }); + // startComplete(result) + code.aload(0); // ServiceNode + code.aload(returnSlot); + code.invokevirtual(serviceNodeDesc, "startComplete", + MethodTypeDesc.of(ConstantDescs.CD_void, objectDesc)); + + // also store in the serviceValues map for recorder proxy resolution + // (legacy recorders resolve service proxies via startupContext.getServiceValue) + // TODO: remove once all recorder consumers are converted to services + ClassDesc serviceGraphDesc = ConstantUtils.classDesc(ServiceGraph.class); + ClassDesc startupContextDesc = ConstantUtils.classDesc(StartupContext.class); + code.aload(0); // ServiceNode + code.invokevirtual(CD_ServiceNode, "graph", + MethodTypeDesc.of(serviceGraphDesc)); + code.invokevirtual(serviceGraphDesc, "startupContext", + MethodTypeDesc.of(startupContextDesc)); + code.loadConstant(serviceKey); + code.aload(0); // ServiceNode + code.invokevirtual(CD_ServiceNode, "value", + MethodTypeDesc.of(objectDesc)); + code.invokevirtual(startupContextDesc, "putServiceValue", + MethodTypeDesc.of(ConstantDescs.CD_void, stringDesc, objectDesc)); + } + + code.return_(); + } + + /** + * Generate the deploy method body for an {@link TransliteratedAction.AliasService}. + *

+ * The generated code reads a value from the startup context using the recorder + * proxy key, re-stores it under the service key, and signals completion. + * + * @param code the code builder to emit instructions into + * @param alias the alias service data + */ + private static void generateAliasDeployBody( + CodeBuilder code, + TransliteratedAction.AliasService alias) { + // slot 0 = ServiceNode (static method) + ClassDesc serviceNodeDesc = ConstantUtils.classDesc(ServiceNode.class); + ClassDesc serviceGraphDesc = ConstantUtils.classDesc(ServiceGraph.class); + ClassDesc startupContextDesc = ConstantUtils.classDesc(StartupContext.class); + ClassDesc objectDesc = ConstantUtils.classDesc(Object.class); + ClassDesc stringDesc = ConstantUtils.classDesc(String.class); + + // get StartupContext: node.graph().startupContext() + code.aload(0); // ServiceNode + code.invokevirtual(serviceNodeDesc, "graph", + MethodTypeDesc.of(serviceGraphDesc)); + code.invokevirtual(serviceGraphDesc, "startupContext", + MethodTypeDesc.of(startupContextDesc)); + int ctxSlot = 1; + code.astore(ctxSlot); + + // load the value from the recorder proxy key + code.aload(ctxSlot); + code.loadConstant(alias.recorderProxyKey()); + code.invokevirtual(startupContextDesc, "getValue", + MethodTypeDesc.of(objectDesc, stringDesc)); + int valueSlot = 2; + code.astore(valueSlot); + + // store it under the service key in the service values map + code.aload(ctxSlot); + code.loadConstant(alias.serviceKey()); + code.aload(valueSlot); + code.invokevirtual(startupContextDesc, "putServiceValue", + MethodTypeDesc.of(ConstantDescs.CD_void, stringDesc, objectDesc)); + + // signal completion: typed if value is non-null, void otherwise + code.aload(valueSlot); + Label nullValue = code.newLabel(); + code.ifnull(nullValue); + code.aload(0); // ServiceNode + code.aload(valueSlot); + code.invokevirtual(serviceNodeDesc, "startComplete", + MethodTypeDesc.of(ConstantDescs.CD_void, objectDesc)); + code.return_(); + code.labelBinding(nullValue); + // diagnostic: log when alias reads null from values map + ClassDesc loggerDesc = ConstantUtils.classDesc(Logger.class); + code.loadConstant("io.quarkus.service.alias"); + code.invokestatic(loggerDesc, "getLogger", + MethodTypeDesc.of(loggerDesc, stringDesc)); + code.loadConstant("Alias '" + alias.serviceKey() + + "' read null from values map (recorder proxy key: " + alias.recorderProxyKey() + ")"); + code.invokevirtual(loggerDesc, "warn", + MethodTypeDesc.of(ConstantDescs.CD_void, objectDesc)); + code.aload(0); // ServiceNode + code.invokevirtual(serviceNodeDesc, "startComplete", + MethodTypeDesc.of(ConstantDescs.CD_void)); + code.return_(); + } + + /** + * Generate the deploy method body for a {@link TransliteratedAction.RuntimeValueWrapper}. + *

+ * The generated code reads the bare service value from the startup context + * using the source service key, wraps it in a new {@code RuntimeValue}, + * stores the wrapper under the rv key, and signals completion. + * + * @param code the code builder to emit instructions into + */ + private static void generateRuntimeValueWrapperDeployBody( + CodeBuilder code, + TransliteratedAction.RuntimeValueWrapper rvw) { + // slot 0 = ServiceNode (static method) + ClassDesc objectDesc = ConstantUtils.classDesc(Object.class); + ClassDesc runtimeValueDesc = ConstantUtils.classDesc(RuntimeValue.class); + + // load the source service value via indexed dependency access (dep 0) + code.aload(0); // ServiceNode + code.loadConstant(0); + code.invokevirtual(CD_ServiceNode, "dependencyValue", + MethodTypeDesc.of(objectDesc, ConstantDescs.CD_int)); + int valueSlot = 1; + code.astore(valueSlot); + + // wrap in new RuntimeValue<>(value) + code.new_(runtimeValueDesc); + code.dup(); + code.aload(valueSlot); + code.invokespecial(runtimeValueDesc, + ExtraConstantDescs.INIT_NAME, + MethodTypeDesc.of(ConstantDescs.CD_void, objectDesc)); + int rvSlot = valueSlot + 1; + code.astore(rvSlot); + + // store in the serviceValues map for recorder proxy resolution + // (legacy recorders resolve service proxies via startupContext.getServiceValue + // because __service$$value() returns true on the proxy) + ClassDesc serviceGraphDesc = ConstantUtils.classDesc(ServiceGraph.class); + ClassDesc startupContextDesc = ConstantUtils.classDesc(StartupContext.class); + ClassDesc stringDesc = ConstantUtils.classDesc(String.class); + code.aload(0); // ServiceNode + code.invokevirtual(CD_ServiceNode, "graph", + MethodTypeDesc.of(serviceGraphDesc)); + code.invokevirtual(serviceGraphDesc, "startupContext", + MethodTypeDesc.of(startupContextDesc)); + code.loadConstant(rvw.rvKey()); + code.aload(rvSlot); + code.invokevirtual(startupContextDesc, "putServiceValue", + MethodTypeDesc.of(ConstantDescs.CD_void, stringDesc, objectDesc)); + + // signal typed completion with the RuntimeValue wrapper + code.aload(0); // ServiceNode + code.aload(rvSlot); + code.invokevirtual(CD_ServiceNode, "startComplete", + MethodTypeDesc.of(ConstantDescs.CD_void, objectDesc)); + + code.return_(); + } + + /** + * Identifies a method in the original enclosing class that must be copied + * into the generated class because it is referenced by an {@code invokedynamic} + * for a nested lambda or method reference. + * + * @param name the original method name in the enclosing class + * @param descriptor the method descriptor + * @param remappedName the prefixed name to use in the generated class + */ + private record InnerMethod(String name, String descriptor, String remappedName) { + } + + /** + * Tracks a remapped anonymous inner class: its new internal name in the + * consolidated class space and the enclosing method in the consolidated class + * (needed to rewrite the {@code EnclosingMethod} attribute). + * + * @param newInternalName the new internal name for the anonymous class copy + * @param enclosingMethodName the name of the method in the consolidated class that contains the {@code NEW} instruction + * @param enclosingMethodType the type of the enclosing method in the consolidated class + */ + private record AnonClassEntry(String newInternalName, + String enclosingMethodName, MethodTypeDesc enclosingMethodType) { + } + + /** + * Build the serial context with custom serializers for constants and config mappings. + */ + private static SerialContext buildSerialContext() { + return SerialContext.builder() + .addDefaultProviders() + .addSerializer(ConstantDescSerializer.INSTANCE) + .addSerializer(ConfigMappingSerializer.INSTANCE) + .addSerializer(QuarkusClassLoaderSerializer.INSTANCE) + .build(); + } + + private static final String LAMBDA_METAFACTORY_INTERNAL = "java/lang/invoke/LambdaMetafactory"; + + /** + * If the given {@code invokedynamic} instruction is a {@link LambdaMetafactory} + * call whose implementation method resides in the specified enclosing class, + * return the implementation {@link MethodHandleEntry}. Otherwise return {@code null}. + * + * @param idi the invokedynamic instruction + * @param enclosingClassInternal the internal name of the enclosing class + * @return the impl method handle, or {@code null} if not a rewritable lambda factory + */ + private static MethodHandleEntry extractLambdaImplHandle( + InvokeDynamicInstruction idi, + String enclosingClassInternal) { + InvokeDynamicEntry ide = idi.invokedynamic(); + BootstrapMethodEntry bsm = ide.bootstrap(); + MethodHandleEntry bsmHandle = bsm.bootstrapMethod(); + MemberRefEntry bsmRef = bsmHandle.reference(); + String bsmOwner = bsmRef.owner().asInternalName(); + if (!bsmOwner.equals(LAMBDA_METAFACTORY_INTERNAL)) { + return null; + } + String bsmName = bsmRef.nameAndType().name().stringValue(); + if (!bsmName.equals("metafactory") && !bsmName.equals("altMetafactory")) { + return null; + } + List args = bsm.arguments(); + if (args.size() < 2 || !(args.get(1) instanceof MethodHandleEntry implHandle)) { + return null; + } + String implOwner = implHandle.reference().owner().asInternalName(); + if (!implOwner.equals(enclosingClassInternal)) { + return null; + } + return implHandle; + } + + /** + * Rewrite a lambda factory {@code invokedynamic} instruction so that its + * implementation method handle points to the generated class with a remapped + * method name instead of the original enclosing class and name. + * + * @param code the code builder to emit the rewritten instruction into + * @param idi the original invokedynamic instruction + * @param generatedClassDesc the class descriptor of the generated class + * @param remappedMethodName the prefixed method name to use in the generated class + */ + private static void rewriteInvokeDynamic( + CodeBuilder code, + InvokeDynamicInstruction idi, + ClassDesc generatedClassDesc, + String remappedMethodName) { + InvokeDynamicEntry oldEntry = idi.invokedynamic(); + BootstrapMethodEntry oldBsm = oldEntry.bootstrap(); + List oldArgs = oldBsm.arguments(); + MethodHandleEntry oldImplHandle = (MethodHandleEntry) oldArgs.get(1); + MemberRefEntry oldImplRef = oldImplHandle.reference(); + + ConstantPoolBuilder cpb = code.constantPool(); + // remap context interface types to ServiceNode in the impl method descriptor + MethodTypeDesc originalImplType = MethodTypeDesc.ofDescriptor( + oldImplRef.nameAndType().type().stringValue()); + MethodTypeDesc remappedImplType = remapContextTypes(originalImplType); + var newNameAndType = cpb.nameAndTypeEntry( + cpb.utf8Entry(remappedMethodName), + cpb.utf8Entry(remappedImplType.descriptorString())); + var newMethodRef = cpb.methodRefEntry( + cpb.classEntry(generatedClassDesc), + newNameAndType); + MethodHandleEntry newImplHandle = cpb.methodHandleEntry(oldImplHandle.kind(), newMethodRef); + + // rebuild bootstrap arguments with the new impl handle and remapped instantiated type + List newArgs = new ArrayList<>(oldArgs); + newArgs.set(1, newImplHandle); + // args[2] is the instantiated method type — remap context types there too + if (newArgs.size() > 2 && newArgs.get(2) instanceof MethodTypeEntry mte) { + MethodTypeDesc instantiated = MethodTypeDesc.ofDescriptor(mte.descriptor().stringValue()); + MethodTypeDesc remappedInstantiated = remapContextTypes(instantiated); + if (remappedInstantiated != instantiated) { + newArgs.set(2, cpb.methodTypeEntry(remappedInstantiated)); + } + } + + BootstrapMethodEntry newBsm = cpb.bsmEntry(oldBsm.bootstrapMethod(), newArgs); + // remap context types in the factory signature (captured parameter types) + MethodTypeDesc factoryType = MethodTypeDesc.ofDescriptor( + oldEntry.nameAndType().type().stringValue()); + MethodTypeDesc remappedFactoryType = remapContextTypes(factoryType); + var factoryNameAndType = remappedFactoryType != factoryType + ? cpb.nameAndTypeEntry(oldEntry.nameAndType().name(), cpb.utf8Entry(remappedFactoryType.descriptorString())) + : oldEntry.nameAndType(); + InvokeDynamicEntry newEntry = cpb.invokeDynamicEntry(newBsm, factoryNameAndType); + code.invokedynamic(newEntry); + } + + /** + * Copy a method body from the original class into the generated class, + * optionally remapping captured parameter slots to constants, rewriting + * nested lambda {@code invokedynamic} instructions, and renaming inner + * method references to avoid collisions in consolidated classes. + *

+ * For the top-level action method, {@code captureSlotCount} is the number of + * leading local variable slots consumed by captured parameters (which are + * replaced by constant loads). For inner lambda methods, pass 0 (no captures). + * + * @param originalCode the source code model to copy + * @param code the target code builder + * @param captureSlotCount the number of leading slots that are captures (0 for inner methods) + * @param slotToCaptureIndex mapping from slot index to capture argument index ({@code -1} for non-start slots) + * @param capturedEmitters the emitters for captured arguments + * @param enclosingClassInternal the internal name of the original enclosing class + * @param generatedClassDesc the class descriptor of the generated class + * @param pendingInnerMethods set to which newly-discovered inner lambda methods are added + * @param innerMethodNameRemapper transforms inner method names to prefixed form for collision avoidance + * @param classRemapTable shared map tracking anonymous inner class remappings (original name → entry) + * @param consolidatedClassInternalName the internal name of the consolidated class being built + * @param anonCounter shared counter for generating unique anonymous class names + * @param currentMethodName the name of the method being written in the consolidated class + * @param currentMethodType the type descriptor of the method being written in the consolidated class + * @param inlineEndLabel if non-null, return instructions are replaced with jumps to this label + * (for inlining the action body into the deploy method). If a value return, the value + * is stored in {@code inlineReturnSlot} before the jump. + * @param inlineReturnSlot the local variable slot to store the return value when inlining + * (only meaningful when {@code inlineEndLabel} is non-null and the return is non-void) + */ + private static void copyMethodBody( + CodeModel originalCode, + CodeBuilder code, + int captureSlotCount, + int[] slotToCaptureIndex, + CaptureEmitter[] capturedEmitters, + String enclosingClassInternal, + ClassDesc generatedClassDesc, + Set pendingInnerMethods, + UnaryOperator innerMethodNameRemapper, + Map classRemapTable, + String consolidatedClassInternalName, + int[] anonCounter, + String currentMethodName, + MethodTypeDesc currentMethodType, + Label inlineEndLabel, + int inlineReturnSlot) { + // when captures are removed, local variable slots are shifted down by captureSlotCount; + // reserve the remapped range so that CodeBuilder.allocateLocal() (used by DedupEmitter + // for captured value deduplication) does not return slots that conflict with remapped locals + if (captureSlotCount > 0) { + int maxOriginalSlot = 0; + for (CodeElement scan : originalCode) { + if (scan instanceof LoadInstruction li) { + maxOriginalSlot = Math.max(maxOriginalSlot, li.slot()); + } else if (scan instanceof StoreInstruction si) { + maxOriginalSlot = Math.max(maxOriginalSlot, si.slot()); + } + } + int remappedMax = maxOriginalSlot - captureSlotCount; + // advance allocateLocal past the remapped local range + while (code.allocateLocal(TypeKind.REFERENCE) < remappedMax) { + // allocateLocal advances the internal counter; loop until past the range + } + } + + for (CodeElement el : originalCode) { + if (el instanceof LoadInstruction li) { + int slot = li.slot(); + if (slot < captureSlotCount) { + int idx = slotToCaptureIndex[slot]; + if (idx < 0) { + throw new TransliterationException( + "Load instruction references middle of a wide capture slot " + slot); + } + capturedEmitters[idx].emit(code); + } else { + code.loadLocal(li.typeKind(), slot - captureSlotCount); + } + } else if (el instanceof StoreInstruction si) { + int slot = si.slot(); + if (slot < captureSlotCount) { + throw new TransliterationException( + "Store to captured variable slot " + slot + " is not allowed"); + } + code.storeLocal(si.typeKind(), slot - captureSlotCount); + } else if (el instanceof IncrementInstruction ii) { + int slot = ii.slot(); + if (slot < captureSlotCount) { + throw new TransliterationException( + "Increment of captured variable slot " + slot + " is not allowed"); + } + code.iinc(slot - captureSlotCount, ii.constant()); + } else if (el instanceof LineNumber + || el instanceof LocalVariable + || el instanceof LocalVariableType) { + // drop debug info + } else if (el instanceof InvokeDynamicInstruction idi) { + MethodHandleEntry implHandle = extractLambdaImplHandle(idi, enclosingClassInternal); + if (implHandle != null) { + MemberRefEntry implRef = implHandle.reference(); + String originalName = implRef.nameAndType().name().stringValue(); + String remappedName = innerMethodNameRemapper.apply(originalName); + pendingInnerMethods.add(new InnerMethod( + originalName, + implRef.nameAndType().type().stringValue(), + remappedName)); + rewriteInvokeDynamic(code, idi, generatedClassDesc, remappedName); + } else { + code.with(el); + } + } else if (el instanceof NewObjectInstruction noi) { + String className = noi.className().asInternalName(); + AnonClassEntry entry = remapIfInnerClass(className, enclosingClassInternal, + classRemapTable, consolidatedClassInternalName, anonCounter, + currentMethodName, currentMethodType); + if (entry != null) { + code.new_(ExtraClassDesc.ofInternalName(entry.newInternalName())); + } else { + code.with(el); + } + } else if (el instanceof InvokeInstruction ii) { + String ownerName = ii.owner().asInternalName(); + if (ii.opcode() == Opcode.INVOKEINTERFACE && CONTEXT_INTERFACE_INTERNALS.contains(ownerName)) { + // rewrite invokeinterface on context interfaces to invokevirtual on ServiceNode + ConstantPoolBuilder cpb = code.constantPool(); + var newRef = cpb.methodRefEntry( + cpb.classEntry(CD_ServiceNode), + ii.method().nameAndType()); + code.invoke(Opcode.INVOKEVIRTUAL, newRef); + } else { + AnonClassEntry entry = classRemapTable.get(ownerName); + if (entry != null) { + ConstantPoolBuilder cpb = code.constantPool(); + var newRef = cpb.methodRefEntry( + cpb.classEntry(ExtraClassDesc.ofInternalName(entry.newInternalName())), + ii.method().nameAndType()); + code.invoke(ii.opcode(), newRef); + } else { + code.with(el); + } + } + } else if (el instanceof TypeCheckInstruction tci) { + String typeName = tci.type().asInternalName(); + AnonClassEntry entry = classRemapTable.get(typeName); + if (entry != null) { + ConstantPoolBuilder cpb = code.constantPool(); + code.with(TypeCheckInstruction.of(tci.opcode(), + cpb.classEntry(ExtraClassDesc.ofInternalName(entry.newInternalName())))); + } else { + code.with(el); + } + } else if (el instanceof ReturnInstruction ri && inlineEndLabel != null) { + // inline mode: replace return with store (if value) + goto endLabel + TypeKind tk = ri.typeKind(); + if (tk != TypeKind.VOID) { + code.storeLocal(tk, inlineReturnSlot); + } + code.goto_(inlineEndLabel); + } else { + code.with(el); + } + } + } + + /** + * If the given class name is an inner class of the enclosing deployment class, + * register it in the remap table (assigning a new name under the consolidated + * class) and return the mapping entry. Returns {@code null} if the class is not + * an inner class of the enclosing class. + * + * @param className the internal name of the class to check + * @param enclosingClassInternal the internal name of the original enclosing class + * @param classRemapTable the shared remap table to populate + * @param consolidatedClassInternalName the internal name of the consolidated class + * @param anonCounter shared counter for unique naming + * @param currentMethodName the enclosing method name in the consolidated class + * @param currentMethodType the enclosing method type in the consolidated class + * @return the remap entry, or {@code null} if not an inner class + */ + private static AnonClassEntry remapIfInnerClass( + String className, + String enclosingClassInternal, + Map classRemapTable, + String consolidatedClassInternalName, + int[] anonCounter, + String currentMethodName, + MethodTypeDesc currentMethodType) { + if (className.startsWith(enclosingClassInternal + "$")) { + return classRemapTable.computeIfAbsent(className, k -> new AnonClassEntry( + consolidatedClassInternalName + "$" + ++anonCounter[0], + currentMethodName, currentMethodType)); + } + return null; + } + + /** + * Copy an anonymous inner class, remapping its name and fixing its + * {@code NestHost}, {@code InnerClasses}, and {@code EnclosingMethod} + * attributes to point to the consolidated class. + *

+ * Pass 1 uses {@link ClassRemapper} to remap the class name and all + * internal references (signatures, descriptors, instructions). + * Pass 2 fixes the three structural attributes that ClassRemapper cannot + * handle correctly because the original enclosing class is not in the + * remap map. + * + * @param originalInternalName the internal name of the original anonymous class + * @param mapping the remap entry with new name and enclosing method info + * @param descMap the ClassDesc remap map (original → new) for ClassRemapper + * @param consolidatedClassDesc the class descriptor of the consolidated class + * @return the transformed class file bytes + */ + private static byte[] copyInnerClass( + String originalInternalName, + AnonClassEntry mapping, + Map descMap, + ClassDesc consolidatedClassDesc) { + byte[] originalBytes = loadClassBytes(originalInternalName); + ClassModel cm = ClassFile.of().parse(originalBytes); + + // Pass 1: remap all class references including the class name + byte[] remapped = ClassRemapper.of(descMap).remapClass(ClassFile.of(), cm); + + // Pass 2: fix nest/inner class/enclosing method attributes + ClassModel remappedModel = ClassFile.of().parse(remapped); + ClassDesc newClassDesc = remappedModel.thisClass().asSymbol(); + return ClassFile.of().transformClass(remappedModel, (cb, ce) -> { + if (ce instanceof NestHostAttribute) { + cb.with(NestHostAttribute.of(consolidatedClassDesc)); + } else if (ce instanceof InnerClassesAttribute) { + cb.with(InnerClassesAttribute.of( + InnerClassInfo.of(newClassDesc, + Optional.empty(), Optional.empty(), 0))); + } else if (ce instanceof EnclosingMethodAttribute) { + cb.with(EnclosingMethodAttribute.of( + consolidatedClassDesc, + Optional.of(mapping.enclosingMethodName()), + Optional.of(mapping.enclosingMethodType()))); + } else { + cb.with(ce); + } + }); + } + + /** + * Extract the {@link SerializedLambda} from a serializable lambda instance + * by invoking its compiler-generated {@code writeReplace()} method. + * + * @param lambda the lambda instance + * @return the serialized lambda metadata + * @throws TransliterationException if extraction fails + */ + private static SerializedLambda extractSerializedLambda(Serializable lambda) { + try { + Method writeReplace = lambda.getClass().getDeclaredMethod("writeReplace"); + writeReplace.setAccessible(true); + return (SerializedLambda) writeReplace.invoke(lambda); + } catch (NoSuchMethodException e) { + throw new TransliterationException( + "Lambda class " + lambda.getClass().getName() + + " does not have writeReplace() — is it a serializable lambda?", + e); + } catch (ReflectiveOperationException e) { + throw new TransliterationException( + "Failed to extract SerializedLambda from " + lambda.getClass().getName(), e); + } + } + + /** + * Load the bytecode of a class by its internal name from the current thread's context classloader. + * + * @param internalName the class internal name (using {@code /} separators) + * @return the class file bytes + * @throws TransliterationException if the class cannot be loaded + */ + private static byte[] loadClassBytes(String internalName) { + String resourceName = internalName + ".class"; + ClassLoader cl = Thread.currentThread().getContextClassLoader(); + try (InputStream is = cl.getResourceAsStream(resourceName)) { + if (is == null) { + throw new TransliterationException( + "Cannot load class bytecode for " + internalName.replace('/', '.') + + " from context classloader"); + } + return is.readAllBytes(); + } catch (IOException e) { + throw new TransliterationException( + "Failed to read class bytecode for " + internalName.replace('/', '.'), e); + } + } + + /** + * Compute the service key used to store/retrieve values in the {@link StartupContext}. + * NOTE: This is only temporary, until bytecode recording has been eliminated. + * + * @param type the service type + * @param nameParts the service name parts (must not be {@code null}) + * @return the key string + */ + public static String serviceKey(Class type, List nameParts) { + return type.getName() + ":" + String.join("/", nameParts); + } + + /** + * Thrown when lambda transliteration fails due to an unsupported construct, + * invalid capture, or bytecode processing error. + */ + public static final class TransliterationException extends RuntimeException { + /** + * Construct a new instance. + * + * @param message the error message + */ + public TransliterationException(String message) { + super(message); + } + + /** + * Construct a new instance. + * + * @param message the error message + * @param cause the cause + */ + public TransliterationException(String message, Throwable cause) { + super(message, cause); + } + } +} diff --git a/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/QuarkusClassLoaderSerializer.java b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/QuarkusClassLoaderSerializer.java new file mode 100644 index 0000000000000..e900f0c3844a6 --- /dev/null +++ b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/QuarkusClassLoaderSerializer.java @@ -0,0 +1,29 @@ +package io.quarkus.core.deployment.service.impl; + +import java.io.IOException; + +import io.quarkus.bootstrap.classloading.QuarkusClassLoader; +import io.smallrye.serial.Serialized; +import io.smallrye.serial.spi.ObjectSerializer; + +/** + * A serializer which handles {@code QuarkusClassLoader} instances. + */ +public final class QuarkusClassLoaderSerializer implements ObjectSerializer { + static final ObjectSerializer INSTANCE = new QuarkusClassLoaderSerializer(); + + private QuarkusClassLoaderSerializer() { + } + + public Serialized serialize(final Context ctxt, final Object object) throws IOException { + if (object instanceof QuarkusClassLoader) { + // todo: improve this detection + return SerializedQuarkusClassLoader.of(SerializedQuarkusClassLoader.Kind.RUNTIME); + } + return ctxt.next(); + } + + public int priority() { + return PRIORITY_CLASS_LOADER + 1; + } +} diff --git a/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/SerializedCaptureEmitter.java b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/SerializedCaptureEmitter.java new file mode 100644 index 0000000000000..428275822d11b --- /dev/null +++ b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/SerializedCaptureEmitter.java @@ -0,0 +1,732 @@ +package io.quarkus.core.deployment.service.impl; + +import static java.lang.constant.ConstantDescs.CD_Class; +import static java.lang.constant.ConstantDescs.CD_Double; +import static java.lang.constant.ConstantDescs.CD_Float; +import static java.lang.constant.ConstantDescs.CD_Integer; +import static java.lang.constant.ConstantDescs.CD_List; +import static java.lang.constant.ConstantDescs.CD_Long; +import static java.lang.constant.ConstantDescs.CD_Map; +import static java.lang.constant.ConstantDescs.CD_Object; +import static java.lang.constant.ConstantDescs.CD_Set; +import static java.lang.constant.ConstantDescs.CD_double; +import static java.lang.constant.ConstantDescs.CD_float; +import static java.lang.constant.ConstantDescs.CD_int; +import static java.lang.constant.ConstantDescs.CD_long; +import static java.lang.constant.ConstantDescs.CD_void; + +import java.lang.constant.ClassDesc; +import java.lang.constant.ConstantDesc; +import java.lang.constant.MethodTypeDesc; +import java.lang.reflect.RecordComponent; +import java.util.Arrays; +import java.util.Comparator; +import java.util.IdentityHashMap; +import java.util.List; + +import io.smallrye.classfile.CodeBuilder; +import io.smallrye.classfile.TypeKind; +import io.smallrye.classfile.extras.constant.ExtraConstantDescs; +import io.smallrye.serial.SerialData; +import io.smallrye.serial.SerialField; +import io.smallrye.serial.Serialized; +import io.smallrye.serial.SerializedClass; +import io.smallrye.serial.SerializedEnum; +import io.smallrye.serial.SerializedNull; +import io.smallrye.serial.SerializedRecord; +import io.smallrye.serial.SerializedRecordClass; +import io.smallrye.serial.SerializedSerializable; +import io.smallrye.serial.SerializedString; +import io.smallrye.serial.StreamData; + +/** + * Walks a {@link Serialized} graph and produces {@link CaptureEmitter} instances + * that emit the bytecode to reconstruct captured values at runtime. + *

+ * All decomposition is performed by {@code smallrye-serial} via custom serializers; + * this class only handles the bytecode emission from the resulting {@code Serialized} tree. + */ +final class SerializedCaptureEmitter { + + private static final ClassDesc CD_Thread = ClassDesc.of("java.lang.Thread"); + private static final ClassDesc CD_ClassLoader = ClassDesc.of("java.lang.ClassLoader"); + + private static final ClassDesc CD_Map_Entry = ClassDesc.of("java.util.Map$Entry"); + private static final ClassDesc CD_ConfigLookup = ClassDesc.of("io.quarkus.runtime.configuration.ConfigLookup"); + + /** CollSer tag: unmodifiable list. */ + private static final int TAG_LIST = 1; + /** CollSer tag: unmodifiable set. */ + private static final int TAG_SET = 2; + /** CollSer tag: unmodifiable map. */ + private static final int TAG_MAP = 3; + + /** Tracks dedup: Serialized identity → emitter (to avoid re-emitting shared nodes). */ + private final IdentityHashMap dedupMap = new IdentityHashMap<>(); + + SerializedCaptureEmitter() { + } + + /** + * Produce a {@link CaptureEmitter} for the given serialized node. + * + * @param serialized the serialized value (must not be {@code null}) + * @param targetType the target type descriptor (for boxing decisions) + * @return the capture emitter + */ + CaptureEmitter emitFor(Serialized serialized, ClassDesc targetType) { + // dedup: return existing emitter for shared Serialized nodes + CaptureEmitter existing = dedupMap.get(serialized); + if (existing != null) { + return existing; + } + + CaptureEmitter result; + boolean doDedup = false; + result = switch (serialized) { + case SerializedConstant sc -> emitConstant(sc, targetType); + case SerializedConfigMapping cm -> { + var res = emitConfigMapping(cm); + doDedup = true; + yield res; + } + case SerializedNull ignored -> CodeBuilder::aconst_null; + case SerializedRecord sr -> { + var res = emitRecord(sr); + doDedup = true; + yield res; + } + case SerializedClass sc -> emitClassConstant(sc); + case SerializedQuarkusClassLoader scl -> emitQuarkusClassLoader(scl); + case SerializedSerializable ss -> { + CaptureEmitter res = emitSerializable(ss); + doDedup = true; + yield res; + } + default -> throw new LambdaTransliterator.TransliterationException( + "Unsupported serialized type: " + serialized.getClass().getName()); + }; + + // wrap non-trivial emitters in dedup (constants are cheap to re-emit) + if (doDedup) { + DedupEmitter dedup = new DedupEmitter(result); + dedupMap.put(serialized, dedup); + return dedup; + } + dedupMap.put(serialized, result); + return result; + } + + // ── Constants ── + + /** + * Emit a constant value, with boxing if the target type is a reference type + * but the constant pushes a primitive. + */ + private static CaptureEmitter emitConstant(SerializedConstant sc, ClassDesc targetType) { + ConstantDesc cd = sc.constantDesc(); + if (targetType.isPrimitive()) { + // target is primitive — load as primitive constant + // Boolean/Byte/Short/Character are already stored as int by ConstantDescSerializer + return code -> code.loadConstant(cd); + } + // target is reference — may need boxing + ClassDesc boxType = sc.boxType(); + if (boxType == null) { + // check for Integer/Long/Float/Double (which push primitives via ldc) + boxType = inferBoxType(cd); + } + if (boxType != null) { + ClassDesc bt = boxType; + ClassDesc primType = inferPrimType(cd); + return code -> { + code.loadConstant(cd); + code.invokestatic(bt, "valueOf", MethodTypeDesc.of(bt, primType)); + }; + } + return code -> code.loadConstant(cd); + } + + /** + * Infer the box type for a ConstantDesc that pushes a primitive via ldc. + * Returns null if the constant pushes a reference (no boxing needed). + */ + private static ClassDesc inferBoxType(ConstantDesc cd) { + if (cd instanceof Integer) { + return CD_Integer; + } + if (cd instanceof Long) { + return CD_Long; + } + if (cd instanceof Float) { + return CD_Float; + } + if (cd instanceof Double) { + return CD_Double; + } + return null; + } + + /** + * Infer the primitive type for a ConstantDesc that pushes a primitive. + * For Boolean/Byte/Short/Character (stored as Integer by ConstantDescSerializer), + * the primitive type is always int. + */ + private static ClassDesc inferPrimType(ConstantDesc cd) { + if (cd instanceof Integer) { + return CD_int; + } + if (cd instanceof Long) { + return CD_long; + } + if (cd instanceof Float) { + return CD_float; + } + if (cd instanceof Double) { + return CD_double; + } + throw new IllegalArgumentException("Unexpected primitive constant: " + cd.getClass()); + } + + // ── Config mappings ── + + /** + * Emit a config mapping lookup via ConfigLookup.getConfigMapping(type). + */ + private static CaptureEmitter emitConfigMapping(SerializedConfigMapping cm) { + ClassDesc configType = cm.configInterface(); + return code -> { + code.loadConstant(configType); + code.invokestatic(CD_ConfigLookup, "getConfigMapping", + MethodTypeDesc.of(CD_Object, CD_Class)); + code.checkcast(configType); + }; + } + + // ── Class constants ── + + /** + * Emit a Class constant from a SerializedClass node. + */ + private static CaptureEmitter emitClassConstant(SerializedClass sc) { + ClassDesc classDesc = sc.descriptor(); + return code -> code.loadConstant(classDesc); + } + + // ── Class loaders ── + + private static CaptureEmitter emitQuarkusClassLoader(SerializedQuarkusClassLoader scl) { + return switch (scl.kind()) { + case RUNTIME -> code -> { + code.invokestatic(CD_Thread, "currentThread", MethodTypeDesc.of(CD_Thread)); + code.invokevirtual(CD_Thread, "getContextClassLoader", MethodTypeDesc.of(CD_ClassLoader)); + }; + }; + } + + // ── Records ── + + /** + * Emit record reconstruction: {@code new RecordType(component1, component2, ...)}. + * Serialized fields are name-sorted (per Java serialization spec), but the canonical + * constructor takes parameters in declaration order. We load the class to get + * RecordComponent[] and reorder accordingly. + */ + private CaptureEmitter emitRecord(SerializedRecord sr) { + SerializedRecordClass rc = sr.recordClass(); + ClassDesc recordDesc = rc.descriptor(); + SerialData fieldData = sr.fieldData(); + + // load the class to get declaration-order components + Class recordClass; + try { + recordClass = Class.forName(rc.name(), false, Thread.currentThread().getContextClassLoader()); + } catch (ClassNotFoundException e) { + throw new LambdaTransliterator.TransliterationException( + "Record class not found: " + rc.name(), e); + } + RecordComponent[] components = recordClass.getRecordComponents(); + + // resolve emitters in declaration order, reading values by name from serialized data + CaptureEmitter[] componentEmitters = new CaptureEmitter[components.length]; + ClassDesc[] componentTypes = new ClassDesc[components.length]; + for (int i = 0; i < components.length; i++) { + RecordComponent comp = components[i]; + String name = comp.getName(); + Class type = comp.getType(); + componentTypes[i] = type.describeConstable().orElseThrow(); + if (type.isPrimitive()) { + SerialField field = rc.streamField(name); + componentEmitters[i] = emitPrimitiveField(fieldData, field); + } else { + Serialized fieldValue = fieldData.getObject(name); + componentEmitters[i] = emitFor(fieldValue, componentTypes[i]); + } + } + + MethodTypeDesc ctorType = MethodTypeDesc.of(CD_void, componentTypes); + return code -> { + code.new_(recordDesc); + code.dup(); + for (CaptureEmitter emitter : componentEmitters) { + emitter.emit(code); + } + code.invokespecial(recordDesc, ExtraConstantDescs.INIT_NAME, ctorType); + }; + } + + /** + * Emit a primitive field value from SerialData as a constant. + */ + private static CaptureEmitter emitPrimitiveField(SerialData data, SerialField field) { + return switch (field.typeCode()) { + case 'Z' -> code -> code.loadConstant(data.getBoolean(field.name()) ? 1 : 0); + case 'B' -> code -> code.loadConstant(data.getByte(field.name())); + case 'C' -> code -> code.loadConstant(data.getChar(field.name())); + case 'S' -> code -> code.loadConstant(data.getShort(field.name())); + case 'I' -> code -> code.loadConstant(data.getInt(field.name())); + case 'J' -> code -> code.loadConstant(data.getLong(field.name())); + case 'F' -> code -> code.loadConstant(data.getFloat(field.name())); + case 'D' -> code -> code.loadConstant(data.getDouble(field.name())); + default -> throw new LambdaTransliterator.TransliterationException( + "Unknown primitive type code: " + field.typeCode()); + }; + } + + // ── Serializable (collections, maps, wrappers) ── + + /** + * Emit a serializable object — dispatches to collection/map handlers based on + * the serialized class name. + */ + private CaptureEmitter emitSerializable(SerializedSerializable ss) { + String className = ss.serializedClass().name(); + return switch (className) { + case "java.util.CollSer" -> emitCollSer(ss); + case "java.util.Collections$EmptyList" -> emitFactoryCall(CD_List, "of", new CaptureEmitter[0]); + case "java.util.Collections$EmptySet" -> emitFactoryCall(CD_Set, "of", new CaptureEmitter[0]); + case "java.util.Collections$EmptyMap" -> emitMapOf(new CaptureEmitter[0], new CaptureEmitter[0]); + case "java.util.Collections$SingletonList" -> emitSingletonCollection(ss, CD_List); + case "java.util.Collections$SingletonSet" -> emitSingletonCollection(ss, CD_Set); + case "java.util.Collections$SingletonMap" -> emitSingletonMap(ss); + case "java.util.Collections$UnmodifiableList", + "java.util.Collections$UnmodifiableRandomAccessList" -> + emitUnmodifiableCollection(ss, CD_List); + case "java.util.Collections$UnmodifiableSet", + "java.util.Collections$UnmodifiableSortedSet", + "java.util.Collections$UnmodifiableNavigableSet" -> + emitUnmodifiableCollection(ss, CD_Set); + case "java.util.Collections$UnmodifiableMap", + "java.util.Collections$UnmodifiableSortedMap", + "java.util.Collections$UnmodifiableNavigableMap" -> + emitUnmodifiableMap(ss); + // mutable collection types — reject when captured directly + case "java.util.ArrayList", "java.util.LinkedList", + "java.util.HashSet", "java.util.TreeSet", + "java.util.HashMap", "java.util.TreeMap", + "java.util.LinkedHashSet", "java.util.LinkedHashMap" -> + throw new LambdaTransliterator.TransliterationException( + "Unsupported collection/map type: " + className + + " — only unmodifiable List, Set, and Map types created via" + + " List.of(), Set.of(), Map.of(), Collections.unmodifiableList/Set/Map()," + + " Collections.singletonList/Set/Map(), or Collections.emptyList/Set/Map()" + + " are supported as captured values"); + default -> throw new LambdaTransliterator.TransliterationException( + "Unsupported serializable type in capture: " + className); + }; + } + + /** + * Handle JDK immutable collections via CollSer. + * The tag field determines the collection type (1=list, 2=set, 3=map, 4=nullable list). + * Elements are in the stream data. + */ + private CaptureEmitter emitCollSer(SerializedSerializable ss) { + SerialData data = ss.dataFor("java.util.CollSer"); + int tag = data.getInt("tag") & 0xFF; + // elements are in the stream data as objects + Serialized[] elements = extractStreamObjects(data); + return switch (tag) { + case TAG_LIST, 4 -> { + CaptureEmitter[] emitters = resolveElements(elements); + yield emitFactoryCall(CD_List, "of", emitters); + } + case TAG_SET -> { + sortSetElements(elements); + CaptureEmitter[] emitters = resolveElements(elements); + yield emitFactoryCall(CD_Set, "of", emitters); + } + case TAG_MAP -> { + // map elements are interleaved: key, value, key, value, ... + sortMapEntries(elements); + int entryCount = elements.length / 2; + CaptureEmitter[] keys = new CaptureEmitter[entryCount]; + CaptureEmitter[] values = new CaptureEmitter[entryCount]; + for (int i = 0; i < entryCount; i++) { + keys[i] = emitFor(elements[i * 2], CD_Object); + values[i] = emitFor(elements[i * 2 + 1], CD_Object); + } + yield emitMapOf(keys, values); + } + default -> throw new LambdaTransliterator.TransliterationException( + "Unsupported CollSer tag " + tag); + }; + } + + /** + * Handle Collections.singletonList / Collections.singleton. + */ + private CaptureEmitter emitSingletonCollection(SerializedSerializable ss, ClassDesc collType) { + // singleton collections have a single "element" field + SerialData data = ss.data().getFirst(); + Serialized element = data.getObject("element"); + CaptureEmitter elementEmitter = emitFor(element, CD_Object); + return emitFactoryCall(collType, "of", new CaptureEmitter[] { elementEmitter }); + } + + /** + * Handle Collections.singletonMap. + */ + private CaptureEmitter emitSingletonMap(SerializedSerializable ss) { + SerialData data = ss.data().getFirst(); + Serialized key = data.getObject("k"); + Serialized value = data.getObject("v"); + return emitMapOf( + new CaptureEmitter[] { emitFor(key, CD_Object) }, + new CaptureEmitter[] { emitFor(value, CD_Object) }); + } + + /** + * Handle Collections.unmodifiableList/Set. + * The backing collection is in the "c" field at the UnmodifiableCollection level. + */ + private CaptureEmitter emitUnmodifiableCollection(SerializedSerializable ss, ClassDesc collType) { + SerialData ucData = ss.dataFor("java.util.Collections$UnmodifiableCollection"); + Serialized backing = ucData.getObject("c"); + if (backing instanceof SerializedSerializable backingSs) { + return emitBackingCollection(backingSs, collType); + } + // backing might be a CollSer (immutable factory type) — recurse normally + return emitFor(backing, collType); + } + + /** + * Handle Collections.unmodifiableMap. + * The backing map is in the "m" field at the UnmodifiableMap level. + */ + private CaptureEmitter emitUnmodifiableMap(SerializedSerializable ss) { + SerialData umData = ss.dataFor("java.util.Collections$UnmodifiableMap"); + Serialized backing = umData.getObject("m"); + if (backing instanceof SerializedSerializable backingSs) { + return emitBackingMap(backingSs); + } + return emitFor(backing, CD_Map); + } + + // ── Backing collections (for unmodifiable wrappers) ── + + /** + * Emit a backing collection (ArrayList, LinkedList, HashSet) as List.of/Set.of. + * Elements are in the stream data written by the collection's writeObject method. + */ + private CaptureEmitter emitBackingCollection(SerializedSerializable ss, ClassDesc collType) { + // ArrayList/LinkedList/HashSet write elements to stream data via writeObject + SerialData data = ss.data().getLast(); + Serialized[] elements = extractStreamObjects(data); + boolean isList = collType.descriptorString().equals(CD_List.descriptorString()); + if (!isList) { + sortSetElements(elements); + } + CaptureEmitter[] emitters = resolveElements(elements); + if (isList) { + return emitFactoryCall(CD_List, "of", emitters); + } else { + return emitFactoryCall(CD_Set, "of", emitters); + } + } + + /** + * Emit a backing map (HashMap) as Map.of. + * Entries are written as key, value, key, value, ... in stream data. + */ + private CaptureEmitter emitBackingMap(SerializedSerializable ss) { + SerialData data = ss.data().getLast(); + Serialized[] elements = extractStreamObjects(data); + sortMapEntries(elements); + int entryCount = elements.length / 2; + CaptureEmitter[] keys = new CaptureEmitter[entryCount]; + CaptureEmitter[] values = new CaptureEmitter[entryCount]; + for (int i = 0; i < entryCount; i++) { + keys[i] = emitFor(elements[i * 2], CD_Object); + values[i] = emitFor(elements[i * 2 + 1], CD_Object); + } + return emitMapOf(keys, values); + } + + // ── Helpers ── + + /** + * Extract all objects from the stream data of a SerialData. + */ + private static Serialized[] extractStreamObjects(SerialData data) { + List streamDataList = data.streamData(); + if (streamDataList.isEmpty()) { + return new Serialized[0]; + } + // collect all objects from all stream data blocks + int total = 0; + for (StreamData sd : streamDataList) { + if (sd instanceof StreamData.OfObjects oo) { + total += oo.size(); + } + } + Serialized[] result = new Serialized[total]; + int idx = 0; + for (StreamData sd : streamDataList) { + if (sd instanceof StreamData.OfObjects oo) { + for (int i = 0; i < oo.size(); i++) { + result[idx++] = oo.getObject(i); + } + } + } + return result; + } + + /** + * Resolve an array of Serialized elements to emitters (for collection elements). + */ + private CaptureEmitter[] resolveElements(Serialized[] elements) { + CaptureEmitter[] emitters = new CaptureEmitter[elements.length]; + for (int i = 0; i < elements.length; i++) { + emitters[i] = emitFor(elements[i], CD_Object); + } + return emitters; + } + + /** + * Emit a static factory call (List.of, Set.of) with the given elements. + * Uses specific overloads for ≤10 elements, varargs for >10. + */ + private static CaptureEmitter emitFactoryCall(ClassDesc owner, String method, CaptureEmitter[] elements) { + int count = elements.length; + if (count <= 10) { + ClassDesc[] params = new ClassDesc[count]; + Arrays.fill(params, CD_Object); + MethodTypeDesc mtd = MethodTypeDesc.of(owner, params); + return code -> { + for (CaptureEmitter e : elements) { + e.emit(code); + } + code.invokestatic(owner, method, mtd, true); + }; + } else { + return code -> { + code.loadConstant(count); + code.anewarray(CD_Object); + for (int i = 0; i < count; i++) { + code.dup(); + code.loadConstant(i); + elements[i].emit(code); + code.aastore(); + } + code.invokestatic(owner, method, + MethodTypeDesc.of(owner, CD_Object.arrayType()), true); + }; + } + } + + /** + * Emit a Map.of or Map.ofEntries call. + */ + private static CaptureEmitter emitMapOf(CaptureEmitter[] keys, CaptureEmitter[] values) { + int count = keys.length; + if (count <= 10) { + return code -> { + ClassDesc[] params = new ClassDesc[count * 2]; + for (int i = 0; i < count; i++) { + keys[i].emit(code); + values[i].emit(code); + params[i << 1] = CD_Object; + params[(i << 1) + 1] = CD_Object; + } + code.invokestatic(CD_Map, "of", + MethodTypeDesc.of(CD_Map, params), true); + }; + } else { + return code -> { + code.loadConstant(count); + code.anewarray(CD_Map_Entry); + for (int i = 0; i < count; i++) { + code.dup(); + code.loadConstant(i); + keys[i].emit(code); + values[i].emit(code); + code.invokestatic(CD_Map, "entry", + MethodTypeDesc.of(CD_Map_Entry, CD_Object, CD_Object), true); + code.aastore(); + } + code.invokestatic(CD_Map, "ofEntries", + MethodTypeDesc.of(CD_Map, CD_Map_Entry.arrayType()), true); + }; + } + } + + // ── Build-reproducibility sorting ── + // + // Hash-based sets and maps (HashSet, HashMap, and the JDK immutable + // Set.of/Map.of variants backed by CollSer) iterate in nondeterministic + // order. When their elements are serialized and emitted as bytecode, + // different iteration orders produce different bytecode, breaking build + // reproducibility. We sort set elements and map entries (by key) before + // emitting, using a comparator over the Serialized tree. Only applied + // when there are two or more elements; singletons and empties are + // inherently stable. + + /** + * Comparator that provides a total order over {@link Serialized} values + * for the purpose of stabilizing bytecode emission order. + *

+ * Handles the leaf types that realistically appear as set elements and + * map keys (constants, strings, enums, class references, config mappings, + * null). Throws for complex types (records, general serializables) used + * as keys — those indicate a design problem at the source. + */ + private static final Comparator SERIALIZED_ORDER = (a, b) -> { + if (a == b) { + return 0; + } + int ta = typeOrdinal(a); + int tb = typeOrdinal(b); + if (ta != tb) { + return Integer.compare(ta, tb); + } + return switch (a) { + case SerializedNull ignored -> 0; + case SerializedString sa -> + sa.string().compareTo(((SerializedString) b).string()); + case SerializedConstant ca -> + compareConstants(ca, (SerializedConstant) b); + case SerializedConfigMapping ca -> + ca.configInterface().descriptorString() + .compareTo(((SerializedConfigMapping) b).configInterface().descriptorString()); + case SerializedClass ca -> + ca.descriptorString().compareTo(((SerializedClass) b).descriptorString()); + case SerializedEnum ea -> + compareEnums(ea, (SerializedEnum) b); + default -> throw new LambdaTransliterator.TransliterationException( + "Cannot deterministically sort serialized value of type " + + a.getClass().getName() + + " — use a sorted collection (TreeSet/TreeMap) at the source"); + }; + }; + + /** + * Assign a type ordinal for cross-type comparison. + */ + private static int typeOrdinal(Serialized s) { + return switch (s) { + case SerializedNull ignored -> 0; + case SerializedString ignored -> 1; + case SerializedConstant ignored -> 2; + case SerializedConfigMapping ignored -> 3; + case SerializedClass ignored -> 4; + case SerializedEnum ignored -> 5; + default -> 6; + }; + } + + /** + * Compare two {@link SerializedConstant} values by their {@link ConstantDesc}. + */ + @SuppressWarnings("unchecked") + private static int compareConstants(SerializedConstant a, SerializedConstant b) { + ConstantDesc da = a.constantDesc(); + ConstantDesc db = b.constantDesc(); + // same concrete type: use natural ordering + if (da.getClass() == db.getClass() && da instanceof Comparable ca) { + return ((Comparable) ca).compareTo(db); + } + // ClassDesc comparison + if (da instanceof ClassDesc ca && db instanceof ClassDesc cb) { + return ca.descriptorString().compareTo(cb.descriptorString()); + } + // cross-type fallback: order by concrete class name + return da.getClass().getName().compareTo(db.getClass().getName()); + } + + /** + * Compare two {@link SerializedEnum} values by enum class name, then constant name. + */ + private static int compareEnums(SerializedEnum a, SerializedEnum b) { + int c = a.enumClass().name().compareTo(b.enumClass().name()); + if (c != 0) { + return c; + } + return a.constantName().string().compareTo(b.constantName().string()); + } + + /** + * Sort set elements in place for build reproducibility. + * No-op for arrays with fewer than two elements. + * + * @param elements the set elements to sort + */ + private static void sortSetElements(Serialized[] elements) { + if (elements.length >= 2) { + Arrays.sort(elements, SERIALIZED_ORDER); + } + } + + /** + * Sort interleaved map entries (key, value, key, value, ...) in place by key + * for build reproducibility. No-op for maps with fewer than two entries. + *

+ * Uses insertion sort — optimal for the small entry counts typical of + * captured maps, and avoids allocations entirely. + * + * @param interleaved the interleaved key-value array + */ + private static void sortMapEntries(Serialized[] interleaved) { + int entryCount = interleaved.length / 2; + if (entryCount < 2) { + return; + } + for (int i = 1; i < entryCount; i++) { + Serialized key = interleaved[i * 2]; + Serialized val = interleaved[i * 2 + 1]; + int j = i - 1; + while (j >= 0 && SERIALIZED_ORDER.compare(interleaved[j * 2], key) > 0) { + interleaved[(j + 1) * 2] = interleaved[j * 2]; + interleaved[(j + 1) * 2 + 1] = interleaved[j * 2 + 1]; + j--; + } + interleaved[(j + 1) * 2] = key; + interleaved[(j + 1) * 2 + 1] = val; + } + } + + /** + * Reuse the existing DedupEmitter from LambdaTransliterator. + * Emits the delegate once and stores in a local; subsequent calls load from local. + */ + private static final class DedupEmitter implements CaptureEmitter { + private final CaptureEmitter delegate; + private int slot = -1; + + DedupEmitter(CaptureEmitter delegate) { + this.delegate = delegate; + } + + @Override + public void emit(CodeBuilder code) { + if (slot == -1) { + delegate.emit(code); + slot = code.allocateLocal(TypeKind.REFERENCE); + code.dup(); + code.astore(slot); + } else { + code.aload(slot); + } + } + } +} diff --git a/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/SerializedConfigMapping.java b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/SerializedConfigMapping.java new file mode 100644 index 0000000000000..d4c51f067c1c2 --- /dev/null +++ b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/SerializedConfigMapping.java @@ -0,0 +1,32 @@ +package io.quarkus.core.deployment.service.impl; + +import java.lang.constant.ClassDesc; + +import io.smallrye.serial.Serialized; + +/** + * A {@link Serialized} representation of a {@code @ConfigMapping} + {@code @ConfigRoot} + * config instance captured by a service action lambda. + *

+ * Produced by {@link ConfigMappingSerializer}. The emitter loads the config mapping + * directly from SmallRye Config at runtime via {@code ConfigLookup.getConfigMapping(type)}. + */ +public final class SerializedConfigMapping extends Serialized { + private final ClassDesc configInterface; + + /** + * Construct a new instance. + * + * @param configInterface the class descriptor of the {@code @ConfigMapping} interface + */ + public SerializedConfigMapping(ClassDesc configInterface) { + this.configInterface = configInterface; + } + + /** + * {@return the class descriptor of the config mapping interface} + */ + public ClassDesc configInterface() { + return configInterface; + } +} diff --git a/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/SerializedConstant.java b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/SerializedConstant.java new file mode 100644 index 0000000000000..6e452df0f6b94 --- /dev/null +++ b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/SerializedConstant.java @@ -0,0 +1,58 @@ +package io.quarkus.core.deployment.service.impl; + +import java.lang.constant.ClassDesc; +import java.lang.constant.ConstantDesc; + +import io.smallrye.serial.Serialized; + +/** + * A {@link Serialized} representation of a constant value ({@link ConstantDesc}). + *

+ * Produced by {@link ConstantDescSerializer} for objects that are {@link ConstantDesc} + * or {@link java.lang.constant.Constable} and resolve to a {@code ConstantDesc}. + * The emitter loads this as a JVM constant via {@code ldc} or equivalent, + * with boxing decided by the target context. + *

+ * For {@link Boolean}, {@link Byte}, {@link Short}, and {@link Character} values, + * the constant is stored as its {@code int} equivalent (suitable for primitive slots), + * and the {@link #boxType()} carries the original wrapper type for reference-target boxing. + */ +public final class SerializedConstant extends Serialized { + private final ConstantDesc constantDesc; + private final ClassDesc boxType; + + /** + * Construct a new instance with no box type (the constant is self-describing). + * + * @param constantDesc the constant descriptor (must not be {@code null}) + */ + public SerializedConstant(ConstantDesc constantDesc) { + this(constantDesc, null); + } + + /** + * Construct a new instance with an explicit box type. + * + * @param constantDesc the constant descriptor (must not be {@code null}) + * @param boxType the original wrapper type for boxing, or {@code null} if not applicable + */ + public SerializedConstant(ConstantDesc constantDesc, ClassDesc boxType) { + this.constantDesc = constantDesc; + this.boxType = boxType; + } + + /** + * {@return the constant descriptor} + */ + public ConstantDesc constantDesc() { + return constantDesc; + } + + /** + * {@return the original wrapper type for boxing, or {@code null} if the constant + * is self-describing (Integer, Long, Float, Double, String, etc.)} + */ + public ClassDesc boxType() { + return boxType; + } +} diff --git a/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/SerializedQuarkusClassLoader.java b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/SerializedQuarkusClassLoader.java new file mode 100644 index 0000000000000..ccb5c651a8bf6 --- /dev/null +++ b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/SerializedQuarkusClassLoader.java @@ -0,0 +1,47 @@ +package io.quarkus.core.deployment.service.impl; + +import io.smallrye.common.constraint.Assert; +import io.smallrye.serial.Serialized; + +/** + * A serialized (captured) Quarkus class loader, which can be rematerialized + * in the target as the defining class loader of the generated class. + */ +public final class SerializedQuarkusClassLoader extends Serialized { + private static final SerializedQuarkusClassLoader RUNTIME = new SerializedQuarkusClassLoader(Kind.RUNTIME); + + private final Kind kind; + + private SerializedQuarkusClassLoader(final Kind kind) { + this.kind = Assert.checkNotNullParam("kind", kind); + } + + /** + * {@return the serialized class loader of the given kind} + * + * @param kind the class loader kind (must not be {@code null}) + */ + public static Serialized of(final Kind kind) { + Assert.checkNotNullParam("kind", kind); + return switch (kind) { + case RUNTIME -> RUNTIME; + }; + } + + /** + * {@return the class loader kind (not {@code null})} + */ + public Kind kind() { + return kind; + } + + /** + * The enumeration of possible class loader kinds. + */ + public enum Kind { + /** + * The kind of the base run time class loader. + */ + RUNTIME, + } +} diff --git a/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/ServiceBuilderImpl.java b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/ServiceBuilderImpl.java new file mode 100644 index 0000000000000..24f84381230db --- /dev/null +++ b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/ServiceBuilderImpl.java @@ -0,0 +1,482 @@ +package io.quarkus.core.deployment.service.impl; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import io.quarkus.builder.item.BuildItem; +import io.quarkus.core.Phase; +import io.quarkus.deployment.builditem.MainBytecodeRecorderBuildItem; +import io.quarkus.deployment.builditem.StaticBytecodeRecorderBuildItem; +import io.quarkus.runtime.annotations.ConfigPhase; +import io.quarkus.runtime.annotations.ConfigRoot; +import io.smallrye.common.constraint.Assert; + +/** + * Common implementation for all {@code ServiceBuilderN} arities. + *

+ * This single class backs all service builder interfaces. The type safety is + * provided by the generated interfaces at the call site; internally, raw types + * are used where necessary. The builder accumulates dependency identities via + * {@code require()} and terminates the chain via {@code action()} or + * {@code actionAsync()}, which triggers lambda extraction and produces the + * necessary build items. + *

+ * The phase (set via {@code atPhase()}) determines which build items are + * produced: {@link Phase#STATIC_INIT} routes to static-init items, all other + * phases route to runtime-init items. + * + * @param the service type + */ +final class ServiceBuilderImpl { + + private final Class serviceType; + private final List serviceNameParts; + private final Consumer recorderProducer; + private final Consumer staticRecorderProducer; + private final Consumer metadataProducer; + private final Consumer staticMetadataProducer; + private final String buildStepName; + /** + * Per-build-step record of which services have been defined and in which phase, shared with the + * owning {@link ServiceRegistrarImpl} and consulted by {@link ServiceRegistrarImpl#getRecorderProxy(Class, String)}. + * The mapping is three-state, keyed by service key: + *

    + *
  • absent ({@code get} returns {@code null}) — no {@code action()}/{@code actionAsync()} has been + * called for this service key in this build step; requesting a recorder proxy for it is an error
  • + *
  • {@code Boolean.TRUE} — the service is defined and static-init; its recorder proxy must report + * {@code __static$$init() == true} and its value must be retained across the phase boundary
  • + *
  • {@code Boolean.FALSE} — the service is defined and runtime; its recorder proxy reports + * {@code __static$$init() == false} and needs no cross-phase retention
  • + *
+ * Entries are inserted by {@link #doAction} once the phase is final, so consumers must call + * {@code getRecorderProxy()} after the corresponding {@code action()}. + */ + private final Map definedServiceStaticInit; + private final List dependencies = new ArrayList<>(); + private final List beforeKeys = new ArrayList<>(); + private final List afterBuildItemClasses = new ArrayList<>(); + private Phase phase = Phase.APPLICATION; + private boolean optional; + + /** + * Construct the initial builder with zero dependencies. + * + * @param serviceType the service type + * @param serviceNameParts the service name parts + * @param recorderProducer consumer for runtime-init build items + * @param staticRecorderProducer consumer for static-init build items + * @param metadataProducer consumer for runtime-init metadata + * @param staticMetadataProducer consumer for static-init metadata + * @param buildStepName the name of the declaring build step + * @param definedServiceStaticInit per-build-step map recording each defined service key's phase + * ({@code true} for static-init); populated at {@link #doAction} time and consulted by + * {@link ServiceRegistrarImpl#getRecorderProxy(Class, String)} + */ + ServiceBuilderImpl( + Class serviceType, + List serviceNameParts, + Consumer recorderProducer, + Consumer staticRecorderProducer, + Consumer metadataProducer, + Consumer staticMetadataProducer, + String buildStepName, + Map definedServiceStaticInit) { + checkServiceType(serviceType); + this.serviceType = serviceType; + this.serviceNameParts = serviceNameParts; + this.recorderProducer = recorderProducer; + this.staticRecorderProducer = staticRecorderProducer; + this.metadataProducer = metadataProducer; + this.staticMetadataProducer = staticMetadataProducer; + this.buildStepName = buildStepName; + this.definedServiceStaticInit = definedServiceStaticInit; + } + + // ── require() ── + // NOTE: during the recorder coexistence period, deploy method ordering is controlled + // by build step ordering (@Consume/@Produce), not by the service dependency graph. + // Service dependencies are validated against the build step ordering, but this + // validation may not detect all missing dependencies. Ensure the corresponding + // build step ordering constraints are in place. + + /** + * Add an unnamed injected dependency. + * + * @param type the dependency type (must not be {@code null}) + */ + public void require(Class type) { + require(type, List.of(), true); + } + + /** + * Add a named injected dependency. + * + * @param type the dependency type (must not be {@code null}) + * @param name the dependency name (must not be {@code null}) + */ + public void require(Class type, String name) { + require(type, List.of(name), true); + } + + /** + * Add an injected dependency with the given name parts. + * + * @param type the dependency type (must not be {@code null}) + * @param name the dependency name parts (must not be {@code null}) + */ + public void require(Class type, List name) { + require(type, name, true); + } + + /** + * Add a dependency with the given name parts and injection flag. + *

+ * If the type is a {@code @ConfigRoot} config mapping, the dependency is resolved + * directly from SmallRye Config at runtime (no service values map involvement). + * + * @param type the dependency type (must not be {@code null}) + * @param name the dependency name parts (must not be {@code null}) + * @param injected {@code true} if this dependency is injected + */ + public void require(Class type, List name, boolean injected) { + Assert.checkNotNullParam("type", type); + Assert.checkNotNullParam("name", name); + checkServiceType(type); + ConfigRoot configRoot = ConfigMappingDetector.findConfigRoot(type); + if (configRoot != null) { + ConfigPhase phase = configRoot.phase(); + if (phase == ConfigPhase.BUILD_TIME) { + throw new IllegalArgumentException( + "Build-time configuration " + type.getName() + + " cannot be required by a service action: " + + "it is not available at runtime."); + } + if (phase == ConfigPhase.RUN_TIME && this.phase == Phase.STATIC_INIT) { + throw new IllegalArgumentException( + "Runtime configuration " + type.getName() + + " cannot be required by a static-init service action: " + + "it is not available during static initialization."); + } + // config-direct: resolved from SmallRye Config, not the service values map + dependencies.add(new Dependency(type, name, (injected ? Dependency.FL_INJECTED : 0) | Dependency.FL_CONFIG_DIRECT)); + return; + } + dependencies.add(new Dependency(type, name, injected ? Dependency.FL_INJECTED : 0)); + } + + // ── request() — optional injected dependencies ── + + /** + * Add an unnamed optional injected dependency. + * The dependency is injected as {@code Optional}; if absent, an empty optional is provided. + * + * @param type the dependency type (must not be {@code null}) + */ + public void request(Class type) { + request(type, List.of()); + } + + /** + * Add a named optional injected dependency. + * + * @param type the dependency type (must not be {@code null}) + * @param name the dependency name (must not be {@code null}) + */ + public void request(Class type, String name) { + request(type, List.of(name)); + } + + /** + * Add an optional injected dependency with the given name parts. + * + * @param type the dependency type (must not be {@code null}) + * @param name the dependency name parts (must not be {@code null}) + */ + public void request(Class type, List name) { + Assert.checkNotNullParam("type", type); + Assert.checkNotNullParam("name", name); + checkServiceType(type); + dependencies.add(new Dependency(type, name, Dependency.FL_INJECTED | Dependency.FL_OPTIONAL)); + } + + // ── consumeAll() — multi-service dependencies ── + + /** + * Consume all services of the given type as a {@code Map}. + * The map keys are the service names; unnamed services use empty string. + * Zero matches produces an empty map (always optional). + * + * @param type the service type to match (must not be {@code null}) + */ + public void consumeAll(Class type) { + Assert.checkNotNullParam("type", type); + checkServiceType(type); + dependencies.add( + new Dependency(type, List.of(), Dependency.FL_INJECTED | Dependency.FL_OPTIONAL | Dependency.FL_CONSUME_ALL)); + } + + // ── after() — optional ordering-only dependencies ── + // NOTE: during the recorder coexistence period, deploy method ordering is still controlled + // by build step ordering (@Consume/@Produce). Declaring after() deps now ensures correct + // ordering once recorders are removed and services run in dependency order. Until then, + // the corresponding @Consume annotation on the build step must be kept as well. + + /** + * Add an optional ordering-only dependency on an enum constant service. + * If the service exists, this service will start after it completes. + * If the service does not exist, this dependency is silently ignored. + * + * @param enumConstant the enum constant (must not be {@code null}) + */ + public void after(Enum enumConstant) { + Assert.checkNotNullParam("enumConstant", enumConstant); + dependencies + .add(new Dependency(enumConstant.getDeclaringClass(), List.of(enumConstant.name()), Dependency.FL_OPTIONAL)); + } + + /** + * Add an optional ordering-only dependency on a void service with the given name. + * If the service exists, this service will start after it completes. + * If the service does not exist, this dependency is silently ignored. + * + * @param name the void service name (must not be {@code null}) + */ + public void after(String name) { + Assert.checkNotNullParam("name", name); + dependencies.add(new Dependency(Void.class, List.of(name), Dependency.FL_OPTIONAL)); + } + + /** + * Add an optional ordering-only dependency on a void service with the given multi-part name. + * + * @param nameParts the void service name parts (must not be {@code null}) + */ + public void after(List nameParts) { + Assert.checkNotNullParam("nameParts", nameParts); + dependencies.add(new Dependency(Void.class, nameParts, Dependency.FL_OPTIONAL)); + } + + /** + * Add an optional ordering-only dependency on an unnamed typed service. + * + * @param type the dependency type (must not be {@code null}) + */ + public void after(Class type) { + Assert.checkNotNullParam("type", type); + dependencies.add(new Dependency(type, List.of(), Dependency.FL_OPTIONAL)); + } + + /** + * Add an optional ordering-only dependency on a named typed service. + * + * @param type the dependency type (must not be {@code null}) + * @param name the service name (must not be {@code null}) + */ + public void after(Class type, String name) { + Assert.checkNotNullParam("type", type); + Assert.checkNotNullParam("name", name); + dependencies.add(new Dependency(type, List.of(name), Dependency.FL_OPTIONAL)); + } + + /** + * Add an optional ordering-only dependency on a typed service with the given multi-part name. + * + * @param type the dependency type (must not be {@code null}) + * @param nameParts the service name parts (must not be {@code null}) + */ + public void after(Class type, List nameParts) { + Assert.checkNotNullParam("type", type); + Assert.checkNotNullParam("nameParts", nameParts); + dependencies.add(new Dependency(type, nameParts, Dependency.FL_OPTIONAL)); + } + + // ── before() — reverse ordering dependencies ── + // Declaring before(X) means X depends on this service: this service starts + // before X and stops after X. This is the inverse of after(). + + /** + * Declare that the given unnamed typed service should start after this + * service and stop before it. If the target service does not exist, + * this declaration is silently ignored. + * + * @param type the target service type (must not be {@code null}) + */ + public void before(Class type) { + Assert.checkNotNullParam("type", type); + if (type == Phase.class) { + throw phaseDependencyException(); + } + beforeKeys.add(LambdaTransliterator.serviceKey(type, List.of())); + } + + /** + * Declare that the given named typed service should start after this + * service and stop before it. If the target service does not exist, + * this declaration is silently ignored. + * + * @param type the target service type (must not be {@code null}) + * @param name the service name (must not be {@code null}) + */ + public void before(Class type, String name) { + Assert.checkNotNullParam("type", type); + if (type == Phase.class) { + throw phaseDependencyException(); + } + beforeKeys.add(LambdaTransliterator.serviceKey(type, List.of(name))); + } + + /** + * Declare that the given named typed service should start after this + * service and stop before it. If the target service does not exist, + * this declaration is silently ignored. + * + * @param type the target service type (must not be {@code null}) + * @param nameParts the service name parts (must not be {@code null}) + */ + public void before(Class type, List nameParts) { + if (type == Phase.class) { + throw phaseDependencyException(); + } + beforeKeys.add(LambdaTransliterator.serviceKey(type, nameParts)); + } + + /** + * Declare that the given void service should start after this + * service and stop before it. If the target service does not exist, + * this declaration is silently ignored. + * + * @param name the void service name (must not be {@code null}) + */ + public void before(String name) { + beforeKeys.add(LambdaTransliterator.serviceKey(Void.class, List.of(name))); + } + + /** + * Declare that the given enum constant service should start after this + * service and stop before it. If the target service does not exist, + * this declaration is silently ignored. + * + * @param enumConstant the enum constant (must not be {@code null}) + */ + public void before(Enum enumConstant) { + if (enumConstant == null) { + throw new IllegalArgumentException("enumConstant must not be null"); + } + if (enumConstant.getDeclaringClass() == Phase.class) { + throw phaseDependencyException(); + } + beforeKeys.add(LambdaTransliterator.serviceKey(enumConstant.getDeclaringClass(), List.of(enumConstant.name()))); + } + + // ── afterBuildItem() — build item ordering dependencies ── + + /** + * Declare that this service must start after the build step that + * produces the given build item has completed. + *

+ * This is a transitional API for services that coexist with legacy + * bytecode recorders. It bridges the service dependency model with + * the build step dependency model by resolving the producing step's + * nodes (with passthrough resolution) and adding them as ordering + * dependencies. + *

+ * Once all recorders are converted to services, this method should + * be replaced with direct service dependencies via {@code require()}, + * {@code after()}, or {@code before()}. + *

+ * If no step produces the given build item, the dependency is + * silently ignored. + * + * @param buildItemClass the build item class (must not be {@code null}) + * @deprecated Transitional API for recorder coexistence. Use direct + * service dependencies once the producing recorder is converted. + */ + @Deprecated(forRemoval = true) + public void afterBuildItem(Class buildItemClass) { + afterBuildItemClasses.add(buildItemClass.getName()); + } + + // ── atPhase() — runtime phase assignment ── + + /** + * Assign this service to a phase. + * The default phase is {@link Phase#APPLICATION}. + * + * @param phase the phase (must not be {@code null}) + */ + public void atPhase(Phase phase) { + this.phase = phase; + } + + // ── optional() — the service may produce no value at runtime ── + + /** + * Mark this service as optional: at runtime its action returns {@code Optional}, and an empty result + * marks the service as present-but-valueless (skipped by {@code consumeAll()} consumers and delivered as an + * empty optional to {@code request()} consumers) rather than being treated as an error. + * + * @throws IllegalStateException if this service has already been marked optional + */ + public void markOptional() { + if (optional) { + throw new IllegalStateException("optional() may only be called once on a service builder"); + } + optional = true; + } + + /** + * Extract the lambda and produce the build items. + *

+ * Class generation is deferred; only the extracted action data is produced here. + * + * @param action the action lambda (must be serializable) + * @param async {@code true} for async actions + */ + public void doAction(Serializable action, boolean async) { + boolean isStaticInit = phase == Phase.STATIC_INIT; + if (isStaticInit && async) { + throw new IllegalStateException( + "Static-init services do not support async actions"); + } + if (optional && async) { + throw new IllegalStateException( + "optional() services do not support async actions"); + } + String serviceKey = LambdaTransliterator.serviceKey(serviceType, serviceNameParts); + // record this service's phase so a later getRecorderProxy() in the same build step can + // resolve the correct proxy phase and guarantee cross-phase retention for static-init + definedServiceStaticInit.put(serviceKey, isStaticInit); + // Automatically make the synthetic phase service depend on this service + if (serviceType != Phase.class) { + beforeKeys.add(LambdaTransliterator.serviceKey(Phase.class, List.of(phase.name()))); + } + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, serviceType, serviceNameParts, dependencies, beforeKeys, afterBuildItemClasses, + async, optional, isStaticInit, buildStepName); + if (isStaticInit) { + staticRecorderProducer.accept(new StaticBytecodeRecorderBuildItem(extracted)); + staticMetadataProducer.accept(new StaticServiceMetadataBuildItem( + serviceType, serviceNameParts, dependencies, buildStepName)); + } else { + recorderProducer.accept(new MainBytecodeRecorderBuildItem(extracted)); + metadataProducer.accept(new ServiceMetadataBuildItem( + serviceType, serviceNameParts, dependencies, buildStepName, phase)); + } + } + + private static void checkServiceType(Class type) { + if (type.getTypeParameters().length > 0) { + throw new IllegalArgumentException("Generic types cannot be used as service types: " + type.getName()); + } + } + + private IllegalStateException phaseDependencyException() { + return new IllegalStateException("Invalid dependency: service '" + + LambdaTransliterator.serviceKey(serviceType, serviceNameParts) + + "' cannot declare a 'before' dependency on Phase. Synthetic phase services act as phase completion markers, " + + "and targeting a phase's start boundary is not supported under Quarkus's concurrent startup model."); + } +} diff --git a/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/ServiceMetadataBuildItem.java b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/ServiceMetadataBuildItem.java new file mode 100644 index 0000000000000..ea9aa4c854e99 --- /dev/null +++ b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/ServiceMetadataBuildItem.java @@ -0,0 +1,103 @@ +package io.quarkus.core.deployment.service.impl; + +import java.util.List; + +import io.quarkus.builder.item.MultiBuildItem; +import io.quarkus.core.Phase; + +/** + * A build item carrying metadata about a service defined via + * {@link ServiceRegistrarImpl}. + *

+ * Each instance describes a single service registration: the service identity + * (type + optional name), the identities of its declared dependencies, and + * the name of the build step that defined it (for diagnostics). + *

+ * All instances are consumed by the service dependency validation step, which + * checks that every declared dependency is actually provided and that no + * cycles exist in the service graph. + */ +public final class ServiceMetadataBuildItem extends MultiBuildItem { + + private final Class serviceType; + private final List serviceNameParts; + private final List dependencies; + private final String buildStepName; + private final Phase phase; + + /** + * Construct a new instance. + * + * @param serviceType the service type (must not be {@code null}) + * @param serviceNameParts the service name parts (must not be {@code null}) + * @param dependencies the declared dependencies (must not be {@code null}) + * @param buildStepName the name of the declaring build step (must not be {@code null}) + * @param phase the runtime phase this service belongs to (must not be {@code null}) + */ + public ServiceMetadataBuildItem( + Class serviceType, + List serviceNameParts, + List dependencies, + String buildStepName, + Phase phase) { + this.serviceType = serviceType; + this.serviceNameParts = List.copyOf(serviceNameParts); + this.dependencies = List.copyOf(dependencies); + this.buildStepName = buildStepName; + this.phase = phase; + } + + /** + * Get the service type. + * + * @return the service type (not {@code null}) + */ + public Class serviceType() { + return serviceType; + } + + /** + * Get the service name parts. + * + * @return the service name parts (not {@code null}) + */ + public List serviceNameParts() { + return serviceNameParts; + } + + /** + * Get the declared dependencies. + * + * @return the dependency list (not {@code null}) + */ + public List dependencies() { + return dependencies; + } + + /** + * Get the name of the build step that defined this service. + * + * @return the build step name (not {@code null}) + */ + public String buildStepName() { + return buildStepName; + } + + /** + * Get the runtime phase this service belongs to. + * + * @return the runtime phase (not {@code null}) + */ + public Phase phase() { + return phase; + } + + /** + * Get the service key used for identity and lookup. + * + * @return the service key string + */ + public String serviceKey() { + return LambdaTransliterator.serviceKey(serviceType, serviceNameParts); + } +} diff --git a/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/ServiceRegistrarImpl.java b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/ServiceRegistrarImpl.java new file mode 100644 index 0000000000000..f1fcef5992f8b --- /dev/null +++ b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/ServiceRegistrarImpl.java @@ -0,0 +1,430 @@ +package io.quarkus.core.deployment.service.impl; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.function.Supplier; + +import io.quarkus.core.Phase; +import io.quarkus.core.deployment.service.ServiceBuilder0; +import io.quarkus.core.deployment.service.ServiceRegistrar; +import io.quarkus.core.deployment.service.VoidServiceBuilder0; +import io.quarkus.deployment.builditem.MainBytecodeRecorderBuildItem; +import io.quarkus.deployment.builditem.StaticBytecodeRecorderBuildItem; +import io.quarkus.deployment.proxy.ProxyConfiguration; +import io.quarkus.deployment.proxy.ProxyFactory; +import io.quarkus.deployment.recording.BytecodeRecorderImpl; +import io.quarkus.runtime.RuntimeValue; + +/** + * The entry point for defining services from build steps. + *

+ * An {@code ServiceRegistrar} is injected as a parameter into {@code @BuildStep} methods. + * It provides a fluent API for defining services that execute at application startup: + * + *

{@code
+ * @BuildStep
+ * void setupMyService(ServiceRegistrar reg) {
+ *     reg.forService(MyService.class)
+ *             .require(SomeDependency.class)
+ *             .onStart((ctx, dep) -> new MyService(dep));
+ * }
+ * }
+ *

+ * Each call to {@code action()} or {@code actionAsync()} on the terminal builder + * extracts the lambda into a {@link TransliteratedAction} record for deferred + * consolidated class generation. + */ +public final class ServiceRegistrarImpl implements ServiceRegistrar { + + private final Consumer recorderProducer; + private final Consumer staticRecorderProducer; + private final Consumer metadataProducer; + private final Consumer staticMetadataProducer; + private final Consumer retentionProducer; + private final String buildStepName; + /** + * Three-state record of the services defined by this build step, keyed by service key: + * absent means undefined (requesting a recorder proxy is an error), {@code Boolean.TRUE} means + * defined as static-init, {@code Boolean.FALSE} means defined as runtime. Populated by + * {@link ServiceBuilderImpl#doAction} and read by {@link #getRecorderProxy(Class, String)} to + * produce a phase-correct proxy and guarantee cross-phase retention for static-init services. + */ + private final Map definedServiceStaticInit = new HashMap<>(); + + /** + * Construct a new instance. + * This constructor is called by the extension loader infrastructure. + * + * @param recorderProducer consumer for producing {@link MainBytecodeRecorderBuildItem}s + * @param staticRecorderProducer consumer for producing {@link StaticBytecodeRecorderBuildItem}s + * @param metadataProducer consumer for producing {@link ServiceMetadataBuildItem}s + * @param staticMetadataProducer consumer for producing {@link StaticServiceMetadataBuildItem}s + * @param retentionProducer consumer for producing {@link ServiceValueRetentionBuildItem}s + * @param buildStepName the name of the declaring build step (for diagnostics) + */ + public ServiceRegistrarImpl( + Consumer recorderProducer, + Consumer staticRecorderProducer, + Consumer metadataProducer, + Consumer staticMetadataProducer, + Consumer retentionProducer, + String buildStepName) { + this.recorderProducer = recorderProducer; + this.staticRecorderProducer = staticRecorderProducer; + this.metadataProducer = metadataProducer; + this.staticMetadataProducer = staticMetadataProducer; + this.retentionProducer = retentionProducer; + this.buildStepName = buildStepName; + } + + public ServiceBuilder0 forService(Class serviceType) { + return forService(serviceType, List.of()); + } + + public ServiceBuilder0 forService(Class serviceType, String serviceName) { + return forService(serviceType, serviceName == null ? List.of() : List.of(serviceName)); + } + + public ServiceBuilder0 forService(Class serviceType, List serviceNameParts) { + return new ServiceBuilderImpl0<>(new ServiceBuilderImpl<>( + serviceType, + serviceNameParts, + recorderProducer, + staticRecorderProducer, + metadataProducer, + staticMetadataProducer, + buildStepName, + definedServiceStaticInit)); + } + + public VoidServiceBuilder0 forService(String serviceName) { + return forVoidService(List.of(serviceName)); + } + + public VoidServiceBuilder0 forService(List serviceNameParts) { + return forVoidService(serviceNameParts); + } + + private VoidServiceBuilder0 forVoidService(List serviceNameParts) { + return new VoidServiceBuilderImpl0(new ServiceBuilderImpl<>( + Void.class, + serviceNameParts, + recorderProducer, + staticRecorderProducer, + metadataProducer, + staticMetadataProducer, + buildStepName, + definedServiceStaticInit)); + } + + public void aliasRecorderValue(Class serviceType, T recorderValue) { + aliasRecorderValue(serviceType, null, recorderValue, Phase.APPLICATION); + } + + public void aliasRecorderValue(Class serviceType, T recorderValue, Phase phase) { + aliasRecorderValue(serviceType, null, recorderValue, phase); + } + + public void aliasRecorderValue(Class serviceType, String serviceName, T recorderValue) { + aliasRecorderValue(serviceType, serviceName, recorderValue, Phase.APPLICATION); + } + + public void aliasRecorderValue(Class serviceType, String serviceName, T recorderValue, Phase phase) { + if (!(recorderValue instanceof BytecodeRecorderImpl.ReturnedProxy rp)) { + throw new IllegalArgumentException( + "aliasRecorderValue requires a recorder proxy (an object returned from a @Recorder method), " + + "but got: " + recorderValue.getClass().getName()); + } + String recorderProxyKey = rp.__returned$proxy$key(); + boolean staticInit = rp.__static$$init(); + List serviceNameParts = serviceName == null ? List.of() : List.of(serviceName); + String serviceKey = LambdaTransliterator.serviceKey(serviceType, serviceNameParts); + + // produce a TransliteratedAction.AliasService instead of generating a class + TransliteratedAction alias = new TransliteratedAction.AliasService(serviceKey, staticInit, recorderProxyKey, + buildStepName); + + // produce the appropriate phase-specific build items + if (staticInit) { + staticRecorderProducer.accept(new StaticBytecodeRecorderBuildItem(alias)); + staticMetadataProducer.accept(new StaticServiceMetadataBuildItem( + serviceType, serviceNameParts, List.of(), buildStepName)); + } else { + recorderProducer.accept(new MainBytecodeRecorderBuildItem(alias)); + metadataProducer.accept(new ServiceMetadataBuildItem( + serviceType, serviceNameParts, List.of(), buildStepName, phase)); + } + } + + // ── Service → RuntimeValue bridge ── + + public RuntimeValue serviceAsRuntimeValue(Class serviceType) { + return serviceAsRuntimeValue(serviceType, List.of()); + } + + public RuntimeValue serviceAsRuntimeValue(Class serviceType, String serviceName) { + return serviceAsRuntimeValue(serviceType, serviceName == null ? List.of() : List.of(serviceName)); + } + + public RuntimeValue serviceAsRuntimeValue(Class serviceType, List serviceNameParts) { + return createRuntimeValueProxy(serviceType, serviceNameParts, false); + } + + public RuntimeValue staticInitServiceAsRuntimeValue(Class serviceType) { + return staticInitServiceAsRuntimeValue(serviceType, List.of()); + } + + public RuntimeValue staticInitServiceAsRuntimeValue(Class serviceType, String serviceName) { + return staticInitServiceAsRuntimeValue(serviceType, serviceName == null ? List.of() : List.of(serviceName)); + } + + public RuntimeValue staticInitServiceAsRuntimeValue(Class serviceType, List serviceNameParts) { + return createRuntimeValueProxy(serviceType, serviceNameParts, true); + } + + /** + * Create a {@link RuntimeValue} proxy that implements {@link BytecodeRecorderImpl.ReturnedProxy} + * and register a {@link TransliteratedAction.RuntimeValueWrapper} to wrap the service value at runtime. + * + * @param serviceType the service type + * @param serviceNameParts the service name parts (must not be {@code null}) + * @param staticInit whether this targets the static-init phase + * @param the service type + * @return a {@code RuntimeValue} proxy + */ + private RuntimeValue createRuntimeValueProxy(Class serviceType, List serviceNameParts, + boolean staticInit) { + // the RuntimeValueWrapper copies the raw service value into a RuntimeValue and stores it + // in the serviceValues map; this is needed because recorder methods that receive + // RuntimeValue expect to call getValue() on a RuntimeValue, not receive the raw value + String serviceKey = LambdaTransliterator.serviceKey(serviceType, serviceNameParts); + String rvKey = "runtimevalue:" + serviceKey; + + TransliteratedAction wrapper = new TransliteratedAction.RuntimeValueWrapper(serviceKey, staticInit, rvKey, + buildStepName); + if (staticInit) { + staticRecorderProducer.accept(new StaticBytecodeRecorderBuildItem(wrapper)); + // the rvKey is consumed by a runtime-init recorder proxy; retain across phases + trackCrossPhaseKey(rvKey); + } else { + recorderProducer.accept(new MainBytecodeRecorderBuildItem(wrapper)); + } + + return new ServiceRuntimeValueProxy<>(rvKey, staticInit); + } + + // ── Service → bare recorder proxy bridge ── + + public T getRecorderProxy(Class serviceType) { + return getRecorderProxy(serviceType, null); + } + + public T getRecorderProxy(Class serviceType, String serviceName) { + List serviceNameParts = serviceName == null ? List.of() : List.of(serviceName); + String serviceKey = LambdaTransliterator.serviceKey(serviceType, serviceNameParts); + + // resolve the phase from the service definition; the service must have been defined + // (its action() called) earlier in this same build step + Boolean staticInit = definedServiceStaticInit.get(serviceKey); + if (staticInit == null) { + throw new IllegalStateException( + "Service " + serviceKey + " is not defined in this build step. " + + "getRecorderProxy() must be called after the service's action()/actionAsync() " + + "in the same build step."); + } + + if (staticInit) { + // the downstream consumer (a legacy recorder or another service) and its phase are + // undetectable here, so guarantee cross-phase survival unconditionally at hand-out time + trackCrossPhaseKey(serviceKey); + } + + // build a phase-correct bare proxy backed by ServiceRecorderProxyHandler + ProxyConfiguration config = new ProxyConfiguration() + .setAnchorClass(ServiceRegistrarImpl.class) + .setClassLoader(Thread.currentThread().getContextClassLoader()) + .setSuperClass(serviceType.isInterface() ? Object.class : serviceType) + .setProxyNameSuffix("$$ServiceRecorderProxy$$" + PROXY_COUNTER.getAndIncrement()) + .setAllowPackagePrivate(true); + if (serviceType.isInterface()) { + config.addAdditionalInterface(serviceType); + } + config.addAdditionalInterface(BytecodeRecorderImpl.ReturnedProxy.class); + + ProxyFactory factory = new ProxyFactory<>(config); + try { + return factory.newInstance(new ServiceRecorderProxyHandler(serviceKey, serviceType, staticInit)); + } catch (IllegalAccessException | InstantiationException e) { + throw new RuntimeException("Failed to create service recorder proxy for " + serviceType.getName(), e); + } + } + + // ── Service → Supplier bridge (temporary) ── + + public Supplier serviceAsRecorderSupplier(Class serviceType) { + return serviceAsRecorderSupplier(serviceType, null); + } + + @SuppressWarnings("unchecked") + public Supplier serviceAsRecorderSupplier(Class serviceType, String serviceName) { + return (Supplier) createSupplierProxy(serviceType, serviceName, false); + } + + /** + * Create a {@code Supplier} proxy that implements {@link BytecodeRecorderImpl.ReturnedProxy}. + * The recorder framework wraps the loaded service value in a {@code Supplier} at runtime. + *

+ * This is a temporary bridge for build items that expose {@code Supplier} + * to unconverted recorder-based consumers. + * + * @param serviceType the service type + * @param serviceName the optional service name + * @param staticInit whether this targets the static-init phase + * @return a proxy implementing both {@code Supplier} and {@code ReturnedProxy} + */ + private Object createSupplierProxy(Class serviceType, String serviceName, boolean staticInit) { + List serviceNameParts = serviceName == null ? List.of() : List.of(serviceName); + String serviceKey = LambdaTransliterator.serviceKey(serviceType, serviceNameParts); + + if (staticInit) { + trackCrossPhaseKey(serviceKey); + } + + ProxyConfiguration config = new ProxyConfiguration() + .setAnchorClass(ServiceRegistrarImpl.class) + .setClassLoader(Thread.currentThread().getContextClassLoader()) + .setSuperClass(Object.class) + .setProxyNameSuffix("$$SupplierRecorderProxy$$" + PROXY_COUNTER.getAndIncrement()) + .addAdditionalInterface(Supplier.class) + .addAdditionalInterface(BytecodeRecorderImpl.ReturnedProxy.class); + ProxyFactory factory = new ProxyFactory<>(config); + try { + return factory.newInstance(new SupplierRecorderProxyHandler(serviceKey, serviceType, staticInit)); + } catch (IllegalAccessException | InstantiationException e) { + throw new RuntimeException("Failed to create supplier recorder proxy for " + serviceType.getName(), e); + } + } + + /** + * Record a service value key that must survive between static-init and runtime-init. + * + * @param key the cross-phase service key + */ + private void trackCrossPhaseKey(String key) { + retentionProducer.accept(new ServiceValueRetentionBuildItem(Set.of(key), false)); + } + + /** + * Counter used to generate unique proxy class name suffixes. + */ + private static final AtomicInteger PROXY_COUNTER = new AtomicInteger(); + + /** + * A {@link RuntimeValue} subclass that implements {@link BytecodeRecorderImpl.ReturnedProxy}. + *

+ * At deployment time, this proxy is passed to recorder methods. The recorder sees + * it as a {@code ReturnedProxy} and generates bytecode that retrieves the actual + * {@code RuntimeValue} wrapper from the startup context at the stored key. + * + * @param the service type + */ + private static final class ServiceRuntimeValueProxy extends RuntimeValue + implements BytecodeRecorderImpl.ReturnedProxy { + private final String rvKey; + private final boolean staticInit; + + /** + * Construct a new instance. + * + * @param rvKey the startup context key for the {@code RuntimeValue} wrapper + * @param staticInit whether this proxy targets the static-init phase + */ + ServiceRuntimeValueProxy(String rvKey, boolean staticInit) { + this.rvKey = rvKey; + this.staticInit = staticInit; + } + + public String __returned$proxy$key() { + return rvKey; + } + + public boolean __static$$init() { + return staticInit; + } + + @Override + public boolean __service$$value() { + return true; + } + } + + /** + * Invocation handler for bare service recorder proxies. + *

+ * Handles the {@link BytecodeRecorderImpl.ReturnedProxy} methods, plus + * {@code toString}, {@code hashCode}, and {@code equals}. All other method + * invocations throw, since the proxy exists only for deployment-time recording. + * + * @param serviceKey the startup context key for the service value + * @param serviceType the service type (for diagnostics) + * @param staticInit whether this proxy targets the static-init phase + */ + private record ServiceRecorderProxyHandler(String serviceKey, Class serviceType, + boolean staticInit) implements InvocationHandler { + @Override + public Object invoke(Object proxy, Method method, Object[] args) { + return switch (method.getName()) { + case "__returned$proxy$key" -> serviceKey; + case "__static$$init" -> staticInit; + case "__service$$value" -> true; + case "__supplier$$wrapper" -> false; + case "toString" -> "Service recorder proxy of " + serviceType.getName() + " with key " + serviceKey; + case "hashCode" -> System.identityHashCode(proxy); + case "equals" -> proxy == args[0]; + default -> throw new RuntimeException( + "You cannot invoke " + method.getName() + + "() directly on a service recorder proxy, " + + "you can only pass it as a parameter to a @Recorder method"); + }; + } + } + + /** + * Invocation handler for {@code Supplier} service recorder proxies. + *

+ * Like {@link ServiceRecorderProxyHandler}, but also implements + * {@code __supplier$$wrapper()} returning {@code true}, causing the recorder + * framework to wrap the loaded service value in a {@code Supplier} at runtime. + *

+ * This is a temporary bridge for the recorder coexistence period. + * + * @param serviceKey the startup context key for the service value + * @param serviceType the service type (for diagnostics) + * @param staticInit whether this proxy targets the static-init phase + */ + private record SupplierRecorderProxyHandler(String serviceKey, Class serviceType, + boolean staticInit) implements InvocationHandler { + @Override + public Object invoke(Object proxy, Method method, Object[] args) { + return switch (method.getName()) { + case "__returned$proxy$key" -> serviceKey; + case "__static$$init" -> staticInit; + case "__service$$value" -> true; + case "__supplier$$wrapper" -> true; + case "toString" -> "Service supplier proxy of " + serviceType.getName() + " with key " + serviceKey; + case "hashCode" -> System.identityHashCode(proxy); + case "equals" -> proxy == args[0]; + default -> throw new RuntimeException( + "You cannot invoke " + method.getName() + + "() directly on a service supplier proxy, " + + "you can only pass it as a parameter to a @Recorder method"); + }; + } + } +} diff --git a/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/ServiceValueRetentionBuildItem.java b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/ServiceValueRetentionBuildItem.java new file mode 100644 index 0000000000000..fbbb3a1d69fc8 --- /dev/null +++ b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/ServiceValueRetentionBuildItem.java @@ -0,0 +1,58 @@ +package io.quarkus.core.deployment.service.impl; + +import java.util.Set; + +import io.quarkus.builder.item.MultiBuildItem; + +/** + * Declares service value keys that must survive beyond the phase that produced them. + *

+ * Instances are produced by: + *

    + *
  • {@link ServiceRegistrarImpl} — for static-init service keys handed out as recorder + * proxies via {@code getRecorderProxy()}, whose downstream consumer (a later-phase + * recorder or service) is undetectable at hand-out time
  • + *
  • {@code SyntheticBeansProcessor} — for CDI service-value bean keys + * that are consumed lazily after startup
  • + *
+ *

+ * {@code MainClassBuildStep} consumes all instances and generates + * {@code retainServiceValues()} calls at phase boundaries to discard + * keys that are no longer needed. + */ +public final class ServiceValueRetentionBuildItem extends MultiBuildItem { + + private final Set keys; + private final boolean neededAfterStartup; + + /** + * Construct a new instance. + * + * @param keys the service value keys that must be retained (must not be {@code null}) + * @param neededAfterStartup {@code true} if these keys are consumed lazily after startup + * (e.g., CDI service-value beans), {@code false} if they are only needed + * during the runtime-init deploy phase + */ + public ServiceValueRetentionBuildItem(Set keys, boolean neededAfterStartup) { + this.keys = Set.copyOf(keys); + this.neededAfterStartup = neededAfterStartup; + } + + /** + * Get the service value keys. + * + * @return the keys (not {@code null}, immutable) + */ + public Set keys() { + return keys; + } + + /** + * Whether these keys are needed after startup (lazily consumed by CDI beans). + * + * @return {@code true} if needed after startup + */ + public boolean neededAfterStartup() { + return neededAfterStartup; + } +} diff --git a/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/StaticServiceMetadataBuildItem.java b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/StaticServiceMetadataBuildItem.java new file mode 100644 index 0000000000000..5a4fdf9cf6018 --- /dev/null +++ b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/StaticServiceMetadataBuildItem.java @@ -0,0 +1,91 @@ +package io.quarkus.core.deployment.service.impl; + +import java.util.List; + +import io.quarkus.builder.item.MultiBuildItem; + +/** + * A build item carrying metadata about a static-init service defined via + * {@link ServiceRegistrarImpl}. + *

+ * Each instance describes a single static-init service registration: the service + * identity (type + optional name), the identities of its declared dependencies, + * and the name of the build step that defined it (for diagnostics). + *

+ * All instances are consumed by the service dependency validation step, which + * validates the static-init dependency graph independently and checks for + * cross-phase duplicates with the runtime service graph. + * + * @see ServiceMetadataBuildItem + */ +public final class StaticServiceMetadataBuildItem extends MultiBuildItem { + + private final Class serviceType; + private final List serviceNameParts; + private final List dependencies; + private final String buildStepName; + + /** + * Construct a new instance. + * + * @param serviceType the service type (must not be {@code null}) + * @param serviceNameParts the service name parts (must not be {@code null}) + * @param dependencies the declared dependencies (must not be {@code null}) + * @param buildStepName the name of the declaring build step (must not be {@code null}) + */ + public StaticServiceMetadataBuildItem( + Class serviceType, + List serviceNameParts, + List dependencies, + String buildStepName) { + this.serviceType = serviceType; + this.serviceNameParts = List.copyOf(serviceNameParts); + this.dependencies = List.copyOf(dependencies); + this.buildStepName = buildStepName; + } + + /** + * Get the service type. + * + * @return the service type (not {@code null}) + */ + public Class serviceType() { + return serviceType; + } + + /** + * Get the service name parts. + * + * @return the service name parts (not {@code null}) + */ + public List serviceNameParts() { + return serviceNameParts; + } + + /** + * Get the declared dependencies. + * + * @return the dependency list (not {@code null}) + */ + public List dependencies() { + return dependencies; + } + + /** + * Get the name of the build step that defined this service. + * + * @return the build step name (not {@code null}) + */ + public String buildStepName() { + return buildStepName; + } + + /** + * Get the service key used for identity and lookup. + * + * @return the service key string + */ + public String serviceKey() { + return LambdaTransliterator.serviceKey(serviceType, serviceNameParts); + } +} diff --git a/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/TransliteratedAction.java b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/TransliteratedAction.java new file mode 100644 index 0000000000000..96d500dc88124 --- /dev/null +++ b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/TransliteratedAction.java @@ -0,0 +1,121 @@ +package io.quarkus.core.deployment.service.impl; + +import java.lang.constant.MethodTypeDesc; +import java.util.List; + +import io.quarkus.runtime.RuntimeValue; +import io.smallrye.classfile.ClassModel; +import io.smallrye.classfile.CodeModel; + +/** + * The result of extracting a service action lambda for deferred class generation. + *

+ * Instances carry all data needed to generate the deploy and action methods + * for a service into a consolidated class. Two variants exist: + *

    + *
  • {@link ActionService} — a full transliterated lambda body
  • + *
  • {@link AliasService} — a trivial forwarder from a recorder proxy key to a service key
  • + *
+ */ +public sealed interface TransliteratedAction { + + /** + * Get the service key used to store the result in the startup context. + * + * @return the service key string + */ + String serviceKey(); + + /** + * Get whether this action targets the static-init phase. + * + * @return {@code true} for static-init, {@code false} for runtime + */ + boolean staticInit(); + + /** + * Get the ID of the build step that produced this action. + * + * @return the step ID (never {@code null}) + */ + String stepId(); + + /** + * A full action service whose lambda body has been extracted and prepared + * for code generation. + * + * @param serviceKey the service key for storing the result + * @param dependencies the declared dependencies, in order + * @param beforeKeys service keys that should depend on this service (reverse ordering) + * @param afterBuildItemClasses build item class names whose producing steps must complete first + * @param async {@code true} if this is an async action + * @param optional {@code true} if this service may produce no value (declared via {@code optional()}); + * its action returns {@code Optional} and an empty result marks the service as valueless + * @param staticInit {@code true} if this is a static-init service + * @param actionMethodType the method descriptor after removing captured parameters + * @param originalCode the parsed lambda body code model + * @param captureSlotCount the number of leading local variable slots consumed by captures + * @param slotToCaptureIndex mapping from slot index to capture argument index ({@code -1} for non-start slots) + * @param capturedEmitters the resolved emitters for captured arguments + * @param enclosingClassInternal the internal name of the lambda's enclosing class + * @param enclosingClassModel the parsed class model of the enclosing class (for inner method lookup) + * @param stepId the producing build step's ID + */ + record ActionService( + String serviceKey, + List dependencies, + List beforeKeys, + List afterBuildItemClasses, + boolean async, + boolean optional, + boolean staticInit, + MethodTypeDesc actionMethodType, + CodeModel originalCode, + int captureSlotCount, + int[] slotToCaptureIndex, + CaptureEmitter[] capturedEmitters, + String enclosingClassInternal, + ClassModel enclosingClassModel, + String stepId) implements TransliteratedAction { + } + + /** + * An alias service that copies a recorder-produced value from its recorder + * proxy key to a service key in the startup context. + * + * @param serviceKey the service key to store the value under + * @param staticInit {@code true} if this is a static-init alias + * @param recorderProxyKey the key under which the recorder stored the value + * @param stepId the producing build step's ID + */ + record AliasService( + String serviceKey, + boolean staticInit, + String recorderProxyKey, + String stepId) implements TransliteratedAction { + } + + /** + * A wrapper action that reads a service value from the startup context, + * wraps it in a {@link RuntimeValue}, and stores the wrapper under a separate key. + *

+ * This enables legacy {@code @Record}-based build steps to consume + * service values via {@code RuntimeValue} during incremental migration. + * + * @param sourceServiceKey the service key to read the bare value from + * @param staticInit {@code true} if this targets the static-init phase + * @param rvKey the key under which the {@code RuntimeValue} wrapper is stored + * @param stepId the producing build step's ID + */ + record RuntimeValueWrapper( + String sourceServiceKey, + boolean staticInit, + String rvKey, + String stepId) implements TransliteratedAction { + + @Override + public String serviceKey() { + return rvKey; + } + } +} diff --git a/core/deployment/src/main/java/io/quarkus/deployment/ExtensionLoader.java b/core/deployment/src/main/java/io/quarkus/deployment/ExtensionLoader.java index f4b5ff941333e..feedb89e4b2c4 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/ExtensionLoader.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/ExtensionLoader.java @@ -61,6 +61,11 @@ import io.quarkus.builder.item.EmptyBuildItem; import io.quarkus.builder.item.MultiBuildItem; import io.quarkus.builder.item.SimpleBuildItem; +import io.quarkus.core.deployment.service.ServiceRegistrar; +import io.quarkus.core.deployment.service.impl.ServiceMetadataBuildItem; +import io.quarkus.core.deployment.service.impl.ServiceRegistrarImpl; +import io.quarkus.core.deployment.service.impl.ServiceValueRetentionBuildItem; +import io.quarkus.core.deployment.service.impl.StaticServiceMetadataBuildItem; import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; import io.quarkus.deployment.annotations.BuildSteps; @@ -454,6 +459,7 @@ private static Consumer loadStepsFromClass(Class clazz, } } final List> methodParamFns; + final List deferredActionProductions = new ArrayList<>(); Consumer methodStepConfig = Functions.discardingConsumer(); BooleanSupplier addStep = () -> true; addStep = and(addStep, supplierFactory, classOnlyIf, false); @@ -473,6 +479,16 @@ private static Consumer loadStepsFromClass(Class clazz, optional ? ProduceFlags.of(ProduceFlag.WEAK) : ProduceFlags.NONE); }); } + // compute the step ID early so it can be captured by both ServiceRegistrar and recorder lambdas; + // when multiple methods share the same name, a hash suffix disambiguates + final String name = clazz.getName() + "#" + method.getName(); + final String stepId; + List methodsWithName = nameToMethods.get(method.getName()); + if (methodsWithName.size() > 1) { + stepId = name + "_" + HashUtil.sha1(Arrays.toString(method.getParameterTypes())); + } else { + stepId = name; + } EnumSet methodConsumingConfigPhases = consumingConfigPhases.clone(); if (methodParameters.length == 0) { methodParamFns = Collections.emptyList(); @@ -605,6 +621,26 @@ private static Consumer loadStepsFromClass(Class clazz, "Cannot pass recorder context to method which is not annotated with " + Record.class); } methodParamFns.add((bc, bri) -> bri); + } else if (parameterClass == BuildContext.class) { + methodParamFns.add((bc, bri) -> bc); + } else if (parameterClass == ServiceRegistrar.class) { + methodStepConfig = methodStepConfig.andThen(bsb -> { + bsb.produces(MainBytecodeRecorderBuildItem.class); + bsb.produces(StaticBytecodeRecorderBuildItem.class); + bsb.produces(ServiceMetadataBuildItem.class); + bsb.produces(StaticServiceMetadataBuildItem.class); + bsb.produces(ServiceValueRetentionBuildItem.class); + }); + // Action items are deferred so they are produced after any recorder + // bytecode, ensuring correct deploy method ordering when @Record + // and ServiceRegistrar are combined in the same build step. + methodParamFns.add((bc, bri) -> new ServiceRegistrarImpl( + item -> deferredActionProductions.add(() -> bc.produce(item)), + item -> deferredActionProductions.add(() -> bc.produce(item)), + item -> deferredActionProductions.add(() -> bc.produce(item)), + item -> deferredActionProductions.add(() -> bc.produce(item)), + item -> deferredActionProductions.add(() -> bc.produce(item)), + stepId)); } else { throw reportError(parameter, "Unsupported method parameter " + parameterType); } @@ -683,17 +719,6 @@ private static Consumer loadStepsFromClass(Class clazz, final Consumer finalStepConfig = stepConfig.andThen(methodStepConfig) .andThen(buildStepBuilder -> buildStepBuilder.buildIf(finalAddStep)); final BiConsumer finalStepInstanceSetup = stepInstanceSetup; - final String name = clazz.getName() + "#" + method.getName(); - final String stepId; - List methodsWithName = nameToMethods.get(method.getName()); - if (methodsWithName.size() > 1) { - // Append the sha1 of the parameter types to resolve the ambiguity - stepId = name + "_" + HashUtil.sha1(Arrays.toString(method.getParameterTypes())); - loadLog.debugf("Build steps with ambiguous name detected: %s, using discriminator suffix for step id: %s", name, - stepId); - } else { - stepId = name; - } MethodHandle methodHandle = unreflect(method, lookup); chainConfig = chainConfig @@ -725,7 +750,8 @@ public void execute(final BuildContext bc) { BytecodeRecorderImpl bri = isRecorder ? new BytecodeRecorderImpl(recordAnnotation.value() == ExecutionTime.STATIC_INIT, clazz.getSimpleName(), method.getName(), - Integer.toString(Math.abs(method.toString().hashCode())), identityComparison) + Integer.toString(Math.abs(method.toString().hashCode())), stepId, + identityComparison) : null; for (int i = 0; i < methodArgs.length; i++) { methodArgs[i] = methodParamFns.get(i).apply(bc, bri); @@ -750,6 +776,9 @@ public void execute(final BuildContext bc) { } } + // Flush deferred ServiceRegistrar items after recorder bytecode + deferredActionProductions.forEach(Runnable::run); + deferredActionProductions.clear(); } @Override diff --git a/core/deployment/src/main/java/io/quarkus/deployment/QuarkusAugmentor.java b/core/deployment/src/main/java/io/quarkus/deployment/QuarkusAugmentor.java index 51efbb443660a..ef36e972eff2e 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/QuarkusAugmentor.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/QuarkusAugmentor.java @@ -25,6 +25,8 @@ import io.quarkus.builder.BuildMetrics; import io.quarkus.builder.BuildResult; import io.quarkus.builder.item.BuildItem; +import io.quarkus.core.deployment.service.impl.ServiceMetadataBuildItem; +import io.quarkus.core.deployment.service.impl.StaticServiceMetadataBuildItem; import io.quarkus.deployment.builditem.AdditionalApplicationArchiveBuildItem; import io.quarkus.deployment.builditem.AppModelProviderBuildItem; import io.quarkus.deployment.builditem.ArchiveRootBuildItem; @@ -39,6 +41,7 @@ import io.quarkus.deployment.logging.LoggingSetupBuildItem; import io.quarkus.deployment.logging.StaticInitLoggingSetupBuildItem; import io.quarkus.deployment.pkg.builditem.BuildSystemTargetBuildItem; +import io.quarkus.deployment.steps.ServiceDependencyValidator; import io.quarkus.dev.spi.DevModeType; import io.quarkus.paths.PathCollection; import io.quarkus.runtime.JVMUnsafeWarningsControl; @@ -140,6 +143,8 @@ public BuildResult run() throws Exception { for (Class i : finalResults) { chainBuilder.addFinal(i); } + chainBuilder.addFinal(ServiceMetadataBuildItem.class); + chainBuilder.addFinal(StaticServiceMetadataBuildItem.class); for (Consumer i : buildChainCustomizers) { i.accept(chainBuilder); } @@ -185,6 +190,12 @@ public BuildResult run() throws Exception { } BuildResult buildResult = execBuilder.execute(); + + // validate service dependency graphs after all build steps have completed + ServiceDependencyValidator.validate( + buildResult.consumeMulti(StaticServiceMetadataBuildItem.class), + buildResult.consumeMulti(ServiceMetadataBuildItem.class)); + String message = "Quarkus augmentation completed in " + TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start) + "ms"; if (launchMode.isProduction()) { diff --git a/core/deployment/src/main/java/io/quarkus/deployment/SnapStartProcessor.java b/core/deployment/src/main/java/io/quarkus/deployment/SnapStartProcessor.java index e88b0c3f96058..220200c8bcc0a 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/SnapStartProcessor.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/SnapStartProcessor.java @@ -6,10 +6,10 @@ import org.jboss.jandex.ClassInfo; +import io.quarkus.core.Phase; +import io.quarkus.core.deployment.service.ServiceRegistrar; import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; -import io.quarkus.deployment.annotations.ExecutionTime; -import io.quarkus.deployment.annotations.Record; import io.quarkus.deployment.builditem.ApplicationArchivesBuildItem; import io.quarkus.deployment.builditem.GeneratedClassBuildItem; import io.quarkus.deployment.builditem.PreloadClassBuildItem; @@ -26,10 +26,9 @@ public class SnapStartProcessor { @BuildStep(onlyIf = IsProduction.class, onlyIfNot = NativeBuild.class) - @Record(ExecutionTime.STATIC_INIT) - public void processSnapStart(BuildProducer preload, + public void processSnapStart(ServiceRegistrar reg, + BuildProducer preload, BuildProducer snapStartEnabled, - SnapStartRecorder recorder, SnapStartConfig config, Optional defaultVal) { Optional snapstartEnabled = config.enabled(); @@ -43,9 +42,16 @@ public void processSnapStart(BuildProducer prelo return; } snapStartEnabled.produce(SnapStartEnabledBuildItem.INSTANCE); - if (config.preloadClasses()) + if (config.preloadClasses()) { preload.produce(new PreloadClassesEnabledBuildItem(config.initializeClasses())); - recorder.register(config.fullWarmup()); + } + boolean fw = config.fullWarmup(); + reg.forService("io.quarkus.snapstart") + .atPhase(Phase.STATIC_INIT) + .onStart(ctx -> { + SnapStartRecorder.enabled = true; + SnapStartRecorder.fullWarmup = fw; + }); } @BuildStep(onlyIf = IsProduction.class, onlyIfNot = NativeBuild.class) diff --git a/core/deployment/src/main/java/io/quarkus/deployment/annotations/ExecutionTime.java b/core/deployment/src/main/java/io/quarkus/deployment/annotations/ExecutionTime.java index ceddc6e37100e..82f92a01d024b 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/annotations/ExecutionTime.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/annotations/ExecutionTime.java @@ -2,7 +2,11 @@ /** * Enum that specifies at what time generated bytecode should be run + * + * @deprecated Use the {@code ServiceRegistrar} service model with {@code Phase} and phase-based + * ordering instead. */ +@Deprecated(since = "4.0") public enum ExecutionTime { /** * The bytecode is run from a generated static initializer diff --git a/core/deployment/src/main/java/io/quarkus/deployment/annotations/Record.java b/core/deployment/src/main/java/io/quarkus/deployment/annotations/Record.java index dde3e43d15dbd..3e6daa8bf308a 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/annotations/Record.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/annotations/Record.java @@ -6,6 +6,7 @@ import java.lang.annotation.Target; import io.quarkus.builder.item.BuildItem; +import io.quarkus.core.deployment.service.impl.ServiceRegistrarImpl; /** * Indicates that this {@link BuildStep} method will also output recorded bytecode. @@ -33,7 +34,12 @@ * - Any arbitrary object via the * {@link io.quarkus.deployment.recording.RecorderContext#registerSubstitution(Class, Class, Class)} mechanism * - arrays, lists and maps of the above + * + * @deprecated Use {@link ServiceRegistrarImpl} instead. Inject an {@code ServiceRegistrar} + * parameter into your {@code @BuildStep} method and use its fluent API to define runtime services + * directly as lambdas. */ +@Deprecated(since = "4.0") @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface Record { diff --git a/core/deployment/src/main/java/io/quarkus/deployment/builditem/BytecodeRecorderConstantDefinitionBuildItem.java b/core/deployment/src/main/java/io/quarkus/deployment/builditem/BytecodeRecorderConstantDefinitionBuildItem.java index c214df8324729..e8ba8cbe7374f 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/builditem/BytecodeRecorderConstantDefinitionBuildItem.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/builditem/BytecodeRecorderConstantDefinitionBuildItem.java @@ -12,7 +12,12 @@ * without introducing new dependencies from build steps * that use the recorder to build steps that create the constant value. * This can be useful in complex dependency graphs. + * + * @deprecated Extension authors should use {@code ServiceRegistrar} instead of producing + * this item directly. The bytecode recorder infrastructure is being replaced + * by a lambda-based service mechanism. */ +@Deprecated(since = "4.0") public final class BytecodeRecorderConstantDefinitionBuildItem extends MultiBuildItem { private final Holder holder; diff --git a/core/deployment/src/main/java/io/quarkus/deployment/builditem/BytecodeRecorderObjectLoaderBuildItem.java b/core/deployment/src/main/java/io/quarkus/deployment/builditem/BytecodeRecorderObjectLoaderBuildItem.java index e14d65a44881b..b99b915104aba 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/builditem/BytecodeRecorderObjectLoaderBuildItem.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/builditem/BytecodeRecorderObjectLoaderBuildItem.java @@ -6,7 +6,7 @@ /** * @deprecated This class is intended only for internal use. */ -@Deprecated(forRemoval = true) +@Deprecated(since = "4.0", forRemoval = true) public final class BytecodeRecorderObjectLoaderBuildItem extends MultiBuildItem { private final ObjectLoader objectLoader; diff --git a/core/deployment/src/main/java/io/quarkus/deployment/builditem/MainBytecodeRecorderBuildItem.java b/core/deployment/src/main/java/io/quarkus/deployment/builditem/MainBytecodeRecorderBuildItem.java index 9070a75922a40..533c3da7da97f 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/builditem/MainBytecodeRecorderBuildItem.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/builditem/MainBytecodeRecorderBuildItem.java @@ -1,6 +1,7 @@ package io.quarkus.deployment.builditem; import io.quarkus.builder.item.MultiBuildItem; +import io.quarkus.core.deployment.service.impl.TransliteratedAction; import io.quarkus.deployment.recording.BytecodeRecorderImpl; /** @@ -8,30 +9,67 @@ *

* Instances of this item can hold either: *

    - *
  • A direct {@link io.quarkus.deployment.recording.BytecodeRecorderImpl} instance via {@link #bytecodeRecorder}.
  • - *
  • The name of a generated startup context class via {@link #generatedStartupContextClassName}.
  • + *
  • A direct {@link BytecodeRecorderImpl} instance via {@link #bytecodeRecorder}.
  • + *
  • A {@link TransliteratedAction} for deferred consolidated class generation.
  • *
+ * + * @deprecated Extension authors should use {@link ServiceRegistrar} + * instead of producing this item directly. This item is still used internally by the + * framework to schedule startup tasks. */ +@Deprecated(since = "4.0") public final class MainBytecodeRecorderBuildItem extends MultiBuildItem { private final BytecodeRecorderImpl bytecodeRecorder; - private final String generatedStartupContextClassName; + private final TransliteratedAction transliteratedAction; + private final String stepId; + /** + * Construct a new instance from a bytecode recorder. + * + * @param bytecodeRecorder the bytecode recorder (must not be {@code null}) + */ public MainBytecodeRecorderBuildItem(BytecodeRecorderImpl bytecodeRecorder) { this.bytecodeRecorder = bytecodeRecorder; - this.generatedStartupContextClassName = null; + this.transliteratedAction = null; + this.stepId = bytecodeRecorder.getStepId(); } - public MainBytecodeRecorderBuildItem(String generatedStartupContextClassName) { - this.generatedStartupContextClassName = generatedStartupContextClassName; + /** + * Construct a new instance from a transliterated action for deferred class generation. + * + * @param transliteratedAction the transliterated action data (must not be {@code null}) + */ + public MainBytecodeRecorderBuildItem(TransliteratedAction transliteratedAction) { + this.transliteratedAction = transliteratedAction; this.bytecodeRecorder = null; + this.stepId = transliteratedAction.stepId(); } + /** + * Get the bytecode recorder, if present. + * + * @return the bytecode recorder, or {@code null} if this item was constructed with a transliterated action + */ public BytecodeRecorderImpl getBytecodeRecorder() { return bytecodeRecorder; } - public String getGeneratedStartupContextClassName() { - return generatedStartupContextClassName; + /** + * Get the transliterated action data, if present. + * + * @return the transliterated action, or {@code null} if this item was constructed with a bytecode recorder + */ + public TransliteratedAction getTransliteratedAction() { + return transliteratedAction; + } + + /** + * Get the ID of the build step that produced this item. + * + * @return the step ID (never {@code null}) + */ + public String getStepId() { + return stepId; } } diff --git a/core/deployment/src/main/java/io/quarkus/deployment/builditem/ObjectSubstitutionBuildItem.java b/core/deployment/src/main/java/io/quarkus/deployment/builditem/ObjectSubstitutionBuildItem.java index 3b1638d92978b..0184a9e7dbc93 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/builditem/ObjectSubstitutionBuildItem.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/builditem/ObjectSubstitutionBuildItem.java @@ -7,6 +7,7 @@ /** * Used to capture object substitution information for non-serializable classes. */ +@Deprecated(since = "4.0") public final class ObjectSubstitutionBuildItem extends MultiBuildItem { /** * Holder to keep type info around for compiler. @@ -14,6 +15,7 @@ public final class ObjectSubstitutionBuildItem extends MultiBuildItem { * @param - from class * @param - to class */ + @Deprecated(since = "4.0") public static final class Holder { public final Class from; diff --git a/core/deployment/src/main/java/io/quarkus/deployment/builditem/ShutdownContextBuildItem.java b/core/deployment/src/main/java/io/quarkus/deployment/builditem/ShutdownContextBuildItem.java index fbc73832f4431..9b914a84051cf 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/builditem/ShutdownContextBuildItem.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/builditem/ShutdownContextBuildItem.java @@ -6,7 +6,10 @@ /** * A build item that can be used to register shutdown tasks in runtime recorders. + * + * @deprecated Use service dependency edges and {@code ctx.onStop(Runnable)} inside {@code ServiceRegistrar} actions instead. */ +@Deprecated(since = "4.0") public final class ShutdownContextBuildItem extends SimpleBuildItem implements ShutdownContext, BytecodeRecorderImpl.ReturnedProxy { @Override diff --git a/core/deployment/src/main/java/io/quarkus/deployment/builditem/StaticBytecodeRecorderBuildItem.java b/core/deployment/src/main/java/io/quarkus/deployment/builditem/StaticBytecodeRecorderBuildItem.java index 991224299a722..2ca9056b66c0a 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/builditem/StaticBytecodeRecorderBuildItem.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/builditem/StaticBytecodeRecorderBuildItem.java @@ -1,20 +1,90 @@ package io.quarkus.deployment.builditem; import io.quarkus.builder.item.MultiBuildItem; +import io.quarkus.core.deployment.service.impl.TransliteratedAction; import io.quarkus.deployment.recording.BytecodeRecorderImpl; /** - * A build item holding a {@link BytecodeRecorderImpl} instance used for generating static initializer bytecode. + * A build item holding bytecode recording information relevant to the static initializer. + *

+ * Instances of this item can hold either: + *

    + *
  • A direct {@link BytecodeRecorderImpl} instance via {@link #getBytecodeRecorder()}.
  • + *
  • A {@link TransliteratedAction} for deferred consolidated class generation.
  • + *
+ * + * @deprecated Extension authors should use {@link ServiceRegistrar} instead of producing + * this item directly. This item is still used internally by the framework to + * schedule static-init startup tasks. */ +@Deprecated(since = "4.0") public final class StaticBytecodeRecorderBuildItem extends MultiBuildItem { private final BytecodeRecorderImpl bytecodeRecorder; + private final TransliteratedAction transliteratedAction; + private final String stepId; + /** + * Construct a new instance from a bytecode recorder. + * + * @param bytecodeRecorder the bytecode recorder (must not be {@code null}) + */ public StaticBytecodeRecorderBuildItem(BytecodeRecorderImpl bytecodeRecorder) { this.bytecodeRecorder = bytecodeRecorder; + this.transliteratedAction = null; + this.stepId = bytecodeRecorder.getStepId(); } + /** + * Construct a new instance from a bytecode recorder. + * + * @param bytecodeRecorder the bytecode recorder (must not be {@code null}) + * @param stepId the producing build step's ID (must not be {@code null}) + * @deprecated Use {@link #StaticBytecodeRecorderBuildItem(BytecodeRecorderImpl)} instead. + * The step ID is now stored in the bytecode recorder itself. + */ + @Deprecated + public StaticBytecodeRecorderBuildItem(BytecodeRecorderImpl bytecodeRecorder, String stepId) { + this.bytecodeRecorder = bytecodeRecorder; + this.transliteratedAction = null; + this.stepId = stepId; + } + + /** + * Construct a new instance from a transliterated action for deferred class generation. + * + * @param transliteratedAction the transliterated action data (must not be {@code null}) + */ + public StaticBytecodeRecorderBuildItem(TransliteratedAction transliteratedAction) { + this.transliteratedAction = transliteratedAction; + this.bytecodeRecorder = null; + this.stepId = transliteratedAction.stepId(); + } + + /** + * Get the bytecode recorder, if present. + * + * @return the bytecode recorder, or {@code null} if this item was constructed with a transliterated action + */ public BytecodeRecorderImpl getBytecodeRecorder() { return bytecodeRecorder; } + + /** + * Get the transliterated action data, if present. + * + * @return the transliterated action, or {@code null} if this item was constructed with a bytecode recorder + */ + public TransliteratedAction getTransliteratedAction() { + return transliteratedAction; + } + + /** + * Get the ID of the build step that produced this item. + * + * @return the step ID (never {@code null}) + */ + public String getStepId() { + return stepId; + } } diff --git a/core/deployment/src/main/java/io/quarkus/deployment/proxy/ProxyConfiguration.java b/core/deployment/src/main/java/io/quarkus/deployment/proxy/ProxyConfiguration.java index 933b8005f7a40..17e8e9da7b517 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/proxy/ProxyConfiguration.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/proxy/ProxyConfiguration.java @@ -14,8 +14,8 @@ public class ProxyConfiguration { private Class anchorClass; private String proxyNameSuffix; private ClassLoader classLoader; - private Class superClass; - private List> additionalInterfaces = new ArrayList<>(0); + private Class superClass; + private final List> additionalInterfaces = new ArrayList<>(0); private boolean allowPackagePrivate = false; public List> getAdditionalInterfaces() { @@ -61,11 +61,11 @@ public String getProxyName() { return getAnchorClass().getName() + proxyNameSuffix; } - public Class getSuperClass() { + public Class getSuperClass() { return superClass; } - public ProxyConfiguration setSuperClass(final Class superClass) { + public ProxyConfiguration setSuperClass(final Class superClass) { this.superClass = superClass; return this; } diff --git a/core/deployment/src/main/java/io/quarkus/deployment/proxy/ProxyFactory.java b/core/deployment/src/main/java/io/quarkus/deployment/proxy/ProxyFactory.java index 539fde92ea2cb..c0ca3d1f002d0 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/proxy/ProxyFactory.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/proxy/ProxyFactory.java @@ -43,7 +43,7 @@ public ProxyFactory(ProxyConfiguration configuration) { Objects.requireNonNull(configuration.getProxyNameSuffix(), "proxyNameSuffix must be set"); this.proxyName = configuration.getProxyName(); - Class superClass = configuration.getSuperClass() != null ? configuration.getSuperClass() : (Class) Object.class; + Class superClass = configuration.getSuperClass() != null ? configuration.getSuperClass() : Object.class; this.superClassName = superClass.getName(); if (!configuration.isAllowPackagePrivate() && !Modifier.isPublic(superClass.getModifiers())) { diff --git a/core/deployment/src/main/java/io/quarkus/deployment/recording/BytecodeRecorderImpl.java b/core/deployment/src/main/java/io/quarkus/deployment/recording/BytecodeRecorderImpl.java index 7a897fcab7911..830ef963ef59c 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/recording/BytecodeRecorderImpl.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/recording/BytecodeRecorderImpl.java @@ -43,6 +43,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Function; +import java.util.function.Supplier; import java.util.stream.Collectors; import jakarta.inject.Inject; @@ -53,6 +54,7 @@ import org.jboss.jandex.MethodInfo; import org.jboss.jandex.Type; +import io.quarkus.core.deployment.service.impl.ServiceRegistrarImpl; import io.quarkus.deployment.proxy.ProxyConfiguration; import io.quarkus.deployment.proxy.ProxyFactory; import io.quarkus.deployment.recording.AnnotationProxyProvider.AnnotationProxy; @@ -69,6 +71,7 @@ import io.quarkus.gizmo.TryBlock; import io.quarkus.runtime.ObjectSubstitution; import io.quarkus.runtime.RuntimeValue; +import io.quarkus.runtime.ServiceValueSupplier; import io.quarkus.runtime.StartupContext; import io.quarkus.runtime.StartupTask; import io.quarkus.runtime.annotations.IgnoreProperty; @@ -101,7 +104,11 @@ * - arrays, lists and maps of the above *

* We instantiate one instance of this class per recording build step method. + * + * @deprecated Use {@link ServiceRegistrarImpl} instead. The bytecode + * recorder infrastructure is being replaced by a lambda-based service mechanism. */ +@Deprecated(since = "4.0") public class BytecodeRecorderImpl implements RecorderContext { private static final Class SINGLETON_LIST_CLASS = Collections.singletonList(1).getClass(); @@ -141,6 +148,7 @@ public class BytecodeRecorderImpl implements RecorderContext { private final Map, SubstitutionHolder> substitutions = new HashMap<>(); private final Map, NonDefaultConstructorHolder> nonDefaultConstructors = new HashMap<>(); private final String className; + private final String stepId; private final Function classCreatorFunction; private final Function methodCreatorFunction; @@ -162,12 +170,47 @@ public class BytecodeRecorderImpl implements RecorderContext { private int deferredParameterCount = 0; private boolean loadComplete; + /** + * Construct a new instance whose step ID is derived from the build step name and method name. + *

+ * The derived step ID ({@code buildStepName + "." + methodName}) does not match the framework's + * step-dependency-graph keys, so a recorder created this way cannot participate in cross-step + * ordering against services. Prefer + * {@link #BytecodeRecorderImpl(boolean, String, String, String, String, boolean)}, which accepts + * the framework step ID explicitly, whenever the recorder takes part in a real build. + * + * @param staticInit {@code true} if this recorder runs during static init, {@code false} for runtime init + * @param buildStepName the simple name of the declaring build step class (used for generated class naming) + * @param methodName the name of the recorder method (used for generated class naming) + * @param uniqueHash a hash disambiguating the generated class name + * @param useIdentityComparison {@code true} to compare recorded objects by identity, {@code false} by equality + */ public BytecodeRecorderImpl(boolean staticInit, String buildStepName, String methodName, String uniqueHash, boolean useIdentityComparison) { + this(staticInit, buildStepName, methodName, uniqueHash, buildStepName + "." + methodName, useIdentityComparison); + } + + /** + * Construct a new instance with an explicit framework step ID. + *

+ * The step ID must match the identifier the framework uses in its step-dependency graph + * (see {@code ExtensionLoader}); this is what allows a legacy recorder to be ordered correctly + * relative to the services it depends on. + * + * @param staticInit {@code true} if this recorder runs during static init, {@code false} for runtime init + * @param buildStepName the simple name of the declaring build step class (used for generated class naming) + * @param methodName the name of the recorder method (used for generated class naming) + * @param uniqueHash a hash disambiguating the generated class name + * @param stepId the framework step ID of the declaring build step (must not be {@code null}) + * @param useIdentityComparison {@code true} to compare recorded objects by identity, {@code false} by equality + */ + public BytecodeRecorderImpl(boolean staticInit, String buildStepName, String methodName, String uniqueHash, + String stepId, boolean useIdentityComparison) { this( Thread.currentThread().getContextClassLoader(), staticInit, toClassName(buildStepName, methodName, uniqueHash), + stepId, classOutput -> { return startupTaskClassCreator(classOutput, toClassName(buildStepName, methodName, uniqueHash)); }, @@ -178,7 +221,7 @@ public BytecodeRecorderImpl(boolean staticInit, String buildStepName, String met // visible for testing BytecodeRecorderImpl(ClassLoader classLoader, boolean staticInit, String className) { - this(classLoader, staticInit, className, + this(classLoader, staticInit, className, null, classOutput -> { return startupTaskClassCreator(classOutput, className); }, @@ -187,12 +230,13 @@ public BytecodeRecorderImpl(boolean staticInit, String buildStepName, String met }, true); } - private BytecodeRecorderImpl(ClassLoader classLoader, boolean staticInit, String className, + private BytecodeRecorderImpl(ClassLoader classLoader, boolean staticInit, String className, String stepId, Function classCreatorFunction, Function methodCreatorFunction, boolean useIdentityComparison) { this.classLoader = classLoader; this.staticInit = staticInit; this.className = className; + this.stepId = stepId; this.classCreatorFunction = classCreatorFunction; this.methodCreatorFunction = methodCreatorFunction; this.useIdentityComparison = useIdentityComparison; @@ -222,6 +266,10 @@ public boolean isEmpty() { return storedMethodCalls.isEmpty(); } + public String getStepId() { + return stepId; + } + @Override public void registerSubstitution(Class from, Class to, Class> substitution) { @@ -309,7 +357,10 @@ private static boolean isProxiable(Class returnType) { boolean returnInterface = returnType.isInterface(); if (!returnInterface) { try { - returnType.getConstructor(); + int mods = returnType.getDeclaredConstructor().getModifiers(); + if (!Modifier.isPublic(mods) && !Modifier.isProtected(mods)) { + return false; + } } catch (NoSuchMethodException e) { return false; } @@ -396,12 +447,13 @@ public void markClassAsConstructorRecordable(Class clazz) { classesToUseRecordableConstructor.add(clazz); } - private ProxyInstance getProxyInstance(Class returnType) throws InstantiationException, IllegalAccessException { + private ProxyInstance getProxyInstance(Class returnType) throws InstantiationException, IllegalAccessException { boolean returnInterface = returnType.isInterface(); - ProxyFactory proxyFactory = returnValueProxy.get(returnType); + @SuppressWarnings("unchecked") + ProxyFactory proxyFactory = (ProxyFactory) returnValueProxy.get(returnType); if (proxyFactory == null) { - ProxyConfiguration proxyConfiguration = new ProxyConfiguration() - .setSuperClass(returnInterface ? Object.class : (Class) returnType) + ProxyConfiguration proxyConfiguration = new ProxyConfiguration() + .setSuperClass(returnInterface ? Object.class : returnType) .setClassLoader(classLoader) .addAdditionalInterface(ReturnedProxy.class) .setAnchorClass(getClass()) @@ -608,6 +660,15 @@ private DeferredParameter loadObjectInstance(Object param, Map existing, Class expectedType, boolean relaxedValidation) { + // StartupContext parameters are passed through from the deploy method + if (expectedType == StartupContext.class) { + return new DeferredParameter() { + @Override + ResultHandle doLoad(MethodContext creator, MethodCreator method, ResultHandle array) { + return method.getMethodParam(0); + } + }; + } //null is easy if (param == null) { return new DeferredParameter() { @@ -736,13 +797,24 @@ ResultHandle doLoad(MethodContext context, MethodCreator method, ResultHandle ar + " was created in a runtime recorder method, while this recorder is for a static init method. The object will not have been created at the time this method is run."); } String proxyId = rp.__returned$proxy$key(); + boolean serviceValue = rp.__service$$value(); + boolean supplierWrapper = rp.__supplier$$wrapper(); //because this is the result of a method invocation that may not have happened at param deserialization time //we just load it from the startup context return new DeferredParameter() { @Override ResultHandle doLoad(MethodContext context, MethodCreator method, ResultHandle array) { - return method.invokeVirtualMethod(ofMethod(StartupContext.class, "getValue", Object.class, String.class), + String methodName = serviceValue ? "getServiceValue" : "getValue"; + ResultHandle value = method.invokeVirtualMethod( + ofMethod(StartupContext.class, methodName, Object.class, String.class), method.getMethodParam(0), method.load(proxyId)); + if (supplierWrapper) { + // wrap the loaded service value in a Supplier for backward-compatible build items + return method.invokeStaticMethod( + ofMethod(ServiceValueSupplier.class, "of", Supplier.class, Object.class), + value); + } + return value; } }; } else if (param instanceof Duration) { @@ -1707,6 +1779,12 @@ public Object invoke(Object proxy, Method method, Object[] args) throws Throwabl if (method.getName().equals("__static$$init")) { return staticInit; } + if (method.getName().equals("__service$$value")) { + return false; + } + if (method.getName().equals("__supplier$$wrapper")) { + return false; + } if (method.getName().equals("toString") && method.getParameterCount() == 0 && method.getReturnType().equals(String.class)) { @@ -1737,6 +1815,31 @@ public interface ReturnedProxy { String __returned$proxy$key(); boolean __static$$init(); + + /** + * Whether this proxy's value is stored in the service values map + * (via {@code putServiceValue}) rather than the recorder values map + * (via {@code putValue}). + * + * @return {@code true} for service value proxies, {@code false} for recorder proxies + */ + default boolean __service$$value() { + return false; + } + + /** + * Whether this proxy should be loaded as a {@link java.util.function.Supplier} + * wrapping the service value, rather than the raw value itself. + *

+ * When {@code true}, the generated bytecode loads the service value and wraps it + * in a {@code Supplier} that returns it. This is a temporary bridge for build items + * that expose {@code Supplier} to unconverted recorder-based consumers. + * + * @return {@code true} if the loaded value should be wrapped in a {@code Supplier} + */ + default boolean __supplier$$wrapper() { + return false; + } } static final class StoredMethodCall implements BytecodeInstruction { diff --git a/core/deployment/src/main/java/io/quarkus/deployment/recording/RecorderContext.java b/core/deployment/src/main/java/io/quarkus/deployment/recording/RecorderContext.java index d9987916e3e30..3c2d366f2daa8 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/recording/RecorderContext.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/recording/RecorderContext.java @@ -4,12 +4,17 @@ import java.util.List; import java.util.function.Function; +import io.quarkus.core.deployment.service.impl.ServiceRegistrarImpl; import io.quarkus.runtime.ObjectSubstitution; import io.quarkus.runtime.RuntimeValue; /** * An injectable utility class that contains methods that can be needed for dealing with recorders. + * + * @deprecated Use {@link ServiceRegistrarImpl} instead. The recorder + * infrastructure is being replaced by a lambda-based service mechanism. */ +@Deprecated(since = "4.0") public interface RecorderContext { /** diff --git a/core/deployment/src/main/java/io/quarkus/deployment/steps/ConfigGenerationBuildStep.java b/core/deployment/src/main/java/io/quarkus/deployment/steps/ConfigGenerationBuildStep.java index 183f44e5b9a49..74971dea5eff6 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/steps/ConfigGenerationBuildStep.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/steps/ConfigGenerationBuildStep.java @@ -506,7 +506,10 @@ void generateConfigBuilders( Set secretKeyHandlerFactories = discoverService(SecretKeysHandlerFactory.class, reflectiveClass); Set configCustomizers = discoverService(SmallRyeConfigBuilderCustomizer.class, reflectiveClass); - // TODO - introduce a way to ignore mappings that are only used for documentation or to prevent warnings + // TODO: these mappings exist only for documentation or warning suppression and are never + // registered with SmallRye Config. This ignore set is duplicated in + // ConfigServiceRegistrationStep.IGNORED_MAPPINGS — any change here must be mirrored there. + // The duplication should be eliminated by sharing the ignore set via a build item. Set ignoreMappings = new LinkedHashSet<>(); ignoreMappings.add(ConfigClass.configClass(BuildAnalyticsConfig.class, "quarkus.analytics")); ignoreMappings.add(ConfigClass.configClass(BuilderConfig.class, "quarkus.builder")); @@ -762,7 +765,7 @@ public void suppressNonRuntimeConfigChanged( } @BuildStep - @Record(ExecutionTime.RUNTIME_INIT) + @Record(ExecutionTime.STATIC_INIT) public void releaseConfigOnShutdown(ShutdownContextBuildItem shutdownContext, ConfigRecorder recorder) { recorder.releaseConfig(shutdownContext); diff --git a/core/deployment/src/main/java/io/quarkus/deployment/steps/LastShutdownTasksProcessor.java b/core/deployment/src/main/java/io/quarkus/deployment/steps/LastShutdownTasksProcessor.java new file mode 100644 index 0000000000000..141d293580da3 --- /dev/null +++ b/core/deployment/src/main/java/io/quarkus/deployment/steps/LastShutdownTasksProcessor.java @@ -0,0 +1,55 @@ +package io.quarkus.deployment.steps; + +import java.util.concurrent.ScheduledExecutorService; + +import io.quarkus.core.Phase; +import io.quarkus.core.deployment.service.ServiceRegistrar; +import io.quarkus.core.impl.LastShutdownTasks; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.runtime.ShutdownContext; + +/** + * Registers the static-init and runtime-init services that run global "last" shutdown tasks. + */ +public class LastShutdownTasksProcessor { + + /** + * Runs global static-init "last" shutdown tasks collected via + * {@link ShutdownContext#addLastShutdownTask(Runnable)}. + *

+ * The ArcContainer service declares {@code after("io.quarkus.core.last-shutdown-tasks")} + * so it stops before this service — bean destruction (and OTel flush etc.) + * complete before static "last" tasks (like SmallRye Context Propagation cleanup) run. + * + * @param reg the service registrar + */ + @BuildStep + void registerLastShutdownTasks(ServiceRegistrar reg) { + reg + .forService("io.quarkus.core.last-shutdown-tasks") + .atPhase(Phase.STATIC_INIT) + .onStart(ctx -> { + ctx.onStop(LastShutdownTasks::runStatic); + }); + } + + /** + * Runs global runtime-init "last" shutdown tasks collected via + * {@link ShutdownContext#addLastShutdownTask(Runnable)}. + *

+ * This service is configured to run at the very end of the runtime-init + * graph's stop cascade, immediately before the core thread pool shuts down. + * + * @param reg the service registrar + */ + @BuildStep + void registerRuntimeLastShutdownTasks(ServiceRegistrar reg) { + reg + .forService("io.quarkus.core.last-runtime-shutdown-tasks") + .atPhase(Phase.INFRASTRUCTURE) + .before(ScheduledExecutorService.class) + .onStart(ctx -> { + ctx.onStop(LastShutdownTasks::runRuntime); + }); + } +} diff --git a/core/deployment/src/main/java/io/quarkus/deployment/steps/MainClassBuildStep.java b/core/deployment/src/main/java/io/quarkus/deployment/steps/MainClassBuildStep.java index a5b30bb256693..5ce06bbb17c3a 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/steps/MainClassBuildStep.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/steps/MainClassBuildStep.java @@ -1,22 +1,29 @@ package io.quarkus.deployment.steps; import static io.quarkus.deployment.steps.KotlinUtil.isKotlinClass; -import static io.quarkus.gizmo.MethodDescriptor.ofConstructor; -import static io.quarkus.gizmo.MethodDescriptor.ofMethod; import java.io.File; +import java.lang.constant.ClassDesc; +import java.lang.constant.ConstantDescs; +import java.lang.constant.MethodTypeDesc; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; import java.lang.reflect.Modifier; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.Comparator; import java.util.HashMap; +import java.util.HashSet; +import java.util.IdentityHashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.function.BiFunction; import java.util.logging.ConsoleHandler; import java.util.logging.Handler; -import java.util.stream.Collectors; import org.jboss.jandex.AnnotationInstance; import org.jboss.jandex.AnnotationValue; @@ -29,11 +36,24 @@ import org.jboss.logging.Logger; import org.objectweb.asm.ClassVisitor; -import io.quarkus.bootstrap.logging.InitialConfigurator; import io.quarkus.bootstrap.logging.QuarkusDelayedHandler; import io.quarkus.bootstrap.naming.DisabledInitialContextManager; import io.quarkus.bootstrap.runner.Timing; +import io.quarkus.builder.BuildContext; import io.quarkus.builder.Version; +import io.quarkus.core.StartContext; +import io.quarkus.core.deployment.service.impl.Dependency; +import io.quarkus.core.deployment.service.impl.LambdaTransliterator; +import io.quarkus.core.deployment.service.impl.ServiceValueRetentionBuildItem; +import io.quarkus.core.deployment.service.impl.TransliteratedAction; +import io.quarkus.core.impl.JfrServiceMonitor; +import io.quarkus.core.impl.NoOpServiceMonitor; +import io.quarkus.core.impl.NodeShutdownContext; +import io.quarkus.core.impl.ServiceGraph; +import io.quarkus.core.impl.ServiceNode; +import io.quarkus.deployment.Capabilities; +import io.quarkus.deployment.Capability; +import io.quarkus.deployment.GeneratedClassGizmo2Adaptor; import io.quarkus.deployment.GeneratedClassGizmoAdaptor; import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; @@ -67,16 +87,24 @@ import io.quarkus.deployment.recording.BytecodeRecorderImpl; import io.quarkus.dev.appstate.ApplicationStateNotification; import io.quarkus.dev.console.QuarkusConsole; -import io.quarkus.gizmo.BytecodeCreator; -import io.quarkus.gizmo.CatchBlockCreator; -import io.quarkus.gizmo.ClassCreator; import io.quarkus.gizmo.ClassTransformer; -import io.quarkus.gizmo.FieldCreator; -import io.quarkus.gizmo.FieldDescriptor; import io.quarkus.gizmo.MethodCreator; import io.quarkus.gizmo.MethodDescriptor; import io.quarkus.gizmo.ResultHandle; -import io.quarkus.gizmo.TryBlock; +import io.quarkus.gizmo2.Const; +import io.quarkus.gizmo2.Expr; +import io.quarkus.gizmo2.Gizmo; +import io.quarkus.gizmo2.LambdaStrategy; +import io.quarkus.gizmo2.LocalVar; +import io.quarkus.gizmo2.ParamVar; +import io.quarkus.gizmo2.StaticFieldVar; +import io.quarkus.gizmo2.Var; +import io.quarkus.gizmo2.creator.BlockCreator; +import io.quarkus.gizmo2.creator.ClassCreator; +import io.quarkus.gizmo2.desc.ClassMethodDesc; +import io.quarkus.gizmo2.desc.ConstructorDesc; +import io.quarkus.gizmo2.desc.FieldDesc; +import io.quarkus.gizmo2.desc.MethodDesc; import io.quarkus.runtime.Application; import io.quarkus.runtime.ExecutionModeManager; import io.quarkus.runtime.JVMUnsafeWarningsControl; @@ -85,6 +113,7 @@ import io.quarkus.runtime.PreventFurtherStepsException; import io.quarkus.runtime.Quarkus; import io.quarkus.runtime.QuarkusApplication; +import io.quarkus.runtime.ShutdownContext; import io.quarkus.runtime.StartupContext; import io.quarkus.runtime.StartupTask; import io.quarkus.runtime.ValueRegistryImpl.ConfigRuntimeSource; @@ -107,29 +136,153 @@ public class MainClassBuildStep { public static final String GENERATE_APP_CDS_SYSTEM_PROPERTY = "quarkus.appcds.generate"; - private static final FieldDescriptor STARTUP_CONTEXT_FIELD = FieldDescriptor.of(Application.APP_CLASS_NAME, STARTUP_CONTEXT, - StartupContext.class); + /** Class descriptor for ServiceGraph. */ + private static final String SERVICE_GRAPH_CLASS = "io.quarkus.core.impl.ServiceGraph"; + /** Class descriptor for ServiceNode. */ + private static final String SERVICE_NODE_CLASS = "io.quarkus.core.impl.ServiceNode"; + + // Class Descriptors (CD_) + private static final ClassDesc CD_Application = ClassDesc.of(Application.class.getName()); + private static final ClassDesc CD_StartupContext = ClassDesc.of(StartupContext.class.getName()); + private static final ClassDesc CD_ServiceGraph = ClassDesc.of(SERVICE_GRAPH_CLASS); + private static final ClassDesc CD_ServiceNode = ClassDesc.of(SERVICE_NODE_CLASS); + private static final ClassDesc CD_LaunchMode = ClassDesc.of(LaunchMode.class.getName()); + + private static final FieldDesc FD_InitialConfigurator_DELAYED_HANDLER = FieldDesc.of( + ClassDesc.of("io.quarkus.bootstrap.logging.InitialConfigurator"), + "DELAYED_HANDLER", + ClassDesc.of("io.quarkus.bootstrap.logging.QuarkusDelayedHandler")); + + // MethodType Descriptors (MTD_) + + static { + ClassDesc.of(Timing.class.getName()); + ClassDesc + .of(RunTimeConfigurationGenerator.class.getName()); + ClassDesc.of(ValueRegistry.class.getName()); + ClassDesc.of(RuntimeInfoProvider.class.getName()); + ClassDesc.of(ConfigRuntimeSource.class.getName()); + ClassDesc.of(ConfigUtils.class.getName()); + ClassDesc.of(QuarkusDelayedHandler.class.getName()); + ClassDesc.of(DisabledInitialContextManager.class.getName()); + ClassDesc.of(JVMUnsafeWarningsControl.class.getName()); + MethodTypeDesc.of(ConstantDescs.CD_void); + MethodTypeDesc.of(ConstantDescs.CD_void, + ConstantDescs.CD_String.arrayType()); + } - public static final MethodDescriptor PRINT_STEP_TIME_METHOD = ofMethod(StepTiming.class.getName(), "printStepTime", - void.class, StartupContext.class); - public static final MethodDescriptor CONFIGURE_STEP_TIME_ENABLED = ofMethod(StepTiming.class.getName(), "configureEnabled", + // Method / Constructor Descriptors (MD_ & CMD_) + public static final MethodDesc MD_StepTiming_configureEnabled = MethodDesc.of(StepTiming.class, "configureEnabled", + void.class); + public static final MethodDesc MD_ExecutionModeManager_staticInit = MethodDesc.of(ExecutionModeManager.class, "staticInit", void.class); - public static final MethodDescriptor RUNTIME_EXECUTION_STATIC_INIT = ofMethod(ExecutionModeManager.class.getName(), - "staticInit", void.class); - public static final MethodDescriptor RUNTIME_EXECUTION_RUNTIME_INIT = ofMethod(ExecutionModeManager.class.getName(), + public static final MethodDesc MD_ExecutionModeManager_runtimeInit = MethodDesc.of(ExecutionModeManager.class, "runtimeInit", void.class); - public static final MethodDescriptor RUNTIME_EXECUTION_RUNNING = ofMethod(ExecutionModeManager.class.getName(), - "running", void.class); - public static final MethodDescriptor RUNTIME_EXECUTION_UNSET = ofMethod(ExecutionModeManager.class.getName(), - "unset", void.class); - public static final MethodDescriptor CONFIGURE_STEP_TIME_START = ofMethod(StepTiming.class.getName(), "configureStart", + public static final MethodDesc MD_ExecutionModeManager_running = MethodDesc.of(ExecutionModeManager.class, "running", + void.class); + public static final MethodDesc MD_ExecutionModeManager_unset = MethodDesc.of(ExecutionModeManager.class, "unset", + void.class); + public static final MethodDesc MD_StepTiming_configureStart = MethodDesc.of(StepTiming.class, "configureStart", void.class); + + private static final MethodDesc MD_DisabledInitialContextManager_register = MethodDesc + .of(DisabledInitialContextManager.class, "register", void.class); + private static final MethodDesc MD_JVMUnsafeWarningsControl_disableUnsafeRelatedWarnings = MethodDesc + .of(JVMUnsafeWarningsControl.class, "disableUnsafeRelatedWarnings", void.class); + private static final MethodDesc MD_System_setProperty = MethodDesc.of(System.class, "setProperty", String.class, + String.class, String.class); + private static final MethodDesc MD_Class_forName = MethodDesc.of(Class.class, "forName", Class.class, String.class, + boolean.class, ClassLoader.class); + private static final MethodDesc MD_Thread_currentThread = MethodDesc.of(Thread.class, "currentThread", Thread.class); + private static final MethodDesc MD_Thread_getContextClassLoader = MethodDesc.of(Thread.class, "getContextClassLoader", + ClassLoader.class); + private static final MethodDesc MD_Timing_staticInitStarted = MethodDesc.of(Timing.class, "staticInitStarted", void.class, + boolean.class); + private static final MethodDesc MD_Timing_mainStarted = MethodDesc.of(Timing.class, "mainStarted", void.class); + private static final MethodDesc MD_Timing_printStartupTime = MethodDesc.of(Timing.class, "printStartupTime", void.class, + String.class, String.class, String.class, String.class, List.class, boolean.class, boolean.class); + private static final MethodDesc MD_Logger_getLogger = MethodDesc.of(Logger.class, "getLogger", Logger.class, String.class); + private static final MethodDesc MD_Application_getValueRegistry = MethodDesc.of(Application.class, "getValueRegistry", + ValueRegistry.class); + private static final MethodDesc MD_StartupContext_putValue = MethodDesc.of(StartupContext.class, "putValue", void.class, + String.class, Object.class); + private static final MethodDesc MD_StartupContext_setCommandLineArguments = MethodDesc.of(StartupContext.class, + "setCommandLineArguments", void.class, String[].class); + private static final MethodDesc MD_StartupContext_getServiceValue = MethodDesc.of(StartupContext.class, "getServiceValue", + Object.class, String.class); + private static final MethodDesc MD_StartupContext_clearServiceValues = MethodDesc.of(StartupContext.class, + "clearServiceValues", void.class); + private static final MethodDesc MD_StartupContext_close = MethodDesc.of(StartupContext.class, "close", void.class); + private static final MethodDesc MD_System_getProperty = MethodDesc.of(System.class, "getProperty", String.class, + String.class); + private static final MethodDesc MD_StringBuilder_length = MethodDesc.of(StringBuilder.class, "length", int.class); + private static final MethodDesc MD_StringBuilder_append = MethodDesc.of(StringBuilder.class, "append", StringBuilder.class, + String.class); + private static final MethodDesc MD_StringBuilder_toString = MethodDesc.of(StringBuilder.class, "toString", String.class); + private static final MethodDesc MD_RuntimeInfoProvider_register = MethodDesc.of(RuntimeInfoProvider.class, "register", + void.class, ValueRegistry.class, RuntimeSource.class); + private static final MethodDesc MD_ConfigRuntimeSource_runtimeSource = MethodDesc.of(ConfigRuntimeSource.class, + "runtimeSource", RuntimeSource.class); + private static final MethodDesc MD_String_join = MethodDesc.of(String.class, "join", String.class, CharSequence.class, + Iterable.class); + private static final MethodDesc MD_ConfigUtils_getProfiles = MethodDesc.of(ConfigUtils.class, "getProfiles", List.class); + private static final MethodDesc MD_QuarkusConsole_start = MethodDesc.of(QuarkusConsole.class, "start", void.class); + private static final MethodDesc MD_QuarkusDelayedHandler_isActivated = MethodDesc.of(QuarkusDelayedHandler.class, + "isActivated", boolean.class); + private static final MethodDesc MD_QuarkusDelayedHandler_setHandlers = MethodDesc.of(QuarkusDelayedHandler.class, + "setHandlers", Handler[].class, Handler[].class); + private static final MethodDesc MD_ApplicationStateNotification_notifyStartupFailed = MethodDesc + .of(ApplicationStateNotification.class, "notifyStartupFailed", void.class, Throwable.class); + private static final MethodDesc MD_NativeImageRuntimePropertiesRecorder_doRuntime = MethodDesc + .of(NativeImageRuntimePropertiesRecorder.class, "doRuntime", void.class); + private static final MethodDesc MD_ServiceGraph_stop = MethodDesc.of(ServiceGraph.class, "stop", void.class); + private static final MethodDesc MD_ServiceGraph_start = MethodDesc.of(ServiceGraph.class, "start", void.class); + private static final MethodDesc MD_ServiceGraph_setTop = MethodDesc.of(ServiceGraph.class, "setTop", void.class, + ServiceNode.class); + private static final MethodDesc MD_ServiceGraph_setBottom = MethodDesc.of(ServiceGraph.class, "setBottom", void.class, + ServiceNode.class); + private static final MethodDesc MD_ServiceGraph_signalStopDone = MethodDesc.of(ServiceGraph.class, "signalStopDone", + void.class); + private static final MethodDesc MD_ServiceGraph_signalStartDone = MethodDesc.of(ServiceGraph.class, "signalStartDone", void.class); + private static final MethodDesc MD_ServiceNode_graph = MethodDesc.of(ServiceNode.class, "graph", ServiceGraph.class); + private static final MethodDesc MD_ServiceNode_startComplete = MethodDesc.of(ServiceNode.class, "startComplete", + void.class); + private static final MethodDesc MD_ServiceNode_startComplete_Object = MethodDesc.of(ServiceNode.class, "startComplete", + void.class, Object.class); + + private static final MethodDesc MD_Config_staticInitConfig = ClassMethodDesc + .of(ClassDesc.of("io.quarkus.runtime.generated.Config"), "staticInitConfig", void.class); + private static final MethodDesc MD_Config_runtimeConfig = ClassMethodDesc + .of(ClassDesc.of("io.quarkus.runtime.generated.Config"), "runtimeConfig", void.class, ValueRegistry.class); + private static final MethodDesc MD_PreInitRunner_executePreInitTasks = ClassMethodDesc + .of(ClassDesc.of("io.quarkus.runtime.generated.PreInitRunner"), "executePreInitTasks", void.class); + + private static final ConstructorDesc CMD_StartupContext_ctor = ConstructorDesc.of(CD_StartupContext); + private static final ConstructorDesc CMD_NodeShutdownContext_ctor_boolean = ConstructorDesc + .of(ClassDesc.of(NodeShutdownContext.class.getName()), boolean.class); + private static final ConstructorDesc CMD_StringBuilder_ctor_String = ConstructorDesc.of(StringBuilder.class, String.class); + private static final ConstructorDesc CMD_ServiceGraph_ctor_StartupContext_ServiceMonitor = ConstructorDesc + .of(CD_ServiceGraph, CD_StartupContext, ClassDesc.of("io.quarkus.core.impl.ServiceMonitor")); + private static final ConstructorDesc CMD_ServiceNode_ctor_String_MethodHandle_ServiceGraph_int = ConstructorDesc.of( + CD_ServiceNode, ConstantDescs.CD_String, ClassDesc.of(MethodHandle.class.getName()), CD_ServiceGraph, + ConstantDescs.CD_int); + private static final ConstructorDesc CMD_ServiceNode_ctor_String_MethodHandle_ServiceGraph_int_ServiceNode = ConstructorDesc + .of(CD_ServiceNode, ConstantDescs.CD_String, ClassDesc.of(MethodHandle.class.getName()), CD_ServiceGraph, + ConstantDescs.CD_int, CD_ServiceNode); + private static final ConstructorDesc CMD_ServiceNode_ctor_String_MethodHandle_ServiceGraph_int_ServiceNode_ServiceNode = ConstructorDesc + .of(CD_ServiceNode, ConstantDescs.CD_String, ClassDesc.of(MethodHandle.class.getName()), CD_ServiceGraph, + ConstantDescs.CD_int, CD_ServiceNode, CD_ServiceNode); + private static final ConstructorDesc CMD_ServiceNode_ctor_String_MethodHandle_ServiceGraph_int_List = ConstructorDesc.of( + CD_ServiceNode, ConstantDescs.CD_String, ClassDesc.of(MethodHandle.class.getName()), CD_ServiceGraph, + ConstantDescs.CD_int, ClassDesc.of(List.class.getName())); + private static final DotName QUARKUS_APPLICATION = DotName.createSimple(QuarkusApplication.class.getName()); private static final DotName OBJECT = DotName.createSimple(Object.class.getName()); private static final Type STRING_ARRAY = Type.create(DotName.createSimple(String[].class.getName()), Type.Kind.ARRAY); @BuildStep void build( + BuildContext buildContext, List staticInitTasks, List substitutions, List runtimeInfoProviders, @@ -148,254 +301,329 @@ void build( ApplicationInfoBuildItem applicationInfo, List allowJNDIBuildItems, Optional preInitBuildItem, - NamingConfig namingConfig) { + NamingConfig namingConfig, + List retentionItems, + Capabilities capabilities) { appClassNameProducer.produce(new ApplicationClassNameBuildItem(Application.APP_CLASS_NAME)); - // Application class - GeneratedClassGizmoAdaptor gizmoOutput = new GeneratedClassGizmoAdaptor(generatedClass, true); - ClassCreator file = new ClassCreator(gizmoOutput, Application.APP_CLASS_NAME, null, - Application.class.getName()); - - // Application class: static init - - // LOG static field - FieldCreator logField = file.getFieldCreator(LOG, Logger.class).setModifiers(Modifier.STATIC); - - FieldCreator scField = file.getFieldCreator(STARTUP_CONTEXT_FIELD); - scField.setModifiers(Modifier.PUBLIC | Modifier.STATIC); - - FieldCreator quarkusVersionField = file.getFieldCreator(QUARKUS_ANALYTICS_QUARKUS_VERSION, String.class) - .setModifiers(Modifier.PRIVATE | Modifier.STATIC | Modifier.FINAL); - - MethodCreator ctor = file.getMethodCreator("", void.class); - ctor.invokeSpecialMethod(ofMethod(Application.class, "", void.class, boolean.class), - ctor.getThis(), ctor.load(launchMode.isAuxiliaryApplication())); - ctor.returnValue(null); - - MethodCreator mv = file.getMethodCreator("", void.class); - mv.setModifiers(Modifier.PUBLIC | Modifier.STATIC); - if (!namingConfig.enableJndi() && allowJNDIBuildItems.isEmpty()) { - mv.invokeStaticMethod(ofMethod(DisabledInitialContextManager.class, "register", void.class)); - } - mv.invokeStaticMethod(ofMethod(JVMUnsafeWarningsControl.class, "disableUnsafeRelatedWarnings", void.class)); - - // very first thing is to set system props (for build time) - // make sure we record the system properties in order for build reproducibility - for (SystemPropertyBuildItem i : properties.stream().sorted(Comparator.comparing(SystemPropertyBuildItem::getKey)) - .toList()) { - mv.invokeStaticMethod(ofMethod(System.class, "setProperty", String.class, String.class, String.class), - mv.load(i.getKey()), mv.load(i.getValue())); - } - - if (preInitBuildItem.isPresent()) { - // we need to initialize JBoss Logging before starting any parallel work, it's too central - ResultHandle tccl = mv.invokeVirtualMethod( - MethodDescriptor.ofMethod(Thread.class, "getContextClassLoader", ClassLoader.class), - mv.invokeStaticMethod(MethodDescriptor.ofMethod(Thread.class, "currentThread", Thread.class))); - mv.invokeStaticMethod( - MethodDescriptor.ofMethod(Class.class, "forName", Class.class, String.class, boolean.class, - ClassLoader.class), - mv.load("org.jboss.logging.LoggerProviders"), mv.load(true), tccl); - - // then we can preinitialize - mv.invokeStaticMethod(PreInitBuildStep.PRE_INIT_RUNNER_EXECUTE_PRE_INIT_TASKS_GIZMO1_METHOD); - } - - //set the launch mode - ResultHandle lm = mv - .readStaticField(FieldDescriptor.of(LaunchMode.class, launchMode.getLaunchMode().name(), LaunchMode.class)); - mv.invokeStaticMethod(ofMethod(LaunchMode.class, "set", void.class, LaunchMode.class), - lm); - - mv.invokeStaticMethod(CONFIGURE_STEP_TIME_ENABLED); - mv.invokeStaticMethod(RUNTIME_EXECUTION_STATIC_INIT); - - mv.invokeStaticMethod(ofMethod(Timing.class, "staticInitStarted", void.class, boolean.class), - mv.load(launchMode.isAuxiliaryApplication())); - - // Create Static Init Config and associate it with the current classloader - mv.invokeStaticMethod(RunTimeConfigurationGenerator.C_STATIC_INIT_CONFIG); - - // Init the LOG instance - mv.writeStaticField(logField.getFieldDescriptor(), mv.invokeStaticMethod( - ofMethod(Logger.class, "getLogger", Logger.class, String.class), mv.load("io.quarkus.application"))); - - // Init the __QUARKUS_ANALYTICS_QUARKUS_VERSION field - mv.writeStaticField(quarkusVersionField.getFieldDescriptor(), - mv.load("__quarkus_analytics__quarkus.version=" + Version.getVersion())); - - ResultHandle startupContext = mv.newInstance(ofConstructor(StartupContext.class)); - mv.writeStaticField(scField.getFieldDescriptor(), startupContext); - TryBlock tryBlock = mv.tryBlock(); - tryBlock.invokeStaticMethod(CONFIGURE_STEP_TIME_START); - for (StaticBytecodeRecorderBuildItem holder : staticInitTasks) { - writeRecordedBytecode(holder.getBytecodeRecorder(), null, substitutions, recordableConstructorBuildItems, loaders, - constants, gizmoOutput, startupContext, tryBlock); - } - tryBlock.returnValue(null); - - CatchBlockCreator cb = tryBlock.addCatch(Throwable.class); - cb.invokeStaticMethod(ofMethod(ApplicationStateNotification.class, "notifyStartupFailed", void.class, Throwable.class), - cb.getCaughtException()); - cb.invokeVirtualMethod(ofMethod(StartupContext.class, "close", void.class), startupContext); - cb.throwException(RuntimeException.class, "Failed to start quarkus", cb.getCaughtException()); - - // Application class: start method - - mv = file.getMethodCreator("doStart", void.class, String[].class); - mv.setModifiers(Modifier.PROTECTED | Modifier.FINAL); - - startupContext = mv.readStaticField(scField.getFieldDescriptor()); - - // Register ValueRegistry with StartupContext, so it can be injected into Recorders - ResultHandle valueRegistry = mv - .invokeVirtualMethod(ofMethod(Application.class, "getValueRegistry", ValueRegistry.class), mv.getThis()); - MethodDescriptor putValueInStartupContext = ofMethod(StartupContext.class, "putValue", void.class, String.class, - Object.class); - mv.invokeVirtualMethod(putValueInStartupContext, startupContext, mv.load(ValueRegistry.class.getName()), valueRegistry); - - // Make sure we set properties in doStartup as well. This is necessary because setting them in the static-init - // sets them at build-time, on the host JVM, while SVM has substitutions for System. get/setProperty at - // run-time which will never see those properties unless we also set them at run-time. - // make sure we record the system properties in order for build reproducibility - for (SystemPropertyBuildItem i : properties.stream().sorted(Comparator.comparing(SystemPropertyBuildItem::getKey)) - .toList()) { - mv.invokeStaticMethod(ofMethod(System.class, "setProperty", String.class, String.class, String.class), - mv.load(i.getKey()), mv.load(i.getValue())); - } - // make sure we record the system properties in order for build reproducibility - for (GeneratedRuntimeSystemPropertyBuildItem i : generatedRuntimeSystemProperties.stream() - .sorted(Comparator.comparing(GeneratedRuntimeSystemPropertyBuildItem::getKey)).toList()) { - mv.invokeStaticMethod(ofMethod(System.class, "setProperty", String.class, String.class, String.class), - mv.load(i.getKey()), - mv.invokeVirtualMethod(MethodDescriptor.ofMethod(i.getGeneratorClass(), "get", String.class.getName()), - mv.newInstance(MethodDescriptor.ofConstructor(i.getGeneratorClass())))); - } - mv.invokeStaticMethod(ofMethod(NativeImageRuntimePropertiesRecorder.class, "doRuntime", void.class)); - mv.invokeStaticMethod(RUNTIME_EXECUTION_RUNTIME_INIT); - - // Set the SSL system properties - if (!javaLibraryPathAdditionalPaths.isEmpty()) { - ResultHandle javaLibraryPath = mv.newInstance(ofConstructor(StringBuilder.class, String.class), - mv.invokeStaticMethod(ofMethod(System.class, "getProperty", String.class, String.class), - mv.load(JAVA_LIBRARY_PATH))); - for (JavaLibraryPathAdditionalPathBuildItem javaLibraryPathAdditionalPath : javaLibraryPathAdditionalPaths) { - ResultHandle javaLibraryPathLength = mv.invokeVirtualMethod(ofMethod(StringBuilder.class, "length", int.class), - javaLibraryPath); - mv.ifNonZero(javaLibraryPathLength).trueBranch() - .invokeVirtualMethod(ofMethod(StringBuilder.class, "append", StringBuilder.class, String.class), - javaLibraryPath, mv.load(File.pathSeparator)); - mv.invokeVirtualMethod(ofMethod(StringBuilder.class, "append", StringBuilder.class, String.class), - javaLibraryPath, - mv.load(javaLibraryPathAdditionalPath.getPath())); - } - mv.invokeStaticMethod(ofMethod(System.class, "setProperty", String.class, String.class, String.class), - mv.load(JAVA_LIBRARY_PATH), - mv.invokeVirtualMethod(ofMethod(StringBuilder.class, "toString", String.class), javaLibraryPath)); - } - - mv.invokeStaticMethod(ofMethod(Timing.class, "mainStarted", void.class)); - - //now set the command line arguments - mv.invokeVirtualMethod( - ofMethod(StartupContext.class, "setCommandLineArguments", void.class, String[].class), - startupContext, mv.getMethodParam(0)); - - mv.invokeStaticMethod(CONFIGURE_STEP_TIME_ENABLED); - - tryBlock = mv.tryBlock(); - tryBlock.invokeStaticMethod(CONFIGURE_STEP_TIME_START); - - // Create Runtime Config and associate it with the current classloader - tryBlock.invokeStaticMethod(RunTimeConfigurationGenerator.C_RUN_TIME_CONFIG, valueRegistry); - - // Register RuntimeInfoProviders with ValueRegistry - for (ValueRegistryRuntimeInfoProviderBuildItem runtimeInfoProviderClass : runtimeInfoProviders) { - ResultHandle runtimeInfoProvider = tryBlock - .newInstance(ofConstructor(runtimeInfoProviderClass.getRuntimeInfoProvider())); - tryBlock.invokeInterfaceMethod( - ofMethod(RuntimeInfoProvider.class, "register", void.class, ValueRegistry.class, RuntimeSource.class), - runtimeInfoProvider, - valueRegistry, - tryBlock.invokeStaticMethod(ofMethod(ConfigRuntimeSource.class, "runtimeSource", RuntimeSource.class))); - } - - for (MainBytecodeRecorderBuildItem holder : mainMethod) { - writeRecordedBytecode(holder.getBytecodeRecorder(), holder.getGeneratedStartupContextClassName(), substitutions, - recordableConstructorBuildItems, - loaders, constants, gizmoOutput, startupContext, tryBlock); - } + // Consolidate transliterated actions into shared classes + Map consolidatedRefs = consolidateActions( + staticInitTasks, mainMethod, generatedClass); - tryBlock.invokeStaticMethod(RUNTIME_EXECUTION_RUNNING); + // Compute service graph plans from build items + step dependency graph + Map> stepGraph = buildContext.getStepDependencyGraph(); + Map> buildItemProducers = buildContext.getBuildItemProducers(); + ServiceGraphBuilder.GraphPlan staticPlan = ServiceGraphBuilder.buildStaticInit( + staticInitTasks, stepGraph, buildItemProducers); + ServiceGraphBuilder.GraphPlan runtimePlan = ServiceGraphBuilder.buildRuntime( + mainMethod, stepGraph, staticPlan.serviceKeys(), buildItemProducers); - // Startup log messages - List featureNames = new ArrayList<>(); - for (FeatureBuildItem feature : features) { - if (featureNames.contains(feature.getName())) { - throw new IllegalStateException( - "Multiple extensions registered a feature of the same name: " + feature.getName()); - } - featureNames.add(feature.getName()); - } - ResultHandle featuresHandle = tryBlock.load(featureNames.stream().sorted().collect(Collectors.joining(", "))); - tryBlock.invokeStaticMethod( - ofMethod(Timing.class, "printStartupTime", void.class, String.class, String.class, String.class, String.class, - List.class, boolean.class, boolean.class), - tryBlock.load(applicationInfo.getName()), - tryBlock.load(applicationInfo.getVersion()), - tryBlock.load(Version.getVersion()), - featuresHandle, - tryBlock.invokeStaticMethod(ofMethod(ConfigUtils.class, "getProfiles", List.class)), - tryBlock.load(LaunchMode.DEVELOPMENT.equals(launchMode.getLaunchMode())), - tryBlock.load(launchMode.isAuxiliaryApplication())); - - tryBlock.invokeStaticMethod( - ofMethod(QuarkusConsole.class, "start", void.class)); - - CatchBlockCreator preventFurtherStepsBlock = tryBlock.addCatch(PreventFurtherStepsException.class); - preventFurtherStepsBlock.invokeVirtualMethod(ofMethod(StartupContext.class, "close", void.class), startupContext); - - cb = tryBlock.addCatch(Throwable.class); - - // an exception was thrown before logging was actually setup, we simply dump everything to the console - // we don't do this for dev mode, as on startup failure dev mode sets up its own logging - if (launchMode.getLaunchMode() != LaunchMode.DEVELOPMENT) { - ResultHandle delayedHandler = cb - .readStaticField( - FieldDescriptor.of(InitialConfigurator.class, "DELAYED_HANDLER", QuarkusDelayedHandler.class)); - ResultHandle isActivated = cb.invokeVirtualMethod( - ofMethod(QuarkusDelayedHandler.class, "isActivated", boolean.class), - delayedHandler); - BytecodeCreator isActivatedFalse = cb.ifNonZero(isActivated).falseBranch(); - ResultHandle handlersArray = isActivatedFalse.newArray(Handler.class, 1); - isActivatedFalse.writeArrayValue(handlersArray, 0, - isActivatedFalse.newInstance(ofConstructor(ConsoleHandler.class))); - isActivatedFalse.invokeVirtualMethod( - ofMethod(QuarkusDelayedHandler.class, "setHandlers", Handler[].class, Handler[].class), - delayedHandler, handlersArray); - isActivatedFalse.breakScope(); - } + // Application class + GeneratedClassGizmo2Adaptor gizmoOutput = new GeneratedClassGizmo2Adaptor(generatedClass, null, null, true); + Gizmo g = Gizmo.create(gizmoOutput).withLambdaStrategy(LambdaStrategy.CLASSIC); + + g.class_(Application.APP_CLASS_NAME, file -> { + file.extends_(Application.class); + + // LOG static field + StaticFieldVar logField = file.staticField(LOG, fc -> { + fc.private_(); + fc.setType(Logger.class); + }); + + // STARTUP_CONTEXT static field + StaticFieldVar scField = file.staticField(STARTUP_CONTEXT, fc -> { + fc.public_(); + fc.setType(StartupContext.class); + }); + + // static fields for service graphs (used by doStop) + StaticFieldVar staticGraphField = file.staticField("STATIC_GRAPH", fc -> { + fc.private_(); + fc.setType(ServiceGraph.class); + }); + + StaticFieldVar runtimeGraphField = file.staticField("RUNTIME_GRAPH", fc -> { + fc.private_(); + fc.setType(ServiceGraph.class); + }); + + StaticFieldVar quarkusVersionField = file.staticField(QUARKUS_ANALYTICS_QUARKUS_VERSION, fc -> { + fc.private_(); + fc.final_(); + fc.setType(String.class); + }); + + file.constructor(ctor -> ctor.body(b0 -> { + b0.invokeSpecial(ConstructorDesc.of(CD_Application, boolean.class), file.this_(), + Const.of(launchMode.isAuxiliaryApplication())); + b0.return_(); + })); + + file.staticInitializer(b0 -> { + if (!namingConfig.enableJndi() && allowJNDIBuildItems.isEmpty()) { + b0.invokeStatic(MD_DisabledInitialContextManager_register); + } + b0.invokeStatic(MD_JVMUnsafeWarningsControl_disableUnsafeRelatedWarnings); - cb.invokeVirtualMethod(ofMethod(StartupContext.class, "close", void.class), startupContext); - cb.throwException(RuntimeException.class, "Failed to start quarkus", cb.getCaughtException()); - mv.returnValue(null); + // very first thing is to set system props (for build time) + // make sure we record the system properties in order for build reproducibility + for (SystemPropertyBuildItem i : properties.stream() + .sorted(Comparator.comparing(SystemPropertyBuildItem::getKey)).toList()) { + b0.invokeStatic(MD_System_setProperty, Const.of(i.getKey()), Const.of(i.getValue())); + } - // Application class: stop method + if (preInitBuildItem.isPresent()) { + // we need to initialize JBoss Logging before starting any parallel work, it's too central + Expr tccl = b0.invokeVirtual(MD_Thread_getContextClassLoader, b0.invokeStatic(MD_Thread_currentThread)); + b0.invokeStatic(MD_Class_forName, Const.of("org.jboss.logging.LoggerProviders"), Const.of(true), tccl); - mv = file.getMethodCreator("doStop", void.class); - mv.setModifiers(Modifier.PROTECTED | Modifier.FINAL); - mv.invokeStaticMethod(RUNTIME_EXECUTION_UNSET); - startupContext = mv.readStaticField(scField.getFieldDescriptor()); - mv.invokeVirtualMethod(ofMethod(StartupContext.class, "close", void.class), startupContext); - mv.returnValue(null); + // then we can preinitialize + b0.invokeStatic(MD_PreInitRunner_executePreInitTasks); + } - // getName method - mv = file.getMethodCreator("getName", String.class); - mv.returnValue(mv.load(applicationInfo.getName())); + //set the launch mode + b0.invokeStatic(MethodDesc.of(LaunchMode.class, "set", void.class, LaunchMode.class), + Expr.staticField(FieldDesc.of(CD_LaunchMode, launchMode.getLaunchMode().name(), CD_LaunchMode))); + + b0.invokeStatic(MD_StepTiming_configureEnabled); + b0.invokeStatic(MD_ExecutionModeManager_staticInit); + + b0.invokeStatic(MD_Timing_staticInitStarted, Const.of(launchMode.isAuxiliaryApplication())); + + // Create Static Init Config and associate it with the current classloader + b0.invokeStatic(MD_Config_staticInitConfig); + + // Init the LOG instance + b0.set(logField, b0.invokeStatic(MD_Logger_getLogger, Const.of("io.quarkus.application"))); + + // Init the __QUARKUS_ANALYTICS_QUARKUS_VERSION field + b0.set(quarkusVersionField, Const.of("__quarkus_analytics__quarkus.version=" + Version.getVersion())); + + b0.invokeStatic(MD_StepTiming_configureStart); + + b0.try_(t1 -> { + t1.body(b2 -> { + // create the shared StartupContext (used by both static-init and runtime-init graphs) + LocalVar sc = b2.localVar("sc", b2.new_(CMD_StartupContext_ctor)); + b2.set(scField, sc); + + // build and start the static-init service graph + LocalVar staticGraph = emitServiceGraph(b2, staticPlan, sc, file, consolidatedRefs, + generatedClass, substitutions, recordableConstructorBuildItems, loaders, constants, "static", + capabilities); + b2.set(staticGraphField, staticGraph); + + // discard static-init-only service values; retain keys needed at runtime-init + Set crossPhaseKeys = computeCrossPhaseKeys(mainMethod, retentionItems, + staticPlan.serviceKeys()); + if (!crossPhaseKeys.isEmpty()) { + generateRetainServiceValues(b2, sc, crossPhaseKeys); + } + }); + t1.catch_(Throwable.class, "t", (b2, t) -> { + b2.invokeStatic(MD_ApplicationStateNotification_notifyStartupFailed, t); + // on failure, stop the static-init graph if it was started + LocalVar failGraph = b2.localVar("failGraph", b2.get(staticGraphField)); + b2.if_(b2.ne(failGraph, Const.ofNull(CD_ServiceGraph)), + b3 -> b3.invokeVirtual(MD_ServiceGraph_stop, failGraph)); + b2.throw_(b2.new_( + ConstructorDesc.of(ClassDesc.of(RuntimeException.class.getName()), + ClassDesc.of(String.class.getName()), ClassDesc.of(Throwable.class.getName())), + Const.of("Failed to start quarkus"), t)); + }); + }); + b0.return_(); + }); + + // Application class: start method + file.method("doStart", MethodTypeDesc.of(ConstantDescs.CD_void, ConstantDescs.CD_String.arrayType()), mc -> { + mc.protected_(); + mc.final_(); + ParamVar args = mc.parameter("args"); + mc.body(b0 -> { + LocalVar sc = b0.localVar("sc", b0.get(scField)); + + // Register ValueRegistry with StartupContext, so it can be injected into Recorders + LocalVar valueRegistry = b0.localVar("valueRegistry", + b0.invokeVirtual(MD_Application_getValueRegistry, file.this_())); + b0.invokeVirtual(MD_StartupContext_putValue, sc, Const.of(ValueRegistry.class.getName()), valueRegistry); + + // Make sure we set properties in doStartup as well. This is necessary because setting them in the static-init + // sets them at build-time, on the host JVM, while SVM has substitutions for System. get/setProperty at + // run-time which will never see those properties unless we also set them at run-time. + // make sure we record the system properties in order for build reproducibility + for (SystemPropertyBuildItem i : properties.stream() + .sorted(Comparator.comparing(SystemPropertyBuildItem::getKey)).toList()) { + b0.invokeStatic(MD_System_setProperty, Const.of(i.getKey()), Const.of(i.getValue())); + } + // make sure we record the system properties in order for build reproducibility + for (GeneratedRuntimeSystemPropertyBuildItem i : generatedRuntimeSystemProperties.stream() + .sorted(Comparator.comparing(GeneratedRuntimeSystemPropertyBuildItem::getKey)).toList()) { + b0.invokeStatic(MD_System_setProperty, Const.of(i.getKey()), + b0.invokeVirtual(ClassMethodDesc.of(ClassDesc.of(i.getGeneratorClass()), "get", String.class), + b0.new_(ConstructorDesc.of(ClassDesc.of(i.getGeneratorClass()))))); + } + b0.invokeStatic(MD_NativeImageRuntimePropertiesRecorder_doRuntime); + b0.invokeStatic(MD_ExecutionModeManager_runtimeInit); + + // Set the SSL system properties + if (!javaLibraryPathAdditionalPaths.isEmpty()) { + LocalVar javaLibraryPath = b0.localVar("javaLibraryPath", b0.new_(CMD_StringBuilder_ctor_String, + b0.invokeStatic(MD_System_getProperty, Const.of(JAVA_LIBRARY_PATH)))); + for (JavaLibraryPathAdditionalPathBuildItem javaLibraryPathAdditionalPath : javaLibraryPathAdditionalPaths) { + LocalVar javaLibraryPathLength = b0.localVar("javaLibraryPathLength", + b0.invokeVirtual(MD_StringBuilder_length, javaLibraryPath)); + b0.if_(b0.ne(javaLibraryPathLength, 0), b1 -> b1.invokeVirtual(MD_StringBuilder_append, + javaLibraryPath, Const.of(File.pathSeparator))); + b0.invokeVirtual(MD_StringBuilder_append, javaLibraryPath, + Const.of(javaLibraryPathAdditionalPath.getPath())); + } + b0.invokeStatic(MD_System_setProperty, Const.of(JAVA_LIBRARY_PATH), + b0.invokeVirtual(MD_StringBuilder_toString, javaLibraryPath)); + } - // Finish application class - file.close(); + b0.invokeStatic(MD_Timing_mainStarted); + + //now set the command line arguments + b0.invokeVirtual(MD_StartupContext_setCommandLineArguments, sc, args); + + b0.invokeStatic(MD_StepTiming_configureEnabled); + + b0.try_(t1 -> { + t1.body(b2 -> { + // Create Runtime Config and associate it with the current classloader + b2.invokeStatic(MD_Config_runtimeConfig, valueRegistry); + + // Register RuntimeInfoProviders with ValueRegistry + for (ValueRegistryRuntimeInfoProviderBuildItem runtimeInfoProviderClass : runtimeInfoProviders) { + LocalVar runtimeInfoProvider = b2.localVar("runtimeInfoProvider", b2.new_(ConstructorDesc + .of(ClassDesc.of(runtimeInfoProviderClass.getRuntimeInfoProvider().getName())))); + b2.invokeInterface(MD_RuntimeInfoProvider_register, runtimeInfoProvider, valueRegistry, + b2.invokeStatic(MD_ConfigRuntimeSource_runtimeSource)); + } + + // build and start the runtime service graph (shares the same StartupContext) + LocalVar runtimeGraph = emitServiceGraph(b2, runtimePlan, sc, file, consolidatedRefs, + generatedClass, substitutions, recordableConstructorBuildItems, loaders, constants, + "runtime", capabilities); + b2.set(runtimeGraphField, runtimeGraph); + + // discard runtime-init service values; retain only CDI service-value bean keys + // (those are self-draining: each bean's creation function calls removeServiceValue) + Set postStartupKeys = computePostStartupKeys(retentionItems); + if (postStartupKeys.isEmpty()) { + b2.invokeVirtual(MD_StartupContext_clearServiceValues, sc); + } else { + generateRetainServiceValues(b2, sc, postStartupKeys); + } + + b2.invokeStatic(MD_ExecutionModeManager_running); + + // Startup log messages + List featureNames = new ArrayList<>(); + for (FeatureBuildItem feature : features) { + if (!featureNames.contains(feature.getName())) { + featureNames.add(feature.getName()); + } + } + LocalVar featuresHandle = b2.localVar("featuresHandle", + b2.listOf(featureNames.stream().sorted().map(Const::of).toArray(Expr[]::new))); + b2.invokeStatic(MD_Timing_printStartupTime, + Const.of(applicationInfo.getName()), + Const.of(applicationInfo.getVersion()), + Const.of(Version.getVersion()), + b2.invokeStatic(MD_String_join, Const.of(", "), featuresHandle), + b2.invokeStatic(MD_ConfigUtils_getProfiles), + Const.of(LaunchMode.DEVELOPMENT.equals(launchMode.getLaunchMode())), + Const.of(launchMode.isAuxiliaryApplication())); + + b2.invokeStatic(MD_QuarkusConsole_start); + }); + t1.catch_(PreventFurtherStepsException.class, "pf", (b2, pf) -> { + // stop both graphs to run shutdown handlers before closing the context + LocalVar pfRtGraph = b2.localVar("pfRtGraph", b2.get(runtimeGraphField)); + b2.if_(b2.ne(pfRtGraph, Const.ofNull(CD_ServiceGraph)), + b3 -> b3.invokeVirtual(MD_ServiceGraph_stop, pfRtGraph)); + LocalVar pfStGraph = b2.localVar("pfStGraph", b2.get(staticGraphField)); + b2.if_(b2.ne(pfStGraph, Const.ofNull(CD_ServiceGraph)), + b3 -> b3.invokeVirtual(MD_ServiceGraph_stop, pfStGraph)); + b2.invokeVirtual(MD_StartupContext_close, sc); + }); + t1.catch_(Throwable.class, "t", (b2, t) -> { + // an exception was thrown before logging was actually setup, we simply dump everything to the console + // we don't do this for dev mode, as on startup failure dev mode sets up its own logging + if (launchMode.getLaunchMode() != LaunchMode.DEVELOPMENT) { + LocalVar delayedHandler = b2.localVar("delayedHandler", + Expr.staticField(FD_InitialConfigurator_DELAYED_HANDLER)); + LocalVar isActivated = b2.localVar("isActivated", + b2.invokeVirtual(MD_QuarkusDelayedHandler_isActivated, delayedHandler)); + b2.if_(b2.eq(isActivated, 0), b3 -> { + LocalVar handlersArray = b3.localVar("handlersArray", + b3.newEmptyArray(ClassDesc.of(Handler.class.getName()), 1)); + b3.set(handlersArray.elem(0), b3.new_(ConstructorDesc.of(ConsoleHandler.class))); + b3.invokeVirtual(MD_QuarkusDelayedHandler_setHandlers, delayedHandler, handlersArray); + }); + } + + // stop both graphs to run shutdown handlers (release Vertx, thread pools, etc.) + LocalVar failRtGraph = b2.localVar("failRtGraph", b2.get(runtimeGraphField)); + b2.if_(b2.ne(failRtGraph, Const.ofNull(CD_ServiceGraph)), + b3 -> b3.invokeVirtual(MD_ServiceGraph_stop, failRtGraph)); + LocalVar failStGraph = b2.localVar("failStGraph", b2.get(staticGraphField)); + b2.if_(b2.ne(failStGraph, Const.ofNull(CD_ServiceGraph)), + b3 -> b3.invokeVirtual(MD_ServiceGraph_stop, failStGraph)); + b2.invokeVirtual(MD_StartupContext_close, sc); + b2.throw_(b2.new_( + ConstructorDesc.of(ClassDesc.of(RuntimeException.class.getName()), + ClassDesc.of(String.class.getName()), ClassDesc.of(Throwable.class.getName())), + Const.of("Failed to start quarkus"), t)); + }); + }); + b0.return_(); + }); + }); + + // Application class: stop method + file.method("doStop", MethodTypeDesc.of(ConstantDescs.CD_void), mc -> { + mc.protected_(); + mc.final_(); + mc.body(b0 -> { + b0.invokeStatic(MD_ExecutionModeManager_unset); + // stop the runtime graph, then the static-init graph + LocalVar rtGraph = b0.localVar("rtGraph", b0.get(runtimeGraphField)); + b0.if_(b0.ne(rtGraph, Const.ofNull(CD_ServiceGraph)), + b1 -> b1.invokeVirtual(MD_ServiceGraph_stop, rtGraph)); + b0.set(runtimeGraphField, Const.ofNull(CD_ServiceGraph)); + + LocalVar stGraph = b0.localVar("stGraph", b0.get(staticGraphField)); + b0.if_(b0.ne(stGraph, Const.ofNull(CD_ServiceGraph)), + b1 -> b1.invokeVirtual(MD_ServiceGraph_stop, stGraph)); + b0.set(staticGraphField, Const.ofNull(CD_ServiceGraph)); + + // close and release the StartupContext (clears values/serviceValues maps) + LocalVar sc = b0.localVar("sc", b0.get(scField)); + b0.if_(b0.ne(sc, Const.ofNull(CD_StartupContext)), b1 -> b1.invokeVirtual(MD_StartupContext_close, sc)); + b0.set(scField, Const.ofNull(CD_StartupContext)); + + b0.return_(); + }); + }); + + // getName method + file.method("getName", MethodTypeDesc.of(ConstantDescs.CD_String), mc -> { + mc.public_(); + mc.body(b0 -> b0.return_(Const.of(applicationInfo.getName()))); + }); + + // hasStaticGraph method + file.method("hasStaticGraph", MethodTypeDesc.of(ConstantDescs.CD_boolean), mc -> { + mc.public_(); + mc.final_(); + mc.body(b0 -> b0.return_(b0.ne(b0.get(staticGraphField), Const.ofNull(CD_ServiceGraph)))); + }); + }); } @BuildStep @@ -441,12 +669,14 @@ public MainClassBuildItem mainClassBuildStep(BuildProducer generatedClass) { - ClassCreator file = new ClassCreator(new GeneratedClassGizmoAdaptor(generatedClass, true), MAIN_CLASS, null, - Object.class.getName()); - - MethodCreator mv = file.getMethodCreator("main", void.class, String[].class); - mv.setModifiers(Modifier.PUBLIC | Modifier.STATIC); - mv.invokeStaticMethod(ofMethod(Quarkus.class, "run", void.class, Class.class, String[].class), - mv.loadClassFromTCCL(quarkusApplicationClassName), - mv.getMethodParam(0)); - mv.returnValue(null); - file.close(); + GeneratedClassGizmo2Adaptor output = new GeneratedClassGizmo2Adaptor(generatedClass, null, null, true); + Gizmo.create(output).class_(MAIN_CLASS, file -> file.staticMethod("main", + MethodTypeDesc.of(ConstantDescs.CD_void, ClassDesc.of(String.class.getName()).arrayType()), mc -> { + mc.public_(); + ParamVar args = mc.parameter("args"); + mc.body(b0 -> { + b0.invokeStatic(MethodDesc.of(Quarkus.class, "run", void.class, Class.class, String[].class), + Const.of(ClassDesc.of(quarkusApplicationClassName)), args); + b0.return_(); + }); + })); + } + + /** + * Record mapping a {@link TransliteratedAction} to its deploy method within a consolidated class. + * + * @param className the fully-qualified consolidated class name (dot-separated) + * @param deployIndex the zero-based index of the deploy method within the consolidated class + */ + private record ConsolidatedRef(String className, int deployIndex) { + } + + /** + * Collect all {@link TransliteratedAction}s from both static-init and runtime build items, + * generate consolidated classes (one per phase), produce {@link GeneratedClassBuildItem}s, + * and return a map from each action to its consolidated class and deploy method index. + * + * @param staticInitTasks the static-init build items + * @param mainMethod the runtime build items + * @param generatedClass the producer for generated class build items + * @return an identity map from each transliterated action to its consolidated reference + */ + private Map consolidateActions( + List staticInitTasks, + List mainMethod, + BuildProducer generatedClass) { + + List staticActions = new ArrayList<>(); + for (StaticBytecodeRecorderBuildItem item : staticInitTasks) { + TransliteratedAction ta = item.getTransliteratedAction(); + if (ta != null) { + staticActions.add(ta); + } + } + List runtimeActions = new ArrayList<>(); + for (MainBytecodeRecorderBuildItem item : mainMethod) { + TransliteratedAction ta = item.getTransliteratedAction(); + if (ta != null) { + runtimeActions.add(ta); + } + } + + if (staticActions.isEmpty() && runtimeActions.isEmpty()) { + return Map.of(); + } + + Map refs = new IdentityHashMap<>(); + consolidateBatch(staticActions, "io/quarkus/generated/service/StaticServiceActions", refs, generatedClass); + consolidateBatch(runtimeActions, "io/quarkus/generated/service/RuntimeServiceActions", refs, generatedClass); + return refs; } - private void writeRecordedBytecode(BytecodeRecorderImpl recorder, String fallbackGeneratedStartupTaskClassName, + /** + * Generate a consolidated class for a batch of actions and register each action in the ref map. + * + * @param actions the list of transliterated actions to consolidate + * @param classInternalName the JVM internal name for the consolidated class (slash-separated) + * @param refs the identity map to populate with consolidated references + * @param generatedClass the producer for generated class build items + */ + private void consolidateBatch( + List actions, + String classInternalName, + Map refs, + BuildProducer generatedClass) { + if (actions.isEmpty()) { + return; + } + Map classes = LambdaTransliterator.generateConsolidatedClass(classInternalName, actions); + for (var entry : classes.entrySet()) { + // service action classes reference application types in their bytecode, + // so they must be application classes to share the same classloader + generatedClass.produce(new GeneratedClassBuildItem( + true, entry.getKey().replace('/', '.'), entry.getValue())); + } + String className = classInternalName.replace('/', '.'); + for (int i = 0; i < actions.size(); i++) { + refs.put(actions.get(i), new ConsolidatedRef(className, i)); + } + } + + /** + * Compute the set of service value keys that must survive between static-init and runtime-init. + * This includes keys consumed by runtime-init service dependencies, runtime-init RuntimeValueWrapper + * source keys, cross-phase recorder proxy keys, and CDI service-value bean keys. + */ + private Set computeCrossPhaseKeys( + List runtimeTasks, + List retentionItems, + Set staticInitServiceKeys) { + Set keys = new HashSet<>(); + // keys from retention build items (proxy keys + CDI serviceValue keys) + for (ServiceValueRetentionBuildItem item : retentionItems) { + keys.addAll(item.keys()); + } + // keys needed by cross-phase proxies: runtime-init service deps + // that match a static-init service key (value read from serviceValues map) + for (MainBytecodeRecorderBuildItem holder : runtimeTasks) { + TransliteratedAction ta = holder.getTransliteratedAction(); + if (ta instanceof TransliteratedAction.ActionService as) { + for (Dependency dep : as.dependencies()) { + if (dep.injected() && !dep.configDirect()) { + String key = LambdaTransliterator.serviceKey(dep.type(), dep.nameParts()); + if (staticInitServiceKeys.contains(key)) { + keys.add(key); + } + } + } + } + } + return keys; + } + + /** + * Compute the set of service value keys that must survive after startup completes. + * Only CDI service-value bean keys qualify (they are lazily accessed and self-draining). + */ + private static Set computePostStartupKeys(List retentionItems) { + Set keys = new HashSet<>(); + for (ServiceValueRetentionBuildItem item : retentionItems) { + if (item.neededAfterStartup()) { + keys.addAll(item.keys()); + } + } + return keys; + } + + /** + * Generate a {@code startupContext.retainServiceValues(Set.of(k1, k2, ...))} call. + */ + private static void generateRetainServiceValues(BlockCreator b0, Expr sc, Set keys) { + Expr array = b0.newArray(ConstantDescs.CD_String, keys.stream().map(Const::of).toArray(Expr[]::new)); + Expr keySet = b0.invokeStatic(MethodDesc.of(Set.class, "of", Set.class, Object[].class), array); + b0.invokeVirtual(MethodDesc.of(StartupContext.class, "retainServiceValues", void.class, Set.class), + sc, keySet); + } + + // ═══════════════════════════════════════════════ + // Service graph code generation + // + // TODO: migrate from Gizmo 1 (io.quarkus.gizmo) to Gizmo 2, + // which uses the ClassFile API (io.smallrye.classfile) + // internally. LambdaTransliterator already uses the ClassFile + // API directly; aligning here would reduce impedance mismatch + // with consolidated class generation. + // ═══════════════════════════════════════════════ + + /** + * Emit bytecode to construct a {@link ServiceGraph} from a + * {@link ServiceGraphBuilder.GraphPlan}. + *

+ * The generated code creates a {@code ServiceGraph}, constructs all + * {@code ServiceNode} instances in topological order (with sentinel + * nodes at the boundaries), wires dependencies via constructor + * parameters, and calls {@code graph.start()}. + *

+ * Each node's action is a {@link MethodHandle} pointing + * to either a deploy method in the consolidated class (for new services) + * or a generated wrapper method (for legacy recorders and sentinels). + * + * @param code the bytecode creator to emit instructions into + * @param plan the graph plan from {@link ServiceGraphBuilder} + * @param sc the startup context expression + * @param file the class creator for generating sentinel/wrapper methods + * @param consolidatedRefs map from transliterated actions to their consolidated class references + * @param generatedClass the producer for generated class build items + * @param substitutions legacy recorder substitutions + * @param recordableConstructorBuildItems legacy recorder constructable items + * @param loaders legacy recorder object loaders + * @param constants legacy recorder constant definitions + * @param phaseName "static" or "runtime", for generated method naming + * @param capabilities the capabilities build item + * @return the result handle for the constructed ServiceGraph + */ + private LocalVar emitServiceGraph( + BlockCreator code, + ServiceGraphBuilder.GraphPlan plan, + Expr sc, + ClassCreator file, + Map consolidatedRefs, + BuildProducer generatedClass, List substitutions, List recordableConstructorBuildItems, List loaders, List constants, - GeneratedClassGizmoAdaptor gizmoOutput, - ResultHandle startupContext, BytecodeCreator bytecodeCreator) { + String phaseName, + Capabilities capabilities) { - if ((recorder == null || recorder.isEmpty()) && fallbackGeneratedStartupTaskClassName == null) { - return; + LocalVar monitor = code.localVar("monitor", capabilities.isPresent(Capability.JFR) + ? Expr.staticField( + FieldDesc.of(ClassDesc.of(JfrServiceMonitor.class.getName()), "INSTANCE", JfrServiceMonitor.class)) + : Expr.staticField( + FieldDesc.of(ClassDesc.of(NoOpServiceMonitor.class.getName()), "INSTANCE", NoOpServiceMonitor.class))); + + if (!plan.hasNodes()) { + return code.localVar("emptyGraph", code.new_(CMD_ServiceGraph_ctor_StartupContext_ServiceMonitor, sc, monitor)); } - if ((recorder != null) && !recorder.isEmpty()) { - for (ObjectSubstitutionBuildItem sub : substitutions) { - sub.holder.registerTo(recorder); + List nodes = plan.nodes(); + + LocalVar graph = code.localVar("graph", code.new_(CMD_ServiceGraph_ctor_StartupContext_ServiceMonitor, sc, monitor)); + + LocalVar[] nodeHandles = new LocalVar[nodes.size()]; + + for (int i = 0; i < nodes.size(); i++) { + ServiceGraphBuilder.NodeDescriptor node = nodes.get(i); + int[] depIndices = node.dependencyIndices(); + + LocalVar mh = emitActionHandle(code, file, node, consolidatedRefs, + generatedClass, substitutions, recordableConstructorBuildItems, loaders, constants, + phaseName, i); + + LocalVar serviceNode; + if (depIndices.length == 0) { + serviceNode = code.localVar("node$" + i, code.new_(CMD_ServiceNode_ctor_String_MethodHandle_ServiceGraph_int, + Const.of(node.name()), mh, graph, Const.of(node.dependentCount()))); + } else if (depIndices.length == 1) { + serviceNode = code.localVar("node$" + i, + code.new_(CMD_ServiceNode_ctor_String_MethodHandle_ServiceGraph_int_ServiceNode, + Const.of(node.name()), mh, graph, Const.of(node.dependentCount()), + nodeHandles[depIndices[0]])); + } else if (depIndices.length == 2) { + serviceNode = code.localVar("node$" + i, + code.new_(CMD_ServiceNode_ctor_String_MethodHandle_ServiceGraph_int_ServiceNode_ServiceNode, + Const.of(node.name()), mh, graph, Const.of(node.dependentCount()), + nodeHandles[depIndices[0]], nodeHandles[depIndices[1]])); + } else { + Expr depArray = code.newArray(CD_ServiceNode, + Arrays.stream(depIndices).mapToObj(idx -> nodeHandles[idx]).toArray(Expr[]::new)); + Expr depList = code.invokeStatic(MethodDesc.of(List.class, "of", List.class, Object[].class), depArray); + serviceNode = code.localVar("node$" + i, + code.new_(CMD_ServiceNode_ctor_String_MethodHandle_ServiceGraph_int_List, + Const.of(node.name()), mh, graph, Const.of(node.dependentCount()), depList)); } - //noinspection removal - for (BytecodeRecorderObjectLoaderBuildItem item : loaders) { - //noinspection removal - recorder.registerObjectLoader(item.getObjectLoader()); + + nodeHandles[i] = serviceNode; + + if (i == plan.topIndex()) { + code.invokeVirtual(MD_ServiceGraph_setTop, graph, serviceNode); + } else if (i == plan.bottomIndex()) { + code.invokeVirtual(MD_ServiceGraph_setBottom, graph, serviceNode); } - for (var item : recordableConstructorBuildItems) { - recorder.markClassAsConstructorRecordable(item.getClazz()); + } + + code.invokeVirtual(MD_ServiceGraph_start, graph); + + return graph; + } + + /** + * Emit bytecode to create the {@link MethodHandle} for a node's action. + *

+ * For sentinel nodes, generates a static method in the Application class + * and returns a handle to it. For service nodes, returns a handle to the + * deploy method in the consolidated class. For legacy recorder nodes, + * generates a wrapper method and returns a handle to it. + * + * @return a result handle for the MethodHandle constant + */ + private LocalVar emitActionHandle( + BlockCreator code, + ClassCreator file, + ServiceGraphBuilder.NodeDescriptor node, + Map consolidatedRefs, + BuildProducer generatedClass, + List substitutions, + List recordableConstructorBuildItems, + List loaders, + List constants, + String phaseName, + int nodeIndex) { + + String methodName; + String targetClass; + + switch (node.kind()) { + case SENTINEL -> { + methodName = phaseName + "$sentinel$" + nodeIndex; + targetClass = Application.APP_CLASS_NAME; + boolean isTop = (nodeIndex == 0); + generateSentinelMethod(file, methodName, isTop); + } + case LEGACY_RECORDER -> { + methodName = phaseName + "$legacy$" + nodeIndex; + targetClass = Application.APP_CLASS_NAME; + boolean staticInit = phaseName.equals("static"); + generateLegacyWrapperMethod(file, methodName, node.recorders(), + staticInit, generatedClass, substitutions, + recordableConstructorBuildItems, loaders, constants); + } + case SERVICE, ALIAS, RV_WRAPPER -> { + ConsolidatedRef ref = consolidatedRefs.get(node.action()); + methodName = "deploy$" + ref.deployIndex(); + targetClass = ref.className(); } - for (BytecodeRecorderConstantDefinitionBuildItem constant : constants) { - constant.register(recorder); + case CROSS_PHASE_PROXY -> { + methodName = phaseName + "$crossphase$" + nodeIndex; + targetClass = Application.APP_CLASS_NAME; + generateCrossPhaseProxyMethod(file, methodName, node.name()); } - recorder.writeBytecode(gizmoOutput); + default -> throw new IllegalStateException("Unknown node kind: " + node.kind()); } - ResultHandle dup = bytecodeCreator - .newInstance(ofConstructor(recorder != null ? recorder.getClassName() : fallbackGeneratedStartupTaskClassName)); - bytecodeCreator.invokeInterfaceMethod(ofMethod(StartupTask.class, "deploy", void.class, StartupContext.class), dup, - startupContext); - bytecodeCreator.invokeStaticMethod(PRINT_STEP_TIME_METHOD, startupContext); + return emitFindStatic(code, targetClass, methodName); + } + + /** + * Emit bytecode to look up a static method handle with signature {@code (ServiceNode) → void}. + * + * @param code the bytecode creator + * @param className the fully-qualified class name containing the method + * @param methodName the method name + * @return a result handle for the MethodHandle + */ + private static LocalVar emitFindStatic(BlockCreator code, String className, String methodName) { + Expr lookup = code.invokeStatic(MethodDesc.of(MethodHandles.class, "lookup", MethodHandles.Lookup.class)); + Expr targetClass = Const.of(ClassDesc.of(className)); + Expr methodType = code.invokeStatic( + MethodDesc.of(MethodType.class, "methodType", MethodType.class, Class.class, Class.class), + Const.of(ConstantDescs.CD_void), + Const.of(CD_ServiceNode)); + Expr mh = code.invokeVirtual( + MethodDesc.of(MethodHandles.Lookup.class, "findStatic", MethodHandle.class, Class.class, String.class, + MethodType.class), + lookup, targetClass, Const.of(methodName), methodType); + return code.localVar("mh$" + className.replace('.', '_').replace('/', '_') + "$" + methodName, mh); + } + + /** + * Generate a sentinel static method in the Application class. + *

+ * Top sentinel: registers a stop handler that signals stop-done, then completes. + * Bottom sentinel: signals start-done, then completes. + * + * @param classCreator the Application class creator + * @param methodName the method name to generate + * @param isTop {@code true} for top sentinel, {@code false} for bottom + */ + private static void generateSentinelMethod(ClassCreator classCreator, String methodName, boolean isTop) { + classCreator.staticMethod(methodName, MethodTypeDesc.of(ConstantDescs.CD_void, CD_ServiceNode), mc -> { + mc.private_(); + ParamVar node = mc.parameter("node"); + mc.body(b0 -> { + if (isTop) { + Expr graph = b0.invokeVirtual(MD_ServiceNode_graph, node); + Expr stopRunnable = b0.lambda(Runnable.class, lc -> { + Var capturedGraph = lc.capture("graph", graph); + lc.body(b1 -> { + b1.invokeVirtual(MD_ServiceGraph_signalStopDone, capturedGraph); + b1.return_(); + }); + }); + b0.invokeInterface(MethodDesc.of(StartContext.class, "onStop", void.class, Runnable.class), + node, stopRunnable); + } else { + Expr graph = b0.invokeVirtual(MD_ServiceNode_graph, node); + b0.invokeVirtual(MD_ServiceGraph_signalStartDone, graph); + } + b0.invokeVirtual(MD_ServiceNode_startComplete, node); + b0.return_(); + }); + }); + } + + /** + * Generate a legacy recorder wrapper method in the Application class. + *

+ * The generated method: + *

    + *
  1. Optionally creates a {@link NodeShutdownContext} and registers + * it as the node's stop handler
  2. + *
  3. Gets the StartupContext from the graph
  4. + *
  5. Instantiates and invokes each legacy recorder's StartupTask
  6. + *
  7. Signals void completion
  8. + *
+ * + * @param classCreator the Application class creator + * @param methodName the method name to generate + * @param recorders the legacy bytecode recorders for this step + * @param staticInit whether this targets the static-init phase + * @param generatedClass the producer for generated class build items + * @param substitutions recorder substitutions + * @param recordableConstructorBuildItems recorder constructable items + * @param loaders recorder object loaders + * @param constants recorder constant definitions + */ + private void generateLegacyWrapperMethod( + ClassCreator classCreator, + String methodName, + List recorders, + boolean staticInit, + BuildProducer generatedClass, + List substitutions, + List recordableConstructorBuildItems, + List loaders, + List constants) { + + classCreator.staticMethod(methodName, MethodTypeDesc.of(ConstantDescs.CD_void, CD_ServiceNode), mc -> { + mc.private_(); + ParamVar node = mc.parameter("node"); + mc.body(b0 -> { + LocalVar graph = b0.localVar("graph", b0.invokeVirtual(MD_ServiceNode_graph, node)); + LocalVar startupContext = b0.localVar("startupContext", + b0.invokeVirtual(MethodDesc.of(ServiceGraph.class, "startupContext", StartupContext.class), graph)); + + LocalVar shutdownContext = b0.localVar("shutdownContext", + b0.new_(CMD_NodeShutdownContext_ctor_boolean, Const.of(staticInit))); + b0.invokeInterface(MethodDesc.of(StartContext.class, "onStop", void.class, Runnable.class), + node, shutdownContext); + + b0.invokeVirtual(MD_StartupContext_putValue, startupContext, Const.of(ShutdownContext.class.getName()), + shutdownContext); + + for (BytecodeRecorderImpl recorder : recorders) { + if (recorder == null || recorder.isEmpty()) { + continue; + } + for (ObjectSubstitutionBuildItem sub : substitutions) { + sub.holder.registerTo(recorder); + } + //noinspection removal + for (BytecodeRecorderObjectLoaderBuildItem item : loaders) { + //noinspection removal + recorder.registerObjectLoader(item.getObjectLoader()); + } + for (var item : recordableConstructorBuildItems) { + recorder.markClassAsConstructorRecordable(item.getClazz()); + } + for (BytecodeRecorderConstantDefinitionBuildItem constant : constants) { + constant.register(recorder); + } + // Adapt Gizmo 2 ClassOutput to Gizmo 1 ClassOutput + GeneratedClassGizmoAdaptor adaptor = new GeneratedClassGizmoAdaptor(generatedClass, true); + recorder.writeBytecode(adaptor::write); + + LocalVar task = b0.localVar("task", b0.new_(ConstructorDesc.of(ClassDesc.of(recorder.getClassName())))); + b0.invokeInterface( + MethodDesc.of(StartupTask.class, "deploy", void.class, StartupContext.class), + task, startupContext); + } + + b0.invokeVirtual(MD_ServiceNode_startComplete, node); + b0.return_(); + }); + }); + } + + /** + * Generate a cross-phase proxy method that reads a static-init service value + * from the serviceValues map and signals typed completion. + *

+ * This bridges a static-init service value into the runtime-init graph, + * allowing runtime services to {@code require()} static-init services. + * + * @param classCreator the Application class creator + * @param methodName the method name to generate + * @param serviceKey the service key to read from the serviceValues map + */ + private static void generateCrossPhaseProxyMethod(ClassCreator classCreator, String methodName, String serviceKey) { + classCreator.staticMethod(methodName, MethodTypeDesc.of(ConstantDescs.CD_void, CD_ServiceNode), mc -> { + mc.private_(); + ParamVar node = mc.parameter("node"); + mc.body(b0 -> { + LocalVar graph = b0.localVar("graph", b0.invokeVirtual(MD_ServiceNode_graph, node)); + LocalVar startupContext = b0.localVar("startupContext", + b0.invokeVirtual(MethodDesc.of(ServiceGraph.class, "startupContext", StartupContext.class), graph)); + + LocalVar value = b0.localVar("value", + b0.invokeVirtual(MD_StartupContext_getServiceValue, startupContext, Const.of(serviceKey))); + + b0.ifElse(b0.eq(value, Const.ofNull(Object.class)), + b1 -> b1.invokeVirtual(MD_ServiceNode_startComplete, node), + b1 -> b1.invokeVirtual(MD_ServiceNode_startComplete_Object, node, value)); + b0.return_(); + }); + }); } /** @@ -599,7 +1268,6 @@ private Result doApply(String originalMainClassName, ClassVisitor classVisitor, ClassTransformer transformer, ClassInfo currentClassInfo) { boolean isTopLevel = currentClassInfo.name().toString().equals(originalMainClassName); - boolean allowStatic = isTopLevel; boolean hasStaticWithArgs = false; boolean hasStaticWithoutArgs = false; boolean hasInstanceWithArgs = false; @@ -610,7 +1278,7 @@ private Result doApply(String originalMainClassName, if (withArgs != null) { if (Modifier.isStatic(withArgs.flags())) { - if (allowStatic) { + if (isTopLevel) { hasStaticWithArgs = true; } } else { @@ -619,7 +1287,7 @@ private Result doApply(String originalMainClassName, } if (withoutArgs != null) { if (Modifier.isStatic(withoutArgs.flags())) { - if (allowStatic) { + if (isTopLevel) { hasStaticWithoutArgs = true; } } else { @@ -648,7 +1316,6 @@ private Result doApply(String originalMainClassName, if (Modifier.isPrivate(withoutArgs.flags())) { // the launch protocol says we can't use this one result = Result.invalid(); - ; } else { // we create a public static void(String[] args) method and all the target from it MethodCreator standardMain = createStandardMain(transformer); @@ -659,17 +1326,19 @@ private Result doApply(String originalMainClassName, } else if (hasInstanceWithArgs) { if (Modifier.isPrivate(withArgs.flags())) { // the launch protocol says we can't use this one, but we still need to rename it to avoid conflicts with the potentially generated main - transformer.modifyMethod(MethodDescriptor.of(withArgs)).rename("$originalMain$"); + transformer.modifyMethod(MethodDescriptor.of(withArgs)).rename("$$main$$"); result = Result.invalid(transformer.applyTo(classVisitor)); } else { // here we need to construct an instance and call the instance method with the args parameter MethodCreator standardMain = createStandardMain(transformer); - ResultHandle instanceHandle = standardMain.newInstance(ofConstructor(originalMainClassName)); + ResultHandle instanceHandle = standardMain + .newInstance(MethodDescriptor.ofConstructor(originalMainClassName)); ResultHandle argsParamHandle = standardMain.getMethodParam(0); if (isTopLevel) { // we need to rename the method in order to avoid having two main methods with the same name standardMain.invokeVirtualMethod( - ofMethod(originalMainClassName, "$$main$$", void.class, String[].class), + MethodDescriptor.ofMethod(originalMainClassName, "$$main$$", void.class, + String[].class), instanceHandle, argsParamHandle); transformer.modifyMethod(MethodDescriptor.of(withArgs)).rename("$$main$$"); @@ -687,7 +1356,8 @@ private Result doApply(String originalMainClassName, } else { // here we need to construct an instance and call the instance method without any parameters MethodCreator standardMain = createStandardMain(transformer); - ResultHandle instanceHandle = standardMain.newInstance(ofConstructor(originalMainClassName)); + ResultHandle instanceHandle = standardMain + .newInstance(MethodDescriptor.ofConstructor(originalMainClassName)); standardMain.invokeVirtualMethod(MethodDescriptor.of(withoutArgs), instanceHandle); standardMain.returnValue(null); result = Result.valid(transformer.applyTo(classVisitor)); diff --git a/core/deployment/src/main/java/io/quarkus/deployment/steps/NativeImageConfigBuildStep.java b/core/deployment/src/main/java/io/quarkus/deployment/steps/NativeImageConfigBuildStep.java index 4687af8ef8ef1..c133fb532f21c 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/steps/NativeImageConfigBuildStep.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/steps/NativeImageConfigBuildStep.java @@ -6,10 +6,10 @@ import org.jboss.logging.Logger; +import io.quarkus.core.Phase; +import io.quarkus.core.deployment.service.ServiceRegistrar; import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; -import io.quarkus.deployment.annotations.ExecutionTime; -import io.quarkus.deployment.annotations.Record; import io.quarkus.deployment.builditem.ExtensionSslNativeSupportBuildItem; import io.quarkus.deployment.builditem.NativeImageEnableAllCharsetsBuildItem; import io.quarkus.deployment.builditem.SslNativeConfigBuildItem; @@ -20,7 +20,7 @@ import io.quarkus.deployment.builditem.nativeimage.NativeImageSystemPropertyBuildItem; import io.quarkus.deployment.builditem.nativeimage.RuntimeInitializedClassBuildItem; import io.quarkus.deployment.pkg.NativeConfig; -import io.quarkus.runtime.ssl.SslContextConfigurationRecorder; +import io.quarkus.runtime.ssl.SslContextConfiguration; //TODO: this should go away, once we decide on which one of the API's we want class NativeImageConfigBuildStep { @@ -28,9 +28,8 @@ class NativeImageConfigBuildStep { private static final Logger log = Logger.getLogger(NativeImageConfigBuildStep.class); @BuildStep - @Record(ExecutionTime.STATIC_INIT) - void build(NativeConfig nativeConfig, - SslContextConfigurationRecorder sslContextConfigurationRecorder, + void build(ServiceRegistrar reg, + NativeConfig nativeConfig, List nativeImageConfigBuildItems, SslNativeConfigBuildItem sslNativeConfig, List nativeImageEnableAllCharsetsBuildItems, @@ -57,7 +56,10 @@ void build(NativeConfig nativeConfig, // For now, we enable SSL native if it hasn't been explicitly disabled // it's probably overly conservative but it's a first step in the right direction - sslContextConfigurationRecorder.setSslNativeEnabled(!sslNativeConfig.isExplicitlyDisabled()); + boolean enableSslNative = !sslNativeConfig.isExplicitlyDisabled(); + reg.forService("io.quarkus.ssl.native-config") + .atPhase(Phase.STATIC_INIT) + .onStart(ctx -> SslContextConfiguration.setSslNativeEnabled(enableSslNative)); Boolean sslNativeEnabled = isSslNativeEnabled(sslNativeConfig, extensionSslNativeSupport); nativeImage.produce(new NativeImageSystemPropertyBuildItem("quarkus.ssl.native", sslNativeEnabled.toString())); diff --git a/core/deployment/src/main/java/io/quarkus/deployment/steps/PhaseServicesProcessor.java b/core/deployment/src/main/java/io/quarkus/deployment/steps/PhaseServicesProcessor.java new file mode 100644 index 0000000000000..e454274e95b65 --- /dev/null +++ b/core/deployment/src/main/java/io/quarkus/deployment/steps/PhaseServicesProcessor.java @@ -0,0 +1,29 @@ +package io.quarkus.deployment.steps; + +import io.quarkus.core.Phase; +import io.quarkus.core.deployment.service.ServiceRegistrar; +import io.quarkus.deployment.annotations.BuildStep; + +/** + * Automatically registers synthetic void-like typed services for each Phase constant. + * These serve as completion markers for their respective phases. + */ +public class PhaseServicesProcessor { + + @BuildStep + public void registerPhaseServices(ServiceRegistrar reg) { + for (Phase phase : Phase.values()) { + var builder = reg.forService(Phase.class, phase.name()) + .atPhase(phase); + + // To chain the phases, each phase (except STATIC_INIT) depends on the previous phase + if (phase != Phase.STATIC_INIT) { + Phase prevPhase = Phase.values()[phase.ordinal() - 1]; + builder.after(prevPhase); + } + + // Register a service that returns the phase enum constant + builder.onStart(ctx -> phase); + } + } +} diff --git a/core/deployment/src/main/java/io/quarkus/deployment/steps/ServiceDependencyValidator.java b/core/deployment/src/main/java/io/quarkus/deployment/steps/ServiceDependencyValidator.java new file mode 100644 index 0000000000000..6e0f351459b40 --- /dev/null +++ b/core/deployment/src/main/java/io/quarkus/deployment/steps/ServiceDependencyValidator.java @@ -0,0 +1,368 @@ +package io.quarkus.deployment.steps; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import io.quarkus.core.Phase; +import io.quarkus.core.deployment.service.impl.Dependency; +import io.quarkus.core.deployment.service.impl.ServiceMetadataBuildItem; +import io.quarkus.core.deployment.service.impl.StaticServiceMetadataBuildItem; + +/** + * Validates the service dependency graphs built from {@link ServiceMetadataBuildItem} + * and {@link StaticServiceMetadataBuildItem} instances. + *

+ * This validator is called from {@link io.quarkus.deployment.QuarkusAugmentor#run() QuarkusAugmentor.run()} after + * all build steps have completed and the metadata items have been collected. + *

+ * Validation is layered: + *

    + *
  1. The static-init graph is validated independently (no pre-existing providers).
  2. + *
  3. The runtime graph is validated with static-init outputs as pre-existing providers, + * allowing runtime services to transparently depend on static-init outputs.
  4. + *
  5. Cross-phase duplicate detection rejects any service key registered in both phases.
  6. + *
+ * Within each phase, the following checks are performed: + *
    + *
  • No duplicate services (same type + name registered more than once)
  • + *
  • All declared dependencies are satisfied (by same-phase providers or pre-existing providers)
  • + *
  • No cycles exist in the dependency graph
  • + *
+ */ +public final class ServiceDependencyValidator { + + private ServiceDependencyValidator() { + } + + /** + * Uniform accessor for service metadata regardless of phase. + */ + private interface ServiceMetadata { + /** + * Get the service key (type + name identity). + * + * @return the service key + */ + String serviceKey(); + + /** + * Get the declared dependencies. + * + * @return the dependency list + */ + List dependencies(); + + /** + * Get the build step name (for diagnostics). + * + * @return the build step name + */ + String buildStepName(); + + /** + * Get the runtime phase, or {@code null} for static-init services. + * + * @return the runtime phase, or {@code null} + */ + Phase phase(); + } + + /** + * Validate the service dependency graphs. + *

+ * Performs layered validation: static-init first (independently), then runtime + * (with static-init outputs as pre-existing providers), then cross-phase duplicate check. + * + * @param staticItems the static-init service metadata items + * @param runtimeItems the runtime service metadata items + * @throws IllegalStateException if validation fails + */ + public static void validate( + List staticItems, + List runtimeItems) { + if (staticItems.isEmpty() && runtimeItems.isEmpty()) { + return; + } + + List errors = new ArrayList<>(); + + // adapt static-init items to uniform accessor + List staticMeta = new ArrayList<>(staticItems.size()); + for (StaticServiceMetadataBuildItem item : staticItems) { + staticMeta.add(new ServiceMetadata() { + public String serviceKey() { + return item.serviceKey(); + } + + public List dependencies() { + return item.dependencies(); + } + + public String buildStepName() { + return item.buildStepName(); + } + + public Phase phase() { + return null; + } + }); + } + + // adapt runtime items to uniform accessor + List runtimeMeta = new ArrayList<>(runtimeItems.size()); + for (ServiceMetadataBuildItem item : runtimeItems) { + runtimeMeta.add(new ServiceMetadata() { + public String serviceKey() { + return item.serviceKey(); + } + + public List dependencies() { + return item.dependencies(); + } + + public String buildStepName() { + return item.buildStepName(); + } + + public Phase phase() { + return item.phase(); + } + }); + } + + // phase 1: validate static-init graph independently + Set staticProviderKeys = validateGraph(staticMeta, Set.of(), "static-init", errors); + + // phase 2: validate runtime graph with static-init outputs as pre-existing providers + Set runtimeProviderKeys = validateGraph(runtimeMeta, staticProviderKeys, "runtime", errors); + + // phase 3: cross-phase duplicate detection + for (String key : runtimeProviderKeys) { + if (staticProviderKeys.contains(key)) { + errors.add("Cross-phase duplicate: service '" + key + + "' is registered in both the static-init and runtime phases"); + } + } + + if (!errors.isEmpty()) { + StringBuilder sb = new StringBuilder("Service dependency validation failed:"); + for (String error : errors) { + sb.append("\n - ").append(error); + } + throw new IllegalStateException(sb.toString()); + } + } + + /** + * Validate a single phase's dependency graph. + *

+ * Checks for duplicate registrations within the phase, verifies all dependencies + * are satisfied (by same-phase providers or pre-existing providers from an earlier phase), + * and detects cycles within the phase. + * + * @param items the service metadata items for this phase + * @param preExistingProviders service keys from earlier phases that are available as providers + * @param phaseName the phase name (for error messages) + * @param errors the error accumulator + * @return the set of provider keys registered in this phase (excluding pre-existing) + */ + private static Set validateGraph( + List items, + Set preExistingProviders, + String phaseName, + List errors) { + // build the set of providers for this phase, checking for duplicates + Map providers = new HashMap<>(); + Set providerKeys = new HashSet<>(); + + for (ServiceMetadata item : items) { + String key = item.serviceKey(); + providerKeys.add(key); + ServiceMetadata existing = providers.put(key, item); + if (existing != null) { + errors.add("Duplicate " + phaseName + " service registration for '" + key + + "': registered by build step '" + existing.buildStepName() + + "' and also by '" + item.buildStepName() + "'"); + } + } + + // check that all non-optional dependencies are satisfied (same-phase or pre-existing) + for (ServiceMetadata item : items) { + for (Dependency dep : item.dependencies()) { + if (dep.configDirect()) { + continue; // resolved directly from SmallRye Config, not from the service graph + } + String depKey = dep.key(); + if (!dep.optional() && !providers.containsKey(depKey) && !preExistingProviders.contains(depKey)) { + errors.add("Unsatisfied dependency: " + phaseName + " service '" + + item.serviceKey() + "' (build step '" + item.buildStepName() + + "') depends on '" + depKey + "' which is not registered"); + } + } + } + + // phase ordering validation: a service may only depend on services in the same or earlier phase + for (ServiceMetadata item : items) { + Phase itemPhase = item.phase(); + if (itemPhase == null) { + continue; // static-init services have no phase + } + for (Dependency dep : item.dependencies()) { + if (dep.configDirect()) { + continue; + } + String depKey; + if (dep.consumeAll()) { + // check each matched provider individually + String prefix = dep.keyPrefix(); + for (Map.Entry entry : providers.entrySet()) { + if (entry.getKey().startsWith(prefix)) { + Phase depPhase = entry.getValue().phase(); + if (depPhase != null && depPhase.ordinal() > itemPhase.ordinal()) { + errors.add("Phase ordering violation: " + phaseName + " service '" + + item.serviceKey() + "' (phase " + itemPhase + + ", build step '" + item.buildStepName() + + "') depends on '" + entry.getKey() + + "' (phase " + depPhase + ")"); + } + } + } + continue; + } + depKey = dep.key(); + ServiceMetadata depMeta = providers.get(depKey); + if (depMeta != null) { + Phase depPhase = depMeta.phase(); + if (depPhase != null && depPhase.ordinal() > itemPhase.ordinal()) { + errors.add("Phase ordering violation: " + phaseName + " service '" + + item.serviceKey() + "' (phase " + itemPhase + + ", build step '" + item.buildStepName() + + "') depends on '" + depKey + + "' (phase " + depPhase + ")"); + } + } + } + } + + // cycle detection (same-phase edges only; cross-phase deps are acyclic by definition) + Map> adjacency = new HashMap<>(); + for (ServiceMetadata item : items) { + List deps = new ArrayList<>(item.dependencies().size()); + for (Dependency dep : item.dependencies()) { + if (dep.configDirect()) { + continue; // not a service graph edge + } + if (dep.consumeAll()) { + // consumeAll: add edges to all same-phase providers matching the type prefix + String prefix = dep.keyPrefix(); + for (String providerKey : providers.keySet()) { + if (providerKey.startsWith(prefix)) { + deps.add(providerKey); + } + } + } else { + String depKey = dep.key(); + // only include same-phase edges for cycle detection + if (providers.containsKey(depKey)) { + deps.add(depKey); + } + } + } + adjacency.put(item.serviceKey(), deps); + } + + Set visited = new HashSet<>(); + Set inStack = new HashSet<>(); + for (String node : adjacency.keySet()) { + if (!visited.contains(node)) { + String cycle = detectCycle(node, adjacency, visited, inStack); + if (cycle != null) { + errors.add("Dependency cycle detected in " + phaseName + " graph: " + cycle); + } + } + } + + return providerKeys; + } + + /** + * Detect a cycle reachable from the given start node using iterative DFS. + * Returns a description of the cycle if found, or {@code null} if no cycle exists. + * + * @param start the starting node key + * @param adjacency the adjacency map + * @param visited the set of fully-explored nodes + * @param inStack the set of nodes currently on the DFS stack + * @return a cycle description, or {@code null} if no cycle was found + */ + private static String detectCycle( + String start, + Map> adjacency, + Set visited, + Set inStack) { + // iterative DFS with explicit stack + Deque stack = new ArrayDeque<>(); + stack.push(new StackFrame(start, 0)); + inStack.add(start); + + while (!stack.isEmpty()) { + StackFrame frame = stack.peek(); + List deps = adjacency.getOrDefault(frame.node, List.of()); + if (frame.index < deps.size()) { + String dep = deps.get(frame.index); + frame.index++; + if (inStack.contains(dep)) { + // found a cycle; reconstruct the path + StringBuilder path = new StringBuilder(dep); + boolean inCycle = false; + for (StackFrame f : stack) { + if (f.node.equals(dep)) { + inCycle = true; + } + if (inCycle) { + path.append(" -> ").append(f.node); + } + } + path.append(" -> ").append(dep); + return path.toString(); + } + if (!visited.contains(dep)) { + inStack.add(dep); + stack.push(new StackFrame(dep, 0)); + } + } else { + visited.add(frame.node); + inStack.remove(frame.node); + stack.pop(); + } + } + return null; + } + + /** + * A DFS stack frame tracking the current node and the index of the next dependency to visit. + */ + private static final class StackFrame { + /** The service key of this node. */ + final String node; + /** The index into the adjacency list for the next edge to explore. */ + int index; + + /** + * Construct a new instance. + * + * @param node the service key + * @param index the initial edge index + */ + StackFrame(String node, int index) { + this.node = node; + this.index = index; + } + } +} diff --git a/core/deployment/src/main/java/io/quarkus/deployment/steps/ServiceGraphBuilder.java b/core/deployment/src/main/java/io/quarkus/deployment/steps/ServiceGraphBuilder.java new file mode 100644 index 0000000000000..1aa9fcab21268 --- /dev/null +++ b/core/deployment/src/main/java/io/quarkus/deployment/steps/ServiceGraphBuilder.java @@ -0,0 +1,615 @@ +package io.quarkus.deployment.steps; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Deque; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import io.quarkus.core.deployment.service.impl.TransliteratedAction; +import io.quarkus.deployment.builditem.MainBytecodeRecorderBuildItem; +import io.quarkus.deployment.builditem.StaticBytecodeRecorderBuildItem; +import io.quarkus.deployment.recording.BytecodeRecorderImpl; + +/** + * Builds a topologically-sorted graph of service nodes from build items + * and the build step dependency graph. + *

+ * This is a build-time helper used by {@link MainClassBuildStep} to compute + * the runtime service graph structure. The output is a {@link GraphPlan} + * containing {@link NodeDescriptor}s in topological order, ready for + * Gizmo code generation. + *

+ * The algorithm: + *

    + *
  1. Group build items by producing step ID
  2. + *
  3. Create logical nodes: legacy recorder chunks (one per step) + * and individual service nodes (one per TransliteratedAction)
  4. + *
  5. Resolve edges from the step dependency graph, skipping + * passthrough steps (those with no recorder/service output)
  6. + *
  7. Add explicit service dependency edges from {@code .after()} + * declarations
  8. + *
  9. Topological sort
  10. + *
  11. Compute dependent counts, identify roots and leaves
  12. + *
+ */ +final class ServiceGraphBuilder { + + private ServiceGraphBuilder() { + } + + // ═══════════════════════════════════════════════ + // Public API + // ═══════════════════════════════════════════════ + + /** + * Build a graph plan for the static-init phase. + * + * @param items the static-init build items + * @param stepGraph the step dependency graph (stepId → dependency stepIds) + * @return the graph plan + */ + static GraphPlan buildStaticInit( + List items, + Map> stepGraph, + Map> buildItemProducers) { + List entries = new ArrayList<>(items.size()); + for (StaticBytecodeRecorderBuildItem item : items) { + entries.add(new ItemEntry(item.getStepId(), item.getBytecodeRecorder(), item.getTransliteratedAction())); + } + return build(entries, stepGraph, Set.of(), buildItemProducers); + } + + /** + * Build a graph plan for the runtime-init phase. + * + * @param items the runtime-init build items + * @param stepGraph the step dependency graph (stepId → dependency stepIds) + * @param staticInitServiceKeys service keys from the static-init phase (for cross-phase proxies) + * @param buildItemProducers build item class name → producing step IDs + * @return the graph plan + */ + static GraphPlan buildRuntime( + List items, + Map> stepGraph, + Set staticInitServiceKeys, + Map> buildItemProducers) { + List entries = new ArrayList<>(items.size()); + for (MainBytecodeRecorderBuildItem item : items) { + entries.add(new ItemEntry(item.getStepId(), item.getBytecodeRecorder(), item.getTransliteratedAction())); + } + return build(entries, stepGraph, staticInitServiceKeys, buildItemProducers); + } + + /** + * Build a graph plan for the runtime-init phase (no cross-phase keys or build item producers). + * Used only by tests. + */ + static GraphPlan buildRuntime( + List items, + Map> stepGraph) { + return buildRuntime(items, stepGraph, Set.of(), Map.of()); + } + + // ═══════════════════════════════════════════════ + // Internal: unified entry for both phases + // ═══════════════════════════════════════════════ + + /** + * A phase-neutral build item entry. + * + * @param stepId the producing step's ID + * @param recorder the legacy recorder, or {@code null} + * @param action the transliterated action, or {@code null} + */ + private record ItemEntry(String stepId, BytecodeRecorderImpl recorder, TransliteratedAction action) { + } + + // ═══════════════════════════════════════════════ + // Core algorithm + // ═══════════════════════════════════════════════ + + private static GraphPlan build(List entries, Map> stepGraph, + Set crossPhaseServiceKeys, Map> buildItemProducers) { + // Step 1: group by step ID, preserving order within each step + Map> byStep = new LinkedHashMap<>(); + for (ItemEntry entry : entries) { + byStep.computeIfAbsent(entry.stepId(), k -> new ArrayList<>()).add(entry); + } + + // Step 2: create logical nodes + List nodes = new ArrayList<>(); + Map> stepToNodes = new HashMap<>(); + Map serviceKeyToNode = new HashMap<>(); + + for (var mapEntry : byStep.entrySet()) { + String stepId = mapEntry.getKey(); + List stepEntries = mapEntry.getValue(); + + // separate legacy recorders from transliterated actions + List recorders = new ArrayList<>(); + List actions = new ArrayList<>(); + for (ItemEntry entry : stepEntries) { + if (entry.recorder() != null) { + recorders.add(entry.recorder()); + } + if (entry.action() != null) { + actions.add(entry.action()); + } + } + + MutableNode recorderNode = null; + if (!recorders.isEmpty()) { + recorderNode = new MutableNode(stepId + "/recorder", stepId, + NodeKind.LEGACY_RECORDER, recorders, null); + nodes.add(recorderNode); + stepToNodes.computeIfAbsent(stepId, k -> new ArrayList<>()).add(recorderNode); + } + + for (TransliteratedAction action : actions) { + MutableNode serviceNode = new MutableNode(action.serviceKey(), stepId, + kindForAction(action), null, action); + // no implicit intra-step ordering: services are independent from + // recorders in the same step. Use afterBuildItem(), after(), or + // require() to declare explicit ordering dependencies. + nodes.add(serviceNode); + stepToNodes.computeIfAbsent(stepId, k -> new ArrayList<>()).add(serviceNode); + serviceKeyToNode.put(action.serviceKey(), serviceNode); + } + } + + if (nodes.isEmpty()) { + return GraphPlan.EMPTY; + } + + // Step 3: resolve step-graph edges (skip passthrough steps) + // Only nodes that interact with the StartupContext maps use step-graph + // ordering: LEGACY_RECORDER (reads/writes values map) and ALIAS (reads + // from values map via recorder proxy key). Pure service nodes get their + // ordering from declared service dependencies (require/after/before) + // and intra-step ordering (service depends on recorder in same step). + // This avoids pulling in irrelevant build-time ordering edges for pure + // service steps (which would cause spurious cycles with before() edges). + Set stepsWithNodes = stepToNodes.keySet(); + Map> passthroughCache = new HashMap<>(); + + for (MutableNode node : nodes) { + if (node.kind != NodeKind.LEGACY_RECORDER && node.kind != NodeKind.ALIAS) { + continue; + } + Set resolved = resolveStepDeps( + node.stepId, stepGraph, stepToNodes, stepsWithNodes, passthroughCache); + node.stepDeps.addAll(resolved); + } + + // Step 4: add explicit service dependency edges + // These go into actionDeps (in declaration order) for stable indexed access. + // Config-direct deps do not produce graph edges (resolved from SmallRye Config). + // ConsumeAll deps expand to multiple consecutive actionDeps (one per matching + // service, sorted by name) for indexed multi-value access. + var nodeIter = nodes.listIterator(); + while (nodeIter.hasNext()) { + MutableNode node = nodeIter.next(); + if (node.action instanceof TransliteratedAction.ActionService as) { + for (var dep : as.dependencies()) { + if (dep.configDirect()) { + continue; + } + if (dep.consumeAll()) { + // expand consumeAll: find all matching nodes by type prefix, + // sorted by service name, and add as consecutive actionDeps + String prefix = dep.keyPrefix(); + List matches = new ArrayList<>(); + for (var entry : serviceKeyToNode.entrySet()) { + String key = entry.getKey(); + if (key.startsWith(prefix) && key.length() > prefix.length()) { + matches.add(entry.getValue()); + } + } + // sort by service name (the part after the prefix) + matches.sort(Comparator.comparing(m -> m.name.substring(prefix.length()))); + node.actionDeps.addAll(matches); + continue; + } + MutableNode target = serviceKeyToNode.get(dep.key()); + if (target != null) { + if (dep.injected()) { + // injected deps go in actionDeps (indexed access in deploy method) + node.actionDeps.add(target); + } else { + // ordering-only deps (from after()) go in stepDeps + node.stepDeps.add(target); + } + } else if (dep.injected() && dep.optional()) { + // optional injected dep with no matching service: + // create an absent placeholder node that completes with + // null value, maintaining index alignment with + // LambdaTransliterator's graphDepIndex + MutableNode absent = new MutableNode( + "<>", node.stepId, + NodeKind.SENTINEL, null, null); + nodeIter.add(absent); + node.actionDeps.add(absent); + } else if (dep.injected()) { + if (crossPhaseServiceKeys.contains(dep.key())) { + // cross-phase dependency: the service was produced in static-init + // and its value is available in the serviceValues map; create a + // synthetic proxy node that reads from the map at runtime + MutableNode proxy = new MutableNode( + dep.key(), node.stepId, + NodeKind.CROSS_PHASE_PROXY, null, null); + nodeIter.add(proxy); + serviceKeyToNode.put(dep.key(), proxy); + node.actionDeps.add(proxy); + } else { + throw new IllegalStateException( + "Service '" + node.name + "' requires '" + dep.key() + + "' but no service with that key exists in the " + + "graph. Check that the producing extension is present " + + "and the service key matches."); + } + } + } + } else if (node.action instanceof TransliteratedAction.RuntimeValueWrapper rvw) { + // RV_WRAPPER reads the source value via indexed dependency access (dep 0) + MutableNode source = serviceKeyToNode.get(rvw.sourceServiceKey()); + if (source != null) { + node.actionDeps.add(source); + } + } + } + + // Step 4b: add reverse dependency edges (.before() declarations) + // A before(X) declaration means X depends on this service (reverse of after). + // This service starts before X and stops after X. + for (MutableNode node : nodes) { + if (node.action instanceof TransliteratedAction.ActionService as) { + for (String targetKey : as.beforeKeys()) { + MutableNode target = serviceKeyToNode.get(targetKey); + if (target != null) { + target.stepDeps.add(node); + } + // silently ignored if target doesn't exist (optional relationship) + } + } + } + + // Step 4c: resolve afterBuildItem() declarations + // For each service that declares afterBuildItem(X), find the step(s) + // that produce X and resolve to their nodes (with passthrough + // resolution for steps that have no nodes in the service graph). + for (MutableNode node : nodes) { + if (node.action instanceof TransliteratedAction.ActionService as) { + for (String buildItemClass : as.afterBuildItemClasses()) { + Set producerSteps = buildItemProducers.getOrDefault(buildItemClass, Set.of()); + for (String producerStepId : producerSteps) { + List producerNodes = stepToNodes.getOrDefault(producerStepId, List.of()); + if (!producerNodes.isEmpty()) { + node.stepDeps.addAll(producerNodes); + } else { + // producer step has no nodes — resolve through its + // dependencies (passthrough) until we find steps with nodes + Set resolved = resolveStepDeps( + producerStepId, stepGraph, stepToNodes, stepsWithNodes, passthroughCache); + node.stepDeps.addAll(resolved); + } + } + } + } + } + + // Step 5: topological sort + List sorted = topologicalSort(nodes); + + // Step 6: assign plan indices and compute dependent counts + int topIndex = 0; + for (int i = 0; i < sorted.size(); i++) { + sorted.get(i).planIndex = i + 1; // offset by 1 for top sentinel + } + int bottomIndex = sorted.size() + 1; + + for (MutableNode node : sorted) { + for (MutableNode dep : node.allDeps()) { + dep.dependentCount++; + } + } + + List roots = new ArrayList<>(); + List leaves = new ArrayList<>(); + for (MutableNode node : sorted) { + if (node.allDeps().isEmpty()) { + roots.add(node); + } + if (node.dependentCount == 0) { + leaves.add(node); + } + } + + // Step 7: build the output plan + List descriptors = new ArrayList<>(sorted.size() + 2); + + // top sentinel: roots.size() dependents + descriptors.add(new NodeDescriptor("<>", NodeKind.SENTINEL, null, null, new int[0], roots.size())); + + for (int i = 0; i < sorted.size(); i++) { + MutableNode node = sorted.get(i); + List allDeps = node.allDeps(); + // resolve dependency indices (action deps first, then step deps) + int[] depIndices; + if (allDeps.isEmpty()) { + // root node: depends on top sentinel only + depIndices = new int[] { topIndex }; + } else { + depIndices = new int[allDeps.size()]; + for (int j = 0; j < allDeps.size(); j++) { + depIndices[j] = allDeps.get(j).planIndex; + } + } + // dependent count: base count + 1 if this is a leaf (for bottom sentinel) + int depCount = node.dependentCount + (leaves.contains(node) ? 1 : 0); + + descriptors.add(new NodeDescriptor(node.name, node.kind, node.recorders, node.action, depIndices, depCount)); + } + + // bottom sentinel: depends on all leaves, 0 dependents + int[] leafIndices = new int[leaves.size()]; + for (int i = 0; i < leaves.size(); i++) { + leafIndices[i] = leaves.get(i).planIndex; + } + descriptors.add(new NodeDescriptor("<>", NodeKind.SENTINEL, null, null, leafIndices, 0)); + + return new GraphPlan(descriptors, topIndex, bottomIndex, Set.copyOf(serviceKeyToNode.keySet())); + } + + // ═══════════════════════════════════════════════ + // Step-graph edge resolution with passthrough skipping + // ═══════════════════════════════════════════════ + + /** + * Resolve the step-graph dependencies for a given step, skipping + * passthrough steps (those without recorder/service nodes) by + * recursively following their dependencies. + * + * @param stepId the step to resolve + * @param stepGraph the full step dependency graph + * @param stepToNodes map of step IDs that have nodes + * @param stepsWithNodes the set of step IDs that have nodes + * @param cache memoization cache for passthrough resolution + * @return the set of nodes that this step depends on + */ + private static Set resolveStepDeps( + String stepId, + Map> stepGraph, + Map> stepToNodes, + Set stepsWithNodes, + Map> cache) { + Set depStepIds = stepGraph.getOrDefault(stepId, Set.of()); + Set result = new HashSet<>(); + for (String depStepId : depStepIds) { + if (stepsWithNodes.contains(depStepId)) { + result.addAll(stepToNodes.get(depStepId)); + } else { + // passthrough step: resolve transitively + result.addAll(resolvePassthrough(depStepId, stepGraph, stepToNodes, stepsWithNodes, cache)); + } + } + return result; + } + + /** + * Recursively resolve a passthrough step's dependencies. + * Results are memoized. + */ + private static Set resolvePassthrough( + String stepId, + Map> stepGraph, + Map> stepToNodes, + Set stepsWithNodes, + Map> cache) { + Set cached = cache.get(stepId); + if (cached != null) { + return cached; + } + // mark as in-progress (empty) to handle cycles + Set result = new HashSet<>(); + cache.put(stepId, result); + + Set depStepIds = stepGraph.getOrDefault(stepId, Set.of()); + for (String depStepId : depStepIds) { + if (stepsWithNodes.contains(depStepId)) { + result.addAll(stepToNodes.get(depStepId)); + } else { + result.addAll(resolvePassthrough(depStepId, stepGraph, stepToNodes, stepsWithNodes, cache)); + } + } + return result; + } + + // ═══════════════════════════════════════════════ + // Topological sort (Kahn's algorithm) + // ═══════════════════════════════════════════════ + + private static List topologicalSort(List nodes) { + // build reverse adjacency and compute in-degree using node fields + // (avoids Map) + Map> reverseAdj = new HashMap<>(); + for (MutableNode node : nodes) { + List allDeps = node.allDeps(); + node.inDegree = 0; + for (MutableNode dep : allDeps) { + node.inDegree++; + reverseAdj.computeIfAbsent(dep, k -> new ArrayList<>()).add(node); + } + } + + Deque queue = new ArrayDeque<>(); + for (MutableNode node : nodes) { + if (node.inDegree == 0) { + queue.add(node); + } + } + + List sorted = new ArrayList<>(nodes.size()); + while (!queue.isEmpty()) { + MutableNode node = queue.poll(); + sorted.add(node); + for (MutableNode dependent : reverseAdj.getOrDefault(node, List.of())) { + if (--dependent.inDegree == 0) { + queue.add(dependent); + } + } + } + + if (sorted.size() != nodes.size()) { + // identify the nodes stuck in the cycle (inDegree > 0 after sort) + List cycleNodes = new ArrayList<>(); + for (MutableNode node : nodes) { + if (node.inDegree > 0) { + List depNames = node.allDeps().stream() + .filter(d -> d.inDegree > 0) + .map(d -> d.name) + .toList(); + cycleNodes.add(node.name + " → " + depNames); + } + } + throw new IllegalStateException( + "Service dependency graph contains a cycle (" + sorted.size() + " of " + nodes.size() + + " nodes sorted). Unsorted nodes:\n " + String.join("\n ", cycleNodes)); + } + return sorted; + } + + // ═══════════════════════════════════════════════ + // Internal types + // ═══════════════════════════════════════════════ + + private static NodeKind kindForAction(TransliteratedAction action) { + return switch (action) { + case TransliteratedAction.ActionService a -> NodeKind.SERVICE; + case TransliteratedAction.AliasService a -> NodeKind.ALIAS; + case TransliteratedAction.RuntimeValueWrapper a -> NodeKind.RV_WRAPPER; + }; + } + + /** + * Mutable node used during graph construction. + */ + private static final class MutableNode { + final String name; + final String stepId; + final NodeKind kind; + final List recorders; // non-null for LEGACY_RECORDER + final TransliteratedAction action; // non-null for SERVICE/ALIAS/RV_WRAPPER + + /** Action-declared dependencies (from .after()), in declaration order. */ + final List actionDeps = new ArrayList<>(); + /** Step-graph-derived dependencies (ordering edges). */ + final Set stepDeps = new HashSet<>(); + // computed during toposort / step 6 + int inDegree; + int dependentCount; + int planIndex = -1; + + MutableNode(String name, String stepId, NodeKind kind, + List recorders, TransliteratedAction action) { + this.name = name; + this.stepId = stepId; + this.kind = kind; + this.recorders = recorders; + this.action = action; + } + + /** + * Get all dependencies (action deps first, then step deps not already in action deps). + * + * @return combined dependency list with action deps at stable positions + */ + List allDeps() { + if (stepDeps.isEmpty()) { + return actionDeps; + } + if (actionDeps.isEmpty()) { + return new ArrayList<>(stepDeps); + } + List result = new ArrayList<>(actionDeps); + for (MutableNode sd : stepDeps) { + if (!actionDeps.contains(sd)) { + result.add(sd); + } + } + return result; + } + } + + // ═══════════════════════════════════════════════ + // Output types + // ═══════════════════════════════════════════════ + + /** + * The kind of service node. + */ + enum NodeKind { + /** Top or bottom sentinel. */ + SENTINEL, + /** Legacy bytecode recorder chunk (one or more recorders from the same step). */ + LEGACY_RECORDER, + /** New-style service with a transliterated lambda body. */ + SERVICE, + /** Alias that copies a recorder proxy value to a service key. */ + ALIAS, + /** Wrapper that wraps a service value in a RuntimeValue. */ + RV_WRAPPER, + /** Synthetic proxy that reads a cross-phase service value from the serviceValues map. */ + CROSS_PHASE_PROXY, + } + + /** + * Descriptor for a single node in the graph plan. + * Indices in {@code dependencyIndices} refer to positions in the + * {@link GraphPlan#nodes()} list. + * + * @param name diagnostic name + * @param kind the node kind + * @param recorders legacy recorders (only for {@link NodeKind#LEGACY_RECORDER}) + * @param action the transliterated action (only for SERVICE/ALIAS/RV_WRAPPER) + * @param dependencyIndices indices of dependency nodes in the plan's node list + * @param dependentCount the number of dependents this node will have + */ + record NodeDescriptor( + String name, + NodeKind kind, + List recorders, + TransliteratedAction action, + int[] dependencyIndices, + int dependentCount) { + } + + /** + * The complete graph construction plan for one phase. + * Nodes are in topological order with sentinels at the ends. + * Index 0 is always the top sentinel; the last index is always + * the bottom sentinel. + * + * @param nodes the node descriptors in topological order + * @param topIndex index of the top sentinel (always 0) + * @param bottomIndex index of the bottom sentinel (always nodes.size() - 1) + */ + record GraphPlan(List nodes, int topIndex, int bottomIndex, Set serviceKeys) { + + static final GraphPlan EMPTY = new GraphPlan(List.of(), -1, -1, Set.of()); + + /** + * Return whether this plan has any service nodes. + * + * @return {@code true} if the plan is non-empty + */ + boolean hasNodes() { + return !nodes.isEmpty(); + } + } +} diff --git a/core/deployment/src/main/java/io/quarkus/deployment/steps/ThreadPoolSetup.java b/core/deployment/src/main/java/io/quarkus/deployment/steps/ThreadPoolSetup.java index 9c7cc3082ee55..42a2b91c754e2 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/steps/ThreadPoolSetup.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/steps/ThreadPoolSetup.java @@ -1,7 +1,10 @@ package io.quarkus.deployment.steps; import java.util.Optional; +import java.util.concurrent.ScheduledExecutorService; +import io.quarkus.core.deployment.action.ActionBuilder; +import io.quarkus.deployment.Phase; import io.quarkus.deployment.annotations.BuildStep; import io.quarkus.deployment.annotations.ExecutionTime; import io.quarkus.deployment.annotations.Record; @@ -20,14 +23,17 @@ public class ThreadPoolSetup { @BuildStep @Record(value = ExecutionTime.RUNTIME_INIT) - public ExecutorBuildItem createExecutor(ExecutorRecorder recorder, ShutdownContextBuildItem shutdownContextBuildItem, + public ExecutorBuildItem createExecutor(ActionBuilder action, ExecutorRecorder recorder, + ShutdownContextBuildItem shutdownContextBuildItem, LaunchModeBuildItem launchModeBuildItem, Optional threadFactoryBuildItem, Optional contextBuildItem) { - return new ExecutorBuildItem( - recorder.setupRunTime(shutdownContextBuildItem, launchModeBuildItem.getLaunchMode(), - threadFactoryBuildItem.map(ThreadFactoryBuildItem::getThreadFactory).orElse(null), - contextBuildItem.map(ContextHandlerBuildItem::contextHandler).orElse(null))); + ScheduledExecutorService proxy = recorder.setupRunTime(shutdownContextBuildItem, + launchModeBuildItem.getLaunchMode(), + threadFactoryBuildItem.map(ThreadFactoryBuildItem::getThreadFactory).orElse(null), + contextBuildItem.map(ContextHandlerBuildItem::contextHandler).orElse(null)); + action.aliasRecorderValue(ScheduledExecutorService.class, proxy, Phase.INFRASTRUCTURE); + return new ExecutorBuildItem(proxy); } @BuildStep diff --git a/core/deployment/src/test/java/io/quarkus/core/deployment/service/impl/LambdaTransliteratorTest.java b/core/deployment/src/test/java/io/quarkus/core/deployment/service/impl/LambdaTransliteratorTest.java new file mode 100644 index 0000000000000..a82f72311201f --- /dev/null +++ b/core/deployment/src/test/java/io/quarkus/core/deployment/service/impl/LambdaTransliteratorTest.java @@ -0,0 +1,865 @@ +package io.quarkus.core.deployment.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.lang.reflect.Method; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; + +import io.quarkus.core.StartContext; +import io.quarkus.core.deployment.service.Action1; +import io.quarkus.core.deployment.service.Action2; +import io.quarkus.core.deployment.service.impl.LambdaTransliterator.TransliterationException; +import io.quarkus.core.impl.ServiceGraph; +import io.quarkus.core.impl.ServiceNode; +import io.quarkus.runtime.RuntimeValue; +import io.quarkus.runtime.StartupContext; + +/** + * Tests for {@link LambdaTransliterator}. + *

+ * Each test extracts a lambda via {@link LambdaTransliterator#extract} and then + * generates a consolidated class via {@link LambdaTransliterator#generateConsolidatedClass}. + * The consolidated class is loaded and deployed via its static {@code deploy$0} method. + */ +class LambdaTransliteratorTest { + + private static final String CONSOLIDATED_CLASS_NAME = "io/quarkus/generated/service/TestConsolidated"; + + @Test + void simpleNoArgLambdaTransliterates() throws Exception { + Action1 action = (StartContext ctx) -> "hello"; + + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, String.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test"); + + assertThat(extracted.serviceKey()).isEqualTo("java.lang.String:"); + + ServiceNode node = deployAndReturn(extracted); + assertThat(node.value()).isEqualTo("hello"); + + } + + @Test + void namedServiceStoresUnderCorrectKey() throws Exception { + Action1 action = (StartContext ctx) -> 42; + + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, Integer.class, List.of("answer"), List.of(), List.of(), List.of(), false, false, false, "test"); + + assertThat(extracted.serviceKey()).isEqualTo("java.lang.Integer:answer"); + + ServiceNode node = deployAndReturn(extracted); + assertThat(node.value()).isEqualTo(42); + + } + + @Test + void lambdaWithDependencyReadsDependencyFromContext() throws Exception { + Action2 action = (StartContext ctx, + Integer dep) -> "value-" + dep; + + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, String.class, List.of(), + List.of(new Dependency(Integer.class, List.of(), Dependency.FL_INJECTED)), + List.of(), List.of(), false, false, false, "test"); + + ServiceGraph graph = new ServiceGraph(); + ServiceNode depNode = completedNode(graph, 99); + ServiceNode node = deployAndReturn(graph, extracted, depNode); + assertThat(node.value()).isEqualTo("value-99"); + + } + + @Test + void lambdaCapturingConstantValueTransliterates() throws Exception { + String captured = "world"; + Action1 action = (StartContext ctx) -> "hello " + captured; + + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, String.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test"); + + ServiceNode node = deployAndReturn(extracted); + assertThat(node.value()).isEqualTo("hello world"); + + } + + @Test + void instanceCapturingLambdaRejected() { + final LambdaTransliteratorTest self = this; + Action1 action = (StartContext ctx) -> self.toString(); + + assertThatThrownBy(() -> LambdaTransliterator.extract( + action, String.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test")) + .isInstanceOf(TransliterationException.class); + } + + @Test + void extractIsDeterministic() { + Action1 action = (StartContext ctx) -> "a"; + + TransliteratedAction.ActionService r1 = LambdaTransliterator.extract( + action, String.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test"); + TransliteratedAction.ActionService r2 = LambdaTransliterator.extract( + action, String.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test"); + + assertThat(r1.serviceKey()).isEqualTo(r2.serviceKey()); + } + + @Test + void nestedLambdaTransliterates() throws Exception { + Action1 action = (StartContext ctx) -> { + List items = List.of("a", "b", "c"); + return items.stream().map(String::toUpperCase).collect(Collectors.joining(",")); + }; + + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, String.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test"); + + ServiceNode node = deployAndReturn(extracted); + assertThat(node.value()).isEqualTo("A,B,C"); + + } + + @Test + void nestedLambdaCapturingOuterLocal() throws Exception { + Action1 action = (StartContext ctx) -> { + String prefix = "X-"; + List items = List.of("a", "b"); + return items.stream().map(s -> prefix + s).collect(Collectors.joining(",")); + }; + + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, String.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test"); + + ServiceNode node = deployAndReturn(extracted); + assertThat(node.value()).isEqualTo("X-a,X-b"); + + } + + @Test + void nestedLambdaCapturingOuterCaptureAndLocal() throws Exception { + // reproduces the pattern from CertificatesProcessor: outer lambda captures a Set, + // creates a local, and a nested lambda captures both the Set and the local + Set captured = Set.of("x", "y"); + Action1 action = (StartContext ctx) -> { + StringBuilder sb = new StringBuilder(); + captured.forEach(sb::append); + return sb.toString(); + }; + + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, String.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test"); + + ServiceNode node = deployAndReturn(extracted); + String result = (String) node.value(); + assertThat(result).contains("x").contains("y"); + + } + + @Test + void deeplyNestedLambda() throws Exception { + Action1 action = (StartContext ctx) -> { + List> nested = List.of(List.of("a", "b"), List.of("c")); + return nested.stream() + .map(list -> list.stream().map(String::toUpperCase).collect(Collectors.joining())) + .collect(Collectors.joining("-")); + }; + + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, String.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test"); + + ServiceNode node = deployAndReturn(extracted); + assertThat(node.value()).isEqualTo("AB-C"); + + } + + @Test + void methodReferenceToRuntimeClassPassesThrough() throws Exception { + Action1 action = (StartContext ctx) -> { + List items = List.of("a", "b", "c"); + return items.stream().map(String::toUpperCase).collect(Collectors.joining(",")); + }; + + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, String.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test"); + + ServiceNode node = deployAndReturn(extracted); + assertThat(node.value()).isEqualTo("A,B,C"); + + } + + @Test + void multipleNestedLambdasInSameBody() throws Exception { + Action1 action = (StartContext ctx) -> { + List items = List.of("a", "b", "c"); + String upper = items.stream().map(String::toUpperCase).collect(Collectors.joining(",")); + String lengths = items.stream().map(s -> String.valueOf(s.length())).collect(Collectors.joining(",")); + return upper + "|" + lengths; + }; + + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, String.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test"); + + ServiceNode node = deployAndReturn(extracted); + assertThat(node.value()).isEqualTo("A,B,C|1,1,1"); + + } + + // ── Static-init tests ── + + @Test + void staticInitSimpleTransliterates() throws Exception { + Action1 action = (StartContext ctx) -> "static-hello"; + + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, String.class, List.of(), List.of(), List.of(), List.of(), false, false, true, "test"); + + assertThat(extracted.staticInit()).isTrue(); + + ServiceNode node = deployAndReturn(extracted); + assertThat(node.value()).isEqualTo("static-hello"); + + } + + @Test + void staticInitWithDependencyTransliterates() throws Exception { + Action2 action = (StartContext ctx, + Integer dep) -> "static-" + dep; + + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, String.class, List.of(), + List.of(new Dependency(Integer.class, List.of(), Dependency.FL_INJECTED)), + List.of(), List.of(), false, false, true, "test"); + + ServiceGraph graph = new ServiceGraph(); + ServiceNode depNode = completedNode(graph, 77); + ServiceNode node = deployAndReturn(graph, extracted, depNode); + assertThat(node.value()).isEqualTo("static-77"); + + } + + @Test + void staticInitRejectsAsync() { + Action1 action = (StartContext ctx) -> "nope"; + + assertThatThrownBy(() -> LambdaTransliterator.extract( + action, String.class, List.of(), List.of(), List.of(), List.of(), true, false, true, "test")) + .isInstanceOf(TransliterationException.class) + .hasMessageContaining("async"); + } + + @Test + void staticInitNestedLambda() throws Exception { + Action1 action = (StartContext ctx) -> { + List items = List.of("x", "y", "z"); + return items.stream().map(String::toUpperCase).collect(Collectors.joining(",")); + }; + + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, String.class, List.of(), List.of(), List.of(), List.of(), false, false, true, "test"); + + ServiceNode node = deployAndReturn(extracted); + assertThat(node.value()).isEqualTo("X,Y,Z"); + + } + + // ── Consolidation tests ── + + @Test + void multipleActionsConsolidateIntoSingleClass() throws Exception { + Action1 action1 = (StartContext ctx) -> "first"; + Action1 action2 = (StartContext ctx) -> 42; + + TransliteratedAction.ActionService extracted1 = LambdaTransliterator.extract( + action1, String.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test"); + TransliteratedAction.ActionService extracted2 = LambdaTransliterator.extract( + action2, Integer.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test"); + + Map classes = LambdaTransliterator.generateConsolidatedClass( + CONSOLIDATED_CLASS_NAME, List.of(extracted1, extracted2)); + String className = CONSOLIDATED_CLASS_NAME.replace('/', '.'); + + ClassLoader cl = makeClassLoader(classes); + Class clazz = cl.loadClass(className); + + // deploy$0 should produce "first" + ServiceGraph graph1 = new ServiceGraph(); + ServiceNode node1 = runDeploy(graph1, clazz, 0); + assertThat(node1.value()).isEqualTo("first"); + + // deploy$1 should produce 42 + ServiceGraph graph2 = new ServiceGraph(); + ServiceNode node2 = runDeploy(graph2, clazz, 1); + assertThat(node2.value()).isEqualTo(42); + } + + @Test + void consolidatedClassWithNestedLambdasDoNotCollide() throws Exception { + // both actions use nested lambdas; inner method names must not collide + Action1 action1 = (StartContext ctx) -> { + List items = List.of("a", "b"); + return items.stream().map(String::toUpperCase).collect(Collectors.joining(",")); + }; + Action1 action2 = (StartContext ctx) -> { + List items = List.of("x", "y"); + return items.stream().map(String::toLowerCase).collect(Collectors.joining("-")); + }; + + TransliteratedAction.ActionService extracted1 = LambdaTransliterator.extract( + action1, String.class, List.of("svc1"), List.of(), List.of(), List.of(), false, false, false, "test"); + TransliteratedAction.ActionService extracted2 = LambdaTransliterator.extract( + action2, String.class, List.of("svc2"), List.of(), List.of(), List.of(), false, false, false, "test"); + + Map classes = LambdaTransliterator.generateConsolidatedClass( + CONSOLIDATED_CLASS_NAME, List.of(extracted1, extracted2)); + String className = CONSOLIDATED_CLASS_NAME.replace('/', '.'); + + ClassLoader cl = makeClassLoader(classes); + Class clazz = cl.loadClass(className); + + ServiceGraph graph1 = new ServiceGraph(); + ServiceNode node1 = runDeploy(graph1, clazz, 0); + assertThat(node1.value()).isEqualTo("A,B"); + + ServiceGraph graph2 = new ServiceGraph(); + ServiceNode node2 = runDeploy(graph2, clazz, 1); + assertThat(node2.value()).isEqualTo("x-y"); + } + + @Test + void aliasServiceConsolidates() throws Exception { + Action1 action = (StartContext ctx) -> "original"; + + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, String.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test"); + + // alias copies a recorder proxy value (from the values map) to a service key + // simulate a recorder having stored a value under a proxy key + String recorderProxyKey = "recorder-proxy-key"; + TransliteratedAction.AliasService alias = new TransliteratedAction.AliasService( + "java.lang.String:alias", false, recorderProxyKey, "test"); + + Map classes = LambdaTransliterator.generateConsolidatedClass( + CONSOLIDATED_CLASS_NAME, List.of(extracted, alias)); + String className = CONSOLIDATED_CLASS_NAME.replace('/', '.'); + + ClassLoader cl = makeClassLoader(classes); + Class clazz = cl.loadClass(className); + + // deploy$0 produces "original" + ServiceGraph graph = new ServiceGraph(); + ServiceNode sourceNode = runDeploy(graph, clazz, 0); + assertThat(sourceNode.value()).isEqualTo("original"); + + // simulate a recorder storing the value under its proxy key + graph.startupContext().putValue(recorderProxyKey, "original"); + + // deploy$1 reads from the recorder values map and produces the alias value + ServiceNode aliasNode = runDeploy(graph, clazz, 1); + assertThat(aliasNode.value()).isEqualTo("original"); + } + + @Test + void runtimeValueWrapperConsolidates() throws Exception { + Action1 action = (StartContext ctx) -> "wrapped-value"; + + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, String.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test"); + + // wrapper reads from the service key and stores a RuntimeValue at the rv key + TransliteratedAction.RuntimeValueWrapper wrapper = new TransliteratedAction.RuntimeValueWrapper( + "java.lang.String:", false, "runtimevalue:java.lang.String:", "test"); + + Map classes = LambdaTransliterator.generateConsolidatedClass( + CONSOLIDATED_CLASS_NAME, List.of(extracted, wrapper)); + String className = CONSOLIDATED_CLASS_NAME.replace('/', '.'); + + ClassLoader cl = makeClassLoader(classes); + Class clazz = cl.loadClass(className); + + ServiceGraph graph = new ServiceGraph(); + // deploy$0 produces the bare value + ServiceNode sourceNode = runDeploy(graph, clazz, 0); + assertThat(sourceNode.value()).isEqualTo("wrapped-value"); + + // deploy$1 wraps it in RuntimeValue via indexed dependency access (dep 0 = source) + ServiceNode wrapperNode = runDeploy(graph, clazz, 1, sourceNode); + assertThat(wrapperNode.value()).isInstanceOf(RuntimeValue.class); + @SuppressWarnings("unchecked") + RuntimeValue rv = (RuntimeValue) wrapperNode.value(); + assertThat(rv.getValue()).isEqualTo("wrapped-value"); + } + + // ── Anonymous inner class tests ── + + /** + * Abstract generic class used to verify that anonymous inner class + * transliteration preserves generic type information (like {@code TypeLiteral}). + * + * @param the carried type + */ + public static abstract class TypeCarrier { + /** + * Retrieve the actual type argument from the generic superclass. + * + * @return the type argument + */ + public Type getType() { + return ((ParameterizedType) getClass().getGenericSuperclass()) + .getActualTypeArguments()[0]; + } + } + + // Anonymous class actions must be created in static methods to avoid capturing + // `this` — javac treats anonymous classes in instance context as inner classes + // with an implicit enclosing instance parameter. + + private static Action1 typeCarrierStringAction() { + return (StartContext ctx) -> new TypeCarrier() { + }; + } + + private static Action1 nestedLambdaTypeCarrierAction() { + return (StartContext ctx) -> { + List items = List.of("a"); + return items.stream().map(s -> new TypeCarrier() { + }).findFirst().orElseThrow(); + }; + } + + @Test + void anonymousInnerClassTransliterates() throws Exception { + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + typeCarrierStringAction(), Object.class, List.of(), List.of(), List.of(), List.of(), false, false, false, + "test"); + + Map classes = LambdaTransliterator.generateConsolidatedClass( + CONSOLIDATED_CLASS_NAME, List.of(extracted)); + + // at least the consolidated class + one anonymous class copy + assertThat(classes.size()).isGreaterThanOrEqualTo(2); + + ClassLoader cl = makeClassLoader(classes); + String className = CONSOLIDATED_CLASS_NAME.replace('/', '.'); + Class clazz = cl.loadClass(className); + + ServiceGraph graph = new ServiceGraph(); + ServiceNode node = runDeploy(graph, clazz, 0); + + assertThat(node.value()).isInstanceOf(TypeCarrier.class); + @SuppressWarnings("unchecked") + TypeCarrier carrier = (TypeCarrier) node.value(); + assertThat(carrier.getType()).isEqualTo(String.class); + } + + @Test + void anonymousInnerClassInNestedLambda() throws Exception { + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + nestedLambdaTypeCarrierAction(), Object.class, List.of(), List.of(), List.of(), List.of(), false, false, + false, "test"); + + Map classes = LambdaTransliterator.generateConsolidatedClass( + CONSOLIDATED_CLASS_NAME, List.of(extracted)); + + assertThat(classes.size()).isGreaterThanOrEqualTo(2); + + ClassLoader cl = makeClassLoader(classes); + String className = CONSOLIDATED_CLASS_NAME.replace('/', '.'); + Class clazz = cl.loadClass(className); + + ServiceGraph graph = new ServiceGraph(); + ServiceNode node = runDeploy(graph, clazz, 0); + + assertThat(node.value()).isInstanceOf(TypeCarrier.class); + @SuppressWarnings("unchecked") + TypeCarrier carrier = (TypeCarrier) node.value(); + assertThat(carrier.getType()).isEqualTo(Integer.class); + } + + // ── Collection capture tests ── + + @Test + void capturedImmutableListTransliterates() throws Exception { + List captured = List.of("a", "b", "c"); + Action1 action = (StartContext ctx) -> captured; + + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, Object.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test"); + + ServiceNode node = deployAndReturn(extracted); + assertThat(node.value()).isEqualTo(List.of("a", "b", "c")); + + } + + @Test + void capturedImmutableSetTransliterates() throws Exception { + Set captured = Set.of(1, 2, 3); + Action1 action = (StartContext ctx) -> captured; + + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, Object.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test"); + + ServiceNode node = deployAndReturn(extracted); + @SuppressWarnings("unchecked") + Set result = (Set) node.value(); + assertThat(result).containsExactlyInAnyOrder(1, 2, 3); + + } + + @Test + void capturedImmutableMapTransliterates() throws Exception { + Map captured = Map.of("k1", "v1", "k2", "v2"); + Action1 action = (StartContext ctx) -> captured; + + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, Object.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test"); + + ServiceNode node = deployAndReturn(extracted); + @SuppressWarnings("unchecked") + Map result = (Map) node.value(); + assertThat(result).containsEntry("k1", "v1").containsEntry("k2", "v2").hasSize(2); + + } + + @Test + void capturedEmptyCollectionsTransliterate() throws Exception { + List emptyList = List.of(); + Set emptySet = Set.of(); + Map emptyMap = Map.of(); + Action1 action = (StartContext ctx) -> List.of(emptyList, emptySet, emptyMap); + + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, Object.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test"); + + ServiceNode node = deployAndReturn(extracted); + @SuppressWarnings("unchecked") + List result = (List) node.value(); + assertThat(result).hasSize(3); + assertThat((List) result.get(0)).isEmpty(); + assertThat((Set) result.get(1)).isEmpty(); + assertThat((Map) result.get(2)).isEmpty(); + + } + + @Test + void capturedSingletonCollectionsTransliterate() throws Exception { + List singletonList = Collections.singletonList("x"); + Set singletonSet = Collections.singleton("y"); + Map singletonMap = Collections.singletonMap("k", "v"); + Action1 action = (StartContext ctx) -> List.of(singletonList, singletonSet, singletonMap); + + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, Object.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test"); + + ServiceNode node = deployAndReturn(extracted); + @SuppressWarnings("unchecked") + List result = (List) node.value(); + assertThat(result.get(0)).isEqualTo(List.of("x")); + assertThat(result.get(1)).isEqualTo(Set.of("y")); + @SuppressWarnings("unchecked") + Map mapResult = (Map) result.get(2); + assertThat(mapResult).containsEntry("k", "v").hasSize(1); + + } + + @Test + void capturedUnmodifiableListTransliterates() throws Exception { + List captured = Collections.unmodifiableList(new ArrayList<>(List.of("a", "b"))); + Action1 action = (StartContext ctx) -> captured; + + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, Object.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test"); + + ServiceNode node = deployAndReturn(extracted); + assertThat(node.value()).isEqualTo(List.of("a", "b")); + + } + + @Test + void capturedUnmodifiableSetTransliterates() throws Exception { + Set captured = Collections.unmodifiableSet(new HashSet<>(Set.of("a", "b"))); + Action1 action = (StartContext ctx) -> captured; + + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, Object.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test"); + + ServiceNode node = deployAndReturn(extracted); + @SuppressWarnings("unchecked") + Set result = (Set) node.value(); + assertThat(result).containsExactlyInAnyOrder("a", "b"); + + } + + @Test + void capturedUnmodifiableMapTransliterates() throws Exception { + Map captured = Collections.unmodifiableMap( + new HashMap<>(Map.of("k1", "v1", "k2", "v2"))); + Action1 action = (StartContext ctx) -> captured; + + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, Object.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test"); + + ServiceNode node = deployAndReturn(extracted); + @SuppressWarnings("unchecked") + Map result = (Map) node.value(); + assertThat(result).containsEntry("k1", "v1").containsEntry("k2", "v2").hasSize(2); + + } + + @Test + void capturedNestedCollectionTransliterates() throws Exception { + List> captured = List.of(List.of("a", "b"), List.of("c")); + Action1 action = (StartContext ctx) -> captured; + + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, Object.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test"); + + ServiceNode node = deployAndReturn(extracted); + assertThat(node.value()).isEqualTo(List.of(List.of("a", "b"), List.of("c"))); + + } + + @Test + void capturedMutableListRejected() { + ArrayList captured = new ArrayList<>(List.of("a")); + Action1 action = (StartContext ctx) -> captured; + + assertThatThrownBy(() -> LambdaTransliterator.extract( + action, Object.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test")) + .isInstanceOf(TransliterationException.class) + .hasMessageContaining("Unsupported collection/map type") + .hasMessageContaining("ArrayList"); + } + + @Test + void capturedMutableMapRejected() { + HashMap captured = new HashMap<>(Map.of("k", "v")); + Action1 action = (StartContext ctx) -> captured; + + assertThatThrownBy(() -> LambdaTransliterator.extract( + action, Object.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test")) + .isInstanceOf(TransliterationException.class) + .hasMessageContaining("Unsupported collection/map type") + .hasMessageContaining("HashMap"); + } + + @Test + void capturedLargeListTransliterates() throws Exception { + List captured = List.of("a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l"); + Action1 action = (StartContext ctx) -> captured; + + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, Object.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test"); + + ServiceNode node = deployAndReturn(extracted); + assertThat(node.value()) + .isEqualTo(List.of("a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l")); + + } + + @Test + void capturedSharedObjectsDedup() throws Exception { + List shared = List.of("shared"); + List captured = List.of(shared, shared); + Action1 action = (StartContext ctx) -> captured; + + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, Object.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test"); + + ServiceNode node = deployAndReturn(extracted); + @SuppressWarnings("unchecked") + List result = (List) node.value(); + assertThat(result).hasSize(2); + assertThat(result.get(0)).isEqualTo(List.of("shared")); + assertThat(result.get(1)).isEqualTo(List.of("shared")); + + } + + // ── Record capture tests ── + + public record SimpleRecord(String name, int value) { + } + + public record NestedRecord(String label, SimpleRecord inner) { + } + + public record RecordWithList(String name, List items) { + } + + @Test + void capturedRecordTransliterates() throws Exception { + SimpleRecord captured = new SimpleRecord("hello", 42); + Action1 action = (StartContext ctx) -> captured; + + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, Object.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test"); + + ServiceNode node = deployAndReturn(extracted); + Object result = node.value(); + assertThat(result).isInstanceOf(SimpleRecord.class); + SimpleRecord r = (SimpleRecord) result; + assertThat(r.name()).isEqualTo("hello"); + assertThat(r.value()).isEqualTo(42); + + } + + @Test + void capturedNestedRecordTransliterates() throws Exception { + NestedRecord captured = new NestedRecord("outer", new SimpleRecord("inner", 7)); + Action1 action = (StartContext ctx) -> captured; + + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, Object.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test"); + + ServiceNode node = deployAndReturn(extracted); + Object result = node.value(); + assertThat(result).isInstanceOf(NestedRecord.class); + NestedRecord r = (NestedRecord) result; + assertThat(r.label()).isEqualTo("outer"); + assertThat(r.inner().name()).isEqualTo("inner"); + assertThat(r.inner().value()).isEqualTo(7); + + } + + @Test + void capturedRecordWithCollectionTransliterates() throws Exception { + RecordWithList captured = new RecordWithList("items", List.of("a", "b", "c")); + Action1 action = (StartContext ctx) -> captured; + + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, Object.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test"); + + ServiceNode node = deployAndReturn(extracted); + Object result = node.value(); + assertThat(result).isInstanceOf(RecordWithList.class); + RecordWithList r = (RecordWithList) result; + assertThat(r.name()).isEqualTo("items"); + assertThat(r.items()).containsExactly("a", "b", "c"); + + } + + // ── Helpers ── + + /** Action that completes a node with a bound value. */ + static void completeWithValue(ServiceNode node, Object value) { + node.startComplete(value); + } + + private static final MethodHandle COMPLETE_WITH_VALUE; + + static { + try { + COMPLETE_WITH_VALUE = MethodHandles.lookup().findStatic( + LambdaTransliteratorTest.class, "completeWithValue", + MethodType.methodType(void.class, ServiceNode.class, Object.class)); + } catch (ReflectiveOperationException e) { + throw new ExceptionInInitializerError(e); + } + } + + /** + * Create a {@link ServiceNode} that is already completed with the given value. + * + * @param graph the owning graph + * @param value the service value + * @return a completed node whose {@link ServiceNode#value()} returns {@code value} + */ + private static ServiceNode completedNode(ServiceGraph graph, Object value) { + MethodHandle mh = MethodHandles.insertArguments(COMPLETE_WITH_VALUE, 1, value); + ServiceNode node = new ServiceNode("dep", mh, graph, 0); + node.run(); + return node; + } + + /** + * Generate a consolidated class with a single action, load it, invoke {@code deploy$0}, + * and return the resulting {@link StartupContext}. + * + * @param extracted the extracted action service + * @return the startup context after deployment + * @throws Exception if class loading or invocation fails + */ + private static ServiceNode deployAndReturn(TransliteratedAction.ActionService extracted) throws Exception { + return deployAndReturn(new ServiceGraph(), extracted); + } + + /** + * Generate a consolidated class with a single action, load it, invoke {@code deploy$0} + * on a graph with the given dependency nodes, and return the completed {@link ServiceNode}. + * + * @param graph the service graph (provides the startup context) + * @param extracted the extracted action service + * @param deps dependency nodes (already completed) to wire into the deploy node + * @return the startup context after deployment + * @throws Exception if class loading or invocation fails + */ + private static ServiceNode deployAndReturn(ServiceGraph graph, + TransliteratedAction.ActionService extracted, ServiceNode... deps) throws Exception { + Map classes = LambdaTransliterator.generateConsolidatedClass( + CONSOLIDATED_CLASS_NAME, List.of(extracted)); + ClassLoader cl = makeClassLoader(classes); + String className = CONSOLIDATED_CLASS_NAME.replace('/', '.'); + Class clazz = cl.loadClass(className); + return runDeploy(graph, clazz, 0, deps); + } + + /** + * Load deploy method {@code deploy$} from the given class, wrap it in a + * {@link ServiceNode}, run it, and return the node. + * + * @param graph the owning graph + * @param clazz the consolidated class containing deploy methods + * @param deployIndex the deploy method index (0, 1, ...) + * @param deps dependency nodes to wire in + * @return the completed service node + * @throws Exception if method lookup or invocation fails + */ + private static ServiceNode runDeploy(ServiceGraph graph, Class clazz, int deployIndex, + ServiceNode... deps) throws Exception { + Method deploy = clazz.getMethod("deploy$" + deployIndex, ServiceNode.class); + MethodHandle mh = MethodHandles.lookup().unreflect(deploy); + ServiceNode node = new ServiceNode("deploy-" + deployIndex, mh, graph, 0, List.of(deps)); + node.run(); + return node; + } + + /** + * Create a classloader that can define one or more generated classes. + * The input map uses internal names (with {@code /} separators); they + * are converted to dot-separated names for class loading. + * + * @param classes map of internal class name → class bytes + * @return a classloader that will define the classes on demand + */ + private static ClassLoader makeClassLoader(Map classes) { + Map dotNamed = new HashMap<>(); + for (var entry : classes.entrySet()) { + dotNamed.put(entry.getKey().replace('/', '.'), entry.getValue()); + } + return new ClassLoader(LambdaTransliteratorTest.class.getClassLoader()) { + @Override + protected Class findClass(String name) throws ClassNotFoundException { + byte[] bytes = dotNamed.get(name); + if (bytes != null) { + return defineClass(name, bytes, 0, bytes.length); + } + throw new ClassNotFoundException(name); + } + }; + } +} diff --git a/core/deployment/src/test/java/io/quarkus/core/deployment/service/impl/PhaseServicesTest.java b/core/deployment/src/test/java/io/quarkus/core/deployment/service/impl/PhaseServicesTest.java new file mode 100644 index 0000000000000..7ed01a5936a3f --- /dev/null +++ b/core/deployment/src/test/java/io/quarkus/core/deployment/service/impl/PhaseServicesTest.java @@ -0,0 +1,107 @@ +package io.quarkus.core.deployment.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import io.quarkus.core.Phase; +import io.quarkus.core.deployment.service.ServiceRegistrar; + +class PhaseServicesTest { + + enum TestEnum { + FOO, + BAR + } + + @Test + void testAfterEnumConstant() { + List metadataItems = new ArrayList<>(); + ServiceRegistrar builder = createBuilder(metadataItems); + + builder.forService(String.class) + .after(TestEnum.FOO) + .onStart(ctx -> "test"); + + assertThat(metadataItems).hasSize(1); + ServiceMetadataBuildItem meta = metadataItems.get(0); + assertThat(meta.dependencies()).hasSize(1); + Dependency dep = meta.dependencies().get(0); + assertThat(dep.key()).isEqualTo("io.quarkus.core.deployment.service.impl.PhaseServicesTest$TestEnum:FOO"); + } + + @Test + void testAfterPhaseEnumConstant() { + List metadataItems = new ArrayList<>(); + ServiceRegistrar builder = createBuilder(metadataItems); + + builder.forService(String.class) + .after(Phase.DATA) + .onStart(ctx -> "test"); + + assertThat(metadataItems).hasSize(1); + ServiceMetadataBuildItem meta = metadataItems.get(0); + assertThat(meta.dependencies()).hasSize(1); + Dependency dep = meta.dependencies().get(0); + assertThat(dep.key()).isEqualTo("io.quarkus.core.Phase:DATA"); + } + + @Test + void testBeforeEnumConstant() { + List metadataItems = new ArrayList<>(); + ServiceRegistrar builder = createBuilder(metadataItems); + + builder.forService(String.class) + .before(TestEnum.BAR) + .onStart(ctx -> "test"); + + // The metadata item won't have beforeKeys, but we can verify it doesn't throw. + assertThat(metadataItems).hasSize(1); + } + + @Test + void testBeforePhaseEnumConstantProhibited() { + List metadataItems = new ArrayList<>(); + ServiceRegistrar builder = createBuilder(metadataItems); + + assertThatThrownBy(() -> { + builder.forService(String.class) + .before(Phase.DATA) + .onStart(ctx -> "test"); + }) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("cannot declare a 'before' dependency on Phase"); + } + + @Test + void testBeforePhaseClassProhibited() { + List metadataItems = new ArrayList<>(); + ServiceRegistrar builder = createBuilder(metadataItems); + + assertThatThrownBy(() -> { + builder.forService(String.class) + .before(Phase.class) + .onStart(ctx -> "test"); + }) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("cannot declare a 'before' dependency on Phase"); + } + + private static ServiceRegistrar createBuilder(List metadataItems) { + return new ServiceRegistrarImpl( + m -> { + }, + s -> { + }, + metadataItems::add, + s -> { + }, + r -> { + }, + "testBuildStep"); + } +} diff --git a/core/deployment/src/test/java/io/quarkus/core/deployment/service/impl/ServiceRegistrarImplTest.java b/core/deployment/src/test/java/io/quarkus/core/deployment/service/impl/ServiceRegistrarImplTest.java new file mode 100644 index 0000000000000..8e8c258327373 --- /dev/null +++ b/core/deployment/src/test/java/io/quarkus/core/deployment/service/impl/ServiceRegistrarImplTest.java @@ -0,0 +1,117 @@ +package io.quarkus.core.deployment.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import io.quarkus.deployment.builditem.MainBytecodeRecorderBuildItem; +import io.quarkus.deployment.builditem.StaticBytecodeRecorderBuildItem; +import io.quarkus.deployment.recording.BytecodeRecorderImpl; +import io.quarkus.runtime.RuntimeValue; + +/** + * Tests for the service-to-recorder bridge methods in {@link ServiceRegistrarImpl}. + *

+ * Only the {@code serviceAsRuntimeValue} family is tested here because + * {@code getRecorderProxy} requires the deployment classloader + * ({@code visibleDefineClass}) for {@link io.quarkus.deployment.proxy.ProxyFactory}. + * Full end-to-end testing of {@code getRecorderProxy} is covered by + * integration tests during extension migration. + */ +class ServiceRegistrarImplTest { + + @Test + void serviceAsRuntimeValueReturnsReturnedProxy() { + List recorderItems = new ArrayList<>(); + ServiceRegistrarImpl builder = createBuilder(recorderItems, new ArrayList<>()); + + RuntimeValue rv = builder.serviceAsRuntimeValue(CharSequence.class); + + assertThat(rv).isInstanceOf(BytecodeRecorderImpl.ReturnedProxy.class); + BytecodeRecorderImpl.ReturnedProxy proxy = (BytecodeRecorderImpl.ReturnedProxy) rv; + assertThat(proxy.__returned$proxy$key()).isEqualTo("runtimevalue:java.lang.CharSequence:"); + assertThat(proxy.__static$$init()).isFalse(); + } + + @Test + void serviceAsRuntimeValueProducesWrapperAction() { + List recorderItems = new ArrayList<>(); + ServiceRegistrarImpl builder = createBuilder(recorderItems, new ArrayList<>()); + + builder.serviceAsRuntimeValue(CharSequence.class); + + assertThat(recorderItems).hasSize(1); + TransliteratedAction ta = recorderItems.get(0).getTransliteratedAction(); + assertThat(ta).isInstanceOf(TransliteratedAction.RuntimeValueWrapper.class); + TransliteratedAction.RuntimeValueWrapper rvw = (TransliteratedAction.RuntimeValueWrapper) ta; + assertThat(rvw.sourceServiceKey()).isEqualTo("java.lang.CharSequence:"); + assertThat(rvw.rvKey()).isEqualTo("runtimevalue:java.lang.CharSequence:"); + assertThat(rvw.staticInit()).isFalse(); + } + + @Test + void namedServiceAsRuntimeValueUsesNameInKey() { + List recorderItems = new ArrayList<>(); + ServiceRegistrarImpl builder = createBuilder(recorderItems, new ArrayList<>()); + + RuntimeValue rv = builder.serviceAsRuntimeValue(CharSequence.class, "primary"); + + BytecodeRecorderImpl.ReturnedProxy proxy = (BytecodeRecorderImpl.ReturnedProxy) rv; + assertThat(proxy.__returned$proxy$key()).isEqualTo("runtimevalue:java.lang.CharSequence:primary"); + } + + @Test + void staticInitServiceAsRuntimeValueSetsStaticInitFlag() { + List staticItems = new ArrayList<>(); + ServiceRegistrarImpl builder = createBuilder(new ArrayList<>(), staticItems); + + RuntimeValue rv = builder.staticInitServiceAsRuntimeValue(CharSequence.class); + + BytecodeRecorderImpl.ReturnedProxy proxy = (BytecodeRecorderImpl.ReturnedProxy) rv; + assertThat(proxy.__static$$init()).isTrue(); + + assertThat(staticItems).hasSize(1); + TransliteratedAction ta = staticItems.get(0).getTransliteratedAction(); + assertThat(ta).isInstanceOf(TransliteratedAction.RuntimeValueWrapper.class); + assertThat(ta.staticInit()).isTrue(); + } + + @Test + void namedStaticInitServiceAsRuntimeValueUsesNameInKey() { + List staticItems = new ArrayList<>(); + ServiceRegistrarImpl builder = createBuilder(new ArrayList<>(), staticItems); + + RuntimeValue rv = builder.staticInitServiceAsRuntimeValue(CharSequence.class, "named"); + + BytecodeRecorderImpl.ReturnedProxy proxy = (BytecodeRecorderImpl.ReturnedProxy) rv; + assertThat(proxy.__returned$proxy$key()).isEqualTo("runtimevalue:java.lang.CharSequence:named"); + assertThat(proxy.__static$$init()).isTrue(); + } + + // ── Helpers ── + + /** + * Create an {@link ServiceRegistrarImpl} with capturing lists for produced build items. + * + * @param recorderItems list to capture runtime recorder items + * @param staticItems list to capture static-init recorder items + * @return the action builder + */ + private static ServiceRegistrarImpl createBuilder( + List recorderItems, + List staticItems) { + return new ServiceRegistrarImpl( + recorderItems::add, + staticItems::add, + m -> { + }, + m -> { + }, + m -> { + }, + "testBuildStep"); + } +} diff --git a/core/deployment/src/test/java/io/quarkus/deployment/steps/ServiceDependencyValidatorTest.java b/core/deployment/src/test/java/io/quarkus/deployment/steps/ServiceDependencyValidatorTest.java new file mode 100644 index 0000000000000..057afa03d515d --- /dev/null +++ b/core/deployment/src/test/java/io/quarkus/deployment/steps/ServiceDependencyValidatorTest.java @@ -0,0 +1,334 @@ +package io.quarkus.deployment.steps; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import io.quarkus.core.Phase; +import io.quarkus.core.deployment.service.impl.Dependency; +import io.quarkus.core.deployment.service.impl.ServiceMetadataBuildItem; +import io.quarkus.core.deployment.service.impl.StaticServiceMetadataBuildItem; + +/** + * Tests for {@link ServiceDependencyValidator}. + */ +class ServiceDependencyValidatorTest { + + /** Convenience: create a runtime service metadata item with default APPLICATION phase. */ + private static ServiceMetadataBuildItem rt(Class type, List name, List deps, String step) { + return new ServiceMetadataBuildItem(type, name, deps, step, Phase.APPLICATION); + } + + // ── Existing runtime-only tests (adapted to two-arg signature) ── + + @Test + void emptyListPassesValidation() { + ServiceDependencyValidator.validate(List.of(), List.of()); + } + + @Test + void singleServiceWithNoDependenciesPasses() { + ServiceDependencyValidator.validate(List.of(), List.of( + rt(String.class, List.of(), List.of(), "TestStep#test"))); + } + + @Test + void satisfiedDependencyPasses() { + ServiceDependencyValidator.validate(List.of(), List.of( + rt(String.class, List.of(), List.of(), "StepA#a"), + rt(Integer.class, List.of(), + List.of(new Dependency(String.class, List.of(), 0)), + "StepB#b"))); + } + + @Test + void namedServicesSatisfyNamedDependencies() { + ServiceDependencyValidator.validate(List.of(), List.of( + rt(String.class, List.of("primary"), List.of(), "StepA#a"), + rt(Integer.class, List.of(), + List.of(new Dependency(String.class, List.of("primary"), 0)), + "StepB#b"))); + } + + @Test + void duplicateServiceDetected() { + assertThatThrownBy(() -> ServiceDependencyValidator.validate(List.of(), List.of( + rt(String.class, List.of(), List.of(), "StepA#a"), + rt(String.class, List.of(), List.of(), "StepB#b")))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Duplicate") + .hasMessageContaining("java.lang.String:") + .hasMessageContaining("StepA#a") + .hasMessageContaining("StepB#b"); + } + + @Test + void duplicateNamedServiceDetected() { + assertThatThrownBy(() -> ServiceDependencyValidator.validate(List.of(), List.of( + rt(String.class, List.of("foo"), List.of(), "StepA#a"), + rt(String.class, List.of("foo"), List.of(), "StepB#b")))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Duplicate") + .hasMessageContaining("java.lang.String:foo"); + } + + @Test + void differentNamesAreNotDuplicates() { + ServiceDependencyValidator.validate(List.of(), List.of( + rt(String.class, List.of("foo"), List.of(), "StepA#a"), + rt(String.class, List.of("bar"), List.of(), "StepB#b"))); + } + + @Test + void unsatisfiedDependencyDetected() { + assertThatThrownBy(() -> ServiceDependencyValidator.validate(List.of(), List.of( + rt(Integer.class, List.of(), + List.of(new Dependency(String.class, List.of(), 0)), + "StepA#a")))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Unsatisfied dependency") + .hasMessageContaining("java.lang.String:") + .hasMessageContaining("StepA#a"); + } + + @Test + void unsatisfiedNamedDependencyDetected() { + // provide unnamed String, but depend on named "foo" String + assertThatThrownBy(() -> ServiceDependencyValidator.validate(List.of(), List.of( + rt(String.class, List.of(), List.of(), "StepA#a"), + rt(Integer.class, List.of(), + List.of(new Dependency(String.class, List.of("foo"), 0)), + "StepB#b")))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Unsatisfied dependency") + .hasMessageContaining("java.lang.String:foo"); + } + + @Test + void directCycleDetected() { + assertThatThrownBy(() -> ServiceDependencyValidator.validate(List.of(), List.of( + rt(String.class, List.of(), + List.of(new Dependency(Integer.class, List.of(), 0)), + "StepA#a"), + rt(Integer.class, List.of(), + List.of(new Dependency(String.class, List.of(), 0)), + "StepB#b")))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("cycle"); + } + + @Test + void transitiveCycleDetected() { + assertThatThrownBy(() -> ServiceDependencyValidator.validate(List.of(), List.of( + rt(String.class, List.of(), + List.of(new Dependency(Long.class, List.of(), 0)), + "StepA#a"), + rt(Integer.class, List.of(), + List.of(new Dependency(String.class, List.of(), 0)), + "StepB#b"), + rt(Long.class, List.of(), + List.of(new Dependency(Integer.class, List.of(), 0)), + "StepC#c")))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("cycle"); + } + + @Test + void diamondDependencyIsNotACycle() { + // A depends on B and C; both B and C depend on D — no cycle + ServiceDependencyValidator.validate(List.of(), List.of( + rt(String.class, List.of("D"), List.of(), "StepD#d"), + rt(String.class, List.of("B"), + List.of(new Dependency(String.class, List.of("D"), 0)), + "StepB#b"), + rt(String.class, List.of("C"), + List.of(new Dependency(String.class, List.of("D"), 0)), + "StepC#c"), + rt(String.class, List.of("A"), + List.of(new Dependency(String.class, List.of("B"), 0), + new Dependency(String.class, List.of("C"), 0)), + "StepA#a"))); + } + + @Test + void multipleErrorsReportedTogether() { + // duplicate AND unsatisfied dependency + assertThatThrownBy(() -> ServiceDependencyValidator.validate(List.of(), List.of( + rt(String.class, List.of(), List.of(), "StepA#a"), + rt(String.class, List.of(), List.of(), "StepB#b"), + rt(Integer.class, List.of(), + List.of(new Dependency(Long.class, List.of(), 0)), + "StepC#c")))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Duplicate") + .hasMessageContaining("Unsatisfied dependency"); + } + + @Test + void serviceKeyFormat() { + assertThat(rt(String.class, List.of(), List.of(), "s").serviceKey()) + .isEqualTo("java.lang.String:"); + assertThat(rt(String.class, List.of("foo"), List.of(), "s").serviceKey()) + .isEqualTo("java.lang.String:foo"); + } + + @Test + void dependencyKeyFormat() { + assertThat(new Dependency(Integer.class, List.of(), 0).key()) + .isEqualTo("java.lang.Integer:"); + assertThat(new Dependency(Integer.class, List.of("bar"), 0).key()) + .isEqualTo("java.lang.Integer:bar"); + } + + // ── Static-init and cross-phase tests ── + + @Test + void staticInitGraphValidatesIndependently() { + // static-init services with satisfied deps within static graph + ServiceDependencyValidator.validate( + List.of( + new StaticServiceMetadataBuildItem(String.class, List.of(), List.of(), "StaticA#a"), + new StaticServiceMetadataBuildItem(Integer.class, List.of(), + List.of(new Dependency(String.class, List.of(), 0)), + "StaticB#b")), + List.of()); + } + + @Test + void runtimeCanDependOnStaticInitOutput() { + // static provides String, runtime depends on it + ServiceDependencyValidator.validate( + List.of( + new StaticServiceMetadataBuildItem(String.class, List.of(), List.of(), "StaticA#a")), + List.of( + rt(Integer.class, List.of(), + List.of(new Dependency(String.class, List.of(), 0)), + "RuntimeB#b"))); + } + + @Test + void crossPhaseDuplicateIsError() { + // same type+name in both phases + assertThatThrownBy(() -> ServiceDependencyValidator.validate( + List.of( + new StaticServiceMetadataBuildItem(String.class, List.of(), List.of(), "StaticA#a")), + List.of( + rt(String.class, List.of(), List.of(), "RuntimeB#b")))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Cross-phase duplicate") + .hasMessageContaining("java.lang.String:"); + } + + @Test + void staticInitCycleDetected() { + // cycle within the static-init graph + assertThatThrownBy(() -> ServiceDependencyValidator.validate( + List.of( + new StaticServiceMetadataBuildItem(String.class, List.of(), + List.of(new Dependency(Integer.class, List.of(), 0)), + "StaticA#a"), + new StaticServiceMetadataBuildItem(Integer.class, List.of(), + List.of(new Dependency(String.class, List.of(), 0)), + "StaticB#b")), + List.of())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("cycle") + .hasMessageContaining("static-init"); + } + + @Test + void staticCannotDependOnRuntimeOutput() { + // static service depends on something only provided by runtime — should fail + assertThatThrownBy(() -> ServiceDependencyValidator.validate( + List.of( + new StaticServiceMetadataBuildItem(Integer.class, List.of(), + List.of(new Dependency(String.class, List.of(), 0)), + "StaticA#a")), + List.of( + rt(String.class, List.of(), List.of(), "RuntimeB#b")))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Unsatisfied dependency") + .hasMessageContaining("static-init"); + } + + @Test + void staticDuplicateWithinPhaseDetected() { + // two static-init services with same key + assertThatThrownBy(() -> ServiceDependencyValidator.validate( + List.of( + new StaticServiceMetadataBuildItem(String.class, List.of(), List.of(), "StaticA#a"), + new StaticServiceMetadataBuildItem(String.class, List.of(), List.of(), "StaticB#b")), + List.of())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Duplicate static-init") + .hasMessageContaining("java.lang.String:"); + } + + @Test + void bothPhasesCanHaveDifferentTypesWithoutConflict() { + // static provides String, runtime provides Integer — no conflict + ServiceDependencyValidator.validate( + List.of( + new StaticServiceMetadataBuildItem(String.class, List.of(), List.of(), "StaticA#a")), + List.of( + rt(Integer.class, List.of(), List.of(), "RuntimeB#b"))); + } + + // ── Phase ordering validation tests ── + + @Test + void samePhaseDepPasses() { + ServiceDependencyValidator.validate(List.of(), List.of( + new ServiceMetadataBuildItem(String.class, List.of(), List.of(), "StepA#a", Phase.DATA), + new ServiceMetadataBuildItem(Integer.class, List.of(), + List.of(new Dependency(String.class, List.of(), 0)), + "StepB#b", Phase.DATA))); + } + + @Test + void earlierPhaseDepPasses() { + ServiceDependencyValidator.validate(List.of(), List.of( + new ServiceMetadataBuildItem(String.class, List.of(), List.of(), "StepA#a", Phase.INFRASTRUCTURE), + new ServiceMetadataBuildItem(Integer.class, List.of(), + List.of(new Dependency(String.class, List.of(), 0)), + "StepB#b", Phase.DATA))); + } + + @Test + void laterPhaseDepIsError() { + assertThatThrownBy(() -> ServiceDependencyValidator.validate(List.of(), List.of( + new ServiceMetadataBuildItem(String.class, List.of(), List.of(), "StepA#a", Phase.APPLICATION), + new ServiceMetadataBuildItem(Integer.class, List.of(), + List.of(new Dependency(String.class, List.of(), 0)), + "StepB#b", Phase.INFRASTRUCTURE)))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Phase ordering violation") + .hasMessageContaining("INFRASTRUCTURE") + .hasMessageContaining("APPLICATION"); + } + + @Test + void optionalDepToLaterPhaseIsStillError() { + assertThatThrownBy(() -> ServiceDependencyValidator.validate(List.of(), List.of( + new ServiceMetadataBuildItem(String.class, List.of(), List.of(), "StepA#a", Phase.SERVING), + new ServiceMetadataBuildItem(Integer.class, List.of(), + List.of(new Dependency(String.class, List.of(), Dependency.FL_INJECTED | Dependency.FL_OPTIONAL)), + "StepB#b", Phase.CONFIG)))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Phase ordering violation"); + } + + @Test + void afterDepToLaterPhaseIsError() { + assertThatThrownBy(() -> ServiceDependencyValidator.validate(List.of(), List.of( + new ServiceMetadataBuildItem(String.class, List.of(), List.of(), "StepA#a", Phase.APPLICATION), + new ServiceMetadataBuildItem(Integer.class, List.of(), + List.of(new Dependency(String.class, List.of(), Dependency.FL_OPTIONAL)), + "StepB#b", Phase.INFRASTRUCTURE)))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Phase ordering violation"); + } +} diff --git a/core/pom.xml b/core/pom.xml index 4bcbd49234e2f..47a06b95c62d6 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -14,6 +14,7 @@ Quarkus - Core pom + service-types-maker deployment runtime processor diff --git a/core/runtime/src/main/java/io/quarkus/core/AsyncStartContext.java b/core/runtime/src/main/java/io/quarkus/core/AsyncStartContext.java new file mode 100644 index 0000000000000..0d917d0513c40 --- /dev/null +++ b/core/runtime/src/main/java/io/quarkus/core/AsyncStartContext.java @@ -0,0 +1,56 @@ +package io.quarkus.core; + +/** + * A context which is consumed by a service that starts asynchronously + * and optionally needs to register a handler for stopping. + *

+ * Exactly one completion method ({@link #startComplete(Object)}, {@link #startFailed(Throwable)}, + * {@link #startCanceled()}) must be called to signal the outcome of the async start. + * The completion methods follow a precedence model: + *

    + *
  • Failure is final: no other completion method may be called after + * {@link #startFailed(Throwable)}, and {@code startFailed} may not be called + * after any other completion method.
  • + *
  • Cancellation supersedes completion: {@link #startComplete(Object)} may not be + * called after {@link #startCanceled()}.
  • + *
  • Cancellation after completion is harmless: calling {@link #startCanceled()} after + * {@link #startComplete(Object)} is a no-op.
  • + *
  • Cancellation is idempotent: calling {@link #startCanceled()} more than once + * has no additional effect.
  • + *
  • For typed services, {@link #startComplete(Object)} is not idempotent: + * calling it more than once throws {@link IllegalStateException}.
  • + *
+ * + * @param the service type + */ +public interface AsyncStartContext extends StartContext { + /** + * Indicate that the service start is complete. + * For typed services, this method is not idempotent. + * + * @param value the service value (must not be {@code null}) + * @throws IllegalStateException if any completion method has already been called + */ + void startComplete(T value); + + /** + * Indicate that the service start was canceled. + * Dependents which consume this service non-optionally will fail to start. + * Dependents which consume this service optionally will receive an empty {@code Optional}. + *

+ * This method is idempotent, and is a harmless no-op if {@link #startComplete(Object)} + * was already called. + * + * @throws IllegalStateException if {@link #startFailed(Throwable)} has already been called + */ + void startCanceled(); + + /** + * Indicate that the service start has failed. + * This method must be the first and only completion method called. + * + * @param e the thrown exception (must not be {@code null}) + * @throws IllegalStateException if any completion method has already been called + */ + void startFailed(Throwable e); +} diff --git a/core/runtime/src/main/java/io/quarkus/core/AsyncStopContext.java b/core/runtime/src/main/java/io/quarkus/core/AsyncStopContext.java new file mode 100644 index 0000000000000..5a0ef2bb25f0d --- /dev/null +++ b/core/runtime/src/main/java/io/quarkus/core/AsyncStopContext.java @@ -0,0 +1,9 @@ +package io.quarkus.core; + +public interface AsyncStopContext { + /** + * Indicate that the service has stopped. + * This method is idempotent (calling it again will have no additional effect). + */ + void stopComplete(); +} diff --git a/core/runtime/src/main/java/io/quarkus/core/AsyncVoidStartContext.java b/core/runtime/src/main/java/io/quarkus/core/AsyncVoidStartContext.java new file mode 100644 index 0000000000000..0772b76d729c8 --- /dev/null +++ b/core/runtime/src/main/java/io/quarkus/core/AsyncVoidStartContext.java @@ -0,0 +1,56 @@ +package io.quarkus.core; + +/** + * A context which is consumed by a void service that starts asynchronously + * and optionally needs to register a handler for stopping. + *

+ * Unlike {@link AsyncStartContext}, this context is for services that + * produce no value ({@link Void}-typed services). The {@link #startComplete()} + * method takes no arguments. + *

+ * Exactly one completion method ({@link #startComplete()}, {@link #startFailed(Throwable)}, + * {@link #startCanceled()}) must be called to signal the outcome of the async start. + * The completion methods follow a precedence model: + *

    + *
  • Failure is final: no other completion method may be called after + * {@link #startFailed(Throwable)}, and {@code startFailed} may not be called + * after any other completion method.
  • + *
  • Cancellation supersedes completion: {@link #startComplete()} may not be + * called after {@link #startCanceled()}.
  • + *
  • Cancellation after completion is harmless: calling {@link #startCanceled()} after + * {@link #startComplete()} is a no-op.
  • + *
  • Both {@link #startComplete()} and {@link #startCanceled()} are individually + * idempotent.
  • + *
+ */ +public interface AsyncVoidStartContext extends StartContext { + /** + * Indicate that the service start is complete. + * This method is idempotent; subsequent calls have no effect. + * + * @throws IllegalStateException if {@link #startCanceled()} or + * {@link #startFailed(Throwable)} has already been called + */ + void startComplete(); + + /** + * Indicate that the service start was canceled. + * Dependents which ordered themselves after this service will not be affected; + * however, dependents which consume this service non-optionally will fail to start. + *

+ * This method is idempotent, and is a harmless no-op if {@link #startComplete()} + * was already called. + * + * @throws IllegalStateException if {@link #startFailed(Throwable)} has already been called + */ + void startCanceled(); + + /** + * Indicate that the service start has failed. + * This method must be the first and only completion method called. + * + * @param e the thrown exception (must not be {@code null}) + * @throws IllegalStateException if any completion method has already been called + */ + void startFailed(Throwable e); +} diff --git a/core/runtime/src/main/java/io/quarkus/core/Phase.java b/core/runtime/src/main/java/io/quarkus/core/Phase.java new file mode 100644 index 0000000000000..1a243ba5f1923 --- /dev/null +++ b/core/runtime/src/main/java/io/quarkus/core/Phase.java @@ -0,0 +1,160 @@ +package io.quarkus.core; + +/** + * Classifies runtime-init services into ordered phases of application startup. + *

+ * Each phase represents a logical stage in the progression from "configuration loaded" to + * "application actively serving requests." Phases impose a partial ordering constraint + * on the service dependency graph: a service may only depend on services in the same phase or + * an earlier phase. The runtime enforces this constraint at build time; a dependency that + * violates it is a build error. + *

+ * Phases are not barriers. The runtime does not wait for all services in + * phase N to complete before starting services in phase N+1. Services + * start as soon as their individual dependencies are satisfied, so services from different + * phases may execute concurrently. The phase classification constrains which dependency + * relationships are legal, but within those constraints, startup proceeds with maximum + * parallelism. Think of phases as a monotonic progression through the startup sequence, + * not as synchronized checkpoints. + *

+ * Phases also serve as control points for startup modes that do not require a full + * application lifecycle: + *

    + *
  • Init-and-exit ({@code quarkus.init-and-exit=true}): runs through + * {@link #INIT}, then stops. Migrations and validation tasks complete; the application + * never serves requests. {@link #BINDING} is skipped (controlled by runtime flag).
  • + *
  • AppCDS generation: runs through {@link #INFRASTRUCTURE} (or less), + * then exits after dumping the class archive.
  • + *
  • CRaC checkpoint: runs through a chosen phase, takes a checkpoint, + * then resumes from the next phase on restore.
  • + *
  • Dev mode reload: lower phases persist across hot reloads; upper + * phases are torn down and rebuilt when application code changes.
  • + *
+ */ +public enum Phase { + + /** + * Static initialization. + *

+ * Services in this phase run during the static initialization of the generated + * application class ({@code }). Their results are available to all + * subsequent phases. In native image mode, static-init values are baked into + * the image heap. + *

+ * Stop handlers registered via {@link StartContext#onStop(Runnable)} + * run during application shutdown, providing symmetric teardown. + */ + STATIC_INIT, + + /** + * Configuration sources and mappings. + *

+ * Services in this phase make runtime configuration available to all subsequent phases. + * This includes registering configuration property classes, validating runtime config + * values, and reporting deprecated or unknown configuration. + *

+ * Configuration source setup (parsing property files, environment variables, + * etc.) occurs during static init. This phase handles runtime-specific configuration + * tasks that depend on the values being fully resolved. + */ + CONFIG, + + /** + * Log manager reconfiguration with runtime configuration. + *

+ * The log manager itself is installed during static init with bootstrap defaults. + * Services in this phase reconfigure it with the real runtime configuration: setting + * log levels per category, installing log handlers, applying formatters, and + * integrating the startup banner. + *

+ * Extensions that export logs through higher-layer mechanisms should install a + * queued handler during this phase and attach the actual drain target in + * a later phase when its dependencies are available. This avoids circular + * dependencies between logging and infrastructure while ensuring no log records + * are lost during the gap. + */ + LOGGING, + + /** + * Core runtime plumbing. + *

+ * Services in this phase create the foundational infrastructure that higher phases + * depend on: event loops, thread pool executors, TLS registries, network transport + * configuration, transaction managers, observability SDKs, context propagation + * mechanisms, and security providers. + *

+ * Infrastructure services are the most stable across dev mode reloads and are + * expected to survive classloader reconstruction in most cases. + */ + INFRASTRUCTURE, + + /** + * Server socket and transport binding. + *

+ * Services in this phase bind server sockets, reserve ports, and establish transport + * endpoints. Binding happens early so that port conflicts are detected before + * expensive initialization work in later phases. + *

+ * Binding does not start accepting connections; that happens in + * {@link #SERVING}. The bound sockets are inert until the application is fully + * wired and ready to handle requests. + *

+ * In dev mode, bound sockets persist across reloads — the port stays open while + * handlers are recycled. In init-and-exit mode, this phase is skipped entirely + * (controlled by a runtime flag) since there is no point binding a socket that + * will never serve. + */ + BINDING, + + /** + * Data connections and resource pools. + *

+ * Services in this phase establish connections to external data systems: connection + * pools, client connections, directory services, and similar resources. These + * connections are the foundation for both initialization tasks (migrations, schema + * validation) and ongoing application data access. + *

+ * Security realms that depend on external data sources also belong in this phase, + * since their construction requires an established data connection. + */ + DATA, + + /** + * One-time initialization tasks. + *

+ * Services in this phase perform work whose value is in its durable side effect: + * database schema migrations, schema validation, persistence layer startup, cache + * warming, security domain assembly, and eager configuration validation. Once an + * init task completes, its outcome survives process exit. + *

+ * In init-and-exit mode, startup proceeds through this phase and then stops — + * all initialization tasks complete, but the application never enters + * {@link #APPLICATION} or {@link #SERVING}. + */ + INIT, + + /** + * Application-level wiring. + *

+ * Services in this phase register the application's request-handling and event-processing + * logic: routes and filters, authentication mechanisms, service handlers, event + * consumers, and scheduled task definitions. + *

+ * This is typically the largest phase. Most extensions that provide user-facing + * functionality register their handlers here. + */ + APPLICATION, + + /** + * Start accepting work. + *

+ * Services in this phase transition the application from "initialized" to "actively + * processing." The specific mechanism depends on the deployment model: accepting + * connections on bound sockets, polling message brokers, starting serverless + * invocation loops, or firing scheduled tasks. + *

+ * This phase is skipped in init-and-exit mode, during class data archive generation, + * and at checkpoint boundaries. It is the last phase in a normal startup sequence. + */ + SERVING, +} diff --git a/core/runtime/src/main/java/io/quarkus/core/RunnableHandle.java b/core/runtime/src/main/java/io/quarkus/core/RunnableHandle.java new file mode 100644 index 0000000000000..6dfe8b0ef3b9e --- /dev/null +++ b/core/runtime/src/main/java/io/quarkus/core/RunnableHandle.java @@ -0,0 +1,38 @@ +package io.quarkus.core; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodType; + +/** + * A simple wrapper around a method handle and captured value which allows + * invocation of the handle via the {@code Runnable} interface + * without involving the lambda metafactory. + */ +final class RunnableHandle implements Runnable { + private final MethodHandle handle; + private final Object argument; + + /** + * Construct a new instance. + * + * @param handle the method handle which accepts a single argument (must not be {@code null}) + * @param argument the argument to pass to the handle + */ + public RunnableHandle(final MethodHandle handle, final Object argument) { + this.handle = handle.asType(MethodType.methodType(void.class, Object.class)); + this.argument = argument; + } + + public void run() { + try { + handle.invokeExact(argument); + } catch (Throwable e) { + throw sneak(e); + } + } + + @SuppressWarnings("unchecked") + private static RuntimeException sneak(Throwable e) throws E { + throw (E) e; + } +} diff --git a/core/runtime/src/main/java/io/quarkus/core/StartContext.java b/core/runtime/src/main/java/io/quarkus/core/StartContext.java new file mode 100644 index 0000000000000..9728bb6a94a00 --- /dev/null +++ b/core/runtime/src/main/java/io/quarkus/core/StartContext.java @@ -0,0 +1,40 @@ +package io.quarkus.core; + +import java.util.function.Consumer; + +/** + * A context which may be consumed by a service that needs to register + * a handler for stopping. + */ +public interface StartContext { + /** + * Register a synchronous stop handler. + * The service is considered to be stopped when the task returns. + * If an exception is thrown by the handler, it will be logged, + * and the service will be considered to be stopped. + * + * @param stopper the stop handler (must not be {@code null}) + */ + default void onStop(Runnable stopper) { + onStopAsync(ctxt -> { + try { + stopper.run(); + } finally { + ctxt.stopComplete(); + } + }); + } + + /** + * Register an asynchronous stop handler. + * The context passed to the handler must be used to indicate when the + * service is stopped. + * The handler is initially called directly from the calling thread, + * but may then schedule subsequent asynchronous work to be done. + * The handler (and any related subordinate task) must not throw exceptions, + * because any thrown exceptions cannot be captured. + * + * @param stopper the stop handler (must not be {@code null}) + */ + void onStopAsync(Consumer stopper); +} diff --git a/core/runtime/src/main/java/io/quarkus/core/impl/AsyncStartContextImpl.java b/core/runtime/src/main/java/io/quarkus/core/impl/AsyncStartContextImpl.java new file mode 100644 index 0000000000000..8120761ca9b90 --- /dev/null +++ b/core/runtime/src/main/java/io/quarkus/core/impl/AsyncStartContextImpl.java @@ -0,0 +1,138 @@ +package io.quarkus.core.impl; + +import java.lang.invoke.ConstantBootstraps; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.VarHandle; +import java.util.concurrent.CountDownLatch; +import java.util.function.Consumer; + +import io.quarkus.core.AsyncStartContext; +import io.quarkus.core.AsyncStopContext; +import io.quarkus.runtime.ShutdownContext; + +/** + * {@link AsyncStartContext} implementation for sequential typed service execution. + *

+ * Stop handlers registered via {@link #onStop(Runnable)} or {@link #onStopAsync(Consumer)} + * are delegated to the {@link ShutdownContext} provided at construction time. + *

+ * For async services, this implementation blocks the calling thread: {@link #startComplete(Object)} + * stores the value, {@link #startFailed(Throwable)} wraps and rethrows, and + * {@link #startCanceled()} stores {@code null}. + *

+ * Passing {@code null} to {@link #startComplete(Object)} is an error; use a void service if + * the service produces no value. + *

+ * Completion methods follow the precedence rules defined by {@link AsyncStartContext}. + * + * @param the service type + */ +public final class AsyncStartContextImpl implements AsyncStartContext { + + private static final int S_PENDING = 0; + private static final int S_COMPLETED = 1; + private static final int S_CANCELED = 2; + private static final int S_FAILED = 3; + + private static final VarHandle stateHandle = ConstantBootstraps.fieldVarHandle( + MethodHandles.lookup(), "state", VarHandle.class, AsyncStartContextImpl.class, int.class); + + private final ShutdownContext shutdownContext; + @SuppressWarnings("unused") // managed by VarHandle + private volatile int state; + private T value; + private Throwable failure; + + /** + * Construct a new instance. + * + * @param shutdownContext the shutdown context to delegate stop handlers to (must not be {@code null}) + */ + public AsyncStartContextImpl(ShutdownContext shutdownContext) { + this.shutdownContext = shutdownContext; + } + + @Override + public void onStopAsync(Consumer stopper) { + // the latch blocks the shutdown thread until stopComplete() is called + shutdownContext.addShutdownTask(() -> { + CountDownLatch latch = new CountDownLatch(1); + stopper.accept(latch::countDown); + boolean intr = false; + try { + while (latch.getCount() > 0) { + try { + latch.await(); + } catch (InterruptedException e) { + intr = true; + } + } + } finally { + if (intr) { + Thread.currentThread().interrupt(); + } + } + }); + } + + @Override + public void startComplete(T value) { + if (!stateHandle.compareAndSet(this, S_PENDING, S_COMPLETED)) { + throw new IllegalStateException("A completion method has already been called"); + } + if (value == null) { + throw new IllegalArgumentException("Service value must not be null; use a void service instead"); + } + this.value = value; + } + + @Override + public void startCanceled() { + // cancellation is idempotent and harmless after completion; only failure is final + int prev; + do { + prev = (int) stateHandle.getVolatile(this); + if (prev == S_FAILED) { + throw new IllegalStateException("Cannot cancel: the service has already failed"); + } + if (prev == S_CANCELED) { + return; + } + } while ((int) stateHandle.compareAndExchange(this, prev, S_CANCELED) != prev); + } + + @Override + public void startFailed(Throwable e) { + if (!stateHandle.compareAndSet(this, S_PENDING, S_FAILED)) { + throw new IllegalStateException("A completion method has already been called"); + } + this.failure = e; + } + + /** + * Get the service value after the action has completed. + * This method should only be called after the action lambda has returned. + * + * @return the service value, or {@code null} if the service was canceled + * @throws RuntimeException if the service start failed + */ + public T getValueOrThrow() { + if (failure != null) { + if (failure instanceof RuntimeException re) { + throw re; + } + throw new RuntimeException("Service start failed", failure); + } + return value; + } + + /** + * Determine whether the async action has signalled completion. + * + * @return {@code true} if {@link #startComplete}, {@link #startCanceled}, + * or {@link #startFailed} has been called + */ + public boolean isCompleted() { + return (int) stateHandle.getVolatile(this) != S_PENDING; + } +} diff --git a/core/runtime/src/main/java/io/quarkus/core/impl/AsyncVoidStartContextImpl.java b/core/runtime/src/main/java/io/quarkus/core/impl/AsyncVoidStartContextImpl.java new file mode 100644 index 0000000000000..177289eeb818b --- /dev/null +++ b/core/runtime/src/main/java/io/quarkus/core/impl/AsyncVoidStartContextImpl.java @@ -0,0 +1,135 @@ +package io.quarkus.core.impl; + +import java.lang.invoke.ConstantBootstraps; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.VarHandle; +import java.util.concurrent.CountDownLatch; +import java.util.function.Consumer; + +import io.quarkus.core.AsyncStopContext; +import io.quarkus.core.AsyncVoidStartContext; +import io.quarkus.runtime.ShutdownContext; + +/** + * {@link AsyncVoidStartContext} implementation for sequential void service execution. + *

+ * Stop handlers registered via {@link #onStop(Runnable)} or {@link #onStopAsync(Consumer)} + * are delegated to the {@link ShutdownContext} provided at construction time. + *

+ * For async void services, this implementation blocks the calling thread: + * {@link #startComplete()} signals success, {@link #startFailed(Throwable)} wraps and rethrows, + * and {@link #startCanceled()} signals cancellation. + *

+ * Completion methods follow the precedence rules defined by {@link AsyncVoidStartContext}. + */ +public final class AsyncVoidStartContextImpl implements AsyncVoidStartContext { + + private static final int S_PENDING = 0; + private static final int S_COMPLETED = 1; + private static final int S_CANCELED = 2; + private static final int S_FAILED = 3; + + private static final VarHandle stateHandle = ConstantBootstraps.fieldVarHandle( + MethodHandles.lookup(), "state", VarHandle.class, AsyncVoidStartContextImpl.class, int.class); + + private final ShutdownContext shutdownContext; + @SuppressWarnings("unused") // managed by VarHandle + private volatile int state; + private Throwable failure; + + /** + * Construct a new instance. + * + * @param shutdownContext the shutdown context to delegate stop handlers to (must not be {@code null}) + */ + public AsyncVoidStartContextImpl(ShutdownContext shutdownContext) { + this.shutdownContext = shutdownContext; + } + + @Override + public void onStopAsync(Consumer stopper) { + // the latch blocks the shutdown thread until stopComplete() is called + shutdownContext.addShutdownTask(() -> { + CountDownLatch latch = new CountDownLatch(1); + stopper.accept(latch::countDown); + boolean intr = false; + try { + while (latch.getCount() > 0) { + try { + latch.await(); + } catch (InterruptedException e) { + intr = true; + } + } + } finally { + if (intr) { + Thread.currentThread().interrupt(); + } + } + }); + } + + @Override + public void startComplete() { + // idempotent; throws only if canceled or failed + int prev; + do { + prev = (int) stateHandle.getVolatile(this); + if (prev == S_COMPLETED) { + return; + } + if (prev != S_PENDING) { + throw new IllegalStateException("Cannot complete: the service has already " + + (prev == S_CANCELED ? "been canceled" : "failed")); + } + } while ((int) stateHandle.compareAndExchange(this, prev, S_COMPLETED) != prev); + } + + @Override + public void startCanceled() { + // idempotent; harmless no-op after completion; only failure is final + int prev; + do { + prev = (int) stateHandle.getVolatile(this); + if (prev == S_FAILED) { + throw new IllegalStateException("Cannot cancel: the service has already failed"); + } + if (prev == S_CANCELED || prev == S_COMPLETED) { + return; + } + } while ((int) stateHandle.compareAndExchange(this, prev, S_CANCELED) != prev); + } + + @Override + public void startFailed(Throwable e) { + if (!stateHandle.compareAndSet(this, S_PENDING, S_FAILED)) { + throw new IllegalStateException("A completion method has already been called"); + } + this.failure = e; + } + + /** + * Check for failure after the action has completed. + * This method should only be called after the action lambda has returned. + * + * @throws RuntimeException if the service start failed + */ + public void checkFailure() { + if (failure != null) { + if (failure instanceof RuntimeException re) { + throw re; + } + throw new RuntimeException("Service start failed", failure); + } + } + + /** + * Determine whether the async action has signalled completion. + * + * @return {@code true} if {@link #startComplete()}, {@link #startCanceled()}, + * or {@link #startFailed(Throwable)} has been called + */ + public boolean isCompleted() { + return (int) stateHandle.getVolatile(this) != S_PENDING; + } +} diff --git a/core/runtime/src/main/java/io/quarkus/core/impl/JfrServiceMonitor.java b/core/runtime/src/main/java/io/quarkus/core/impl/JfrServiceMonitor.java new file mode 100644 index 0000000000000..6c99020776524 --- /dev/null +++ b/core/runtime/src/main/java/io/quarkus/core/impl/JfrServiceMonitor.java @@ -0,0 +1,166 @@ +package io.quarkus.core.impl; + +import java.util.concurrent.ConcurrentHashMap; + +import jdk.jfr.Category; +import jdk.jfr.Description; +import jdk.jfr.Event; +import jdk.jfr.Label; +import jdk.jfr.Name; +import jdk.jfr.StackTrace; + +/** + * A JFR-enabled service monitor that fires JDK Flight Recorder events + * for service lifecycle transitions. + */ +public final class JfrServiceMonitor extends ServiceMonitor { + + /** + * Map holding active startup events indexed by service name. + */ + private final ConcurrentHashMap activeStarts = new ConcurrentHashMap<>(); + + /** + * Map holding active stop events indexed by service name. + */ + private final ConcurrentHashMap activeStops = new ConcurrentHashMap<>(); + + /** + * The singleton instance of the JFR service monitor. + */ + public static final JfrServiceMonitor INSTANCE = new JfrServiceMonitor(); + + /** + * Private constructor. + */ + private JfrServiceMonitor() { + } + + /** + * {@inheritDoc} + */ + @Override + public void startInitiated(String name) { + ServiceStartEvent event = new ServiceStartEvent(); + event.serviceName = name; + event.begin(); + activeStarts.put(name, event); + } + + /** + * {@inheritDoc} + */ + @Override + public void startComplete(String name) { + ServiceStartEvent event = activeStarts.remove(name); + if (event != null) { + event.commit(); + } + } + + /** + * {@inheritDoc} + */ + @Override + public void startFailed(String name, String reason) { + ServiceStartEvent event = activeStarts.remove(name); + if (event != null) { + event.failed = true; + event.failureReason = reason; + event.commit(); + } + } + + /** + * {@inheritDoc} + */ + @Override + public void stopInitiated(String name) { + ServiceStopEvent event = new ServiceStopEvent(); + event.serviceName = name; + event.begin(); + activeStops.put(name, event); + } + + /** + * {@inheritDoc} + */ + @Override + public void stopComplete(String name) { + ServiceStopEvent event = activeStops.remove(name); + if (event != null) { + event.commit(); + } + } + + /** + * {@inheritDoc} + */ + @Override + public void stopFailed(String name, String reason) { + ServiceStopEvent event = activeStops.remove(name); + if (event != null) { + event.failed = true; + event.failureReason = reason; + event.commit(); + } + } + + /** + * JFR event representing a Quarkus service startup lifecycle transition. + */ + @Label("Service Start") + @Category({ "Quarkus", "Service" }) + @Name("quarkus.ServiceStart") + @Description("Quarkus service startup lifecycle transition") + @StackTrace(false) + public static class ServiceStartEvent extends Event { + + /** + * The name/key of the service. + */ + @Label("Service Name") + public String serviceName; + + /** + * Whether the service startup failed. + */ + @Label("Failed") + public boolean failed; + + /** + * The failure reason/exception representation if failed. + */ + @Label("Failure Reason") + public String failureReason; + } + + /** + * JFR event representing a Quarkus service stop lifecycle transition. + */ + @Label("Service Stop") + @Category({ "Quarkus", "Service" }) + @Name("quarkus.ServiceStop") + @Description("Quarkus service stop lifecycle transition") + @StackTrace(false) + public static class ServiceStopEvent extends Event { + + /** + * The name/key of the service. + */ + @Label("Service Name") + public String serviceName; + + /** + * Whether the service stop failed. + */ + @Label("Failed") + public boolean failed; + + /** + * The failure reason/exception representation if failed. + */ + @Label("Failure Reason") + public String failureReason; + } +} diff --git a/core/runtime/src/main/java/io/quarkus/core/impl/LastShutdownTasks.java b/core/runtime/src/main/java/io/quarkus/core/impl/LastShutdownTasks.java new file mode 100644 index 0000000000000..aefdb39a3e06e --- /dev/null +++ b/core/runtime/src/main/java/io/quarkus/core/impl/LastShutdownTasks.java @@ -0,0 +1,98 @@ +package io.quarkus.core.impl; + +import java.util.ArrayDeque; + +import org.jboss.logging.Logger; + +/** + * Global collector for "last" shutdown tasks registered via + * {@link io.quarkus.runtime.ShutdownContext#addLastShutdownTask(Runnable)}. + *

+ * Tasks are collected during application startup and executed during + * the static-init graph's stop cascade, after all runtime services and + * Arc bean destruction have completed. This preserves the original + * "runs last" semantics that the per-node {@code NodeShutdownContext} + * cannot provide. + *

+ * A static-init service in the deployment module registers a stop handler + * that calls {@link #runStatic()} to execute all collected tasks. + * + * @see NodeShutdownContext + */ +public final class LastShutdownTasks { + + private static final Logger LOG = Logger.getLogger("io.quarkus.service"); + + /** + * The static-init "last" shutdown tasks. + */ + private static final ArrayDeque staticTasks = new ArrayDeque<>(); + + /** + * The runtime-init "last" shutdown tasks. + */ + private static final ArrayDeque runtimeTasks = new ArrayDeque<>(); + + private LastShutdownTasks() { + } + + /** + * Add a task to the global static-init "last" shutdown list. + * Called by {@link NodeShutdownContext#addLastShutdownTask(Runnable)}. + * + * @param task the shutdown task (must not be {@code null}) + */ + public static void addStatic(Runnable task) { + synchronized (staticTasks) { + staticTasks.addFirst(task); + } + } + + /** + * Add a task to the global runtime-init "last" shutdown list. + * Called by {@link NodeShutdownContext#addLastShutdownTask(Runnable)}. + * + * @param task the shutdown task (must not be {@code null}) + */ + public static void addRuntime(Runnable task) { + synchronized (runtimeTasks) { + runtimeTasks.addFirst(task); + } + } + + /** + * Execute all registered static-init tasks in LIFO order, then clear the list. + * Called by the static-init cleanup service's stop handler. + */ + public static void runStatic() { + runTasks(staticTasks); + } + + /** + * Execute all registered runtime-init tasks in LIFO order, then clear the list. + * Called by the runtime-init cleanup service's stop handler. + */ + public static void runRuntime() { + runTasks(runtimeTasks); + } + + /** + * Run all tasks in the provided queue in LIFO order. + * + * @param queue the queue of tasks to execute + */ + private static void runTasks(ArrayDeque queue) { + Runnable[] snapshot; + synchronized (queue) { + snapshot = queue.toArray(Runnable[]::new); + queue.clear(); + } + for (Runnable task : snapshot) { + try { + task.run(); + } catch (Throwable t) { + LOG.error("Running a last shutdown task failed", t); + } + } + } +} diff --git a/core/runtime/src/main/java/io/quarkus/core/impl/NoOpServiceMonitor.java b/core/runtime/src/main/java/io/quarkus/core/impl/NoOpServiceMonitor.java new file mode 100644 index 0000000000000..283fa0231dc3d --- /dev/null +++ b/core/runtime/src/main/java/io/quarkus/core/impl/NoOpServiceMonitor.java @@ -0,0 +1,60 @@ +package io.quarkus.core.impl; + +/** + * A service monitor that does nothing. + */ +public final class NoOpServiceMonitor extends ServiceMonitor { + + /** + * The singleton instance of the no-op service monitor. + */ + public static final NoOpServiceMonitor INSTANCE = new NoOpServiceMonitor(); + + /** + * Private constructor. + */ + private NoOpServiceMonitor() { + } + + /** + * {@inheritDoc} + */ + @Override + public void startInitiated(String name) { + } + + /** + * {@inheritDoc} + */ + @Override + public void startComplete(String name) { + } + + /** + * {@inheritDoc} + */ + @Override + public void startFailed(String name, String reason) { + } + + /** + * {@inheritDoc} + */ + @Override + public void stopInitiated(String name) { + } + + /** + * {@inheritDoc} + */ + @Override + public void stopComplete(String name) { + } + + /** + * {@inheritDoc} + */ + @Override + public void stopFailed(String name, String reason) { + } +} diff --git a/core/runtime/src/main/java/io/quarkus/core/impl/NodeShutdownContext.java b/core/runtime/src/main/java/io/quarkus/core/impl/NodeShutdownContext.java new file mode 100644 index 0000000000000..b52aac3b9247f --- /dev/null +++ b/core/runtime/src/main/java/io/quarkus/core/impl/NodeShutdownContext.java @@ -0,0 +1,120 @@ +package io.quarkus.core.impl; + +import java.util.ArrayDeque; + +import org.jboss.logging.Logger; + +import io.quarkus.runtime.ShutdownContext; + +/** + * Per-node {@link ShutdownContext} for legacy bytecode recorder compatibility. + *

+ * Legacy recorders obtain a {@code ShutdownContext} via recorder proxy and + * register shutdown tasks on it. This class collects those tasks and + * implements {@link Runnable} so it can be registered as the service + * node's stop handler via {@link ServiceNode#onStop(Runnable)}. + *

+ * Tasks are executed in LIFO order (matching the behavior of the + * original {@code StartupContext} shutdown mechanism). "Last" tasks + * run after all normal tasks. Each task list is snapshot and cleared + * atomically before execution to ensure safe concurrent access. + * + * @see ServiceNode#onStop(Runnable) + */ +public final class NodeShutdownContext implements ShutdownContext, Runnable { + + private static final Logger LOG = Logger.getLogger("io.quarkus.service"); + + /** + * Whether this context was registered during the static-init phase. + */ + private final boolean staticInit; + + /** Normal shutdown tasks, stored in LIFO order (addFirst). */ + private final ArrayDeque tasks = new ArrayDeque<>(); + + /** + * Construct a new instance, defaulting to static-init phase. + */ + public NodeShutdownContext() { + this(true); + } + + /** + * Construct a new instance. + * + * @param staticInit whether this context operates during the static-init phase + */ + public NodeShutdownContext(boolean staticInit) { + this.staticInit = staticInit; + } + + /** + * {@inheritDoc} + */ + @Override + public void addShutdownTask(Runnable runnable) { + if (runnable == null) { + throw new IllegalArgumentException("Shutdown task must not be null"); + } + synchronized (tasks) { + tasks.addFirst(runnable); + } + } + + /** + * {@inheritDoc} + */ + @Override + public void addLastShutdownTask(Runnable runnable) { + if (runnable == null) { + throw new IllegalArgumentException("Last shutdown task must not be null"); + } + // delegate to the global collector using the phase of this context + if (staticInit) { + LastShutdownTasks.addStatic(runnable); + } else { + LastShutdownTasks.addRuntime(runnable); + } + } + + /** + * Execute all registered shutdown tasks in LIFO order. + * Normal tasks run first, then last-priority tasks. + * Each task list is snapshot and cleared before execution. + * Exceptions are logged and do not prevent subsequent tasks from running. + */ + @Override + public void run() { + runSnapshot(snapshotAndClear(tasks)); + } + + /** + * Atomically snapshot and clear a task deque. + * + * @param deque the deque to snapshot (also used as the lock object) + * @return the tasks as an array (in iteration order, which is LIFO) + */ + private static Runnable[] snapshotAndClear(ArrayDeque deque) { + synchronized (deque) { + Runnable[] snapshot = deque.toArray(Runnable[]::new); + deque.clear(); + return snapshot; + } + } + + /** + * Run all tasks in the snapshot, logging any failures. + * + * @param snapshot the task array to execute + */ + private static void runSnapshot(Runnable[] snapshot) { + for (Runnable task : snapshot) { + try { + task.run(); + } catch (Throwable t) { + LOG.error("Running a shutdown task failed", t); + } + } + } +} diff --git a/core/runtime/src/main/java/io/quarkus/core/impl/ServiceGraph.java b/core/runtime/src/main/java/io/quarkus/core/impl/ServiceGraph.java new file mode 100644 index 0000000000000..9f677e84acbde --- /dev/null +++ b/core/runtime/src/main/java/io/quarkus/core/impl/ServiceGraph.java @@ -0,0 +1,404 @@ +package io.quarkus.core.impl; + +import java.lang.invoke.ConstantBootstraps; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.VarHandle; +import java.util.ArrayDeque; +import java.util.concurrent.Executor; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.BooleanSupplier; + +import org.jboss.logging.Logger; + +import io.quarkus.runtime.StartupContext; + +/** + * The runtime service startup graph. + *

+ * Manages the lifecycle of a set of {@link ServiceNode}s connected by + * dependency edges. The graph uses two sentinel nodes: + *

    + *
  • Top sentinel: has no dependencies; all root services + * depend on it. When the graph starts, the top sentinel is + * submitted to the executor; its completion triggers all root + * services.
  • + *
  • Bottom sentinel: depends on all leaf services. + * When all leaves complete, the bottom sentinel becomes ready; + * its action signals that startup is done. During stop, its + * stop is initiated first, cascading backward through the + * graph.
  • + *
+ *

+ * Usage: + *

    + *
  1. Create the graph, then create sentinel and service nodes + * in topological order (top sentinel first, bottom sentinel + * last), passing this graph to each constructor.
  2. + *
  3. Call {@link #setTop(ServiceNode)} and + * {@link #setBottom(ServiceNode)} to register the sentinels.
  4. + *
  5. Call {@link #start()} to execute the graph. The calling + * thread acts as the initial task executor, draining a + * built-in task queue.
  6. + *
  7. Call {@link #stop()} to tear down the graph in reverse + * dependency order.
  8. + *
+ *

+ * Call {@link #setExecutor(Executor)} to upgrade to a thread pool + * mid-startup (typically during the {@code INFRASTRUCTURE} phase); + * this returns the previous executor for later restoration during + * shutdown. + * + * @see ServiceNode + */ +public final class ServiceGraph { + + private static final Logger LOG = Logger.getLogger("io.quarkus.service"); + + private static final VarHandle EXECUTOR_HANDLE = ConstantBootstraps.fieldVarHandle( + MethodHandles.lookup(), "executor", VarHandle.class, ServiceGraph.class, Executor.class); + private static final VarHandle TOP_HANDLE = ConstantBootstraps.fieldVarHandle( + MethodHandles.lookup(), "top", VarHandle.class, ServiceGraph.class, ServiceNode.class); + private static final VarHandle BOTTOM_HANDLE = ConstantBootstraps.fieldVarHandle( + MethodHandles.lookup(), "bottom", VarHandle.class, ServiceGraph.class, ServiceNode.class); + private static final VarHandle FIRST_FAILURE_HANDLE = ConstantBootstraps.fieldVarHandle( + MethodHandles.lookup(), "firstFailure", VarHandle.class, ServiceGraph.class, Throwable.class); + + // --- main-thread task queue --- + + /** Lock protecting the task queue and done conditions. */ + private final ReentrantLock lock = new ReentrantLock(); + + /** Signaled when a task is submitted or a done flag changes. */ + private final Condition taskAvailable = lock.newCondition(); + + /** Task queue for the main-thread executor. */ + private final ArrayDeque queue = new ArrayDeque<>(); + + /** + * The main-thread executor. Submits tasks to the internal queue + * and signals the draining thread. + */ + private final Executor mainThreadExecutor = this::submitToMainThread; + + // --- graph endpoints (set-once via CAS) --- + + /** Top sentinel node. Set once via {@link #setTop(ServiceNode)}. */ + @SuppressWarnings("unused") // managed by VarHandle + private volatile ServiceNode top; + + /** Bottom sentinel node. Set once via {@link #setBottom(ServiceNode)}. */ + @SuppressWarnings("unused") // managed by VarHandle + private volatile ServiceNode bottom; + + // --- executor --- + + /** The current executor. Initially the main-thread executor. */ + @SuppressWarnings("unused") // managed by VarHandle + private volatile Executor executor; + + // --- done flags --- + + /** Set by the bottom sentinel's action to signal startup completion. */ + private volatile boolean startDone; + + /** Set by the top sentinel's stop handler to signal stop completion. */ + private volatile boolean stopDone; + + /** The first failure encountered during startup, if any. */ + @SuppressWarnings("unused") // managed by VarHandle + private volatile Throwable firstFailure; + + /** + * Shared startup context for legacy bytecode recorder compatibility. + * Legacy recorders read and write values through this context. + * Must be concurrent-safe (maps replaced with ConcurrentHashMap). + */ + private final StartupContext startupContext; + + /** + * The service monitor used to track service state transitions. + */ + private final ServiceMonitor monitor; + + /** + * Construct a new service graph. + * The graph starts with the main-thread executor. Sentinel nodes + * must be registered via {@link #setTop(ServiceNode)} and + * {@link #setBottom(ServiceNode)} before calling {@link #start()}. + */ + /** + * Construct a new service graph with a fresh startup context. + */ + public ServiceGraph() { + this(new StartupContext(), NoOpServiceMonitor.INSTANCE); + } + + /** + * Construct a new service graph with the given startup context. + * + * @param startupContext the startup context for legacy recorder compatibility + * (must not be {@code null}) + */ + public ServiceGraph(StartupContext startupContext) { + this(startupContext, NoOpServiceMonitor.INSTANCE); + } + + /** + * Construct a new service graph with the given startup context and monitor. + * + * @param startupContext the startup context for legacy recorder compatibility + * (must not be {@code null}) + * @param monitor the service monitor used for JFR and lifecycle metrics (must not be {@code null}) + */ + public ServiceGraph(StartupContext startupContext, ServiceMonitor monitor) { + this.executor = mainThreadExecutor; + this.startupContext = startupContext; + this.monitor = monitor != null ? monitor : NoOpServiceMonitor.INSTANCE; + } + + /** + * Get the service monitor for this graph. + * + * @return the service monitor (never {@code null}) + */ + public ServiceMonitor monitor() { + return monitor; + } + + // --- sentinel setters (set-once) --- + + /** + * Set the top sentinel node. Must be called exactly once. + * The top sentinel has no dependencies; all root services should + * depend on it. + * + * @param top the top sentinel (must not be {@code null}) + * @throws IllegalStateException if already set + */ + public void setTop(ServiceNode top) { + if (TOP_HANDLE.compareAndExchangeRelease(this, null, top) != null) { + throw new IllegalStateException("Top sentinel already set"); + } + } + + /** + * Set the bottom sentinel node. Must be called exactly once. + * The bottom sentinel should depend on all leaf services. + * + * @param bottom the bottom sentinel (must not be {@code null}) + * @throws IllegalStateException if already set + */ + public void setBottom(ServiceNode bottom) { + if (BOTTOM_HANDLE.compareAndExchangeRelease(this, null, bottom) != null) { + throw new IllegalStateException("Bottom sentinel already set"); + } + } + + // --- executor --- + + /** + * Get the current executor. + * + * @return the current executor (never {@code null}) + */ + Executor executor() { + return (Executor) EXECUTOR_HANDLE.getVolatile(this); + } + + /** + * Set a new executor for dispatching service actions. + * Returns the previous executor so it can be restored later + * (typically during shutdown). + * + * @param newExecutor the new executor (must not be {@code null}) + * @return the previous executor + */ + public Executor setExecutor(Executor newExecutor) { + return (Executor) EXECUTOR_HANDLE.getAndSet(this, newExecutor); + } + + /** + * Get the shared startup context for legacy recorder compatibility. + * + * @return the startup context (never {@code null}) + */ + public StartupContext startupContext() { + return startupContext; + } + + // --- signaling (called by sentinel actions/stop handlers) --- + + /** + * Record a startup failure. The first failure becomes the primary + * exception; subsequent failures are added as suppressed exceptions + * on the primary. Called by {@link ServiceNode#propagateFailure}. + * + * @param cause the failure cause + */ + void recordFailure(Throwable cause) { + Throwable existing = (Throwable) FIRST_FAILURE_HANDLE.compareAndExchange(this, null, cause); + if (existing != null && existing != cause) { + existing.addSuppressed(cause); + } + } + + /** + * Signal that startup is complete. Called by the bottom sentinel's + * action when it runs (indicating all leaf services have completed). + */ + public void signalStartDone() { + startDone = true; + signalMainThread(); + } + + /** + * Signal that stop is complete. Called by the top sentinel's stop + * handler when it runs (indicating all services have been torn down). + */ + public void signalStopDone() { + stopDone = true; + signalMainThread(); + } + + // --- start --- + + /** + * Start the service graph. + *

+ * Submits the top sentinel to the executor, which cascades to all + * root services. Drains the main-thread task queue until the bottom + * sentinel signals startup completion. + *

+ * If any service fails, the failure cascades forward (canceling + * dependents) and backward (stopping completed services). The bottom + * sentinel's action still runs, signaling start completion, after + * which this method returns. The caller should inspect the graph + * for failures. + * + * @throws RuntimeException if interrupted while waiting + */ + public void start() { + ServiceNode top = (ServiceNode) TOP_HANDLE.getVolatile(this); + if (top == null) { + throw new IllegalStateException("Top sentinel not set"); + } + ServiceNode bottom = (ServiceNode) BOTTOM_HANDLE.getVolatile(this); + if (bottom == null) { + throw new IllegalStateException("Bottom sentinel not set"); + } + executor().execute(top); + // drain until the bottom sentinel either completes (signaling startDone) + // or is canceled/failed (failure propagated to sentinel) + drainUntil(() -> startDone || bottom.state() >= ServiceNode.S_FAILED); + // rethrow the first failure so callers see it as if it were thrown directly + Throwable failure = (Throwable) FIRST_FAILURE_HANDLE.getVolatile(this); + if (failure != null) { + if (failure instanceof RuntimeException re) { + throw re; + } + if (failure instanceof Error e) { + throw e; + } + throw new RuntimeException(failure); + } + } + + /** + * Stop the service graph. + *

+ * Initiates the stop cascade from the bottom sentinel and drains + * the main-thread task queue until the top sentinel's stop handler + * signals stop completion. + */ + public void stop() { + ServiceNode bottom = (ServiceNode) BOTTOM_HANDLE.getVolatile(this); + if (bottom == null) { + return; // empty graph (no services were registered) + } + ServiceNode top = (ServiceNode) TOP_HANDLE.getVolatile(this); + bottom.initiateStop(); + // drain until the top sentinel's stop handler signals stopDone + // or the top is already in a terminal state (failure during start) + drainUntil(() -> stopDone || top.state() >= ServiceNode.S_STOPPED); + // disconnect the node graph so that the MethodHandle action fields + // (which pin QuarkusClassLoader-loaded classes) become unreachable + TOP_HANDLE.setRelease(this, null); + BOTTOM_HANDLE.setRelease(this, null); + // eagerly release the StartupContext maps + startupContext.close(); + } + + // --- main-thread task queue --- + + /** + * Submit a task to the main-thread queue. + * + * @param task the task to submit + */ + private void submitToMainThread(Runnable task) { + lock.lock(); + try { + queue.add(task); + taskAvailable.signal(); + } finally { + lock.unlock(); + } + } + + /** + * Signal the main thread to wake up and re-check conditions. + * Called by {@link ServiceNode#dependencyFailed} when a node is + * canceled, in case the canceled node is a sentinel whose action + * would otherwise have signaled completion. + */ + void signalMainThread() { + lock.lock(); + try { + taskAvailable.signal(); + } finally { + lock.unlock(); + } + } + + /** + * Drain the main-thread task queue until the given condition is met. + * + * @param done the condition to check + */ + private void drainUntil(BooleanSupplier done) { + lock.lock(); + try { + while (!done.getAsBoolean()) { + Runnable task = queue.poll(); + if (task == null) { + taskAvailable.await(); + } else { + lock.unlock(); + try { + task.run(); + } finally { + lock.lock(); + } + } + } + // flush any remaining tasks (nodes submitted before the condition was met + // but not yet processed — e.g. services submitted before a failure cascade + // canceled the sentinel) + Runnable remaining; + while ((remaining = queue.poll()) != null) { + lock.unlock(); + try { + remaining.run(); + } finally { + lock.lock(); + } + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted during service graph execution", e); + } finally { + lock.unlock(); + } + } +} diff --git a/core/runtime/src/main/java/io/quarkus/core/impl/ServiceMonitor.java b/core/runtime/src/main/java/io/quarkus/core/impl/ServiceMonitor.java new file mode 100644 index 0000000000000..0d1634deb1d87 --- /dev/null +++ b/core/runtime/src/main/java/io/quarkus/core/impl/ServiceMonitor.java @@ -0,0 +1,57 @@ +package io.quarkus.core.impl; + +/** + * Monitors service startup and shutdown transitions. + */ +public abstract sealed class ServiceMonitor permits NoOpServiceMonitor, JfrServiceMonitor { + + /** + * Package-private constructor to restrict subclassing. + */ + ServiceMonitor() { + } + + /** + * Called when service startup has initiated. + * + * @param name the name of the service (must not be {@code null}) + */ + public abstract void startInitiated(String name); + + /** + * Called when service startup has successfully completed. + * + * @param name the name of the service (must not be {@code null}) + */ + public abstract void startComplete(String name); + + /** + * Called when service startup has failed. + * + * @param name the name of the service (must not be {@code null}) + * @param reason the string representation of the exception or failure reason (must not be {@code null}) + */ + public abstract void startFailed(String name, String reason); + + /** + * Called when service stop has initiated. + * + * @param name the name of the service (must not be {@code null}) + */ + public abstract void stopInitiated(String name); + + /** + * Called when service stop has successfully completed. + * + * @param name the name of the service (must not be {@code null}) + */ + public abstract void stopComplete(String name); + + /** + * Called when service stop has failed. + * + * @param name the name of the service (must not be {@code null}) + * @param reason the string representation of the exception or failure reason (must not be {@code null}) + */ + public abstract void stopFailed(String name, String reason); +} diff --git a/core/runtime/src/main/java/io/quarkus/core/impl/ServiceNode.java b/core/runtime/src/main/java/io/quarkus/core/impl/ServiceNode.java new file mode 100644 index 0000000000000..cbe3968cbaa54 --- /dev/null +++ b/core/runtime/src/main/java/io/quarkus/core/impl/ServiceNode.java @@ -0,0 +1,913 @@ +package io.quarkus.core.impl; + +import java.lang.invoke.ConstantBootstraps; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.VarHandle; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; + +import org.jboss.logging.Logger; + +import io.quarkus.core.AsyncStartContext; +import io.quarkus.core.AsyncStopContext; +import io.quarkus.core.AsyncVoidStartContext; +import io.smallrye.common.constraint.Assert; + +/** + * A node in the service startup graph. + *

+ * Each node represents a single service action (either a new-style service + * or a legacy recorder chunk). The node is simultaneously: + *

    + *
  • the graph node (dependencies, dependents, value)
  • + *
  • the runnable task (submitted to the executor when ready)
  • + *
  • the start context for the action (stop handler registration, async completion)
  • + *
+ *

+ * Nodes must be constructed in topological order so that all dependencies + * exist before their dependents are created. The constructor wires the + * reverse (dependent) links automatically. + *

+ * Lifecycle: + *

    + *
  1. Construction (single-threaded): nodes are created with their + * dependency lists (final). Each constructor call registers this node + * as a dependent of each dependency.
  2. + *
  3. Start: root nodes (those with no dependencies) are submitted + * to the executor by {@link ServiceGraph#start()}. Each node's + * {@link #run()} method invokes the action via {@link MethodHandle}, + * and the action calls {@link #startComplete(Object)} or + * {@link #startComplete()} to signal success. Completion triggers + * {@link #dependencySatisfied()} on each dependent. When a + * dependent's remaining count hits zero, it submits itself to the + * executor.
  4. + *
  5. Stop: reverse wave-front. When a node's live-dependent count + * reaches zero and it is in the {@code COMPLETED} state, it runs its + * stop handler. Upon stop completion, it notifies each dependency via + * {@link #dependentTerminated()}, decrementing their live-dependent + * counts and potentially triggering their stop in turn.
  6. + *
+ *

+ * State machine: + * + *

+ * PENDING → RUNNING → COMPLETED → STOPPING → STOPPED
+ *                   ↘ FAILED
+ * PENDING → CANCELED (a dependency failed before this node started)
+ * 
+ *

+ * Atomicity: + * The lifecycle state, remaining dependency count, and live dependent count + * are packed into a single {@code long} field and updated atomically via + * {@code compareAndExchange}. This eliminates races between concurrent state + * transitions and counter updates — for example, a dependent terminating + * (decrementing live-dependents) while the node is completing (transitioning + * RUNNING → COMPLETED). With separate fields, these two operations could + * interleave such that neither thread sees the combined condition (COMPLETED + * + zero live-dependents) that should trigger the stop sequence. The packed + * field makes the check-and-act atomic. + * + * @see ServiceGraph + */ +public final class ServiceNode implements Runnable, AsyncStartContext, AsyncVoidStartContext { + + private static final Logger LOG = Logger.getLogger("io.quarkus.service"); + + // ═══════════════════════════════════════════════ + // State constants + // ═══════════════════════════════════════════════ + + /** Node has not yet started. */ + static final int S_PENDING = 0; + /** Node's action is executing. */ + static final int S_RUNNING = 1; + /** Node's action completed successfully. */ + static final int S_COMPLETED = 2; + /** Node's action failed. */ + static final int S_FAILED = 3; + /** Node was canceled because a dependency failed. */ + static final int S_CANCELED = 4; + /** Node's stop handler is executing. */ + static final int S_STOPPING = 5; + /** Node's action failed, and stop handler is executing. */ + static final int S_FAILED_STOPPING = 6; + /** Node's stop handler completed. */ + static final int S_STOPPED = 7; + /** Node's action failed, and stop handler has completed. */ + static final int S_FAILED_STOPPED = 8; + + // ═══════════════════════════════════════════════ + // Packed field layout + // ═══════════════════════════════════════════════ + // + // A single 64-bit long packs three fields into independent bit ranges. + // All transitions use compareAndExchange on this single field, ensuring + // that state checks and counter modifications are always atomic with + // respect to each other. The witness value returned on failure is used + // directly as the retry value, avoiding a redundant volatile read. + // + // Bits 0–15 : state (max 65535; only 7 values used) + // Bits 16–31 : remainingDeps (max 65535 dependencies) + // Bits 32–47 : liveDependents (max 65535 dependents) + // Bits 48–63 : reserved (zero) + + /** Bit width of each packed sub-field (16 bits → max 65535). */ + private static final long FIELD_MASK = 0xFFFFL; + + /** Shift for the remaining-dependencies count. */ + private static final int REMAINING_DEPS_SHIFT = 16; + + /** Shift for the live-dependents count. */ + private static final int LIVE_DEPENDENTS_SHIFT = 32; + + /** + * Extract the state component from a packed value. + * + * @param packed the packed long value + * @return the state constant (one of {@code S_*}) + */ + static int stateOf(long packed) { + return (int) (packed & FIELD_MASK); + } + + /** + * Extract the remaining-dependencies count from a packed value. + * + * @param packed the packed long value + * @return the number of unsatisfied dependencies + */ + static int remainingDepsOf(long packed) { + return (int) ((packed >>> REMAINING_DEPS_SHIFT) & FIELD_MASK); + } + + /** + * Extract the live-dependents count from a packed value. + * + * @param packed the packed long value + * @return the number of dependents not yet in a terminal state + */ + static int liveDependentsOf(long packed) { + return (int) ((packed >>> LIVE_DEPENDENTS_SHIFT) & FIELD_MASK); + } + + /** + * Pack the three components into a single long value. + * + * @param state the state constant + * @param remainingDeps the remaining-dependencies count + * @param liveDependents the live-dependents count + * @return the packed long + */ + static long pack(int state, int remainingDeps, int liveDependents) { + return ((long) state) + | ((long) remainingDeps << REMAINING_DEPS_SHIFT) + | ((long) liveDependents << LIVE_DEPENDENTS_SHIFT); + } + + // ═══════════════════════════════════════════════ + // VarHandles for atomic field operations + // ═══════════════════════════════════════════════ + + /** VarHandle for the packed lifecycle field. */ + private static final VarHandle PACKED = ConstantBootstraps.fieldVarHandle( + MethodHandles.lookup(), "packed", VarHandle.class, ServiceNode.class, long.class); + + /** VarHandle for the stop handler field. */ + private static final VarHandle STOP_HANDLER = ConstantBootstraps.fieldVarHandle( + MethodHandles.lookup(), "stopHandler", VarHandle.class, ServiceNode.class, Consumer.class); + + // ═══════════════════════════════════════════════ + // Graph structure (set during single-threaded construction) + // ═══════════════════════════════════════════════ + + /** Diagnostic name for logging. */ + private final String name; + + /** + * The action to invoke. Signature: {@code (ServiceNode) → void}. + * The action must call {@link #startComplete(Object)}, {@link #startComplete()}, + * or {@link #startFailed(Throwable)} to signal its outcome. + *

+ * Non-final so that {@link #run()} can null it out after invocation, + * preventing the {@link MethodHandle} from persisting in the native + * image heap (where it would make the target method and all its + * transitive references reachable to GraalVM's analysis). + */ + private MethodHandle action; + + /** Direct dependencies. Immutable after construction. */ + private final List dependencies; + + /** The owning graph (provides executor access). */ + private final ServiceGraph graph; + + /** Direct dependents. Built up during construction, used as-is during lifecycle. */ + private final ArrayList dependents = new ArrayList<>(); + + // ═══════════════════════════════════════════════ + // Packed lifecycle state (atomic via PACKED VarHandle) + // ═══════════════════════════════════════════════ + + /** + * Packed lifecycle state combining state, remaining dependency count, + * and live dependent count in a single atomic field. + */ + @SuppressWarnings("unused") // managed by VarHandle + private volatile long packed; + + // ═══════════════════════════════════════════════ + // Value and failure + // ═══════════════════════════════════════════════ + + /** The produced value (for typed services). Set before state → COMPLETED. */ + private volatile Object value; + + /** The failure cause. Set before state → FAILED. */ + private Throwable failure; + + /** Whether the stop handler failed (threw an exception). */ + private volatile boolean stopFailed; + + // ═══════════════════════════════════════════════ + // Stop handler + // ═══════════════════════════════════════════════ + + /** + * The stop handler registered via {@link #onStopAsync(Consumer)}. + * At most one registration is allowed; subsequent attempts throw. + */ + @SuppressWarnings("unused") // managed by VarHandle + private volatile Consumer stopHandler; + + // ═══════════════════════════════════════════════ + // Constructors + // ═══════════════════════════════════════════════ + + /** + * Construct a new node with no dependencies. + * + * @param name the diagnostic name (must not be {@code null}) + * @param action the action handle (must not be {@code null}) + * @param graph the owning graph (must not be {@code null}) + * @param dependentCount the number of dependents this node will have + */ + public ServiceNode(String name, MethodHandle action, ServiceGraph graph, int dependentCount) { + this(name, action, graph, dependentCount, List.of()); + } + + /** + * Construct a new node with one dependency. + * + * @param name the diagnostic name (must not be {@code null}) + * @param action the action handle (must not be {@code null}) + * @param graph the owning graph (must not be {@code null}) + * @param dependentCount the number of dependents this node will have + * @param dep the dependency (must not be {@code null}) + */ + public ServiceNode(String name, MethodHandle action, ServiceGraph graph, int dependentCount, ServiceNode dep) { + this(name, action, graph, dependentCount, List.of(dep)); + } + + /** + * Construct a new node with two dependencies. + * + * @param name the diagnostic name (must not be {@code null}) + * @param action the action handle (must not be {@code null}) + * @param graph the owning graph (must not be {@code null}) + * @param dependentCount the number of dependents this node will have + * @param dep1 the first dependency (must not be {@code null}) + * @param dep2 the second dependency (must not be {@code null}) + */ + public ServiceNode(String name, MethodHandle action, ServiceGraph graph, int dependentCount, + ServiceNode dep1, ServiceNode dep2) { + this(name, action, graph, dependentCount, List.of(dep1, dep2)); + } + + /** + * Construct a new node with an arbitrary number of dependencies. + * + * @param name the diagnostic name (must not be {@code null}) + * @param action the action handle (must not be {@code null}) + * @param graph the owning graph (must not be {@code null}) + * @param dependentCount the number of dependents this node will have + * @param dependencies the dependency list (must not be {@code null}; defensively copied) + */ + public ServiceNode(String name, MethodHandle action, ServiceGraph graph, int dependentCount, + List dependencies) { + this.name = name; + this.action = action; + this.graph = graph; + this.dependencies = List.copyOf(dependencies); + this.packed = pack(S_PENDING, dependencies.size(), dependentCount); + for (ServiceNode dep : dependencies) { + dep.addDependent(this); + } + } + + // ═══════════════════════════════════════════════ + // Construction-phase wiring + // ═══════════════════════════════════════════════ + + /** + * Add a dependent to this node's mutable dependent list. + * Must only be called during single-threaded graph construction. + * + * @param dependent the dependent node (must not be {@code null}) + */ + private void addDependent(ServiceNode dependent) { + dependents.add(dependent); + } + + // ═══════════════════════════════════════════════ + // Start path + // ═══════════════════════════════════════════════ + + /** + * Run the node's action on the executor thread. + *

+ * Transitions the node from {@code PENDING} to {@code RUNNING}, + * invokes the action handle, and relies on the action to signal + * completion via {@link #startComplete(Object)} or + * {@link #startComplete()}. If the action throws, the node + * transitions to {@code FAILED} and the failure cascades. + *

+ * If the node was canceled before the executor reached it (a + * dependency failed between submission and execution), this + * method returns immediately. + */ + @Override + public void run() { + // transition PENDING → RUNNING + long old = (long) PACKED.getVolatile(this); + for (;;) { + if (stateOf(old) != S_PENDING) { + return; // canceled before execution + } + long next = pack(S_RUNNING, remainingDepsOf(old), liveDependentsOf(old)); + long wit = (long) PACKED.compareAndExchange(this, old, next); + if (wit == old) { + break; + } + old = wit; + } + LOG.tracef("Running service '%s'", name); + graph.monitor().startInitiated(name); + try { + action.invokeExact(this); + } catch (Throwable t) { + // try to transition RUNNING → FAILED + old = (long) PACKED.getVolatile(this); + for (;;) { + int s = stateOf(old); + if (s != S_RUNNING) { + // action already signaled completion before throwing + if (s != S_COMPLETED && s != S_STOPPING && s != S_STOPPED) { + LOG.errorf(t, "Service '%s' threw after signaling %s", name, stateName(s)); + } + return; + } + long next = pack(S_FAILED, remainingDepsOf(old), liveDependentsOf(old)); + long wit = (long) PACKED.compareAndExchange(this, old, next); + if (wit == old) { + break; + } + old = wit; + } + propagateFailure(t); + } finally { + // release the MethodHandle so it does not persist in the native + // image heap, where it would make its target method (and all + // transitive class references) reachable to GraalVM's analysis + action = null; + } + } + + /** + * Signal that the typed service action has completed successfully. + * Stores the value and notifies dependents. + *

+ * After transitioning to COMPLETED, checks whether all dependents + * have already terminated (live-dependents == 0). If so, immediately + * initiates the stop sequence — this handles the case where + * {@link #dependentTerminated()} raced ahead of completion. + * + * @param value the service value (must not be {@code null}) + * @throws IllegalStateException if a completion method has already been called + */ + @Override + public void startComplete(Object value) { + if (value == null) { + throw new IllegalArgumentException("Service value must not be null; use a void service instead"); + } + completeWithValue(value); + } + + /** + * Signal that an {@code optional()} service action has completed, with an optional value. + *

+ * Unlike {@link #startComplete(Object)}, a {@code null}/empty result is permitted here: an empty optional + * marks the service as present-but-valueless, so {@code consumeAll()} consumers skip it and + * {@code request()} consumers receive an empty optional. This is invoked from generated code for services + * declared via {@code optional()}. + * + * @param value the optional service value ({@code null} or empty means no value was produced) + */ + public void startCompleteOptional(Optional value) { + completeWithValue(value == null ? null : value.orElse(null)); + } + + /** + * Store the given (possibly {@code null}) value, transition to {@code COMPLETED}, and notify dependents. + * + * @param value the service value, or {@code null} if none was produced + */ + private void completeWithValue(Object value) { + this.value = value; + // transition RUNNING → COMPLETED + long old = (long) PACKED.getVolatile(this); + for (;;) { + if (stateOf(old) != S_RUNNING) { + throw new IllegalStateException( + "Service '" + name + "': completion method already called (state is " + stateName(stateOf(old)) + ")"); + } + long next = pack(S_COMPLETED, remainingDepsOf(old), liveDependentsOf(old)); + long wit = (long) PACKED.compareAndExchange(this, old, next); + if (wit == old) { + break; + } + old = wit; + } + graph.monitor().startComplete(name); + notifyDependentsSatisfied(); + // only check for immediate stop if this node actually has dependents; + // nodes with no dependents (e.g. the bottom sentinel) are stopped via + // graph.stop() → initiateStop(), not by auto-stop on completion + if (!dependents.isEmpty()) { + tryInitiateStopIfReady(); + } + } + + /** + * Signal that the void service action has completed successfully. + * Notifies dependents. + *

+ * Idempotent for void services: subsequent calls are silently ignored. + * After transitioning to COMPLETED, checks whether all dependents + * have already terminated and initiates stop if so. + */ + @Override + public void startComplete() { + // transition RUNNING → COMPLETED (idempotent: bail out if not RUNNING) + long old = (long) PACKED.getVolatile(this); + for (;;) { + if (stateOf(old) != S_RUNNING) { + return; // idempotent for void services + } + long next = pack(S_COMPLETED, remainingDepsOf(old), liveDependentsOf(old)); + long wit = (long) PACKED.compareAndExchange(this, old, next); + if (wit == old) { + break; + } + old = wit; + } + graph.monitor().startComplete(name); + notifyDependentsSatisfied(); + if (!dependents.isEmpty()) { + tryInitiateStopIfReady(); + } + } + + /** + * Signal that the service start was canceled. + * Dependents that consume this service non-optionally will fail to start. + * + * @throws IllegalStateException if {@link #startFailed(Throwable)} has already been called + */ + @Override + public void startCanceled() { + // transition RUNNING → CANCELED + long old = (long) PACKED.getVolatile(this); + for (;;) { + int s = stateOf(old); + if (s == S_FAILED) { + throw new IllegalStateException("Service '" + name + "': cannot cancel after failure"); + } + if (s != S_RUNNING) { + return; // already canceled, completed, or in another non-cancelable state + } + long next = pack(S_CANCELED, remainingDepsOf(old), liveDependentsOf(old)); + long wit = (long) PACKED.compareAndExchange(this, old, next); + if (wit == old) { + break; + } + old = wit; + } + propagateCancel(); + } + + /** + * Signal that the service start has failed. + * + * @param e the failure cause (must not be {@code null}) + * @throws IllegalStateException if a completion method has already been called + */ + @Override + public void startFailed(Throwable e) { + // transition RUNNING → FAILED + long old = (long) PACKED.getVolatile(this); + for (;;) { + if (stateOf(old) != S_RUNNING) { + throw new IllegalStateException( + "Service '" + name + "': completion method already called (state is " + stateName(stateOf(old)) + ")"); + } + long next = pack(S_FAILED, remainingDepsOf(old), liveDependentsOf(old)); + long wit = (long) PACKED.compareAndExchange(this, old, next); + if (wit == old) { + break; + } + old = wit; + } + propagateFailure(e); + } + + /** + * Store the failure cause, log, and propagate to dependents (forward) + * and dependencies (backward). + *

+ * Must only be called after the state has already been set to + * {@link #S_FAILED} via CAX. + * + * @param cause the failure cause + */ + private void propagateFailure(Throwable cause) { + graph.monitor().startFailed(name, cause.toString()); + this.failure = cause; + graph.recordFailure(cause); + LOG.debugf(cause, "Service '%s' failed to start", name); + // forward: cancel dependents that haven't started yet + for (ServiceNode dep : dependents) { + dep.dependencyFailed(); + } + // transition S_FAILED → S_FAILED_STOPPING + long old = (long) PACKED.getVolatile(this); + for (;;) { + int s = stateOf(old); + if (s != S_FAILED) { + return; + } + long next = pack(S_FAILED_STOPPING, remainingDepsOf(old), liveDependentsOf(old)); + long wit = (long) PACKED.compareAndExchange(this, old, next); + if (wit == old) { + break; + } + old = wit; + } + runStopHandler(); + } + + /** + * Notify dependents that this dependency has been satisfied. + */ + private void notifyDependentsSatisfied() { + for (ServiceNode dep : dependents) { + dep.dependencySatisfied(); + } + } + + // ═══════════════════════════════════════════════ + // Dependency satisfaction + // ═══════════════════════════════════════════════ + + /** + * Called by a dependency when it completes. + * Atomically decrements the remaining dependency count; if it reaches + * zero and the state is still {@code PENDING}, this node submits + * itself to the executor. + */ + void dependencySatisfied() { + long old = (long) PACKED.getVolatile(this); + long next; + for (;;) { + next = pack(stateOf(old), remainingDepsOf(old) - 1, liveDependentsOf(old)); + long wit = (long) PACKED.compareAndExchange(this, old, next); + if (wit == old) { + break; + } + old = wit; + } + if (remainingDepsOf(next) == 0 && stateOf(next) == S_PENDING) { + graph.executor().execute(this); + } + } + + // ═══════════════════════════════════════════════ + // Failure propagation (forward) + // ═══════════════════════════════════════════════ + + /** + * Called when a dependency has failed or been canceled. + * If this node is still pending, it transitions to canceled and + * propagates the cancellation forward to its own dependents. + */ + void dependencyFailed() { + // transition PENDING → CANCELED + long old = (long) PACKED.getVolatile(this); + for (;;) { + if (stateOf(old) != S_PENDING) { + return; // already running, completed, failed, or canceled + } + long next = pack(S_CANCELED, remainingDepsOf(old), liveDependentsOf(old)); + long wit = (long) PACKED.compareAndExchange(this, old, next); + if (wit == old) { + break; + } + old = wit; + } + // wake the main thread in case this is a sentinel node + graph.signalMainThread(); + propagateCancel(); + } + + /** + * Propagate cancellation: notify dependents of failure (forward) + * and notify dependencies that this node is terminal (backward). + */ + private void propagateCancel() { + for (ServiceNode dep : dependents) { + dep.dependencyFailed(); + } + for (ServiceNode dep : dependencies) { + dep.dependentTerminated(); + } + } + + // ═══════════════════════════════════════════════ + // Stop path (backward propagation) + // ═══════════════════════════════════════════════ + + /** + * Called when a dependent reaches a terminal state (stopped, failed, + * or canceled). Atomically decrements the live-dependent count. + *

+ * If the decrement brings the count to zero and the state is + * {@code COMPLETED}, the transition to {@code STOPPING} happens in + * the same CAX — this is the key advantage of the packed field, as it + * eliminates the race where separate atomic fields could allow both + * this method and {@link #startComplete()} to miss the combined + * condition. + */ + void dependentTerminated() { + long old = (long) PACKED.getVolatile(this); + boolean shouldStop = false; + for (;;) { + int ld = liveDependentsOf(old) - 1; + int s = stateOf(old); + long next; + if (ld == 0 && s == S_COMPLETED) { + // atomically decrement AND transition to STOPPING + next = pack(S_STOPPING, remainingDepsOf(old), 0); + shouldStop = true; + } else { + next = pack(s, remainingDepsOf(old), ld); + shouldStop = false; + } + long wit = (long) PACKED.compareAndExchange(this, old, next); + if (wit == old) { + break; + } + old = wit; + } + if (shouldStop) { + runStopHandler(); + } + } + + /** + * Initiate the stop sequence for this node. + * Transitions from {@code COMPLETED} to {@code STOPPING} via CAX, + * ensuring that only one thread can initiate the stop. + *

+ * Called by {@link ServiceGraph#stop()} on the bottom sentinel + * to begin the reverse cascade. + */ + void initiateStop() { + // transition COMPLETED → STOPPING, FAILED → FAILED_STOPPING + long old = (long) PACKED.getVolatile(this); + for (;;) { + int s = stateOf(old); + if (s != S_COMPLETED && s != S_FAILED) { + return; + } + int nextState = switch (s) { + case S_COMPLETED -> S_STOPPING; + case S_FAILED -> S_FAILED_STOPPING; + default -> throw Assert.impossibleSwitchCase(s); + }; + long next = pack(nextState, remainingDepsOf(old), liveDependentsOf(old)); + long wit = (long) PACKED.compareAndExchange(this, old, next); + if (wit == old) { + break; + } + old = wit; + } + runStopHandler(); + } + + /** + * Check whether this node should stop immediately after completing. + *

+ * If all dependents have already terminated (live-dependents == 0) + * by the time the node reaches {@code COMPLETED}, this method + * transitions to {@code STOPPING} and runs the stop handler. + * Called by {@link #startComplete(Object)} and {@link #startComplete()}. + */ + private void tryInitiateStopIfReady() { + long old = (long) PACKED.getVolatile(this); + for (;;) { + int s = stateOf(old); + if (liveDependentsOf(old) != 0 || (s != S_COMPLETED && s != S_FAILED)) { + return; + } + int nextState = switch (s) { + case S_COMPLETED -> S_STOPPING; + case S_FAILED -> S_FAILED_STOPPING; + default -> throw Assert.impossibleSwitchCase(s); + }; + long next = pack(nextState, remainingDepsOf(old), 0); + long wit = (long) PACKED.compareAndExchange(this, old, next); + if (wit == old) { + break; + } + old = wit; + } + runStopHandler(); + } + + /** + * Run the registered stop handler (if any) and then complete the + * stop sequence via {@link #finishStop()}. + */ + private void runStopHandler() { + graph.monitor().stopInitiated(name); + Consumer handler = (Consumer) STOP_HANDLER.getVolatile(this); + if (handler != null) { + try { + handler.accept(this::finishStop); + } catch (Throwable t) { + LOG.errorf(t, "Stop handler for service '%s' threw an exception", name); + this.stopFailed = true; + graph.monitor().stopFailed(name, t.toString()); + finishStop(); + } + } else { + finishStop(); + } + } + + /** + * Complete the stop sequence. Transitions to {@code STOPPED} or {@code S_FAILED_STOPPED} + * and notifies each dependency that this dependent has terminated, + * potentially triggering their stop in turn. + */ + private void finishStop() { + // transition S_STOPPING → S_STOPPED, S_FAILED_STOPPING → S_FAILED_STOPPED + long old = (long) PACKED.getVolatile(this); + for (;;) { + int s = stateOf(old); + int nextState = switch (s) { + case S_FAILED_STOPPING -> S_FAILED_STOPPED; + default -> S_STOPPED; + }; + long next = pack(nextState, remainingDepsOf(old), liveDependentsOf(old)); + long wit = (long) PACKED.compareAndExchange(this, old, next); + if (wit == old) { + break; + } + old = wit; + } + if (!this.stopFailed) { + graph.monitor().stopComplete(name); + } + // release the service value, stop handler, and dependent links so that + // heavy objects (e.g. SSL contexts, thread pools) and the MethodHandle + // action references (which pin the QuarkusClassLoader) are eligible for + // GC as soon as the node graph is disconnected from its ServiceGraph + this.value = null; + STOP_HANDLER.setRelease(this, null); + for (ServiceNode dep : dependencies) { + dep.dependentTerminated(); + } + dependents.clear(); + } + + // ═══════════════════════════════════════════════ + // Stop handler registration (StartContext interface) + // ═══════════════════════════════════════════════ + + /** + * {@inheritDoc} + *

+ * Only one stop handler may be registered per node. Subsequent calls + * will throw {@link IllegalStateException}. + */ + @Override + public void onStopAsync(Consumer stopper) { + if (!STOP_HANDLER.compareAndSet(this, null, stopper)) { + throw new IllegalStateException("Service '" + name + "': stop handler already registered"); + } + } + + // ═══════════════════════════════════════════════ + // Accessors + // ═══════════════════════════════════════════════ + + /** + * Get the owning service graph. + * + * @return the graph (never {@code null}) + */ + public ServiceGraph graph() { + return graph; + } + + /** + * Get the diagnostic name of this service node. + * + * @return the name (never {@code null}) + */ + public String name() { + return name; + } + + /** + * Get the value produced by this service. + * Only valid after the node has completed. + * + * @return the service value, or {@code null} for void services + */ + public Object value() { + return value; + } + + /** + * Get the value of a dependency by index. + * The index corresponds to the position in the dependency list + * passed to the constructor. + * + * @param index the dependency index + * @return the dependency's value (may be {@code null} for void dependencies) + */ + public Object dependencyValue(int index) { + return dependencies.get(index).value; + } + + /** + * Get the current lifecycle state. + * + * @return the state constant + */ + int state() { + return stateOf((long) PACKED.getVolatile(this)); + } + + /** + * Get the failure cause, if the node failed. + * + * @return the failure cause, or {@code null} if the node did not fail + */ + public Throwable failure() { + return failure; + } + + /** + * Get a human-readable name for a state constant. + * + * @param s the state constant + * @return the state name + */ + static String stateName(int s) { + return switch (s) { + case S_PENDING -> "PENDING"; + case S_RUNNING -> "RUNNING"; + case S_COMPLETED -> "COMPLETED"; + case S_FAILED -> "FAILED"; + case S_CANCELED -> "CANCELED"; + case S_STOPPING -> "STOPPING"; + case S_FAILED_STOPPING -> "FAILED_STOPPING"; + case S_STOPPED -> "STOPPED"; + case S_FAILED_STOPPED -> "FAILED_STOPPED"; + default -> "UNKNOWN(" + s + ")"; + }; + } + + /** + * {@inheritDoc} + */ + @Override + public String toString() { + return "ServiceNode[" + name + ":" + stateName(state()) + "]"; + } +} diff --git a/core/runtime/src/main/java/io/quarkus/core/impl/SortedNullSafeMap.java b/core/runtime/src/main/java/io/quarkus/core/impl/SortedNullSafeMap.java new file mode 100644 index 0000000000000..1c13fe1be9192 --- /dev/null +++ b/core/runtime/src/main/java/io/quarkus/core/impl/SortedNullSafeMap.java @@ -0,0 +1,135 @@ +package io.quarkus.core.impl; + +import java.util.Collections; +import java.util.Map; +import java.util.TreeMap; + +/** + * Utility for building sorted, unmodifiable maps from key-value pairs where + * null values are silently excluded. Used by the service action codegen to + * construct the result of {@code consumeAll()} dependencies. + *

+ * Keys must be provided in sorted order. Null values are skipped + * (the corresponding key is not included in the result). + */ +public final class SortedNullSafeMap { + + private SortedNullSafeMap() { + } + + /** + * Return an empty sorted map. + * + * @return an empty unmodifiable sorted map + * @param the value type + */ + public static Map of() { + return Map.of(); + } + + /** + * Return a sorted map with up to one entry. + * + * @param k1 the key (must not be {@code null}) + * @param v1 the value (may be {@code null}, in which case it is excluded) + * @return an unmodifiable sorted map + * @param the value type + */ + public static Map of(String k1, V v1) { + if (v1 == null) { + return Map.of(); + } + return Map.of(k1, v1); + } + + /** + * Return a sorted map with up to two entries. + * + * @param k1 the first key (must not be {@code null}) + * @param v1 the first value (may be {@code null}) + * @param k2 the second key (must not be {@code null}) + * @param v2 the second value (may be {@code null}) + * @return an unmodifiable sorted map + * @param the value type + */ + //@formatter:off + public static Map of(String k1, V v1, String k2, V v2) { + return v1 == null ? of(k2, v2) : + v2 == null ? Map.of(k1, v1) : + ofEntries(k1, k2, v1, v2); + } + //@formatter:on + + /** + * Return a sorted map with up to three entries. + * + * @param k1 the first key (must not be {@code null}) + * @param v1 the first value (may be {@code null}) + * @param k2 the second key (must not be {@code null}) + * @param v2 the second value (may be {@code null}) + * @param k3 the third key (must not be {@code null}) + * @param v3 the third value (may be {@code null}) + * @return an unmodifiable sorted map + * @param the value type + */ + //@formatter:off + public static Map of(String k1, V v1, String k2, V v2, String k3, V v3) { + return v1 == null ? of(k2, v2, k3, v3) : + v2 == null ? of(k1, v1, k3, v3) : + ofEntries(k1, v1, k2, v2, k3, v3); + } + //@formatter:on + + /** + * Return a sorted map with up to four entries. + * + * @param k1 the first key (must not be {@code null}) + * @param v1 the first value (may be {@code null}) + * @param k2 the second key (must not be {@code null}) + * @param v2 the second value (may be {@code null}) + * @param k3 the third key (must not be {@code null}) + * @param v3 the third value (may be {@code null}) + * @param k4 the fourth key (must not be {@code null}) + * @param v4 the fourth value (may be {@code null}) + * @return an unmodifiable sorted map + * @param the value type + */ + //@formatter:off + public static Map of(String k1, V v1, String k2, V v2, String k3, V v3, String k4, V v4) { + return v1 == null ? of(k2, v2, k3, v3, k4, v4) : + v2 == null ? of(k1, v1, k3, v3, k4, v4) : + ofEntries(k1, v1, k2, v2, k3, v3, k4, v4); + } + //@formatter:on + + /** + * Return a sorted map from an array of alternating key-value pairs. + * Keys must be {@code String} and must not be {@code null}. + * Values may be {@code null}, in which case the entry is excluded. + * + * @param entries alternating key-value pairs ({@code key1, value1, key2, value2, ...}) + * @return an unmodifiable sorted map + * @param the value type + */ + @SuppressWarnings("unchecked") + public static Map ofEntries(Object... entries) { + if (entries.length == 0) { + return Collections.emptySortedMap(); + } + if ((entries.length & 1) != 0) { + throw new IllegalArgumentException("Odd number of arguments"); + } + TreeMap map = new TreeMap<>(); + for (int i = 0; i < entries.length; i += 2) { + String key = (String) entries[i]; + V value = (V) entries[i + 1]; + if (value != null) { + map.put(key, value); + } + } + if (map.isEmpty()) { + return Collections.emptySortedMap(); + } + return Collections.unmodifiableSortedMap(map); + } +} diff --git a/core/runtime/src/main/java/io/quarkus/runtime/Application.java b/core/runtime/src/main/java/io/quarkus/runtime/Application.java index db5a864779076..8bd343220b882 100644 --- a/core/runtime/src/main/java/io/quarkus/runtime/Application.java +++ b/core/runtime/src/main/java/io/quarkus/runtime/Application.java @@ -284,4 +284,8 @@ public void awaitShutdown() { public boolean isStarted() { return state == ST_STARTED; } + + public boolean hasStaticGraph() { + return false; + } } diff --git a/core/runtime/src/main/java/io/quarkus/runtime/ObjectSubstitution.java b/core/runtime/src/main/java/io/quarkus/runtime/ObjectSubstitution.java index 26a4706c94dac..ae193d282a710 100644 --- a/core/runtime/src/main/java/io/quarkus/runtime/ObjectSubstitution.java +++ b/core/runtime/src/main/java/io/quarkus/runtime/ObjectSubstitution.java @@ -9,7 +9,9 @@ * * @param The non-serializable class * @param The serializable class + * @deprecated The bytecode recorder infrastructure is being replaced by a lambda-based service mechanism. */ +@Deprecated(since = "4.0") public interface ObjectSubstitution { T serialize(F obj); diff --git a/core/runtime/src/main/java/io/quarkus/runtime/QuarkusContextHandler.java b/core/runtime/src/main/java/io/quarkus/runtime/QuarkusContextHandler.java new file mode 100644 index 0000000000000..728eb021e6bd0 --- /dev/null +++ b/core/runtime/src/main/java/io/quarkus/runtime/QuarkusContextHandler.java @@ -0,0 +1,30 @@ +package io.quarkus.runtime; + +import org.jboss.threads.ContextHandler; + +/** + * A non-generic sub-interface of {@link ContextHandler} to support + * ActionBuilder service registration without generic types. + */ +public interface QuarkusContextHandler extends ContextHandler { + + /** + * Create a QuarkusContextHandler that delegates to a raw JBoss Threads ContextHandler. + * + * @param delegate the delegate context handler (must not be {@code null}) + * @return the delegating QuarkusContextHandler + */ + static QuarkusContextHandler of(ContextHandler delegate) { + return new QuarkusContextHandler() { + @Override + public Object captureContext() { + return delegate.captureContext(); + } + + @Override + public void runWith(Runnable task, Object state) { + delegate.runWith(task, state); + } + }; + } +} diff --git a/core/runtime/src/main/java/io/quarkus/runtime/RuntimeValue.java b/core/runtime/src/main/java/io/quarkus/runtime/RuntimeValue.java index 46b4361fa24bd..fb2bbced32246 100644 --- a/core/runtime/src/main/java/io/quarkus/runtime/RuntimeValue.java +++ b/core/runtime/src/main/java/io/quarkus/runtime/RuntimeValue.java @@ -6,7 +6,11 @@ * Represents a proxyable object that can be returned from a bytecode recorder, * and passed between recorders. * + * @deprecated Use {@code ServiceRegistrar} and its service dependency mechanism instead. + * Services can declare dependencies on other services directly, replacing + * the need for {@code RuntimeValue} as a parameter-passing mechanism. */ +@Deprecated(since = "4.0") public class RuntimeValue { private final T value; diff --git a/core/runtime/src/main/java/io/quarkus/runtime/ServiceValueSupplier.java b/core/runtime/src/main/java/io/quarkus/runtime/ServiceValueSupplier.java new file mode 100644 index 0000000000000..93935c7ef6796 --- /dev/null +++ b/core/runtime/src/main/java/io/quarkus/runtime/ServiceValueSupplier.java @@ -0,0 +1,38 @@ +package io.quarkus.runtime; + +import java.util.function.Supplier; + +/** + * A {@link Supplier} that wraps a service value for backward compatibility + * with recorder-based consumers that expect {@code Supplier}. + *

+ * This is a temporary bridge used during the recorder-to-service coexistence + * period. It will be removed once all consumers are converted to use + * {@code require()} on the service directly. + * + * @param the service value type + */ +public final class ServiceValueSupplier implements Supplier { + private final T value; + + private ServiceValueSupplier(T value) { + this.value = value; + } + + /** + * Create a supplier wrapping the given value. + * + * @param value the service value + * @return a supplier that returns the value + * @param the value type + */ + @SuppressWarnings("unchecked") + public static Supplier of(Object value) { + return new ServiceValueSupplier<>((T) value); + } + + @Override + public T get() { + return value; + } +} diff --git a/core/runtime/src/main/java/io/quarkus/runtime/ShutdownContext.java b/core/runtime/src/main/java/io/quarkus/runtime/ShutdownContext.java index 92e5c3ecd1ad7..cc7b15d69472e 100644 --- a/core/runtime/src/main/java/io/quarkus/runtime/ShutdownContext.java +++ b/core/runtime/src/main/java/io/quarkus/runtime/ShutdownContext.java @@ -9,12 +9,32 @@ * A context that can be passed into runtime recorders that allows for shutdown tasks to be added. * * Tasks are executed in the reverse order that they are added. + * + * @deprecated Use the {@code ActionBuilder} service model and register stop handlers via {@code ctx.onStop(Runnable)} instead. */ +@Deprecated(since = "4.0") public interface ShutdownContext { void addShutdownTask(Runnable runnable); - // these are executed after all the ones add via addShutdownTask in the reverse order from which they were added + /** + * Register a shutdown task that is deferred for some unspecified amount of time + * after the tasks registered via {@link #addShutdownTask(Runnable)}. + *

+ * Despite the name, this method does not guarantee that the task + * will run "last". The complexity of the service dependency graph means that + * no such ordering guarantee is feasible: other services may stop after this + * task runs, and the task may run before all other shutdown activity has + * completed. + *

+ * New code should use the {@code ActionBuilder} service model to express + * shutdown ordering via explicit dependency edges, rather than relying on + * this method's weak ordering semantics. + * + * @param runnable the shutdown task (must not be {@code null}) + * @deprecated Use service dependency edges to control shutdown ordering instead. + */ + @Deprecated(forRemoval = true) void addLastShutdownTask(Runnable runnable); class CloseRunnable implements Runnable { diff --git a/core/runtime/src/main/java/io/quarkus/runtime/SnapStartRecorder.java b/core/runtime/src/main/java/io/quarkus/runtime/SnapStartRecorder.java index 305166e9d9fac..29772c9b28429 100644 --- a/core/runtime/src/main/java/io/quarkus/runtime/SnapStartRecorder.java +++ b/core/runtime/src/main/java/io/quarkus/runtime/SnapStartRecorder.java @@ -1,18 +1,17 @@ package io.quarkus.runtime; -import io.quarkus.runtime.annotations.Recorder; - /** * Stores the optimizations to execute for SnapStart. */ -@Recorder public class SnapStartRecorder { + /** + * Whether SnapStart is enabled. + */ public static boolean enabled = false; - public static boolean fullWarmup = false; - public void register(boolean fw) { - enabled = true; - fullWarmup = fw; - } + /** + * Whether to perform a full warmup. + */ + public static boolean fullWarmup = false; } diff --git a/core/runtime/src/main/java/io/quarkus/runtime/StartupContext.java b/core/runtime/src/main/java/io/quarkus/runtime/StartupContext.java index d264332c4064c..2b46c65cb8b71 100644 --- a/core/runtime/src/main/java/io/quarkus/runtime/StartupContext.java +++ b/core/runtime/src/main/java/io/quarkus/runtime/StartupContext.java @@ -1,16 +1,13 @@ package io.quarkus.runtime; import java.io.Closeable; -import java.util.Deque; -import java.util.HashMap; import java.util.Map; -import java.util.concurrent.ConcurrentLinkedDeque; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.function.Supplier; import org.jboss.logging.Logger; -import io.quarkus.runtime.shutdown.ShutdownAction; - public class StartupContext implements Closeable { public static final String RAW_COMMAND_LINE_ARGS = StartupContext.class.getName() + ".raw-command-line-args"; @@ -20,34 +17,23 @@ public class StartupContext implements Closeable { // Holds values for returned proxies // These values are usually returned from recorder methods but can be also set explicitly // For example, the raw command line args and ShutdownContext are set when the StartupContext is created - private final Map values = new HashMap<>(); + // Concurrent: legacy recorder nodes may run in parallel on the service graph executor + private final Map values = new ConcurrentHashMap<>(); + + /* + * Service value storage exists for compatibility with the bytecode recorder execution model, + * which clears the main values map between static-init and runtime-init phases. Service values + * use a separate map so they can selectively survive this clearing. Once bytecode recorders + * are removed, service value storage will be replaced with a direct value-passing mechanism + * that does not require a map. + * Concurrent: service nodes may run in parallel on the service graph executor. + */ + private final Map serviceValues = new ConcurrentHashMap<>(); - private final Deque shutdownTasks = new ConcurrentLinkedDeque<>(); - private final Deque lastShutdownTasks = new ConcurrentLinkedDeque<>(); private String[] commandLineArgs; private String currentBuildStepName; public StartupContext() { - ShutdownContext shutdownContext = new ShutdownContext() { - @Override - public void addShutdownTask(Runnable runnable) { - if (runnable != null) { - shutdownTasks.addFirst(runnable); - } else { - throw new IllegalArgumentException("Extension passed an invalid shutdown handler"); - } - } - - @Override - public void addLastShutdownTask(Runnable runnable) { - if (runnable != null) { - lastShutdownTasks.addFirst(runnable); - } else { - throw new IllegalArgumentException("Extension passed an invalid last shutdown handler"); - } - } - }; - values.put(ShutdownContext.class.getName(), shutdownContext); values.put(RAW_COMMAND_LINE_ARGS, new Supplier() { @Override public String[] get() { @@ -60,35 +46,70 @@ public String[] get() { } public void putValue(String name, Object value) { - values.put(name, value); + if (value != null) { + values.put(name, value); + } } public Object getValue(String name) { return values.get(name); } + /** + * Store a service value produced by a service action. + * + * @param name the service key (must not be {@code null}) + * @param value the service value (must not be {@code null}) + */ + public void putServiceValue(String name, Object value) { + if (value != null) { + serviceValues.put(name, value); + } + } + + /** + * Retrieve a service value. + * + * @param name the service key + * @return the service value, or {@code null} if not found + */ + public Object getServiceValue(String name) { + return serviceValues.get(name); + } + + /** + * Remove and return a service value. + * Used by CDI service-value beans to drain the map after lazy instantiation. + * + * @param name the service key + * @return the previous value, or {@code null} if not found + */ + public Object removeServiceValue(String name) { + return serviceValues.remove(name); + } + + /** + * Retain only the specified service value keys, removing all others. + * Called between static-init and runtime-init to discard static-init-only + * service values while keeping those needed by runtime-init services. + * + * @param keys the service keys to retain + */ + public void retainServiceValues(Set keys) { + serviceValues.keySet().retainAll(keys); + } + + /** + * Clear all service values. Called after all deploy methods complete. + */ + public void clearServiceValues() { + serviceValues.clear(); + } + @Override public void close() { - runAllAndClear(shutdownTasks, ShutdownAction.SHUTDOWN_TASK); - runAllAndClear(lastShutdownTasks, ShutdownAction.LAST_SHUTDOWN_TASK); values.clear(); - } - - private void runAllAndClear(Deque tasks, ShutdownAction action) { - while (!tasks.isEmpty()) { - try { - var runnable = tasks.remove(); - Object info; - if (runnable instanceof ShutdownContext.CloseRunnable cr) { - info = cr.getShutdownInfo(); - } else { - info = runnable.getClass().getName(); - } - action.run(info, runnable); - } catch (Throwable ex) { - LOG.error("Running a shutdown task failed", ex); - } - } + serviceValues.clear(); } @SuppressWarnings("unused") diff --git a/core/runtime/src/main/java/io/quarkus/runtime/annotations/RecordableConstructor.java b/core/runtime/src/main/java/io/quarkus/runtime/annotations/RecordableConstructor.java index 8a649b648910b..eecdbe2c98a4f 100644 --- a/core/runtime/src/main/java/io/quarkus/runtime/annotations/RecordableConstructor.java +++ b/core/runtime/src/main/java/io/quarkus/runtime/annotations/RecordableConstructor.java @@ -13,7 +13,10 @@ * * TODO: move this out of Quarkus core and into a tiny annotation-only module * that could then be used outside of Quarkus (for example in RESTEasy Reactive) + * + * @deprecated The recorder infrastructure is being replaced by a lambda-based service mechanism. */ +@Deprecated(since = "4.0") @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.CONSTRUCTOR) public @interface RecordableConstructor { diff --git a/core/runtime/src/main/java/io/quarkus/runtime/annotations/Recorder.java b/core/runtime/src/main/java/io/quarkus/runtime/annotations/Recorder.java index 24b6a1832d719..4d8404a4a16a7 100644 --- a/core/runtime/src/main/java/io/quarkus/runtime/annotations/Recorder.java +++ b/core/runtime/src/main/java/io/quarkus/runtime/annotations/Recorder.java @@ -31,7 +31,10 @@ * {@code io.quarkus.deployment.recording.RecorderContext#registerSubstitution(Class, Class, Class)} mechanism * - arrays, lists and maps of the above * + * @deprecated Use {@code ActionBuilder} instead. Define runtime behavior directly as lambdas + * via the {@code ActionBuilder} fluent API injected into {@code @BuildStep} methods. */ +@Deprecated(since = "4.0") @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) public @interface Recorder { diff --git a/core/runtime/src/main/java/io/quarkus/runtime/configuration/ConfigLookup.java b/core/runtime/src/main/java/io/quarkus/runtime/configuration/ConfigLookup.java new file mode 100644 index 0000000000000..bc7f9bb23535a --- /dev/null +++ b/core/runtime/src/main/java/io/quarkus/runtime/configuration/ConfigLookup.java @@ -0,0 +1,49 @@ +package io.quarkus.runtime.configuration; + +import java.lang.invoke.MethodHandles; + +import org.eclipse.microprofile.config.ConfigProvider; + +import io.smallrye.config.SmallRyeConfig; + +/** + * Bootstrap methods for resolving configuration mappings as dynamic constants. + *

+ * This class provides the {@code condy} (constant dynamic) bootstrap method used by + * the ActionBuilder lambda transliteration system. When a lambda captures a + * {@code @ConfigRoot(phase = BUILD_AND_RUN_TIME_FIXED)} configuration mapping, + * the transliterator replaces the capture with a {@code CONSTANT_Dynamic} entry + * whose bootstrap method is {@link #configMapping(MethodHandles.Lookup, String, Class)}. + * The JVM lazily resolves the constant on first access and caches it thereafter. + */ +public final class ConfigLookup { + + private ConfigLookup() { + } + + /** + * Condy bootstrap method that resolves a {@code @ConfigMapping} interface + * from the current SmallRye configuration. + * + * @param lookup the lookup context (unused, required by condy signature) + * @param name the constant name (unused, required by condy signature) + * @param type the configuration mapping interface class to resolve + * @return the resolved configuration mapping instance + */ + public static Object configMapping(MethodHandles.Lookup lookup, String name, Class type) { + return getConfigMapping(type); + } + + /** + * Resolve a {@code @ConfigMapping} interface from the current SmallRye configuration. + *

+ * This method is called directly by generated {@code StartupTask} implementations + * that provide runtime configuration mappings as services. + * + * @param type the configuration mapping interface class to resolve + * @return the resolved configuration mapping instance + */ + public static Object getConfigMapping(Class type) { + return ConfigProvider.getConfig().unwrap(SmallRyeConfig.class).getConfigMapping(type); + } +} diff --git a/core/runtime/src/main/java/io/quarkus/runtime/init/InitializationTaskRecorder.java b/core/runtime/src/main/java/io/quarkus/runtime/init/InitializationTaskRecorder.java index 761c0941fbd68..42fa22b6c638e 100644 --- a/core/runtime/src/main/java/io/quarkus/runtime/init/InitializationTaskRecorder.java +++ b/core/runtime/src/main/java/io/quarkus/runtime/init/InitializationTaskRecorder.java @@ -6,27 +6,35 @@ import io.quarkus.runtime.PreventFurtherStepsException; import io.quarkus.runtime.Quarkus; -import io.quarkus.runtime.RuntimeValue; -import io.quarkus.runtime.annotations.Recorder; /** - * A {@link Recorder} that is used to check if the application should exit once all initialization tasks are completed. + * Utility for checking if the application should exit once all initialization tasks are completed. */ -@Recorder public class InitializationTaskRecorder { - private final RuntimeValue initRuntimeConfig; - public InitializationTaskRecorder(RuntimeValue initRuntimeConfig) { - this.initRuntimeConfig = initRuntimeConfig; + private InitializationTaskRecorder() { } - public void exitIfNeeded() { - if (initRuntimeConfig.getValue().initAndExit()) { + /** + * Exit the application if init-and-exit mode is enabled. + * + * @param config the init runtime configuration + */ + public static void exitIfNeeded(InitRuntimeConfig config) { + if (config.initAndExit()) { preventFurtherRecorderSteps(5, "Error attempting to gracefully shutdown after initialization", () -> new PreventFurtherStepsException("Gracefully exiting after initialization.", 0)); } } + /** + * Initiate a graceful shutdown and throw a {@link PreventFurtherStepsException} to halt + * remaining recorder steps. + * + * @param waitSeconds the maximum time to wait for shutdown + * @param waitErrorMessage the message to log if interrupted + * @param supplier the exception supplier + */ public static void preventFurtherRecorderSteps(int waitSeconds, String waitErrorMessage, Supplier supplier) { CountDownLatch latch = new CountDownLatch(1); diff --git a/core/runtime/src/main/java/io/quarkus/runtime/ssl/SslContextConfigurationRecorder.java b/core/runtime/src/main/java/io/quarkus/runtime/ssl/SslContextConfigurationRecorder.java deleted file mode 100644 index 5f20419f319ae..0000000000000 --- a/core/runtime/src/main/java/io/quarkus/runtime/ssl/SslContextConfigurationRecorder.java +++ /dev/null @@ -1,11 +0,0 @@ -package io.quarkus.runtime.ssl; - -import io.quarkus.runtime.annotations.Recorder; - -@Recorder -public class SslContextConfigurationRecorder { - - public void setSslNativeEnabled(boolean sslNativeEnabled) { - SslContextConfiguration.setSslNativeEnabled(sslNativeEnabled); - } -} diff --git a/core/runtime/src/test/java/io/quarkus/core/impl/ServiceGraphTest.java b/core/runtime/src/test/java/io/quarkus/core/impl/ServiceGraphTest.java new file mode 100644 index 0000000000000..0a6d17a8e3831 --- /dev/null +++ b/core/runtime/src/test/java/io/quarkus/core/impl/ServiceGraphTest.java @@ -0,0 +1,380 @@ +package io.quarkus.core.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.Executor; + +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link ServiceNode} and {@link ServiceGraph} lifecycle. + *

+ * Tests verify the state machine transitions, dependency satisfaction, + * failure cascade, stop cascade, and executor swap behaviors. + */ +class ServiceGraphTest { + + // ═══════════════════════════════════════════════ + // Action handles for test services + // ═══════════════════════════════════════════════ + + /** Action that completes with a string value. */ + static void typedAction(ServiceNode node) { + node.startComplete("value-from-" + node.name()); + } + + /** Action that completes as void. */ + static void voidAction(ServiceNode node) { + node.startComplete(); + } + + /** Action that fails immediately. */ + static void failingAction(ServiceNode node) { + node.startFailed(new RuntimeException("deliberate failure in " + node.name())); + } + + /** Action that reads dependency values. */ + static void depReadingAction(ServiceNode node) { + Object dep0 = node.dependencyValue(0); + node.startComplete("got-" + dep0); + } + + /** Top sentinel action: registers stop-done handler, then completes. */ + static void topSentinel(ServiceNode node) { + node.onStop(() -> node.graph().signalStopDone()); + node.startComplete(); + } + + /** Bottom sentinel action: signals start-done, then completes. */ + static void bottomSentinel(ServiceNode node) { + node.graph().signalStartDone(); + node.startComplete(); + } + + private static final MethodHandle MH_TYPED; + private static final MethodHandle MH_VOID; + private static final MethodHandle MH_FAILING; + private static final MethodHandle MH_DEP_READING; + private static final MethodHandle MH_TOP; + private static final MethodHandle MH_BOTTOM; + + static { + try { + MethodHandles.Lookup lookup = MethodHandles.lookup(); + MethodType mt = MethodType.methodType(void.class, ServiceNode.class); + MH_TYPED = lookup.findStatic(ServiceGraphTest.class, "typedAction", mt); + MH_VOID = lookup.findStatic(ServiceGraphTest.class, "voidAction", mt); + MH_FAILING = lookup.findStatic(ServiceGraphTest.class, "failingAction", mt); + MH_DEP_READING = lookup.findStatic(ServiceGraphTest.class, "depReadingAction", mt); + MH_TOP = lookup.findStatic(ServiceGraphTest.class, "topSentinel", mt); + MH_BOTTOM = lookup.findStatic(ServiceGraphTest.class, "bottomSentinel", mt); + } catch (ReflectiveOperationException e) { + throw new ExceptionInInitializerError(e); + } + } + + // ═══════════════════════════════════════════════ + // Basic lifecycle + // ═══════════════════════════════════════════════ + + @Test + void singleNodeStartsAndStops() { + ServiceGraph graph = new ServiceGraph(); + ServiceNode top = new ServiceNode("top", MH_TOP, graph, 1); + ServiceNode svc = new ServiceNode("svc", MH_VOID, graph, 1, top); + ServiceNode bottom = new ServiceNode("bottom", MH_BOTTOM, graph, 0, svc); + graph.setTop(top); + graph.setBottom(bottom); + + graph.start(); + + assertThat(top.state()).isEqualTo(ServiceNode.S_COMPLETED); + assertThat(svc.state()).isEqualTo(ServiceNode.S_COMPLETED); + assertThat(bottom.state()).isEqualTo(ServiceNode.S_COMPLETED); + + graph.stop(); + + assertThat(bottom.state()).isEqualTo(ServiceNode.S_STOPPED); + assertThat(svc.state()).isEqualTo(ServiceNode.S_STOPPED); + assertThat(top.state()).isEqualTo(ServiceNode.S_STOPPED); + } + + @Test + void typedServiceProducesValue() { + ServiceGraph graph = new ServiceGraph(); + ServiceNode top = new ServiceNode("top", MH_TOP, graph, 1); + ServiceNode svc = new ServiceNode("svc", MH_TYPED, graph, 1, top); + ServiceNode bottom = new ServiceNode("bottom", MH_BOTTOM, graph, 0, svc); + graph.setTop(top); + graph.setBottom(bottom); + + graph.start(); + + assertThat(svc.value()).isEqualTo("value-from-svc"); + } + + @Test + void dependencyValueAccessWorks() { + ServiceGraph graph = new ServiceGraph(); + ServiceNode top = new ServiceNode("top", MH_TOP, graph, 1); + ServiceNode producer = new ServiceNode("producer", MH_TYPED, graph, 1, top); + // depReadingAction reads dependencyValue(0), which is the first dep after top + // but dep index 0 = top (first in constructor), dep index 1 = producer + // Actually, we need producer at index 0 for depReadingAction to read it. + // Let's put producer first in the dep list. + ServiceNode consumer = new ServiceNode("consumer", MH_DEP_READING, graph, 1, + List.of(producer, top)); + ServiceNode bottom = new ServiceNode("bottom", MH_BOTTOM, graph, 0, consumer); + graph.setTop(top); + graph.setBottom(bottom); + + graph.start(); + + assertThat(consumer.value()).isEqualTo("got-value-from-producer"); + } + + // ═══════════════════════════════════════════════ + // Diamond topology + // ═══════════════════════════════════════════════ + + @Test + void diamondTopologyCompletesAllNodes() { + ServiceGraph graph = new ServiceGraph(); + ServiceNode top = new ServiceNode("top", MH_TOP, graph, 1); + ServiceNode a = new ServiceNode("A", MH_VOID, graph, 2, top); + ServiceNode b = new ServiceNode("B", MH_VOID, graph, 1, a); + ServiceNode c = new ServiceNode("C", MH_VOID, graph, 1, a); + ServiceNode d = new ServiceNode("D", MH_VOID, graph, 1, b, c); + ServiceNode bottom = new ServiceNode("bottom", MH_BOTTOM, graph, 0, d); + graph.setTop(top); + graph.setBottom(bottom); + + graph.start(); + + assertThat(a.state()).isEqualTo(ServiceNode.S_COMPLETED); + assertThat(b.state()).isEqualTo(ServiceNode.S_COMPLETED); + assertThat(c.state()).isEqualTo(ServiceNode.S_COMPLETED); + assertThat(d.state()).isEqualTo(ServiceNode.S_COMPLETED); + } + + @Test + void diamondStopsInReverseOrder() { + List stopOrder = Collections.synchronizedList(new ArrayList<>()); + ServiceGraph graph = new ServiceGraph(); + ServiceNode top = new ServiceNode("top", MH_TOP, graph, 1); + ServiceNode a = new ServiceNode("A", makeStopTracker("A", stopOrder), graph, 2, top); + ServiceNode b = new ServiceNode("B", makeStopTracker("B", stopOrder), graph, 1, a); + ServiceNode c = new ServiceNode("C", makeStopTracker("C", stopOrder), graph, 1, a); + ServiceNode d = new ServiceNode("D", makeStopTracker("D", stopOrder), graph, 1, b, c); + ServiceNode bottom = new ServiceNode("bottom", MH_BOTTOM, graph, 0, d); + graph.setTop(top); + graph.setBottom(bottom); + + graph.start(); + graph.stop(); + + // D must stop before B and C; B and C must stop before A + assertThat(stopOrder.indexOf("D")).isLessThan(stopOrder.indexOf("B")); + assertThat(stopOrder.indexOf("D")).isLessThan(stopOrder.indexOf("C")); + assertThat(stopOrder.indexOf("B")).isLessThan(stopOrder.indexOf("A")); + assertThat(stopOrder.indexOf("C")).isLessThan(stopOrder.indexOf("A")); + } + + // ═══════════════════════════════════════════════ + // Failure scenarios + // ═══════════════════════════════════════════════ + + @Test + void failureCancelsDependents() { + ServiceGraph graph = new ServiceGraph(); + ServiceNode top = new ServiceNode("top", MH_TOP, graph, 1); + ServiceNode a = new ServiceNode("A", MH_FAILING, graph, 1, top); + ServiceNode b = new ServiceNode("B", MH_VOID, graph, 1, a); + ServiceNode bottom = new ServiceNode("bottom", MH_BOTTOM, graph, 0, b); + graph.setTop(top); + graph.setBottom(bottom); + + try { + graph.start(); + } catch (RuntimeException expected) { + assertThat(expected.getMessage()).contains("deliberate failure"); + } + + assertThat(a.state()).isEqualTo(ServiceNode.S_FAILED_STOPPED); + assertThat(b.state()).isEqualTo(ServiceNode.S_CANCELED); + assertThat(a.failure()).isNotNull(); + assertThat(a.failure().getMessage()).contains("deliberate failure"); + } + + @Test + void failureInDiamondCancelsDownstreamButCompletedNodesStop() { + List stopOrder = Collections.synchronizedList(new ArrayList<>()); + ServiceGraph graph = new ServiceGraph(); + ServiceNode top = new ServiceNode("top", MH_TOP, graph, 1); + ServiceNode a = new ServiceNode("A", makeStopTracker("A", stopOrder), graph, 2, top); + ServiceNode b = new ServiceNode("B", MH_FAILING, graph, 1, a); // B fails + ServiceNode c = new ServiceNode("C", makeStopTracker("C", stopOrder), graph, 1, a); // C completes + ServiceNode d = new ServiceNode("D", MH_VOID, graph, 1, b, c); // D depends on B → canceled + ServiceNode bottom = new ServiceNode("bottom", MH_BOTTOM, graph, 0, d); + graph.setTop(top); + graph.setBottom(bottom); + + try { + graph.start(); + } catch (RuntimeException expected) { + assertThat(expected.getMessage()).contains("deliberate failure"); + } + + assertThat(b.state()).isEqualTo(ServiceNode.S_FAILED_STOPPED); + assertThat(d.state()).isEqualTo(ServiceNode.S_CANCELED); + // A and C completed and should be stopped + assertThat(c.state()).isEqualTo(ServiceNode.S_STOPPED); + assertThat(a.state()).isEqualTo(ServiceNode.S_STOPPED); + // stop order: C before A (C depends on A) + assertThat(stopOrder.indexOf("C")).isLessThan(stopOrder.indexOf("A")); + } + + // ═══════════════════════════════════════════════ + // Stop handler + // ═══════════════════════════════════════════════ + + @Test + void stopHandlerIsCalled() { + List stopped = new ArrayList<>(); + ServiceGraph graph = new ServiceGraph(); + ServiceNode top = new ServiceNode("top", MH_TOP, graph, 1); + ServiceNode svc = new ServiceNode("svc", MH_VOID, graph, 1, top); + ServiceNode bottom = new ServiceNode("bottom", MH_BOTTOM, graph, 0, svc); + graph.setTop(top); + graph.setBottom(bottom); + + graph.start(); + + // register stop handler manually (normally done by the action) + svc.onStop(() -> stopped.add("svc-stopped")); + + graph.stop(); + + assertThat(stopped).containsExactly("svc-stopped"); + } + + @Test + void duplicateStopHandlerThrows() { + ServiceGraph graph = new ServiceGraph(); + ServiceNode top = new ServiceNode("top", MH_TOP, graph, 1); + ServiceNode svc = new ServiceNode("svc", MH_VOID, graph, 1, top); + ServiceNode bottom = new ServiceNode("bottom", MH_BOTTOM, graph, 0, svc); + graph.setTop(top); + graph.setBottom(bottom); + + graph.start(); + svc.onStop(() -> { + }); + + assertThatThrownBy(() -> svc.onStop(() -> { + })) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("stop handler already registered"); + } + + // ═══════════════════════════════════════════════ + // NodeShutdownContext + // ═══════════════════════════════════════════════ + + @Test + void nodeShutdownContextRunsTasksInLifoOrder() { + NodeShutdownContext sc = new NodeShutdownContext(); + List order = new ArrayList<>(); + sc.addShutdownTask(() -> order.add("first")); + sc.addShutdownTask(() -> order.add("second")); + sc.addShutdownTask(() -> order.add("third")); + + sc.run(); + + assertThat(order).containsExactly("third", "second", "first"); + } + + @Test + void lastShutdownTasksDelegatedToGlobalCollector() { + NodeShutdownContext scStatic = new NodeShutdownContext(true); + NodeShutdownContext scRuntime = new NodeShutdownContext(false); + List order = new ArrayList<>(); + scStatic.addShutdownTask(() -> order.add("normalStatic")); + scStatic.addLastShutdownTask(() -> order.add("lastStatic")); + scRuntime.addShutdownTask(() -> order.add("normalRuntime")); + scRuntime.addLastShutdownTask(() -> order.add("lastRuntime")); + + // normal tasks run on the node + scStatic.run(); + scRuntime.run(); + // "last" tasks are collected globally and run separately + LastShutdownTasks.runRuntime(); + LastShutdownTasks.runStatic(); + + assertThat(order).containsExactly("normalStatic", "normalRuntime", "lastRuntime", "lastStatic"); + } + + @Test + void nodeShutdownContextClearsAfterRun() { + NodeShutdownContext sc = new NodeShutdownContext(); + List order = new ArrayList<>(); + sc.addShutdownTask(() -> order.add("task")); + + sc.run(); + assertThat(order).hasSize(1); + + // second run should be empty + sc.run(); + assertThat(order).hasSize(1); + } + + // ═══════════════════════════════════════════════ + // Executor swap + // ═══════════════════════════════════════════════ + + @Test + void executorSwapReturnsOldExecutor() { + ServiceGraph graph = new ServiceGraph(); + Executor original = graph.executor(); + + Executor custom = Runnable::run; + Executor returned = graph.setExecutor(custom); + + assertThat(returned).isSameAs(original); + assertThat(graph.executor()).isSameAs(custom); + } + + // ═══════════════════════════════════════════════ + // Helpers + // ═══════════════════════════════════════════════ + + /** + * Create an action handle that completes as void and registers a stop handler + * that records the node name into the given list. + */ + private static MethodHandle makeStopTracker(String name, List stopOrder) { + // we can't easily create a closure MethodHandle, so use a workaround: + // store the stop order list in a static field and use the node name + // Actually, let's use a simple approach: the action registers the stop handler + try { + MethodHandle base = MethodHandles.lookup().findStatic( + ServiceGraphTest.class, "stopTrackingAction", + MethodType.methodType(void.class, ServiceNode.class, List.class)); + return MethodHandles.insertArguments(base, 1, stopOrder); + } catch (ReflectiveOperationException e) { + throw new RuntimeException(e); + } + } + + static void stopTrackingAction(ServiceNode node, List stopOrder) { + node.onStop(() -> stopOrder.add(node.name())); + node.startComplete(); + } +} diff --git a/core/service-types-maker/pom.xml b/core/service-types-maker/pom.xml new file mode 100644 index 0000000000000..869f2be25d66a --- /dev/null +++ b/core/service-types-maker/pom.xml @@ -0,0 +1,24 @@ + + + 4.0.0 + + + io.quarkus + quarkus-core-parent + 999-SNAPSHOT + + + quarkus-service-types-maker + + Quarkus - Action maker (internal utility) + A code generator to provide supplementary types to the Quarkus Core module + + + + io.smallrye.jdeparser + jdeparser + + + \ No newline at end of file diff --git a/core/service-types-maker/src/main/java/io/quarkus/core/servicetypesmaker/MakeServiceTypes.java b/core/service-types-maker/src/main/java/io/quarkus/core/servicetypesmaker/MakeServiceTypes.java new file mode 100644 index 0000000000000..3b071b3189f15 --- /dev/null +++ b/core/service-types-maker/src/main/java/io/quarkus/core/servicetypesmaker/MakeServiceTypes.java @@ -0,0 +1,1639 @@ +package io.quarkus.core.servicetypesmaker; + +import static io.quarkus.core.servicetypesmaker.Util.ord; +import static io.quarkus.core.servicetypesmaker.Util.plural; +import static io.smallrye.jdeparser.Expr.$v; + +import java.io.IOException; +import java.io.Serializable; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import io.smallrye.jdeparser.Expr; +import io.smallrye.jdeparser.JDeparser; +import io.smallrye.jdeparser.SourceVersion; +import io.smallrye.jdeparser.Sources; +import io.smallrye.jdeparser.Type; +import io.smallrye.jdeparser.Var; +import io.smallrye.jdeparser.creator.ClassCreator; +import io.smallrye.jdeparser.creator.InterfaceCreator; +import io.smallrye.jdeparser.creator.SourceFileCreator; +import io.smallrye.jdeparser.format.Filer; +import io.smallrye.jdeparser.format.FormatPreferences; + +/** + * Code generator that produces the service action and service builder interfaces + * for the Quarkus service mechanism. + *

+ * Generates: + *

    + *
  • {@code Action0} through Action<n> - functional interfaces for typed service actions
  • + *
  • {@code VoidAction0} through VoidAction<n> - functional interfaces for void service actions
  • + *
  • {@code ServiceBuilder0} through ServiceBuilder<n-1> - type-safe builder chain interfaces
  • + *
  • {@code VoidServiceBuilder0} through VoidServiceBuilder<n-1> - builder chain interfaces for void + * services
  • + *
+ */ +public final class MakeServiceTypes { + + /** + * Maximum number of action parameters (StartContext not counted). + */ + private static final int MAX_PCNT = 25; + + /** + * Maximum number of dependency parameters for service builders. + * This is one less than {@code MAX_PCNT} because the start context + * always occupies the first action parameter slot. + */ + private static final int MAX_DEPS = MAX_PCNT - 1; + + private static final String PKG = "io.quarkus.core.deployment.service"; + private static final String IMPL_PKG = "io.quarkus.core.deployment.service.impl"; + private static final String START_CONTEXT = "io.quarkus.core.StartContext"; + private static final String ASYNC_START_CONTEXT = "io.quarkus.core.AsyncStartContext"; + private static final String ASYNC_VOID_START_CONTEXT = "io.quarkus.core.AsyncVoidStartContext"; + + MakeServiceTypes() { + } + + /** + * Entry point for the code generator. + * + * @param argsArray command line arguments; the first argument is the output directory + * @throws IOException if writing the generated sources fails + */ + public static void main(String[] argsArray) throws IOException { + List args = List.of(argsArray); + if (args.isEmpty()) { + throw new IllegalArgumentException("Must provide destination directory as an argument"); + } + Path outputDir = Path.of(args.getFirst()); + Sources sources = JDeparser.createSources(Filer.newInstance(outputDir), FormatPreferences.defaults(), + SourceVersion.JAVA_17); + generateActionInterfaces(sources); + generateVoidActionInterfaces(sources); + generateServiceBuilderInterfaces(sources); + generateServiceBuilderImpls(sources); + generateVoidServiceBuilderInterfaces(sources); + generateVoidServiceBuilderImpls(sources); + System.out.println("Writing sources to " + outputDir); + sources.writeSources(); + } + + // ── Action interfaces ── + + /** + * Generate {@code Action0} through {@code Action10} functional interfaces. + * Each extends {@link Serializable} to support lambda transliteration. + * + * @param sources the JDeparser sources to generate into + */ + private static void generateActionInterfaces(Sources sources) { + for (int i = 0; i <= MAX_PCNT; i++) { + String className = "Action" + i; + final int cnt = i; + sources.createSourceFile(PKG, className, sf -> { + sf.import_(Serializable.class); + sf.interface_(className, cc -> { + cc.public_(); + cc.docComment(dcc -> dcc.text("An action which takes " + cnt + " " + plural("argument", cnt) + ".")); + cc.extends_(Type.of(Serializable.class)); + Type tee = cc.typeParam("T", tpc -> tpc.docComment(dcc -> dcc.text("the return type of the action"))); + for (int j = 0; j < cnt; j++) { + final int paramIdx = j + 1; + cc.typeParam("P" + paramIdx, + tpc -> tpc.docComment(dcc -> dcc.text("the type of the " + ord(paramIdx) + " parameter"))); + } + cc.method("run", mc -> { + mc.docComment(dcc -> { + dcc.text("Perform the action. If the action returns "); + dcc.code("null"); + dcc.text(" then start will fail if there are any non-"); + dcc.code("Optional"); + dcc.text(" consumers."); + dcc.return_(rc -> { + rc.text("the result of the action, or "); + rc.code("null"); + rc.text(" if there is no result"); + }); + }); + mc.returning(tee); + for (int j = 0; j < cnt; j++) { + final int paramIdx = j + 1; + mc.param("p" + paramIdx, Type.named("P" + paramIdx), pc -> pc.docComment(dcc -> { + dcc.text("the argument for the " + ord(paramIdx) + " parameter (not "); + dcc.code("null"); + dcc.text(")"); + })); + } + mc.throws_(Type.of(Exception.class)); + }); + }); + }); + } + } + + /** + * Generate {@code VoidAction0} through {@code VoidAction10} functional interfaces. + * These are like the {@code Action} interfaces but with a {@code void} return type + * and no {@code T} type parameter. + * + * @param sources the JDeparser sources to generate into + */ + private static void generateVoidActionInterfaces(Sources sources) { + for (int i = 0; i <= MAX_PCNT; i++) { + String className = "VoidAction" + i; + final int cnt = i; + sources.createSourceFile(PKG, className, sf -> { + sf.import_(Serializable.class); + sf.interface_(className, cc -> { + cc.public_(); + cc.docComment(dcc -> dcc + .text("A void action which takes " + cnt + " " + plural("argument", cnt) + ".")); + cc.extends_(Type.of(Serializable.class)); + for (int j = 0; j < cnt; j++) { + final int paramIdx = j + 1; + cc.typeParam("P" + paramIdx, + tpc -> tpc.docComment(dcc -> dcc.text("the type of the " + ord(paramIdx) + " parameter"))); + } + cc.method("run", mc -> { + mc.docComment(dcc -> dcc.text("Perform the action.")); + mc.returning(Type.VOID); + for (int j = 0; j < cnt; j++) { + final int paramIdx = j + 1; + mc.param("p" + paramIdx, Type.named("P" + paramIdx), pc -> pc.docComment(dcc -> { + dcc.text("the argument for the " + ord(paramIdx) + " parameter (not "); + dcc.code("null"); + dcc.text(")"); + })); + } + mc.throws_(Type.of(Exception.class)); + }); + }); + }); + } + } + + // ── Typed service builder interfaces ── + + /** + * Generate {@code ServiceBuilder0} through {@code ServiceBuilderN} type-safe builder chain interfaces. + *

+ * Each {@code ServiceBuilderN} has: + *

    + *
  • {@code require(Class)} and {@code require(Class, String)} methods that return {@code ServiceBuilder(N+1)} + * (except for {@code ServiceBuilder9} which has reached the maximum arity)
  • + *
  • {@code after()} methods for ordering-only dependencies (5 overloads)
  • + *
  • {@code onStart(Action(N+1))} - synchronous terminal method; the action's first parameter is always + * a {@code StartContext}
  • + *
  • {@code onStartAsync(Action(N+1))} - asynchronous terminal method; the action's first parameter is always + * an {@code AsyncStartContext}
  • + *
+ * + * @param sources the JDeparser sources to generate into + */ + private static void generateServiceBuilderInterfaces(Sources sources) { + generateServiceBuilderInterfacesParameterized( + sources, "ServiceBuilder", START_CONTEXT); + } + + /** + * Parameterized generation of typed service builder interfaces. + * + * @param sources the JDeparser sources to generate into + * @param classPrefix the class name prefix + * @param contextFqn the fully-qualified context type name + */ + private static void generateServiceBuilderInterfacesParameterized( + Sources sources, + String classPrefix, + String contextFqn) { + for (int i = 0; i <= MAX_DEPS; i++) { + String className = classPrefix + i; + final int depCount = i; + // action arity is depCount + 1 (context occupies the first slot) + final int actionArity = depCount + 1; + sources.createSourceFile(PKG, className, sf -> { + sf.import_(List.class); + sf.import_(Optional.class); + sf.import_(Type.named(contextFqn)); + sf.import_(Type.named(ASYNC_START_CONTEXT)); + sf.interface_(className, cc -> { + cc.public_(); + cc.docComment(dcc -> { + dcc.text("A service builder with " + depCount + " " + plural("dependency", depCount) + + " declared."); + dcc.text(" Use {@code require} to add dependencies, {@code after} to add" + + " ordering-only dependencies, or {@code onStart}/" + + "{@code onStartAsync} to terminate the builder chain."); + }); + // T = service type + Type svcTypeParam = cc.typeParam("T", tpc -> tpc.docComment(dcc -> dcc.text("the service type"))); + // P1..PN = dependency types + for (int j = 0; j < depCount; j++) { + final int paramIdx = j + 1; + cc.typeParam("P" + paramIdx, + tpc -> tpc.docComment(dcc -> dcc.text("the type of the " + ord(paramIdx) + " dependency"))); + } + + // self type for after() return + Type[] selfTypeArgs = new Type[depCount + 1]; + selfTypeArgs[0] = svcTypeParam; + for (int j = 0; j < depCount; j++) { + selfTypeArgs[j + 1] = Type.named("P" + (j + 1)); + } + Type selfType = Type.named(className).typeArg(selfTypeArgs); + + // require() methods - only if we haven't hit max deps + if (depCount < MAX_DEPS) { + final int nextDepCount = depCount + 1; + String nextBuilder = classPrefix + nextDepCount; + // require(Class type) + cc.method("require", mc -> { + mc.docComment(dcc -> { + dcc.text("Add a dependency on a service of the given type."); + dcc.return_(rc -> rc.text("a builder with the added dependency")); + }); + Type pn1 = mc.typeParam("P" + nextDepCount, + tpc -> tpc.docComment(dcc -> dcc.text("the type of the dependency to add"))); + // return type: Builder(N+1) + Type[] nextTypeArgs = new Type[nextDepCount + 1]; + nextTypeArgs[0] = svcTypeParam; + for (int j = 0; j < depCount; j++) { + nextTypeArgs[j + 1] = Type.named("P" + (j + 1)); + } + nextTypeArgs[nextDepCount] = pn1; + mc.returning(Type.named(nextBuilder).typeArg(nextTypeArgs)); + mc.param("type", Type.of(Class.class).typeArg(pn1), + pc -> pc.docComment(dcc -> dcc.text("the dependency type (must not be {@code null})"))); + }); + // require(Class type, String name) + cc.method("require", mc -> { + mc.docComment(dcc -> { + dcc.text("Add a dependency on a named service of the given type."); + dcc.return_(rc -> rc.text("a builder with the added dependency")); + }); + Type pn1 = mc.typeParam("P" + nextDepCount, + tpc -> tpc.docComment(dcc -> dcc.text("the type of the dependency to add"))); + Type[] nextTypeArgs = new Type[nextDepCount + 1]; + nextTypeArgs[0] = svcTypeParam; + for (int j = 0; j < depCount; j++) { + nextTypeArgs[j + 1] = Type.named("P" + (j + 1)); + } + nextTypeArgs[nextDepCount] = pn1; + mc.returning(Type.named(nextBuilder).typeArg(nextTypeArgs)); + mc.param("type", Type.of(Class.class).typeArg(pn1), + pc -> pc.docComment(dcc -> dcc.text("the dependency type (must not be {@code null})"))); + mc.param("name", Type.of(String.class), + pc -> pc.docComment(dcc -> dcc.text("the dependency name (must not be {@code null})"))); + }); + } + + // request() methods - optional dependencies (only if we haven't hit max deps) + if (depCount < MAX_DEPS) { + final int nextDepCount = depCount + 1; + String nextBuilder = classPrefix + nextDepCount; + sf.import_(Optional.class); + generateRequestInterfaceMethods(cc, svcTypeParam, depCount, nextDepCount, nextBuilder, true); + } + + // consumeAll() methods - multi-service dependencies (only if we haven't hit max deps) + if (depCount < MAX_DEPS) { + final int nextDepCount = depCount + 1; + String nextBuilder = classPrefix + nextDepCount; + sf.import_(Map.class); + generateConsumeAllInterfaceMethod(cc, svcTypeParam, depCount, nextDepCount, nextBuilder, true); + } + + // after() methods - ordering-only dependencies + generateAfterInterfaceMethods(cc, selfType); + + // before() methods - reverse ordering dependencies + generateBeforeInterfaceMethods(cc, selfType); + + // afterBuildItem() method + generateAfterBuildItemInterfaceMethod(cc, selfType); + + // withPhase() - runtime phase assignment (only on Builder0) + if (depCount == 0) { + generateAtPhaseInterfaceMethod(cc, sf, selfType); + } + + // optional() - retype T to Optional so the terminal action returns Optional + { + Type[] optTypeArgs = new Type[depCount + 1]; + optTypeArgs[0] = Type.of(Optional.class).typeArg(svcTypeParam); + for (int j = 0; j < depCount; j++) { + optTypeArgs[j + 1] = Type.named("P" + (j + 1)); + } + Type optReturn = Type.named(className).typeArg(optTypeArgs); + cc.method("optional", mc -> { + mc.docComment(dcc -> { + dcc.text("Declare that this service may produce no value at run time. "); + dcc.text("The terminal {@code action} is retyped to return {@code Optional}: an empty "); + dcc.text("result marks the service as present-but-valueless (skipped by {@code consumeAll()} "); + dcc.text("consumers and delivered as an empty optional to {@code request()} consumers) "); + dcc.text("rather than being treated as a startup failure. May only be called once."); + dcc.return_(rc -> rc + .text("this builder, retyped so the action returns {@code Optional}")); + }); + mc.returning(optReturn); + }); + } + + // onStart() - sync terminal + cc.method("onStart", mc -> { + mc.docComment(dcc -> { + dcc.text("Define the synchronous start action for this service. "); + dcc.text("The action receives a {@code " + contextFqn.substring(contextFqn.lastIndexOf('.') + 1) + + "} followed by all declared dependencies."); + }); + mc.returning(Type.VOID); + Type[] actionTypeArgs = new Type[actionArity + 1]; + actionTypeArgs[0] = svcTypeParam; + actionTypeArgs[1] = Type.named(contextFqn); + for (int j = 0; j < depCount; j++) { + actionTypeArgs[j + 2] = Type.named("P" + (j + 1)); + } + mc.param("action", Type.named("Action" + actionArity).typeArg(actionTypeArgs), + pc -> pc.docComment(dcc -> dcc.text("the service start action (must not be {@code null})"))); + }); + + // onStartAsync() - async terminal (only for runtime builders) + cc.method("onStartAsync", mc -> { + mc.docComment(dcc -> { + dcc.text("Define the asynchronous start action for this service. "); + dcc.text( + "The action receives an {@code AsyncStartContext} followed by all declared dependencies."); + }); + mc.returning(Type.VOID); + Type[] actionTypeArgs = new Type[actionArity + 1]; + actionTypeArgs[0] = Type.of(Void.class); + actionTypeArgs[1] = Type.named(ASYNC_START_CONTEXT).typeArg(svcTypeParam); + for (int j = 0; j < depCount; j++) { + actionTypeArgs[j + 2] = Type.named("P" + (j + 1)); + } + mc.param("action", Type.named("Action" + actionArity).typeArg(actionTypeArgs), + pc -> pc.docComment(dcc -> dcc.text("the service action (must not be {@code null})"))); + }); + }); + }); + } + } + + // ── Void service builder interfaces ── + + /** + * Generate {@code VoidServiceBuilder0} through {@code VoidServiceBuilder9} builder chain interfaces + * for void runtime services. + * + * @param sources the JDeparser sources to generate into + */ + private static void generateVoidServiceBuilderInterfaces(Sources sources) { + generateVoidServiceBuilderInterfacesParameterized( + sources, "VoidServiceBuilder", START_CONTEXT, ASYNC_VOID_START_CONTEXT); + } + + /** + * Parameterized generation of void service builder interfaces. + * + * @param sources the JDeparser sources to generate into + * @param classPrefix the class name prefix + * @param contextFqn the fully-qualified sync context type name + * @param asyncContextFqn the fully-qualified async context type name + */ + private static void generateVoidServiceBuilderInterfacesParameterized( + Sources sources, + String classPrefix, + String contextFqn, + String asyncContextFqn) { + for (int i = 0; i <= MAX_DEPS; i++) { + String className = classPrefix + i; + final int depCount = i; + final int actionArity = depCount + 1; + sources.createSourceFile(PKG, className, sf -> { + sf.import_(List.class); + sf.import_(Type.named(contextFqn)); + sf.import_(Type.named(asyncContextFqn)); + sf.interface_(className, cc -> { + cc.public_(); + cc.docComment(dcc -> { + dcc.text("A void service builder with " + depCount + " " + plural("dependency", depCount) + + " declared."); + dcc.text(" Use {@code require} to add dependencies, {@code after} to add" + + " ordering-only dependencies, or {@code onStart}/" + + "{@code onStartAsync} to terminate the builder chain."); + }); + // P1..PN = dependency types (no T type param for void builders) + for (int j = 0; j < depCount; j++) { + final int paramIdx = j + 1; + cc.typeParam("P" + paramIdx, + tpc -> tpc.docComment(dcc -> dcc.text("the type of the " + ord(paramIdx) + " dependency"))); + } + + // self type for after() return + Type selfType; + if (depCount == 0) { + selfType = Type.named(className); + } else { + Type[] selfTypeArgs = new Type[depCount]; + for (int j = 0; j < depCount; j++) { + selfTypeArgs[j] = Type.named("P" + (j + 1)); + } + selfType = Type.named(className).typeArg(selfTypeArgs); + } + + // require() methods - only if we haven't hit max deps + if (depCount < MAX_DEPS) { + final int nextDepCount = depCount + 1; + String nextBuilder = classPrefix + nextDepCount; + // require(Class type) + cc.method("require", mc -> { + mc.docComment(dcc -> { + dcc.text("Add a dependency on a service of the given type."); + dcc.return_(rc -> rc.text("a builder with the added dependency")); + }); + Type pn1 = mc.typeParam("P" + nextDepCount, + tpc -> tpc.docComment(dcc -> dcc.text("the type of the dependency to add"))); + Type[] nextTypeArgs = new Type[nextDepCount]; + for (int j = 0; j < depCount; j++) { + nextTypeArgs[j] = Type.named("P" + (j + 1)); + } + nextTypeArgs[depCount] = pn1; + mc.returning(Type.named(nextBuilder).typeArg(nextTypeArgs)); + mc.param("type", Type.of(Class.class).typeArg(pn1), + pc -> pc.docComment(dcc -> dcc.text("the dependency type (must not be {@code null})"))); + }); + // require(Class type, String name) + cc.method("require", mc -> { + mc.docComment(dcc -> { + dcc.text("Add a dependency on a named service of the given type."); + dcc.return_(rc -> rc.text("a builder with the added dependency")); + }); + Type pn1 = mc.typeParam("P" + nextDepCount, + tpc -> tpc.docComment(dcc -> dcc.text("the type of the dependency to add"))); + Type[] nextTypeArgs = new Type[nextDepCount]; + for (int j = 0; j < depCount; j++) { + nextTypeArgs[j] = Type.named("P" + (j + 1)); + } + nextTypeArgs[depCount] = pn1; + mc.returning(Type.named(nextBuilder).typeArg(nextTypeArgs)); + mc.param("type", Type.of(Class.class).typeArg(pn1), + pc -> pc.docComment(dcc -> dcc.text("the dependency type (must not be {@code null})"))); + mc.param("name", Type.of(String.class), + pc -> pc.docComment(dcc -> dcc.text("the dependency name (must not be {@code null})"))); + }); + } + + // request() methods - optional dependencies (only if we haven't hit max deps) + if (depCount < MAX_DEPS) { + final int nextDepCount2 = depCount + 1; + String nextBuilder2 = classPrefix + nextDepCount2; + sf.import_(Optional.class); + generateRequestInterfaceMethods(cc, null, depCount, nextDepCount2, nextBuilder2, false); + } + + // consumeAll() methods - multi-service dependencies (only if we haven't hit max deps) + if (depCount < MAX_DEPS) { + final int nextDepCount2 = depCount + 1; + String nextBuilder2 = classPrefix + nextDepCount2; + sf.import_(Map.class); + generateConsumeAllInterfaceMethod(cc, null, depCount, nextDepCount2, nextBuilder2, false); + } + + // after() methods - ordering-only dependencies + generateAfterInterfaceMethods(cc, selfType); + + // before() methods - reverse ordering dependencies + generateBeforeInterfaceMethods(cc, selfType); + + // afterBuildItem() method + generateAfterBuildItemInterfaceMethod(cc, selfType); + + // withPhase() - runtime phase assignment (only on Builder0) + if (depCount == 0) { + generateAtPhaseInterfaceMethod(cc, sf, selfType); + } + + // onStart() - sync terminal using VoidAction + cc.method("onStart", mc -> { + mc.docComment(dcc -> { + dcc.text("Define the synchronous start action for this void service. "); + dcc.text("The action receives a {@code " + contextFqn.substring(contextFqn.lastIndexOf('.') + 1) + + "} followed by all declared dependencies."); + }); + mc.returning(Type.VOID); + Type[] actionTypeArgs = new Type[actionArity]; + actionTypeArgs[0] = Type.named(contextFqn); + for (int j = 0; j < depCount; j++) { + actionTypeArgs[j + 1] = Type.named("P" + (j + 1)); + } + mc.param("action", Type.named("VoidAction" + actionArity).typeArg(actionTypeArgs), + pc -> pc.docComment(dcc -> dcc.text("the service action (must not be {@code null})"))); + }); + + // onStartAsync() - async terminal + cc.method("onStartAsync", mc -> { + mc.docComment(dcc -> { + dcc.text("Define the asynchronous start action for this void service. "); + dcc.text("The action receives an {@code AsyncVoidStartContext}" + + " followed by all declared dependencies."); + }); + mc.returning(Type.VOID); + Type[] actionTypeArgs = new Type[actionArity]; + actionTypeArgs[0] = Type.named(asyncContextFqn); + for (int j = 0; j < depCount; j++) { + actionTypeArgs[j + 1] = Type.named("P" + (j + 1)); + } + mc.param("action", Type.named("VoidAction" + actionArity).typeArg(actionTypeArgs), + pc -> pc.docComment(dcc -> dcc.text("the service start action (must not be {@code null})"))); + }); + }); + }); + } + } + + /** + * Generate the five {@code after()} method declarations on a builder interface. + * These are ordering-only dependency methods that return the same builder type. + * + * @param cc the class/interface context to add methods to + * @param selfType the self-referencing return type of the builder + */ + private static void generateAfterInterfaceMethods( + InterfaceCreator cc, + Type selfType) { + // after(Enum enumConstant) + cc.method("after", mc -> { + mc.docComment(dcc -> { + dcc.text("Add an ordering-only dependency on an enum constant service."); + dcc.return_(rc -> rc.text("a builder with the added dependency")); + }); + mc.returning(selfType); + mc.param("enumConstant", Type.of(Enum.class).typeArg(Type.WILDCARD), + pc -> pc.docComment(dcc -> dcc.text("the enum constant (must not be {@code null})"))); + }); + // after(String name) + cc.method("after", mc -> { + mc.docComment(dcc -> { + dcc.text("Add an ordering-only dependency on a void service with the given name. "); + dcc.text("The service must complete before this service starts, but its value is not injected."); + dcc.return_(rc -> rc.text("a builder with the added dependency")); + }); + mc.returning(selfType); + mc.param("name", Type.of(String.class), + pc -> pc.docComment(dcc -> dcc.text("the void service name (must not be {@code null})"))); + }); + // after(List nameParts) + cc.method("after", mc -> { + mc.docComment(dcc -> { + dcc.text("Add an ordering-only dependency on a void service with the given multi-part name."); + dcc.return_(rc -> rc.text("a builder with the added dependency")); + }); + mc.returning(selfType); + mc.param("nameParts", Type.of(List.class).typeArg(Type.of(String.class)), + pc -> pc.docComment(dcc -> dcc.text("the void service name parts (must not be {@code null})"))); + }); + // after(Class type) + cc.method("after", mc -> { + mc.docComment(dcc -> { + dcc.text("Add an ordering-only dependency on an unnamed typed service."); + dcc.return_(rc -> rc.text("a builder with the added dependency")); + }); + mc.returning(selfType); + mc.param("type", Type.of(Class.class).typeArg(Type.WILDCARD), + pc -> pc.docComment(dcc -> dcc.text("the service type (must not be {@code null})"))); + }); + // after(Class type, String name) + cc.method("after", mc -> { + mc.docComment(dcc -> { + dcc.text("Add an ordering-only dependency on a named typed service."); + dcc.return_(rc -> rc.text("a builder with the added dependency")); + }); + mc.returning(selfType); + mc.param("type", Type.of(Class.class).typeArg(Type.WILDCARD), + pc -> pc.docComment(dcc -> dcc.text("the service type (must not be {@code null})"))); + mc.param("name", Type.of(String.class), + pc -> pc.docComment(dcc -> dcc.text("the service name (must not be {@code null})"))); + }); + // after(Class type, List nameParts) + cc.method("after", mc -> { + mc.docComment(dcc -> { + dcc.text("Add an ordering-only dependency on a typed service with the given multi-part name."); + dcc.return_(rc -> rc.text("a builder with the added dependency")); + }); + mc.returning(selfType); + mc.param("type", Type.of(Class.class).typeArg(Type.WILDCARD), + pc -> pc.docComment(dcc -> dcc.text("the service type (must not be {@code null})"))); + mc.param("nameParts", Type.of(List.class).typeArg(Type.of(String.class)), + pc -> pc.docComment(dcc -> dcc.text("the service name parts (must not be {@code null})"))); + }); + } + + /** + * Generate the four {@code before()} method declarations on a builder interface. + * These are reverse ordering methods: declaring {@code before(X)} means X depends + * on this service — this service starts before X and stops after X. + * + * @param cc the class/interface context to add methods to + * @param selfType the self-referencing return type of the builder + */ + private static void generateBeforeInterfaceMethods( + InterfaceCreator cc, + Type selfType) { + // before(Enum enumConstant) + cc.method("before", mc -> { + mc.docComment(dcc -> { + dcc.text("Declare that the given enum constant service should start after this service and stop before it."); + dcc.return_(rc -> rc.text("this builder")); + }); + mc.returning(selfType); + mc.param("enumConstant", Type.of(Enum.class).typeArg(Type.WILDCARD), + pc -> pc.docComment(dcc -> dcc.text("the enum constant (must not be {@code null})"))); + }); + // before(String name) + cc.method("before", mc -> { + mc.docComment(dcc -> { + dcc.text("Declare that the given void service should start after this service and stop before it. "); + dcc.text("If the target does not exist, the declaration is silently ignored."); + dcc.return_(rc -> rc.text("this builder")); + }); + mc.returning(selfType); + mc.param("name", Type.of(String.class), + pc -> pc.docComment(dcc -> dcc.text("the void service name (must not be {@code null})"))); + }); + // before(Class type) + cc.method("before", mc -> { + mc.docComment(dcc -> { + dcc.text("Declare that the given unnamed typed service should start after this service and stop before it. "); + dcc.text("If the target does not exist, the declaration is silently ignored."); + dcc.return_(rc -> rc.text("this builder")); + }); + mc.returning(selfType); + mc.param("type", Type.of(Class.class).typeArg(Type.WILDCARD), + pc -> pc.docComment(dcc -> dcc.text("the service type (must not be {@code null})"))); + }); + // before(Class type, String name) + cc.method("before", mc -> { + mc.docComment(dcc -> { + dcc.text("Declare that the given named typed service should start after this service and stop before it. "); + dcc.text("If the target does not exist, the declaration is silently ignored."); + dcc.return_(rc -> rc.text("this builder")); + }); + mc.returning(selfType); + mc.param("type", Type.of(Class.class).typeArg(Type.WILDCARD), + pc -> pc.docComment(dcc -> dcc.text("the service type (must not be {@code null})"))); + mc.param("name", Type.of(String.class), + pc -> pc.docComment(dcc -> dcc.text("the service name (must not be {@code null})"))); + }); + // before(Class type, List nameParts) + cc.method("before", mc -> { + mc.docComment(dcc -> { + dcc.text("Declare that the given typed service should start after this service and stop before it. "); + dcc.text("If the target does not exist, the declaration is silently ignored."); + dcc.return_(rc -> rc.text("this builder")); + }); + mc.returning(selfType); + mc.param("type", Type.of(Class.class).typeArg(Type.WILDCARD), + pc -> pc.docComment(dcc -> dcc.text("the service type (must not be {@code null})"))); + mc.param("nameParts", Type.of(List.class).typeArg(Type.of(String.class)), + pc -> pc.docComment(dcc -> dcc.text("the service name parts (must not be {@code null})"))); + }); + } + + /** + * Generate the {@code afterBuildItem(Class)} method declaration on a builder interface. + * + * @param cc the interface context + * @param selfType the self-referencing return type + */ + private static void generateAfterBuildItemInterfaceMethod( + InterfaceCreator cc, + Type selfType) { + cc.method("afterBuildItem", mc -> { + mc.annotate(Type.of(Deprecated.class)); + mc.docComment(dcc -> { + dcc.text("Declare that this service must start after the build step that produces "); + dcc.text("the given build item has completed. "); + dcc.text("This is a transitional API for services that coexist with legacy "); + dcc.text("bytecode recorders. Once the producing recorder is converted to a "); + dcc.text("service, replace with a direct service dependency via "); + dcc.code("require()"); + dcc.text(", "); + dcc.code("after()"); + dcc.text(", or "); + dcc.code("before()"); + dcc.text(". "); + dcc.text("If no step produces the item, the dependency is silently ignored."); + dcc.return_(rc -> rc.text("this builder")); + dcc.deprecated("Transitional API for recorder coexistence."); + }); + mc.returning(selfType); + mc.param("buildItemClass", Type.of(Class.class).typeArg( + Type.named("io.quarkus.builder.item.BuildItem").wildcardExtends()), + pc -> pc.docComment(dcc -> dcc.text("the build item class (must not be {@code null})"))); + }); + } + + /** + * Generate the {@code afterBuildItem(Class)} method implementation on a builder impl class. + * + * @param cc the class context + * @param selfType the self-referencing return type + */ + private static void generateAfterBuildItemImplMethod( + ClassCreator cc, + Type selfType) { + cc.method("afterBuildItem", mc -> { + mc.public_(); + mc.annotate(Type.of(Override.class)); + mc.returning(selfType); + Var buildItemClass = mc.param("buildItemClass", Type.of(Class.class).typeArg( + Type.named("io.quarkus.builder.item.BuildItem").wildcardExtends())); + mc.body(b0 -> { + b0.emit($v("sbi").call("afterBuildItem", buildItemClass)); + b0.return_(Expr.THIS); + }); + }); + } + + /** + * Generate the {@code atPhase(Phase)} method declaration on a builder interface. + * This method assigns the service to a runtime phase and returns the same builder type. + * + * @param cc the interface context + * @param sf the source file context (for imports) + * @param selfType the self-referencing return type + */ + private static void generateAtPhaseInterfaceMethod( + InterfaceCreator cc, + SourceFileCreator sf, + Type selfType) { + Type phaseType = Type.named("io.quarkus.core.Phase"); + sf.import_(phaseType); + cc.method("atPhase", mc -> { + mc.docComment(dcc -> { + dcc.text("Assign this service to a runtime phase. "); + dcc.text("The default phase is {@code Phase.APPLICATION}."); + dcc.return_(rc -> rc.text("this builder")); + }); + mc.returning(selfType); + mc.param("phase", phaseType, pc -> pc.docComment(dcc -> dcc.text("the runtime phase (must not be {@code null})"))); + }); + } + + /** + * Generate the {@code atPhase(Phase)} method implementation on a builder impl class. + * Delegates to {@code sbi.atPhase()} and returns {@code this}. + * + * @param cc the class context + * @param sf the source file context (for imports) + * @param selfType the self-referencing return type + */ + private static void generateAtPhaseImplMethod( + ClassCreator cc, + SourceFileCreator sf, + Type selfType) { + Type phaseType = Type.named("io.quarkus.core.Phase"); + sf.import_(phaseType); + cc.method("atPhase", mc -> { + mc.public_(); + mc.annotate(Type.of(Override.class)); + mc.returning(selfType); + Var phase = mc.param("phase", phaseType); + mc.body(b0 -> { + b0.emit($v("sbi").call("atPhase", phase)); + b0.return_(Expr.THIS); + }); + }); + } + + // Turn off the formatter because it doesn't understand how HTML relates to @code + //@formatter:off + /** + * Generate the two {@code request()} method declarations on a builder interface. + * These are optional dependency methods that step to the next arity with {@code Optional

} + * as the type parameter. + * + * @param cc the interface context + * @param svcTypeParam the service type parameter ({@code T}), or {@code null} for void builders + * @param depCount the current dependency count + * @param nextDepCount the next dependency count (depCount + 1) + * @param nextBuilder the next builder class name + * @param hasServiceTypeParam whether the builder has a {@code T} type parameter + */ + //@formatter:on + private static void generateRequestInterfaceMethods( + InterfaceCreator cc, + Type svcTypeParam, + int depCount, + int nextDepCount, + String nextBuilder, + boolean hasServiceTypeParam) { + // request(Class type) + cc.method("request", mc -> { + mc.docComment(dcc -> { + dcc.text("Add an optional dependency on a service of the given type. "); + dcc.text("The dependency is injected as {@code Optional}; if absent, an empty optional is provided."); + dcc.return_(rc -> rc.text("a builder with the added dependency")); + }); + Type pn1 = mc.typeParam("P" + nextDepCount, + tpc -> tpc.docComment(dcc -> dcc.text("the type of the dependency to add"))); + Type optionalPn1 = Type.of(Optional.class).typeArg(pn1); + Type[] nextTypeArgs; + if (hasServiceTypeParam) { + nextTypeArgs = new Type[nextDepCount + 1]; + nextTypeArgs[0] = svcTypeParam; + for (int j = 0; j < depCount; j++) { + nextTypeArgs[j + 1] = Type.named("P" + (j + 1)); + } + nextTypeArgs[nextDepCount] = optionalPn1; + } else { + nextTypeArgs = new Type[nextDepCount]; + for (int j = 0; j < depCount; j++) { + nextTypeArgs[j] = Type.named("P" + (j + 1)); + } + nextTypeArgs[depCount] = optionalPn1; + } + mc.returning(Type.named(nextBuilder).typeArg(nextTypeArgs)); + mc.param("type", Type.of(Class.class).typeArg(pn1), + pc -> pc.docComment(dcc -> dcc.text("the dependency type (must not be {@code null})"))); + }); + // request(Class type, String name) + cc.method("request", mc -> { + mc.docComment(dcc -> { + dcc.text("Add an optional dependency on a named service of the given type. "); + dcc.text("The dependency is injected as {@code Optional}; if absent, an empty optional is provided."); + dcc.return_(rc -> rc.text("a builder with the added dependency")); + }); + Type pn1 = mc.typeParam("P" + nextDepCount, + tpc -> tpc.docComment(dcc -> dcc.text("the type of the dependency to add"))); + Type optionalPn1 = Type.of(Optional.class).typeArg(pn1); + Type[] nextTypeArgs; + if (hasServiceTypeParam) { + nextTypeArgs = new Type[nextDepCount + 1]; + nextTypeArgs[0] = svcTypeParam; + for (int j = 0; j < depCount; j++) { + nextTypeArgs[j + 1] = Type.named("P" + (j + 1)); + } + nextTypeArgs[nextDepCount] = optionalPn1; + } else { + nextTypeArgs = new Type[nextDepCount]; + for (int j = 0; j < depCount; j++) { + nextTypeArgs[j] = Type.named("P" + (j + 1)); + } + nextTypeArgs[depCount] = optionalPn1; + } + mc.returning(Type.named(nextBuilder).typeArg(nextTypeArgs)); + mc.param("type", Type.of(Class.class).typeArg(pn1), + pc -> pc.docComment(dcc -> dcc.text("the dependency type (must not be {@code null})"))); + mc.param("name", Type.of(String.class), + pc -> pc.docComment(dcc -> dcc.text("the dependency name (must not be {@code null})"))); + }); + } + + // ── Typed service builder impls ── + + /** + * Generate runtime {@code ServiceBuilderImpl0} through {@code ServiceBuilderImpl9} wrapper classes. + * + * @param sources the JDeparser sources to generate into + */ + private static void generateServiceBuilderImpls(Sources sources) { + generateServiceBuilderImplsParameterized( + sources, "ServiceBuilderImpl", "ServiceBuilder", START_CONTEXT); + } + + /** + * Parameterized generation of typed service builder implementation classes. + * + * @param sources the JDeparser sources to generate into + * @param implPrefix the implementation class name prefix + * @param interfacePrefix the interface class name prefix + * @param contextFqn the fully-qualified context type name + */ + private static void generateServiceBuilderImplsParameterized( + Sources sources, + String implPrefix, + String interfacePrefix, + String contextFqn) { + for (int i = 0; i <= MAX_DEPS; i++) { + String className = implPrefix + i; + String interfaceFqn = PKG + "." + interfacePrefix + i; + final int depCount = i; + // action arity is depCount + 1 (context occupies the first slot) + final int actionArity = depCount + 1; + sources.createSourceFile(IMPL_PKG, className, sf -> sf.class_(className, cc -> { + cc.public_(); + cc.final_(); + Type sbiType = Type.named(IMPL_PKG + ".ServiceBuilderImpl"); + sf.import_(sbiType); + cc.field("sbi", fc -> { + fc.type(sbiType); + fc.private_(); + fc.final_(); + }); + List interfaceTypeArgs = new ArrayList<>(depCount + 1); + // T = service type + Type svcTypeParam = cc.typeParam("T", tpc -> { + }); + interfaceTypeArgs.add(Type.named("T")); + // P1..PN = dependency types + for (int j = 0; j < depCount; j++) { + String tpName = "P" + (j + 1); + cc.typeParam(tpName, tpc -> { + }); + interfaceTypeArgs.add(Type.named(tpName)); + } + Type interfaceRawType = Type.named(interfaceFqn); + cc.implements_(interfaceRawType.typeArg(interfaceTypeArgs)); + sf.import_(interfaceRawType); + sf.import_(Optional.class); + + // self type for after() return + Type[] selfTypeArgs = new Type[depCount + 1]; + selfTypeArgs[0] = svcTypeParam; + for (int j = 0; j < depCount; j++) { + selfTypeArgs[j + 1] = Type.named("P" + (j + 1)); + } + Type selfType = Type.named(className).typeArg(selfTypeArgs); + + // constructor + cc.constructor(mc -> { + mc.public_(); + Var sbi = mc.param("sbi", sbiType); + mc.body(b0 -> b0.emit(Expr.THIS.field("sbi").assign(sbi))); + }); + + // require() method impls - only if we haven't hit max deps + if (depCount < MAX_DEPS) { + final int nextDepCount = depCount + 1; + String nextBuilder = implPrefix + nextDepCount; + // require(Class type) + cc.method("require", mc -> { + mc.public_(); + mc.annotate(Type.of(Override.class)); + Type pn1 = mc.typeParam("P" + nextDepCount, tpc -> { + }); + // return type: Impl(N+1) + Type[] nextTypeArgs = new Type[nextDepCount + 1]; + nextTypeArgs[0] = svcTypeParam; + for (int j = 0; j < depCount; j++) { + nextTypeArgs[j + 1] = Type.named("P" + (j + 1)); + } + nextTypeArgs[nextDepCount] = pn1; + Type nextType = Type.named(nextBuilder).typeArg(nextTypeArgs); + mc.returning(nextType); + Var type = mc.param("type", Type.of(Class.class).typeArg(pn1)); + mc.body(b0 -> { + b0.emit($v("sbi").call("require", type)); + b0.return_(nextType.new_($v("sbi"))); + }); + }); + // require(Class type, String name) + cc.method("require", mc -> { + mc.public_(); + mc.annotate(Type.of(Override.class)); + Type pn1 = mc.typeParam("P" + nextDepCount, tpc -> { + }); + Type[] nextTypeArgs = new Type[nextDepCount + 1]; + // return type: Impl(N+1) + nextTypeArgs[0] = svcTypeParam; + for (int j = 0; j < depCount; j++) { + nextTypeArgs[j + 1] = Type.named("P" + (j + 1)); + } + nextTypeArgs[nextDepCount] = pn1; + Type nextType = Type.named(nextBuilder).typeArg(nextTypeArgs); + mc.returning(nextType); + Var type = mc.param("type", Type.of(Class.class).typeArg(pn1)); + Var name = mc.param("name", Type.of(String.class)); + mc.body(b0 -> { + b0.emit($v("sbi").call("require", type, name)); + b0.return_(nextType.new_($v("sbi"))); + }); + }); + } + + // request() method impls - optional dependencies (only if we haven't hit max deps) + if (depCount < MAX_DEPS) { + final int nextDepCount = depCount + 1; + String nextBuilder = implPrefix + nextDepCount; + generateRequestImplMethods(cc, sf, svcTypeParam, depCount, nextDepCount, nextBuilder, true); + } + + // consumeAll() method impls (only if we haven't hit max deps) + if (depCount < MAX_DEPS) { + final int nextDepCount = depCount + 1; + String nextBuilder = implPrefix + nextDepCount; + generateConsumeAllImplMethod(cc, sf, svcTypeParam, depCount, nextDepCount, nextBuilder, true); + } + + // after() method impls + generateAfterImplMethods(cc, sf, selfType); + + // before() method impls + generateBeforeImplMethods(cc, sf, selfType); + + // afterBuildItem() method impl + generateAfterBuildItemImplMethod(cc, selfType); + + // withPhase() method impl (only on Impl0) + if (depCount == 0) { + generateAtPhaseImplMethod(cc, sf, selfType); + } + + // optional() method impl - mark optional on the shared builder and retype T to Optional + { + Type[] optTypeArgs = new Type[depCount + 1]; + optTypeArgs[0] = Type.of(Optional.class).typeArg(svcTypeParam); + for (int j = 0; j < depCount; j++) { + optTypeArgs[j + 1] = Type.named("P" + (j + 1)); + } + Type optReturn = interfaceRawType.typeArg(optTypeArgs); + cc.method("optional", mc -> { + mc.public_(); + mc.annotate(Type.of(Override.class)); + mc.annotate(Type.of(SuppressWarnings.class), ac -> ac.value(Expr.str("unchecked"))); + mc.returning(optReturn); + mc.body(b0 -> { + b0.emit($v("sbi").call("markOptional")); + b0.return_(Expr.THIS.cast(optReturn)); + }); + }); + } + + // onStart() - sync terminal + cc.method("onStart", mc -> { + mc.public_(); + mc.annotate(Type.of(Override.class)); + mc.returning(Type.VOID); + Type[] actionTypeArgs = new Type[actionArity + 1]; + actionTypeArgs[0] = svcTypeParam; + actionTypeArgs[1] = Type.named(contextFqn); + sf.import_(actionTypeArgs[1]); + for (int j = 0; j < depCount; j++) { + actionTypeArgs[j + 2] = Type.named("P" + (j + 1)); + } + Type actionType = Type.named(PKG + ".Action" + actionArity); + sf.import_(actionType); + Var action = mc.param("action", actionType.typeArg(actionTypeArgs)); + mc.body(b0 -> b0.emit($v("sbi").call("doAction", action, Expr.FALSE))); + }); + + // onStartAsync() - async terminal + cc.method("onStartAsync", mc -> { + mc.public_(); + mc.annotate(Type.of(Override.class)); + mc.returning(Type.VOID); + Type[] actionTypeArgs = new Type[actionArity + 1]; + actionTypeArgs[0] = Type.of(Void.class); + actionTypeArgs[1] = Type.named(ASYNC_START_CONTEXT).typeArg(svcTypeParam); + sf.import_(actionTypeArgs[1].erasure()); + for (int j = 0; j < depCount; j++) { + actionTypeArgs[j + 2] = Type.named("P" + (j + 1)); + } + Type actionType = Type.named(PKG + ".Action" + actionArity); + sf.import_(actionType); + Var action = mc.param("action", actionType.typeArg(actionTypeArgs)); + mc.body(b0 -> b0.emit($v("sbi").call("doAction", action, Expr.TRUE))); + }); + })); + } + } + + // ── Void service builder impls ── + + /** + * Generate runtime {@code VoidServiceBuilderImpl0} through {@code VoidServiceBuilderImpl9} wrapper classes. + * + * @param sources the JDeparser sources to generate into + */ + private static void generateVoidServiceBuilderImpls(Sources sources) { + generateVoidServiceBuilderImplsParameterized( + sources, "VoidServiceBuilderImpl", "VoidServiceBuilder", START_CONTEXT); + } + + /** + * Parameterized generation of void service builder implementation classes. + * + * @param sources the JDeparser sources to generate into + * @param implPrefix the implementation class name prefix + * @param interfacePrefix the interface class name prefix + * @param contextFqn the fully-qualified context type name + */ + private static void generateVoidServiceBuilderImplsParameterized( + Sources sources, + String implPrefix, + String interfacePrefix, + String contextFqn) { + for (int i = 0; i <= MAX_DEPS; i++) { + String className = implPrefix + i; + String interfaceFqn = PKG + "." + interfacePrefix + i; + final int depCount = i; + final int actionArity = depCount + 1; + sources.createSourceFile(IMPL_PKG, className, sf -> sf.class_(className, cc -> { + cc.public_(); + cc.final_(); + Type sbiType = Type.named(IMPL_PKG + ".ServiceBuilderImpl"); + sf.import_(sbiType); + cc.field("sbi", fc -> { + fc.type(sbiType); + fc.private_(); + fc.final_(); + }); + List interfaceTypeArgs = new ArrayList<>(depCount); + // P1..PN = dependency types (no T for void) + for (int j = 0; j < depCount; j++) { + String tpName = "P" + (j + 1); + cc.typeParam(tpName, tpc -> { + }); + interfaceTypeArgs.add(Type.named(tpName)); + } + Type interfaceRawType = Type.named(interfaceFqn); + if (depCount > 0) { + cc.implements_(interfaceRawType.typeArg(interfaceTypeArgs)); + } else { + cc.implements_(interfaceRawType); + } + sf.import_(interfaceRawType); + + // self type for after() return + Type selfType; + if (depCount == 0) { + selfType = Type.named(className); + } else { + Type[] selfTypeArgs = new Type[depCount]; + for (int j = 0; j < depCount; j++) { + selfTypeArgs[j] = Type.named("P" + (j + 1)); + } + selfType = Type.named(className).typeArg(selfTypeArgs); + } + + // constructor + cc.constructor(mc -> { + mc.public_(); + Var sbi = mc.param("sbi", sbiType); + mc.body(b0 -> b0.emit(Expr.THIS.field("sbi").assign(sbi))); + }); + + // require() method impls - only if we haven't hit max deps + if (depCount < MAX_DEPS) { + final int nextDepCount = depCount + 1; + String nextBuilder = implPrefix + nextDepCount; + // require(Class type) + cc.method("require", mc -> { + mc.public_(); + mc.annotate(Type.of(Override.class)); + Type pn1 = mc.typeParam("P" + nextDepCount, tpc -> { + }); + Type[] nextTypeArgs = new Type[nextDepCount]; + for (int j = 0; j < depCount; j++) { + nextTypeArgs[j] = Type.named("P" + (j + 1)); + } + nextTypeArgs[depCount] = pn1; + Type nextType = Type.named(nextBuilder).typeArg(nextTypeArgs); + mc.returning(nextType); + Var type = mc.param("type", Type.of(Class.class).typeArg(pn1)); + mc.body(b0 -> { + b0.emit($v("sbi").call("require", type)); + b0.return_(nextType.new_($v("sbi"))); + }); + }); + // require(Class type, String name) + cc.method("require", mc -> { + mc.public_(); + mc.annotate(Type.of(Override.class)); + Type pn1 = mc.typeParam("P" + nextDepCount, tpc -> { + }); + Type[] nextTypeArgs = new Type[nextDepCount]; + for (int j = 0; j < depCount; j++) { + nextTypeArgs[j] = Type.named("P" + (j + 1)); + } + nextTypeArgs[depCount] = pn1; + Type nextType = Type.named(nextBuilder).typeArg(nextTypeArgs); + mc.returning(nextType); + Var type = mc.param("type", Type.of(Class.class).typeArg(pn1)); + Var name = mc.param("name", Type.of(String.class)); + mc.body(b0 -> { + b0.emit($v("sbi").call("require", type, name)); + b0.return_(nextType.new_($v("sbi"))); + }); + }); + } + + // request() method impls - optional dependencies (only if we haven't hit max deps) + if (depCount < MAX_DEPS) { + final int nextDepCount2 = depCount + 1; + String nextBuilder2 = implPrefix + nextDepCount2; + generateRequestImplMethods(cc, sf, null, depCount, nextDepCount2, nextBuilder2, false); + } + + // consumeAll() method impls (only if we haven't hit max deps) + if (depCount < MAX_DEPS) { + final int nextDepCount2 = depCount + 1; + String nextBuilder2 = implPrefix + nextDepCount2; + generateConsumeAllImplMethod(cc, sf, null, depCount, nextDepCount2, nextBuilder2, false); + } + + // after() method impls + generateAfterImplMethods(cc, sf, selfType); + + // before() method impls + generateBeforeImplMethods(cc, sf, selfType); + + // afterBuildItem() method impl + generateAfterBuildItemImplMethod(cc, selfType); + + // withPhase() method impl (only on Impl0) + if (depCount == 0) { + generateAtPhaseImplMethod(cc, sf, selfType); + } + + // onStart() - sync terminal using VoidAction + cc.method("onStart", mc -> { + mc.public_(); + mc.annotate(Type.of(Override.class)); + mc.returning(Type.VOID); + Type[] actionTypeArgs = new Type[actionArity]; + actionTypeArgs[0] = Type.named(contextFqn); + sf.import_(actionTypeArgs[0]); + for (int j = 0; j < depCount; j++) { + actionTypeArgs[j + 1] = Type.named("P" + (j + 1)); + } + Type actionType = Type.named(PKG + ".VoidAction" + actionArity); + sf.import_(actionType); + Var action = mc.param("action", actionType.typeArg(actionTypeArgs)); + mc.body(b0 -> b0.emit($v("sbi").call("doAction", action, Expr.FALSE))); + }); + + // onStartAsync() - async terminal + cc.method("onStartAsync", mc -> { + mc.public_(); + mc.annotate(Type.of(Override.class)); + mc.returning(Type.VOID); + Type[] actionTypeArgs = new Type[actionArity]; + actionTypeArgs[0] = Type.named(ASYNC_VOID_START_CONTEXT); + sf.import_(actionTypeArgs[0]); + for (int j = 0; j < depCount; j++) { + actionTypeArgs[j + 1] = Type.named("P" + (j + 1)); + } + Type actionType = Type.named(PKG + ".VoidAction" + actionArity); + sf.import_(actionType); + Var action = mc.param("action", actionType.typeArg(actionTypeArgs)); + mc.body(b0 -> b0.emit($v("sbi").call("doAction", action, Expr.TRUE))); + }); + })); + } + } + + /** + * Generate the five {@code after()} method implementations on a builder impl class. + * Each calls the corresponding method on the wrapped {@code ServiceBuilderImpl} and returns {@code this}. + * + * @param cc the class context to add methods to + * @param sf the source file context (for imports) + * @param selfType the self-referencing return type + */ + private static void generateAfterImplMethods( + ClassCreator cc, + SourceFileCreator sf, + Type selfType) { + // after(Enum enumConstant) + cc.method("after", mc -> { + mc.public_(); + mc.annotate(Type.of(Override.class)); + mc.returning(selfType); + Var enumConstant = mc.param("enumConstant", Type.of(Enum.class).typeArg(Type.WILDCARD)); + mc.body(b0 -> { + b0.emit($v("sbi").call("after", enumConstant)); + b0.return_(Expr.THIS); + }); + }); + // after(String name) + cc.method("after", mc -> { + mc.public_(); + mc.annotate(Type.of(Override.class)); + mc.returning(selfType); + Var name = mc.param("name", Type.of(String.class)); + mc.body(b0 -> { + b0.emit($v("sbi").call("after", name)); + b0.return_(Expr.THIS); + }); + }); + // after(List nameParts) + cc.method("after", mc -> { + mc.public_(); + mc.annotate(Type.of(Override.class)); + mc.returning(selfType); + sf.import_(List.class); + Var nameParts = mc.param("nameParts", Type.of(List.class).typeArg(Type.of(String.class))); + mc.body(b0 -> { + b0.emit($v("sbi").call("after", nameParts)); + b0.return_(Expr.THIS); + }); + }); + // after(Class type) + cc.method("after", mc -> { + mc.public_(); + mc.annotate(Type.of(Override.class)); + mc.returning(selfType); + Var type = mc.param("type", Type.of(Class.class).typeArg(Type.WILDCARD)); + mc.body(b0 -> { + b0.emit($v("sbi").call("after", type)); + b0.return_(Expr.THIS); + }); + }); + // after(Class type, String name) + cc.method("after", mc -> { + mc.public_(); + mc.annotate(Type.of(Override.class)); + mc.returning(selfType); + Var type = mc.param("type", Type.of(Class.class).typeArg(Type.WILDCARD)); + Var name = mc.param("name", Type.of(String.class)); + mc.body(b0 -> { + b0.emit($v("sbi").call("after", type, name)); + b0.return_(Expr.THIS); + }); + }); + // after(Class type, List nameParts) + cc.method("after", mc -> { + mc.public_(); + mc.annotate(Type.of(Override.class)); + mc.returning(selfType); + sf.import_(List.class); + Var type = mc.param("type", Type.of(Class.class).typeArg(Type.WILDCARD)); + Var nameParts = mc.param("nameParts", Type.of(List.class).typeArg(Type.of(String.class))); + mc.body(b0 -> { + b0.emit($v("sbi").call("after", type, nameParts)); + b0.return_(Expr.THIS); + }); + }); + } + + /** + * Generate the four {@code before()} method implementations on a builder impl class. + * Each calls the corresponding method on the wrapped {@code ServiceBuilderImpl} and returns {@code this}. + * + * @param cc the class context to add methods to + * @param sf the source file context (for imports) + * @param selfType the self-referencing return type + */ + private static void generateBeforeImplMethods( + ClassCreator cc, + SourceFileCreator sf, + Type selfType) { + // before(Enum enumConstant) + cc.method("before", mc -> { + mc.public_(); + mc.annotate(Type.of(Override.class)); + mc.returning(selfType); + Var enumConstant = mc.param("enumConstant", Type.of(Enum.class).typeArg(Type.WILDCARD)); + mc.body(b0 -> { + b0.emit($v("sbi").call("before", enumConstant)); + b0.return_(Expr.THIS); + }); + }); + // before(String name) + cc.method("before", mc -> { + mc.public_(); + mc.annotate(Type.of(Override.class)); + mc.returning(selfType); + Var name = mc.param("name", Type.of(String.class)); + mc.body(b0 -> { + b0.emit($v("sbi").call("before", name)); + b0.return_(Expr.THIS); + }); + }); + // before(Class type) + cc.method("before", mc -> { + mc.public_(); + mc.annotate(Type.of(Override.class)); + mc.returning(selfType); + Var type = mc.param("type", Type.of(Class.class).typeArg(Type.WILDCARD)); + mc.body(b0 -> { + b0.emit($v("sbi").call("before", type)); + b0.return_(Expr.THIS); + }); + }); + // before(Class type, String name) + cc.method("before", mc -> { + mc.public_(); + mc.annotate(Type.of(Override.class)); + mc.returning(selfType); + Var type = mc.param("type", Type.of(Class.class).typeArg(Type.WILDCARD)); + Var name = mc.param("name", Type.of(String.class)); + mc.body(b0 -> { + b0.emit($v("sbi").call("before", type, name)); + b0.return_(Expr.THIS); + }); + }); + // before(Class type, List nameParts) + cc.method("before", mc -> { + mc.public_(); + mc.annotate(Type.of(Override.class)); + mc.returning(selfType); + sf.import_(List.class); + Var type = mc.param("type", Type.of(Class.class).typeArg(Type.WILDCARD)); + Var nameParts = mc.param("nameParts", Type.of(List.class).typeArg(Type.of(String.class))); + mc.body(b0 -> { + b0.emit($v("sbi").call("before", type, nameParts)); + b0.return_(Expr.THIS); + }); + }); + } + + // Turn off the formatter because it doesn't understand how HTML relates to @code + //@formatter:off + /** + * Generate the two {@code request()} method implementations on a builder impl class. + * Each calls {@code sbi.request()} and returns a new next-arity wrapper with + * {@code Optional

} as the type parameter. + * + * @param cc the class context + * @param sf the source file context (for imports) + * @param svcTypeParam the service type parameter ({@code T}), or {@code null} for void builders + * @param depCount the current dependency count + * @param nextDepCount the next dependency count (depCount + 1) + * @param nextBuilder the next builder impl class name + * @param hasServiceTypeParam whether the builder has a {@code T} type parameter + */ + //@formatter:on + private static void generateRequestImplMethods( + ClassCreator cc, + SourceFileCreator sf, + Type svcTypeParam, + int depCount, + int nextDepCount, + String nextBuilder, + boolean hasServiceTypeParam) { + sf.import_(Optional.class); + // request(Class type) + cc.method("request", mc -> { + mc.public_(); + mc.annotate(Type.of(Override.class)); + Type pn1 = mc.typeParam("P" + nextDepCount, tpc -> { + }); + Type optionalPn1 = Type.of(Optional.class).typeArg(pn1); + Type[] nextTypeArgs; + if (hasServiceTypeParam) { + nextTypeArgs = new Type[nextDepCount + 1]; + nextTypeArgs[0] = svcTypeParam; + for (int j = 0; j < depCount; j++) { + nextTypeArgs[j + 1] = Type.named("P" + (j + 1)); + } + nextTypeArgs[nextDepCount] = optionalPn1; + } else { + nextTypeArgs = new Type[nextDepCount]; + for (int j = 0; j < depCount; j++) { + nextTypeArgs[j] = Type.named("P" + (j + 1)); + } + nextTypeArgs[depCount] = optionalPn1; + } + Type nextType = Type.named(nextBuilder).typeArg(nextTypeArgs); + mc.returning(nextType); + Var type = mc.param("type", Type.of(Class.class).typeArg(pn1)); + mc.body(b0 -> { + b0.emit($v("sbi").call("request", type)); + b0.return_(nextType.new_($v("sbi"))); + }); + }); + // request(Class type, String name) + cc.method("request", mc -> { + mc.public_(); + mc.annotate(Type.of(Override.class)); + Type pn1 = mc.typeParam("P" + nextDepCount, tpc -> { + }); + Type optionalPn1 = Type.of(Optional.class).typeArg(pn1); + Type[] nextTypeArgs; + if (hasServiceTypeParam) { + nextTypeArgs = new Type[nextDepCount + 1]; + nextTypeArgs[0] = svcTypeParam; + for (int j = 0; j < depCount; j++) { + nextTypeArgs[j + 1] = Type.named("P" + (j + 1)); + } + nextTypeArgs[nextDepCount] = optionalPn1; + } else { + nextTypeArgs = new Type[nextDepCount]; + for (int j = 0; j < depCount; j++) { + nextTypeArgs[j] = Type.named("P" + (j + 1)); + } + nextTypeArgs[depCount] = optionalPn1; + } + Type nextType = Type.named(nextBuilder).typeArg(nextTypeArgs); + mc.returning(nextType); + Var type = mc.param("type", Type.of(Class.class).typeArg(pn1)); + Var name = mc.param("name", Type.of(String.class)); + mc.body(b0 -> { + b0.emit($v("sbi").call("request", type, name)); + b0.return_(nextType.new_($v("sbi"))); + }); + }); + } + + /** + * Generate the {@code consumeAll()} method declaration on a builder interface. + * Consumes all services of the given type as a {@code Map}. + * Steps arity like {@code require()}. + * + * @param cc the interface context + * @param svcTypeParam the service type parameter ({@code T}), or {@code null} for void builders + * @param depCount the current dependency count + * @param nextDepCount the next dependency count (depCount + 1) + * @param nextBuilder the next builder class name + * @param hasServiceTypeParam whether the builder has a {@code T} type parameter + */ + private static void generateConsumeAllInterfaceMethod( + InterfaceCreator cc, + Type svcTypeParam, + int depCount, + int nextDepCount, + String nextBuilder, + boolean hasServiceTypeParam) { + cc.method("consumeAll", mc -> { + mc.docComment(dcc -> { + dcc.text("Consume all services of the given type. "); + dcc.text("The services are injected as a {@code Map} where keys are service names. "); + dcc.text("Zero matches produces an empty map."); + dcc.return_(rc -> rc.text("a builder with the added dependency")); + }); + Type pn1 = mc.typeParam("P" + nextDepCount, + tpc -> tpc.docComment(dcc -> dcc.text("the type of the services to consume"))); + Type mapPn1 = Type.of(Map.class).typeArg(Type.of(String.class), pn1); + Type[] nextTypeArgs; + if (hasServiceTypeParam) { + nextTypeArgs = new Type[nextDepCount + 1]; + nextTypeArgs[0] = svcTypeParam; + for (int j = 0; j < depCount; j++) { + nextTypeArgs[j + 1] = Type.named("P" + (j + 1)); + } + nextTypeArgs[nextDepCount] = mapPn1; + } else { + nextTypeArgs = new Type[nextDepCount]; + for (int j = 0; j < depCount; j++) { + nextTypeArgs[j] = Type.named("P" + (j + 1)); + } + nextTypeArgs[depCount] = mapPn1; + } + mc.returning(Type.named(nextBuilder).typeArg(nextTypeArgs)); + mc.param("type", Type.of(Class.class).typeArg(pn1), + pc -> pc.docComment(dcc -> dcc.text("the service type to consume (must not be {@code null})"))); + }); + } + + /** + * Generate the {@code consumeAll()} method implementation on a builder impl class. + * Calls {@code sbi.consumeAll()} and returns a new next-arity wrapper with + * {@code Map} as the type parameter. + * + * @param cc the class context + * @param sf the source file context (for imports) + * @param svcTypeParam the service type parameter ({@code T}), or {@code null} for void builders + * @param depCount the current dependency count + * @param nextDepCount the next dependency count (depCount + 1) + * @param nextBuilder the next builder impl class name + * @param hasServiceTypeParam whether the builder has a {@code T} type parameter + */ + private static void generateConsumeAllImplMethod( + ClassCreator cc, + SourceFileCreator sf, + Type svcTypeParam, + int depCount, + int nextDepCount, + String nextBuilder, + boolean hasServiceTypeParam) { + sf.import_(Map.class); + cc.method("consumeAll", mc -> { + mc.public_(); + mc.annotate(Type.of(Override.class)); + Type pn1 = mc.typeParam("P" + nextDepCount, tpc -> { + }); + Type mapPn1 = Type.of(Map.class).typeArg(Type.of(String.class), pn1); + Type[] nextTypeArgs; + if (hasServiceTypeParam) { + nextTypeArgs = new Type[nextDepCount + 1]; + nextTypeArgs[0] = svcTypeParam; + for (int j = 0; j < depCount; j++) { + nextTypeArgs[j + 1] = Type.named("P" + (j + 1)); + } + nextTypeArgs[nextDepCount] = mapPn1; + } else { + nextTypeArgs = new Type[nextDepCount]; + for (int j = 0; j < depCount; j++) { + nextTypeArgs[j] = Type.named("P" + (j + 1)); + } + nextTypeArgs[depCount] = mapPn1; + } + Type nextType = Type.named(nextBuilder).typeArg(nextTypeArgs); + mc.returning(nextType); + Var type = mc.param("type", Type.of(Class.class).typeArg(pn1)); + mc.body(b0 -> { + b0.emit($v("sbi").call("consumeAll", type)); + b0.return_(nextType.new_($v("sbi"))); + }); + }); + } +} diff --git a/core/service-types-maker/src/main/java/io/quarkus/core/servicetypesmaker/Util.java b/core/service-types-maker/src/main/java/io/quarkus/core/servicetypesmaker/Util.java new file mode 100644 index 0000000000000..68051ba67cdfc --- /dev/null +++ b/core/service-types-maker/src/main/java/io/quarkus/core/servicetypesmaker/Util.java @@ -0,0 +1,55 @@ +package io.quarkus.core.servicetypesmaker; + +final class Util { + private Util() { + } + + static String plural(String str, int cnt) { + return cnt == 1 ? str : str.endsWith("y") ? str.substring(0, str.length() - 1) + "ies" : str + "s"; + } + + static String ord(int i) { + return switch (i) { + case 10 -> "tenth"; + case 11 -> "eleventh"; + case 12 -> "twelfth"; + case 13 -> "thirteenth"; + case 14 -> "fourteenth"; + case 15 -> "fifteenth"; + case 16 -> "sixteenth"; + case 17 -> "seventeenth"; + case 18 -> "eighteenth"; + case 19 -> "nineteenth"; + case 20 -> "twentieth"; + case 30 -> "thirtieth"; + case 40 -> "fortieth"; + case 50 -> "fiftieth"; + case 60 -> "sixtieth"; + case 70 -> "seventieth"; + case 80 -> "eightieth"; + case 90 -> "nineteenth"; + default -> switch (i / 10) { + case 2 -> "twenty-"; + case 3 -> "thirty-"; + case 4 -> "forty-"; + case 5 -> "fifty-"; + case 6 -> "sixty-"; + case 7 -> "seventy-"; + case 8 -> "eighty-"; + case 9 -> "ninety-"; + default -> ""; + } + switch (i % 10) { + case 1 -> "first"; + case 2 -> "second"; + case 3 -> "third"; + case 4 -> "fourth"; + case 5 -> "fifth"; + case 6 -> "sixth"; + case 7 -> "seventh"; + case 8 -> "eighth"; + case 9 -> "ninth"; + default -> throw new IllegalStateException(); + }; + }; + } +} diff --git a/extensions/oidc-client/deployment/src/test/java/io/quarkus/oidc/client/OidcClientCredentialsJwtSecretTestCase.java b/extensions/oidc-client/deployment/src/test/java/io/quarkus/oidc/client/OidcClientCredentialsJwtSecretTestCase.java index 8d6b4ab3b8eeb..6850f857f105a 100644 --- a/extensions/oidc-client/deployment/src/test/java/io/quarkus/oidc/client/OidcClientCredentialsJwtSecretTestCase.java +++ b/extensions/oidc-client/deployment/src/test/java/io/quarkus/oidc/client/OidcClientCredentialsJwtSecretTestCase.java @@ -97,7 +97,7 @@ public void accept(BuildChainBuilder builder) { public void execute(BuildContext context) { BytecodeRecorderImpl bytecodeRecorder = new BytecodeRecorderImpl(false, TestRecorder.class.getSimpleName(), "createRuntimeSecretProvider", - "" + TestRecorder.class.hashCode(), true); + "" + TestRecorder.class.hashCode(), context.getStepId(), true); context.produce(new MainBytecodeRecorderBuildItem(bytecodeRecorder)); // We need to use reflection due to some class loading problems diff --git a/extensions/oidc/deployment/src/test/java/io/quarkus/oidc/test/CodeFlowRuntimeCredentialsProviderTest.java b/extensions/oidc/deployment/src/test/java/io/quarkus/oidc/test/CodeFlowRuntimeCredentialsProviderTest.java index 1ba43cc26d37c..14d825316dd3d 100644 --- a/extensions/oidc/deployment/src/test/java/io/quarkus/oidc/test/CodeFlowRuntimeCredentialsProviderTest.java +++ b/extensions/oidc/deployment/src/test/java/io/quarkus/oidc/test/CodeFlowRuntimeCredentialsProviderTest.java @@ -89,7 +89,7 @@ public void accept(BuildChainBuilder builder) { public void execute(BuildContext context) { BytecodeRecorderImpl bytecodeRecorder = new BytecodeRecorderImpl(false, TestRecorder.class.getSimpleName(), "createRuntimeSecretProvider", - "" + TestRecorder.class.hashCode(), true); + "" + TestRecorder.class.hashCode(), context.getStepId(), true); context.produce(new MainBytecodeRecorderBuildItem(bytecodeRecorder)); // We need to use reflection due to some class loading problems diff --git a/independent-projects/arc/pom.xml b/independent-projects/arc/pom.xml index 7bbad16ee1388..bc90932efc300 100644 --- a/independent-projects/arc/pom.xml +++ b/independent-projects/arc/pom.xml @@ -45,7 +45,7 @@ 2.0.1 1.10.1 - 2.3.0 + 2.3.1 3.6.0 3.6.3.Final 3.3.0 diff --git a/independent-projects/qute/pom.xml b/independent-projects/qute/pom.xml index e65966b4a2a2b..436a7aa151b47 100644 --- a/independent-projects/qute/pom.xml +++ b/independent-projects/qute/pom.xml @@ -41,7 +41,7 @@ 6.1.3 3.27.7 3.6.0 - 2.3.0 + 2.3.1 3.6.3.Final 2.20.0 3.3.0 From 333e00739d20e226778e67bcc211b038895ea98a Mon Sep 17 00:00:00 2001 From: "David M. Lloyd" Date: Wed, 30 Sep 2026 13:27:12 -0500 Subject: [PATCH 027/254] Documentation --- docs/src/main/asciidoc/cdi-integration.adoc | 27 +- .../recorders-to-services-migration.adoc | 203 ++++++++++++ .../src/main/asciidoc/writing-extensions.adoc | 299 +++++++++++++++++- docs/src/main/resources/categories.yaml | 1 + 4 files changed, 514 insertions(+), 16 deletions(-) create mode 100644 docs/src/main/asciidoc/recorders-to-services-migration.adoc diff --git a/docs/src/main/asciidoc/cdi-integration.adoc b/docs/src/main/asciidoc/cdi-integration.adoc index 0d63c97bc993c..ef4d5352f16b7 100644 --- a/docs/src/main/asciidoc/cdi-integration.adoc +++ b/docs/src/main/asciidoc/cdi-integration.adoc @@ -306,9 +306,30 @@ You can: 1. Generate the bytecode of the `Contextual#create(CreationalContext)` method directly via `ExtendedBeanConfigurator.creator(Consumer)`. 2. Pass a subclass of `io.quarkus.arc.BeanCreator` via `ExtendedBeanConfigurator#creator(Class>)`, and possibly specify some build-time parameters via `ExtendedBeanConfigurator#param()` and synthetic injection points via `ExtendedBeanConfigurator#addInjectionPoint()`. -3. Produce the runtime instance through a proxy returned from a <> and set it via `ExtendedBeanConfigurator#runtimeValue(RuntimeValue)`, `ExtendedBeanConfigurator#runtimeProxy(Object)`, `ExtendedBeanConfigurator#supplier(Supplier)` or `ExtendedBeanConfigurator#createWith(Function, )`. +3. Produce the runtime instance through a service using the <> and set it via `SyntheticBeanBuildItem.configure(Class).serviceValue(Class, String)`. (Or, for legacy/deprecated extensions, produce a proxy returned from a legacy <> and set it via `ExtendedBeanConfigurator#runtimeValue(RuntimeValue)`, `ExtendedBeanConfigurator#runtimeProxy(Object)`, `ExtendedBeanConfigurator#supplier(Supplier)` or `ExtendedBeanConfigurator#createWith(Function, )`). -.`SyntheticBeanBuildItem` Example 2 +.`SyntheticBeanBuildItem` Example 2 (Modern - using Services) +[source,java] +---- +@BuildStep +SyntheticBeanBuildItem syntheticBean(ServiceRegistrar reg) { + reg.forService(Foo.class, "foo-service") + .onStart(ctx -> new Foo()); <1> + + return SyntheticBeanBuildItem.configure(Foo.class).scope(Singleton.class) + .serviceValue(Foo.class, "foo-service") <2> + .done(); +} +---- +<1> Define a service that constructs the `Foo` bean at startup. +<2> Bind the synthetic bean to the value produced by the service. + +[NOTE] +==== +For legacy extensions, synthetic beans can also be initialized using deprecated bytecode recorders as shown in the example below. +==== + +.`SyntheticBeanBuildItem` Example 3 (Legacy - using Deprecated Recorders) [source,java] ---- @BuildStep @@ -324,7 +345,7 @@ SyntheticBeanBuildItem syntheticBean(TestRecorder recorder) { It is also possible to create a generic synthetic bean `Foo`. -.`SyntheticBeanBuildItem` Example 3 +.`SyntheticBeanBuildItem` Example 4 [source,java] ---- @BuildStep diff --git a/docs/src/main/asciidoc/recorders-to-services-migration.adoc b/docs/src/main/asciidoc/recorders-to-services-migration.adoc new file mode 100644 index 0000000000000..737e1b16c6c4b --- /dev/null +++ b/docs/src/main/asciidoc/recorders-to-services-migration.adoc @@ -0,0 +1,203 @@ +[id='recorders-to-services-migration'] += Recorders-to-Services Migration Guide +:diataxis-type: howto +include::_attributes.adoc[] +:extension-status: deprecated + +This guide describes how to migrate Quarkus extensions from the legacy `@Record`/`@Recorder` pattern to the modern `ServiceRegistrar` service system. + +The `ServiceRegistrar` service system replaces `@Recorder` with a typed service model. Services declare dependencies explicitly and execute in dependency order, not build step order. The system is designed for incremental migration — converted and unconverted extensions may coexist. + +== Architectural Differences + +=== Explicit Dependencies Only +Services get **no** implicit ordering from the build step graph. All ordering comes from `require()`, `after()`, `before()`, or `afterBuildItem()`. + +=== Services are Independent from Recorders in the Same Step +Even if a build step method contains both a `@Record` call and an `ServiceRegistrar` call, the service and recorder are separate nodes in the build execution plan with no implicit ordering between them. + +=== Values Flow Through the Dependency Graph +Service values are passed from producer to consumer via indexed dependency access on `ServiceNode`, not through the `StartupContext` maps (except when using recorder proxy bridges). + +--- + +== Prerequisites + +Before converting, verify that all runtime dependencies of your recorder are available as services: + +|=== +|Dependency Type |Availability + +|`ArcContainer` +|Service in `ArcProcessor#initializeContainer` + +|`BeanContainer` +|Service in `ArcProcessor#createBeanContainer` + +|`ScheduledExecutorService` +|Aliased by `ThreadPoolSetup` + +|`@ConfigMapping` (`RUN_TIME`) +|Auto-registered by `ConfigServiceRegistrationStep` + +|`@ConfigMapping` (`BUILD_AND_RUN_TIME_FIXED`) +|Auto-registered as static-init + +|`@ConfigMapping` (`BUILD_TIME`) +|**Cannot be a service** — extract values into local variables first +|=== + +If other runtime objects are needed, search the codebase for `aliasRecorderValue` or `forService` to see if they are published as services. + +--- + +== Step-by-Step Conversion + +=== 1. Analyze the Recorder +For each `@Recorder` method, determine: + +* **Return type**: becomes the service type. `RuntimeValue` unwraps to `X`. `void` becomes `Void.class` (using `reg.forService(String name)`). +* **Parameters**: `RuntimeValue` parameters become `.require(X.class)`. Simple values (String, int, boolean, enum) can be captured by the lambda. Immutable collections (`List.of()`, `Set.of()`) can be captured. +* **Instance fields**: configuration `RuntimeValue`s injected via a constructor should be resolved via `.require(ConfigType.class)`. +* **Trivial methods**: can be inlined directly into the lambda. + +=== 2. Convert the Recorder Class +* **Trivial methods**: delete them and inline their logic into the lambda. +* **Complex methods**: remove the `@Recorder` annotation, make methods `static`, and unwrap `RuntimeValue` parameters and return types. +* If all methods are converted, delete the recorder class. Otherwise, keep `@Recorder` only for remaining legacy methods. + +=== 3. Convert the Build Step + +Let's look at a classic conversion. + +==== Before: +[source,java] +---- +@BuildStep +@Record(ExecutionTime.RUNTIME_INIT) +ServiceStartBuildItem setup(MyRecorder recorder, SomeBuildItem item) { + recorder.initialize(item.getValue()); + return new ServiceStartBuildItem("my-feature"); +} +---- + +==== After: +[source,java] +---- +@BuildStep +ServiceStartBuildItem setup(ServiceRegistrar reg, SomeBuildItem item) { + reg.forService("io.quarkus.my-feature.setup") + .onStart(ctx -> MyRecorder.initialize(item.getValue())); + return new ServiceStartBuildItem("my-feature"); +} +---- + +=== 4. Declare Dependencies Explicitly + +Every dependency must be explicit. There is no implicit ordering from the build step graph. + +|=== +|Old Recorder Pattern |New Service Pattern + +|`recorder.method(beanContainer.getValue())` +|`.require(BeanContainer.class)` + +|`recorder.method(someRuntimeValue)` +|`.require(SomeType.class)` + +|`recorder.method(config.maxSize())` +|Capture `int maxSize = config.maxSize()` before the lambda + +|`@Consume(SyntheticBeansRuntimeInitBuildItem.class)` on step +|`.afterBuildItem(SyntheticBeansRuntimeInitBuildItem.class)` on service +|=== + +==== Bridging to Build Step Ordering (`afterBuildItem()`) +When a service depends on state produced by a legacy recorder (e.g., synthetic beans must be initialized), declare the dependency via `afterBuildItem()`: + +[source,java] +---- +reg.forService("io.quarkus.arc.lifecycle") + .afterBuildItem(SyntheticBeansRuntimeInitBuildItem.class) + .onStart(ctx -> ArcRecorder.fireLifecycleEvent(new StartupEvent())); +---- + +This resolves the producing step's nodes and creates ordering edges. +NOTE: `afterBuildItem()` is deprecated — it exists only for recorder coexistence. Once the producing recorder is converted to a service, replace with `require()` or `after()`, or drop it as appropriate. + +==== Cross-Phase Dependencies +Runtime services can `require()` static-init services. The framework automatically creates a `CROSS_PHASE_PROXY` node that reads the value from the `serviceValues` map. + +[source,java] +---- +// Static-init service +reg.forService(ArcContainer.class) + .atPhase(Phase.STATIC_INIT) + .afterBuildItem(ResourcesGeneratedPhaseBuildItem.class) + .onStart(ctx -> Arc.initialize()); + +// Runtime service that depends on it +reg.forService("io.quarkus.my-ext.setup") + .require(ArcContainer.class) // Cross-phase proxy resolution + .onStart((ctx, container) -> { ... }); +---- + +--- + +== Coexistence and Bridge APIs + +During the transitional period, the `ServiceRegistrar` provides bridge APIs to allow incremental migration of extensions. + +=== 1. Recorder $\rightarrow$ Service Bridge +If you have a value produced by a legacy recorder that a new service needs to depend on, register it using `aliasRecorderValue`: + +[source,java] +---- +@BuildStep +@Record(ExecutionTime.RUNTIME_INIT) +void setupLegacyAndService(MyRecorder recorder, ServiceRegistrar reg) { + RuntimeValue val = recorder.createLegacyValue(); + + // Publish legacy value to the ServiceRegistrar graph + reg.aliasRecorderValue(MyLegacyValue.class, val); +} +---- + +Downstream services can now simply use `.require(MyLegacyValue.class)`. + +=== 2. Service $\rightarrow$ Recorder Bridge +If an unconverted legacy recorder or an existing build item consumes a value that is now produced by a service, use the service-to-recorder bridge methods: + +* **`serviceAsRuntimeValue`**: Produces a `RuntimeValue` proxy for a runtime service. +* **`staticInitServiceAsRuntimeValue`**: Produces a `RuntimeValue` proxy for a static-init service. +* **`getRecorderProxy`**: Produces a bare `T` proxy (for proxyable interfaces/classes). + +[source,java] +---- +@BuildStep +void bridgeServiceToLegacy(ServiceRegistrar reg, BuildProducer producer) { + reg.forService(MyConnection.class) + .onStart(ctx -> new MyConnection()); + + RuntimeValue runtimeVal = reg.serviceAsRuntimeValue(MyConnection.class); + producer.produce(new LegacyBuildItem(runtimeVal)); +} +---- + +--- + +== Common Conversion Pitfalls + +=== ClassLoader & Thread Context ClassLoader (TCCL) +Recorder bytecode always ran with the **runtime classloader as the TCCL**. Any recorder code that captured `Thread.currentThread().getContextClassLoader()` for later use relied on that. +* After converting, ensure that the service still runs with the runtime CL as TCCL, and that captured/inherited CLs are the runtime CL (not the system `AppClassLoader`). +* Clean up threads or `ThreadLocal`s your service owns in `onStop()`; never leak the `QuarkusClassLoader`. + +=== Concrete Class Proxying +`staticInitServiceAsRecorderValue(ConcreteClass.class)` fails if the class has a non-trivial constructor. Use `staticInitServiceAsRuntimeValue()` instead — `RuntimeValue` is always proxyable. + +=== Package-Private Visibility +Lambda bytecode runs at runtime but references runtime classes directly. Package-private classes become inaccessible from the generated consolidated class. Ensure any classes referenced within the lambda are `public`. + +=== Never Reference Deployment Classes inside Service Lambdas +Always place helper methods, custom interfaces, or static factory methods inside your **runtime** module classes (which are present at runtime). Doing so inside your processor class compiles successfully but throws `NoClassDefFoundError` at runtime. diff --git a/docs/src/main/asciidoc/writing-extensions.adoc b/docs/src/main/asciidoc/writing-extensions.adoc index 11cc75211098c..2eaf1f0661947 100644 --- a/docs/src/main/asciidoc/writing-extensions.adoc +++ b/docs/src/main/asciidoc/writing-extensions.adoc @@ -157,11 +157,11 @@ It's started during the <>. TIP: `BUILD_AND_RUN_TIME_FIXED` and `RUN_TIME` config roots can be injected in any bean. `RUN_TIME` config roots should only be injected after the bootstrap though. -It is possible to invoke a bean method from a <> though. -If you need to access a bean in a `@Record(STATIC_INIT)` build step then is must either depend on the `BeanContainerBuildItem` or wrap the logic in a `BeanContainerListenerBuildItem`. +It is possible to invoke a bean method from a <> or a legacy <>. +If you need to access a bean in a static-init service (`Phase.STATIC_INIT`) or legacy `@Record(STATIC_INIT)` build step, then it must depend on `.require(BeanContainer.class)` (for services), or depend on the `BeanContainerBuildItem` / wrap the logic in a `BeanContainerListenerBuildItem` (for legacy recorders). The reason is simple - we need to make sure the CDI container is fully initialized and started. -However, it is safe to expect that the CDI container is fully initialized and running in a `@Record(RUNTIME_INIT)` build step. -You can obtain a reference to the container via `CDI.current()` or Quarkus-specific `Arc.container()`. +However, it is safe to expect that the CDI container is fully initialized and running in a runtime-init service (`Phase.APPLICATION`) or legacy `@Record(RUNTIME_INIT)` build step. +For services, prefer using `.require(BeanContainer.class)` and `.beanInstance(BeanType.class)`; for legacy recorders, you can obtain a reference to the container via `CDI.current()` or Quarkus-specific `Arc.container()`. IMPORTANT: Don't forget to make sure the bean state guarantees the visibility, e.g. via the `volatile` keyword. @@ -281,10 +281,28 @@ In this chapter, we will cover a use case that can be used to initialize extensi The `SyntheticBeanBuildItem` can be used to register a synthetic bean: -* whose instance can be easily produced through a <>, +* whose instance can be easily produced through a <> (or, for legacy code, a <>), * to provide a "context" bean that holds all the information collected during augmentation so that the real components do not need any "late initialization" because they can inject the context bean directly. -.Instance Produced Through Recorder +.Instance Produced Through Service (Recommended) +[source%nowrap,java] +---- +@BuildStep +SyntheticBeanBuildItem syntheticBean(ServiceRegistrar reg) { + reg.forService(Foo.class, "foo-service") + .onStart(ctx -> new Foo("parameters are recorded at build time")); <1> + + return SyntheticBeanBuildItem.configure(Foo.class).scope(Singleton.class) + .serviceValue(Foo.class, "foo-service") <2> + .done(); +} +---- +<1> Define a service that constructs the `Foo` bean at startup. +<2> Bind the synthetic bean to the value produced by the service. + +[NOTE] +==== +.Instance Produced Through Recorder (Legacy) [source%nowrap,java] ---- @BuildStep @@ -296,8 +314,25 @@ SyntheticBeanBuildItem syntheticBean(TestRecorder recorder) { } ---- <1> The string value is recorded in the bytecode and used to initialize the instance of `Foo`. +==== -."Context" Holder +.Context Holder Produced Through Service (Recommended) +[source%nowrap,java] +---- +@BuildStep +SyntheticBeanBuildItem syntheticBean(ServiceRegistrar reg) { + reg.forService(TestContext.class, "test-context") + .onStart(ctx -> new TestContext("parameters are recorded at build time")); + + return SyntheticBeanBuildItem.configure(TestContext.class).scope(Singleton.class) + .serviceValue(TestContext.class, "test-context") + .done(); +} +---- + +[NOTE] +==== +."Context" Holder Produced Through Recorder (Legacy) [source%nowrap,java] ---- @BuildStep @@ -309,6 +344,7 @@ SyntheticBeanBuildItem syntheticBean(TestRecorder recorder) { } ---- <1> The "real" components can inject the `TestContext` directly. +==== === Some types of extensions @@ -873,7 +909,7 @@ The types of values that can be injected include: - <> produced by previous build steps - <> to produce items for subsequent build steps - <> types -- Template objects for <> +- `ServiceRegistrar` (for defining startup services) or legacy `@Recorder` template objects (for <>) WARNING: Objects which are injected into a build step method or its class _must not_ be used outside that method's execution. @@ -959,10 +995,10 @@ process. In the example above, `GeneratedClassBuildItem` would only be produced if `ExecutorClassBuildItem` is consumed by some other build step. -Note that when using <>, the implicitly generated class can be declared to be weak by -using the `optional` attribute of the `@io.quarkus.deployment.annotations.Record` annotation. +Note that when using legacy <>, the implicitly generated class can be declared to be weak by +using the `optional` attribute of the `@io.quarkus.deployment.annotations.Record` annotation. (For modern startup services, use the `.optional()` builder method instead). -.Example of using a bytecode recorder where the generated class is weakly produced +.Example of using a legacy bytecode recorder where the generated class is weakly produced [source%nowrap,java] ---- /** @@ -985,7 +1021,7 @@ ExecutorBuildItem createExecutor( // <2> } ---- <1> Note the `optional` attribute. -<2> This example is using recorder proxies; see the section on <> for more information. +<2> This example is using legacy recorder proxies; see the section on <> for more information. ==== Application Archives @@ -1457,7 +1493,244 @@ class MyDevModeProcessor { <3> The other build step will only be executed in dev mode. [id='bytecode-recording'] -=== Generating Bytecode +=== Application Startup and Services + +Quarkus uses a compile-time built, runtime-executed dependency graph of lightweight startup services instead of executing build steps directly at runtime. This allows for explicit dependency declarations, deterministic initialization ordering, and skipping boot-time reflection and proxy-creation overhead. + +==== Services and the ServiceRegistrar API + +The main entry point for defining startup services is `io.quarkus.core.deployment.service.ServiceRegistrar`. An instance of `ServiceRegistrar` can be injected directly into any `@BuildStep` method: + +[source%nowrap,java] +---- +@BuildStep +ServiceStartBuildItem setup(ServiceRegistrar reg) { + reg.forService("io.quarkus.hello") + .onStart(ctx -> System.out.println("Hello World")); + return new ServiceStartBuildItem("hello"); +} +---- + +===== Service Types + +Services can be typed (producing a value at startup) or void (producing no value, identified by a unique name): + +* *Typed Services*: Defined via `reg.forService(Class)` or `reg.forService(Class, String name)`. +* *Void Services*: Defined via `reg.forService(String name)` or `reg.forService(List nameParts)`. By convention, void service names use hierarchical dot-separated names, e.g., `"io.quarkus.."`. + +===== Synchronous and Asynchronous Start Actions + +Services can be started either synchronously or asynchronously, depending on which method is called on the service builder: + +* *Synchronous Start* (`onStart`): The service is considered started when the action returns. The action receives a `StartContext` (for void services) or returns the service value directly (for typed services). + +* *Asynchronous Start* (`onStartAsync`): The action is initially invoked in the calling thread, but the service is not considered started until a completion method is called. The action receives an `AsyncVoidStartContext` (for void services) or `AsyncStartContext` (for typed services). The completion method can be called from the same thread or from a different thread, allowing the action to schedule asynchronous work. + +.Example of synchronous start +[source%nowrap,java] +---- +reg.forService(MyService.class) + .onStart(ctx -> new MyService()); // Service is started when this returns +---- + +.Example of asynchronous start for a typed service +[source%nowrap,java] +---- +reg.forService(MyService.class) + .require(ExecutorService.class) + .onStartAsync((ctx, executor) -> { + // Action runs in the calling thread initially + executor.execute(() -> { + MyService service = new MyService(); + // Complete from a different thread + ctx.startComplete(service); + }); + }); +---- + +.Example of asynchronous start for a void service +[source%nowrap,java] +---- +reg.forService("io.quarkus.my-async-service") + .onStartAsync(ctx -> { + // Action runs in the calling thread initially + asyncOperation.whenComplete((result, error) -> { + if (error != null) { + ctx.startFailed(error); + } else { + // Complete when the async operation finishes + ctx.startComplete(); + } + }); + }); +---- + +The async start context provides three completion methods: + +* `startComplete()` or `startComplete(T value)`: Indicates successful completion +* `startFailed(Throwable)`: Indicates the service failed to start +* `startCanceled()`: Indicates the service start was canceled + +==== Service Startup Phases + +Services execute in defined, ordered phases of application startup. The default phase is `Phase.APPLICATION`. You can customize the phase using `.atPhase(Phase)` on the service builder: + +[source%nowrap,java] +---- +reg.forService("io.quarkus.my-service") + .atPhase(Phase.INIT) + .onStart(ctx -> { ... }); +---- + +The available startup phases (defined in `io.quarkus.core.Phase`) in execution order are: + +1. `STATIC_INIT`: Runs during static initialization of the generated application class. Results are baked into the native image heap. +2. `CONFIG`: Registers configuration property classes, validates configuration, and reports deprecations. +3. `LOGGING`: Reconfigures the log manager with real runtime configuration (log levels, handlers, formatters). +4. `INFRASTRUCTURE`: Creates foundational infrastructure (event loops, executors, TLS registries, transaction managers). +5. `BINDING`: Binds server sockets, reserves ports, and establishes transport endpoints. +6. `DATA`: Establishes connections to external data systems (connection pools, client connections, directory services). +7. `INIT`: Performs one-time initialization tasks such as database migrations and schema validation. +8. `APPLICATION`: Registers the application's request-handling and event-processing logic (CDI beans, routes, filters). +9. `SERVING`: Transitions the application to actively processing requests (accepting connections, polling brokers). + +[IMPORTANT] +.Phases are not Barriers +==== +The runtime does not wait for all services in phase _N_ to complete before starting services in phase _N+1_. Services start as soon as their individual dependencies are satisfied. +==== + +==== Declaring Dependencies and Ordering + +Every service dependency and execution ordering constraint must be explicitly declared on the service itself. **Build step graph dependencies (e.g. `@Consume`/`@Produce` or method parameter ordering) have zero influence on service execution order.** + +===== Service Values via `require()` + +To depend on and consume the value produced by another service, use `require(Class)` or `require(Class, String name)`: + +[source%nowrap,java] +---- +reg.forService("io.quarkus.my-service") + .require(ArcContainer.class) + .onStart((ctx, container) -> { + // 'container' is the value produced by the ArcContainer service + }); +---- + +===== Execution Ordering via `after()` and `before()` + +To order services without consuming their values: + +* `after(...)`: Declares that this service must run after the specified service class or name. +* `before(...)`: Declares that this service must run before the specified service class or name. This is particularly useful for cleanup services that must outlive their dependents. + +[source%nowrap,java] +---- +reg.forService("io.quarkus.vertx.netty-cleanup") + .atPhase(Phase.STATIC_INIT) + .before(IOThreadDetector.class) + .before(ArcContainer.class) + .onStart(ctx -> { + ctx.onStop(() -> InternalThreadLocalMap.remove()); + }); +---- + +==== Lambda Capture Rules + +Because the action lambda bytecode (defined inside `.onStart()`) is executed at runtime, values from the build step can be captured inside the lambda under strict rules. + +===== Supported Captures +* Primitives, `String`, `Class`, and enum values. +* Immutable collections (e.g., via `List.of()`, `Set.copyOf()`, `Map.copyOf()`). +* Configuration mappings from `BUILD_AND_RUN_TIME_FIXED` and `RUN_TIME` phases. +* `Constable` values (any type implementing `java.lang.constant.Constable` with `describeConstable()`). +* Record values, provided all their components are recursively capturable. + +===== Unsupported Captures (Build Errors) +* Mutable collections (always copy them via `List.copyOf()` or `Set.copyOf()` before the lambda). +* `BUILD_TIME` configuration objects (extract the specific values into local variables first). +* Arbitrary runtime objects (obtain them via `.require()` instead). + +==== Critical Pitfalls & Best Practices + +===== 1. Never Reference Deployment Classes in Service Lambdas +Because service lambda bodies run at runtime, they must never reference, instantiate, or invoke methods on any deployment classes (such as your `@BuildStep` processor classes). Doing so will compile successfully but will throw a `ClassNotFoundException` or `NoClassDefFoundError` at runtime. + +* *The Anonymous Inner Class Trap*: Writing an anonymous inner class (e.g. `new MyInterface() {}`) inside an instance-method lambda forces `javac` to generate a constructor that captures `EnclosingProcessor.this`. This causes a build-time `TransliterationException`. +* *The Static Helper Method Trap*: Moving the anonymous class instantiation to a `private static` helper method inside your processor class solves the instance capture, but the compiled lambda still references the processor class, causing `NoClassDefFoundError` at runtime. +* *Solution*: Always place helper methods, custom interfaces, or static factory methods inside your **runtime** module classes (which are present on both classpaths). + +===== 2. Package-Private Visibility +Lambda bytecode runs at runtime but references runtime classes directly. Package-private classes can become inaccessible from the generated consolidated class. Make classes public or move them to public subpackages. + +===== 3. Use `require()` instead of direct class queries +Always access CDI beans through `.require(BeanContainer.class)` instead of direct `Arc.container()` calls to ensure proper dependency graph tracking and teardown ordering. + +==== Synthetic Beans with Services + +Synthetic beans can easily consume or be produced by services. Use `.serviceValue()` on the synthetic bean builder: + +[source%nowrap,java] +---- +reg.forService(MyBean.class, "my-bean-service") + .onStart(ctx -> new MyBean()); + +SyntheticBeanBuildItem.configure(MyBean.class) + .serviceValue(MyBean.class, "my-bean-service") + .done(); +---- + +==== Teardown & Shutdown Handlers + +Services can register cleanup actions inside the `onStart` or `onStartAsync` action. Stop handlers run in the exact reverse of the service dependency order, ensuring reliable teardown. + +===== Synchronous and Asynchronous Stop Handlers + +Similar to start actions, stop handlers can be either synchronous or asynchronous: + +* *Synchronous Stop* (`ctx.onStop(Runnable)`): The service is considered stopped when the runnable returns. + +* *Asynchronous Stop* (`ctx.onStopAsync(Consumer)`): The stop handler is initially invoked in the calling thread, but the service is not considered stopped until `AsyncStopContext.stopComplete()` is called. The completion method can be called from the same thread or from a different thread, allowing the handler to schedule asynchronous cleanup work. + +.Example of synchronous stop +[source%nowrap,java] +---- +reg.forService(MyService.class) + .onStart(ctx -> { + MyService service = new MyService(); + ctx.onStop(() -> { + service.shutdown(); // Service is stopped when this returns + }); + return service; + }); +---- + +.Example of asynchronous stop +[source%nowrap,java] +---- +reg.forService(MyService.class) + .onStart(ctx -> { + MyService service = new MyService(); + ctx.onStopAsync(stopCtx -> { + // Handler runs in the calling thread initially + service.shutdownAsync().whenComplete((result, error) -> { + // Complete from a different thread when async shutdown finishes + stopCtx.stopComplete(); + }); + }); + return service; + }); +---- + +NOTE: The `AsyncStopContext.stopComplete()` method is idempotent. Calling it multiple times has no additional effect beyond the first call. + +[id='legacy-bytecode-recording'] +=== Legacy Bytecode Recorders (Deprecated) + +[WARNING] +==== +Bytecode recorders (the `@Record` and `@Recorder` API) are **deprecated** and will be removed in a future release. All new extensions must use the new `ServiceRegistrar` service model. Existing recorders should be migrated to services using the xref:recorders-to-services-migration.adoc[Recorders-to-Services Migration Guide]. +==== ==== Bytecode Recording diff --git a/docs/src/main/resources/categories.yaml b/docs/src/main/resources/categories.yaml index 83ab287bde76e..3443c00c971ef 100644 --- a/docs/src/main/resources/categories.yaml +++ b/docs/src/main/resources/categories.yaml @@ -528,6 +528,7 @@ categories: description: Extension development for Quarkus guides: - writing-extensions.adoc + - recorders-to-services-migration.adoc - building-my-first-extension.adoc - extension-metadata.adoc - capabilities.adoc From d293edf3a07b138eae6cb039b446f204bc600260 Mon Sep 17 00:00:00 2001 From: "David M. Lloyd" Date: Wed, 8 Jul 2026 14:11:17 -0500 Subject: [PATCH 028/254] Add a skill for converting from recorder to actions --- .../converting-recorders-to-services/SKILL.md | 415 ++++++++++++++++++ AGENTS.md | 1 + CLAUDE.md | 2 + 3 files changed, 418 insertions(+) create mode 100644 .agents/skills/converting-recorders-to-services/SKILL.md diff --git a/.agents/skills/converting-recorders-to-services/SKILL.md b/.agents/skills/converting-recorders-to-services/SKILL.md new file mode 100644 index 0000000000000..8af00806c47bd --- /dev/null +++ b/.agents/skills/converting-recorders-to-services/SKILL.md @@ -0,0 +1,415 @@ +--- +name: converting-recorders-to-services +description: > + Step-by-step guide for converting Quarkus extensions from the legacy + @Record/@Recorder pattern to the ServiceRegistrar service system. +--- + +# Converting Recorders to Services + +## Architecture + +The `ServiceRegistrar` service system replaces `@Recorder` with typed service. Services declare dependencies explicitly and execute in dependency +order, not build step order. The system is designed for incremental +migration — converted and unconverted extensions may coexist. + +### Key principles + +1. **Explicit dependencies only.** Services get NO implicit ordering from + the build step graph. All ordering comes from `require()`, `after()`, + `before()`, or `afterBuildItem()`. +2. **Services are independent from recorders in the same step.** Even if + a build step method contains both a `@Record` call and an `ServiceRegistrar` + call, the service and recorder are separate nodes with no implicit + ordering between them. +3. **Values flow through the dependency graph.** Service values are passed + from producer to consumer via indexed dependency access on `ServiceNode`, + not through the `StartupContext` maps (except for recorder proxy bridges). + +### Node types in the service graph + +| Kind | Description | Gets step-graph deps? | +|------|-------------|-----------------------| +| `LEGACY_RECORDER` | Recorder chunk from a `@Record` step | Yes | +| `ALIAS` | Bridges a recorder value to a service key | Yes | +| `SERVICE` | `ServiceRegistrar` service with a lambda body | No | +| `RV_WRAPPER` | Wraps a service value in `RuntimeValue` for recorders | No | +| `CROSS_PHASE_PROXY` | Bridges a static-init service to the runtime graph | No | + +Legacy recorders and aliases use step-graph ordering for compatibility. +Service nodes use only explicit declarations. + +## Prerequisites + +Before converting, verify that all runtime dependencies are available as +services: + +| Dependency Type | Availability | +|-----------------|-------------| +| `ArcContainer` | Service in `ArcProcessor#initializeContainer` | +| `BeanContainer` | Service in `ArcProcessor#createBeanContainer` | +| `ScheduledExecutorService` | Aliased by `ThreadPoolSetup` | +| `@ConfigMapping` (`RUN_TIME`) | Auto-registered by `ConfigServiceRegistrationStep` | +| `@ConfigMapping` (`BUILD_AND_RUN_TIME_FIXED`) | Auto-registered as static-init | +| `@ConfigMapping` (`BUILD_TIME`) | **Cannot be a service** — extract values into locals | +| Other runtime objects | Search for `aliasRecorderValue` or `forService` | + +## Conversion steps + +### 1. Analyze the recorder + +For each `@Recorder` method, determine: + +- **Return type** — becomes the service type. `RuntimeValue` unwraps + to `X`. `void` becomes `Void.class` with a descriptive name. +- **Parameters** — `RuntimeValue` params become `require(X.class)`. + Simple values (String, int, boolean, enum) can be captured. Immutable + collections (`List.of()`, `Set.of()`, etc.) can be captured. +- **Instance fields** — config `RuntimeValue`s injected via constructor + become `require()` dependencies. +- **Trivial methods** — inline directly into the lambda. + +### 2. Convert the recorder class + +- **Trivial methods** — delete; inline into the lambda. +- **Complex methods** — remove `@Recorder`, make methods `static`, + unwrap `RuntimeValue` parameters and return types. +- If all methods are converted, delete the class. Otherwise keep + `@Recorder` for remaining methods. + +### 3. Convert the build step + +**Before:** +```java +@BuildStep +@Record(ExecutionTime.RUNTIME_INIT) +ServiceStartBuildItem setup(MyRecorder recorder, SomeBuildItem item) { + recorder.initialize(item.getValue()); + return new ServiceStartBuildItem("my-feature"); +} +``` + +**After:** +```java +@BuildStep +ServiceStartBuildItem setup(ServiceRegistrar reg, SomeBuildItem item) { + reg + .forService("io.quarkus.my-feature.setup") + .onStart(ctx -> MyRecorder.initialize()); + return new ServiceStartBuildItem("my-feature"); +} +``` + +Key changes: +- Remove `@Record(ExecutionTime.*)` annotation. +- Replace recorder parameter with `ServiceRegistrar reg`. +- Use `.atPhase(Phase.STATIC_INIT)` for static-init services (default + is runtime-init / `Phase.APPLICATION`). +- For void services, use hierarchical dot-separated names: + `"io.quarkus.."`. + +### 4. Declare dependencies + +**Every dependency must be explicit.** There is no implicit ordering +from the build step graph. + +| Old Pattern | New Pattern | +|------------|-------------| +| `recorder.method(beanContainer.getValue())` | `.require(BeanContainer.class)` | +| `recorder.method(someRuntimeValue)` | `.require(SomeType.class)` | +| `recorder.method(config.maxSize())` | Capture `int maxSize = config.maxSize()` before lambda | +| `@Consume(SyntheticBeansRuntimeInitBuildItem.class)` on step | `.afterBuildItem(SyntheticBeansRuntimeInitBuildItem.class)` on service | + +#### `afterBuildItem()` — bridging to build step ordering + +When a service depends on state produced by a legacy recorder (e.g., +synthetic beans must be initialized), declare the dependency via +`afterBuildItem()`: + +```java +reg + .forService("io.quarkus.arc.lifecycle") + .afterBuildItem(SyntheticBeansRuntimeInitBuildItem.class) + .onStart(ctx -> ArcRecorder.fireLifecycleEvent(new StartupEvent())); +``` + +This resolves the producing step's nodes (with passthrough resolution +for steps that have no service graph nodes) and creates ordering edges. + +**`afterBuildItem()` is deprecated** — it exists only for recorder +coexistence. Once the producing recorder is converted to a service, +replace with `require()` or `after()`, or drop it, as appropriate. + +#### Cross-phase dependencies + +Runtime services can `require()` static-init services. The framework +automatically creates a `CROSS_PHASE_PROXY` node that reads the value +from the `serviceValues` map (populated during static-init and retained +via `retainServiceValues()`). + +```java +// static-init service +reg.forService(ArcContainer.class) + .atPhase(Phase.STATIC_INIT) + .afterBuildItem(ResourcesGeneratedPhaseBuildItem.class) + .onStart(ctx -> Arc.initialize()); + +// runtime service that depends on it +reg.forService("io.quarkus.my-ext.setup") + .require(ArcContainer.class) // cross-phase: resolved via proxy + .onStart((ctx, container) -> { ... }); +``` + +#### Config mappings + +- `BUILD_TIME` — extract values into locals before the lambda. +- `BUILD_AND_RUN_TIME_FIXED` — use `.require(ConfigType.class)` (resolved + directly from SmallRye Config, no graph edge). +- `RUN_TIME` — use `.require(ConfigType.class)` (same mechanism). + +### 5. Stop ordering with `before()` + +`before(X)` declares "X depends on me" — this service starts before X +and stops after X. Use it for cleanup services that must outlive their +dependents: + +```java +reg + .forService("io.quarkus.vertx.netty-thread-local-cleanup") + .atPhase(Phase.STATIC_INIT) + .before(IOThreadDetector.class) + .before(ArcContainer.class) + .onStart(ctx -> { + ctx.onStop(() -> InternalThreadLocalMap.remove()); + }); +``` + +### 6. Bridge to legacy build items + +Build items consumed by unconverted extensions need bridge proxies: + +| Build Item Stores | Bridge Method | +|-------------------|---------------| +| `RuntimeValue` | `reg.staticInitServiceAsRuntimeValue(T.class)` or `reg.serviceAsRuntimeValue(T.class)` | +| Bare `T` (interface) | `reg.staticInitServiceAsRecorderValue(T.class)` or `reg.serviceAsRecorderValue(T.class)` | +| Bare `T` (concrete) | Use `RuntimeValue` variant — concrete classes may not be proxyable | + +Recorder proxies resolve via `startupContext.getServiceValue()` (for +`__service$$value()` proxies) or `startupContext.getValue()` (for +standard recorder proxies). The service deploy body stores values in +the `serviceValues` map for this resolution. + +### 7. Synthetic beans + +**Before:** +```java +Supplier supplier = recorder.createBeanSupplier(args); +SyntheticBeanBuildItem.configure(MyBean.class).supplier(supplier).done(); +``` + +**After:** +```java +reg.forService(MyBean.class, "my-bean-name") + .onStart(ctx -> new MyBean(args)); + +SyntheticBeanBuildItem.configure(MyBean.class) + .serviceValue(MyBean.class, "my-bean-name") + .done(); +``` + +### 8. Shutdown handlers + +Register cleanup via `ctx.onStop(Runnable)` or +`ctx.onStopAsync(Consumer)`. Stop handlers run in +reverse dependency order. + +`addLastShutdownTask()` is **deprecated** — its per-node "last" semantics +don't provide global ordering. Use `before()` to ensure your service +stops after the services it cleans up for. + +### 9. Runtime-optional services + +A value-producing service must return a non-`null` value — the +generated code throws `"...returned null; use a void service if no value is +produced"` otherwise. For a service whose *identity* is known at build time +but which may be **absent at runtime** (e.g. a handler gated by +`quarkus.log.file.enabled`), declare `optional()` before `onStart()`. This +flips the service's return type from `T` to `Optional`; returning +`Optional.empty()` marks the service absent (nothing constructed, no side +effects) instead of failing. + +```java +reg.forService(Handler.class, "file") + .atPhase(Phase.LOGGING) + .require(LogRuntimeConfig.class) + .optional() + .onStart((ctx, config) -> config.file().enabled() + ? Optional.of(buildFileHandler(config.file())) + : Optional.empty()); +``` + +Consuming an optional service: +- `consumeAll(T.class)` skips absent producers (the map omits them). +- `request(T.class)` yields `Optional.empty()` when absent. +- `require(T.class)` on an optional service is a **build error** — a + mandatory dependency cannot depend on a maybe-absent value. Use + `request()` or `consumeAll()` instead. + +Do not return `null` from a normal (non-`optional`) service, and do not use a +sentinel value — `optional()` is the supported mechanism. (The `null` +tolerance in `consumeAll` applies only to aliased recorder values that +resolve to `null`, not to direct services.) + +### 10. Lambda capture rules + +Supported captures: +- Primitives, `String`, `Class`, enum values +- Immutable collections: `List.of()`, `Set.of()`, `Map.of()`, + `Collections.singletonList/Set/Map()`, `Collections.empty*()`, + `Collections.unmodifiable*()`, `Set.copyOf()`, `List.copyOf()`, + `Map.copyOf()` +- `BUILD_AND_RUN_TIME_FIXED` config mappings (via `ConfigCaptureInterceptor`) +- `Constable` values — any type implementing `java.lang.constant.Constable` + is captured via its `describeConstable()`. To make a custom value type + capturable, implement `Constable` with a `describeConstable()` that + reconstructs it (e.g. a `DynamicConstantDesc` invoking a static factory), + rather than registering a recorder `ObjectSubstitution`. Immutable + collections of `Constable` values (via `Map.copyOf`/`List.copyOf`) are + themselves capturable. +- `record` values — captured by reconstructing via the canonical + constructor, as long as every component is itself capturable (recursively). + Note this is the **capture** path; it is unrelated to the *proxy* path — a + `record` still cannot be used as an aliased recorder return type (see + "Aliased recorder return types must be proxyable" below). + +**Not capturable:** +- Mutable collections — use `Set.copyOf()` / `List.copyOf()` first +- `BUILD_TIME` config objects — extract values into locals +- `RUN_TIME` config objects — use `require()` instead +- Arbitrary runtime objects — use `require()` instead + +## Common pitfalls + +### No implicit ordering from build steps + +Services get NO ordering from `@Consume`/`@Produce` annotations or +build item parameters on their build step method. Every ordering +dependency must be declared on the service itself. + +If a service needs to run after a legacy recorder step, use +`afterBuildItem()` to depend on a build item that step produces. + +### Concrete class proxying + +`staticInitServiceAsRecorderValue(ConcreteClass.class)` fails if the +class has a non-trivial constructor. Use +`staticInitServiceAsRuntimeValue()` instead — `RuntimeValue` is always +proxyable. + +### Aliased recorder return types must be proxyable + +A value published via `aliasRecorderValue(T.class, recorderValue)` — or any +`@Recorder` method whose return value is used as a recorder proxy — must have +a **proxyable** type: an interface or a non-`final` class. A `record` is +`final`, so it fails with *"the return type cannot be proxied; use +RuntimeValue to wrap the return value instead"*. When bridging a bundle of +recorder values into a single service value, make the holder an **interface** +(with a nested `record` impl for the runtime value), or wrap it in +`RuntimeValue`. Abstract JDK types such as `java.util.logging.Formatter` and +`java.util.logging.Handler` are already proxyable. + +### Package-private visibility + +Lambda bytecode runs at runtime but references runtime classes directly. +Package-private classes become inaccessible from the generated +consolidated class. + +Solutions: +1. Add a public static factory method on a public runtime class. +2. Move the class to a `.impl` subpackage and make it public. + +### Never reference deployment classes from within service lambdas + +Action lambda bytecode (defined inside `.onStart(ctx -> { ... })`) is executed at **runtime**. Therefore, the lambda body must **never** reference, instantiate, or invoke methods on any deployment classes (such as your `@BuildStep` processor classes). Doing so will compile successfully but will throw a `ClassNotFoundException` / `NoClassDefFoundError` at runtime because deployment modules are absent from the runtime classpath. + +* **The Anonymous Inner Class Trap**: Writing an anonymous inner class (e.g. `new MyInterface() {}`) inside an instance-method lambda forces `javac` to generate a constructor that captures `EnclosingProcessor.this` (via a synthetic `this$0` parameter) to bind the inner class to its enclosing processor scope. This turns your lambda into an instance-capturing lambda (throwing a `TransliterationException` at build time). +* **The Static Helper Method Trap**: Moving the anonymous class instantiation to a `private static` helper method inside your processor class solves the `EnclosingProcessor.this` capture, but because the compiled lambda still references the static helper method on your deployment processor, it causes a `NoClassDefFoundError` at runtime. +* **Solution**: Always place helper methods, custom interfaces, or static factory methods inside your **runtime** module classes (which are safely present at both build time and runtime). + +### Use `require()` instead of `Arc.container()` + +Access CDI beans through `require(BeanContainer.class)` and +`beanInstance()`. Direct `Arc.container()` calls bypass the dependency +graph and can cause ordering failures. + +### Mutable collections must be copied + +```java +Set captured = Set.copyOf(endpoints); // before the lambda +``` + +## Behavioral parity with recorders + +Conversion must preserve behavior the recorder model provided *implicitly*. +These are recurring, high-cost regressions — check each when converting a +step that touches classloaders, threads, cleanup, or failure handling. + +### TCCL parity (thread context classloader) + +Recorder bytecode always ran with the **runtime classloader as the TCCL** +(the generated `doStart` sets it). Any recorder code that captured +`Thread.currentThread().getContextClassLoader()` for later use — request +handlers, thread factories, config/`ServiceLoader` lookups — relied on that. + +- After converting, ensure the service still runs with the runtime CL as + TCCL, and that captured/inherited CLs are the runtime CL (not the system + `AppClassLoader`). A wrong TCCL surfaces as `ServiceLoader` "not a subtype" + errors or dev-mode failures, not as an obvious classloader bug. +- **Threads created during startup inherit the creating thread's TCCL.** + A long-lived or library thread that captures the per-app runtime CL and + outlives the app **leaks the `QuarkusClassLoader`** (metaspace growth + across test/dev restarts). Internal/scheduler threads that run no user + code should not hold an app CL. Clean up threads/`ThreadLocal`s your + service owns in `onStop`; never rely on a global "reset all TCCLs" sweep. + +### Teardown-ordering parity + +Start ordering is explicit (`require`/`after`/`before`) — but so is **stop +ordering**, and that is the easy thing to lose. The recorder model gave +cleanup incidental ordering from build-step order; a converted service's +`onStop` runs only in reverse *dependency* order. If a cleanup implicitly +depended on running before/after another (e.g. bean destruction before a +"last" task, or a listener firing before container teardown), declare it +with `before()`/`after()`. Lost teardown ordering surfaces as unrelated +downstream symptoms, so verify shutdown explicitly. + +### Failure-path parity + +Recorders logged **nothing in-band** on failure — `doStart` caught, wrapped +in `RuntimeException("Failed to start quarkus", cause)`, and rethrew. The +service graph rethrows the first failure (concurrent ones attach as +suppressed), so nothing is lost by staying quiet. **Do not add ERROR/WARN +logging in service bodies for expected failures** — let them propagate. Some +tests assert clean propagation (no WARNING+ log records, no suppressed +exceptions) for a static-init failure, e.g. `StaticInitFailureTest`. + +### `consumeAll` uses indexed access + +`consumeAll` dependencies expand to multiple consecutive entries in the +ServiceNode's dependency array (one per matching service, sorted by +name). Both `ServiceGraphBuilder` and `LambdaTransliterator` compute +the same match list using the same prefix and sort order, ensuring +index alignment. + +## Checklist + +- [ ] Identify all `@Record` build steps in the processor +- [ ] For each, determine if all dependencies are available as services +- [ ] Convert recorder methods (inline trivial ones, make complex ones static) +- [ ] Update build steps: remove `@Record`, add `ServiceRegistrar`, register services +- [ ] Declare ALL dependencies explicitly (`require`, `after`, `before`, `afterBuildItem`) +- [ ] Do NOT rely on build step ordering for service execution order +- [ ] Bridge values to legacy build items where needed +- [ ] Convert synthetic beans from `supplier()`/`runtimeValue()` to `serviceValue()` +- [ ] Check for package-private visibility issues +- [ ] `./mvnw install` the modified modules before running integration tests diff --git a/AGENTS.md b/AGENTS.md index 7cf2215be673c..8a555e9268038 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -121,3 +121,4 @@ Consult the relevant skill when you are about to do that type of work: | `writing-extension-devui` | Writing a Dev UI for a Quarkus extension | | `building-docs` | Building, previewing, or testing documentation changes locally | | `manage-deprecations` | Removing code deprecated for over 12 months, adding missed `@Deprecated` annotations, or applying a new deprecation | +| `converting-recorders-to-services` | Converting extensions from `@Record`/`@Recorder` to `ServiceRegistrar` services | diff --git a/CLAUDE.md b/CLAUDE.md index 42388149bb272..1da00d8081405 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,3 +29,5 @@ guidance before starting work: when removing code that has been deprecated for over 12 months, adding `@Deprecated` annotations that were missed on companions of a deprecated element, or applying a new deprecation the user requests. +- **Converting recorders to services** — Read `.agents/skills/converting-recorders-to-services/SKILL.md` + when converting extensions from `@Record`/`@Recorder` to `ServiceRegistrar` services. From 88d0eea8f4ffcd648471f84c285f0ac1c83a1c32 Mon Sep 17 00:00:00 2001 From: "David M. Lloyd" Date: Fri, 4 Sep 2026 18:36:46 -0500 Subject: [PATCH 029/254] Service conversion: Thread pool setup --- .../deployment/steps/ThreadPoolSetup.java | 47 ++-- .../io/quarkus/runtime/ExecutorRecorder.java | 263 ++++++++++-------- 2 files changed, 173 insertions(+), 137 deletions(-) diff --git a/core/deployment/src/main/java/io/quarkus/deployment/steps/ThreadPoolSetup.java b/core/deployment/src/main/java/io/quarkus/deployment/steps/ThreadPoolSetup.java index 42a2b91c754e2..5c1210d961c91 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/steps/ThreadPoolSetup.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/steps/ThreadPoolSetup.java @@ -1,39 +1,44 @@ package io.quarkus.deployment.steps; -import java.util.Optional; import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadFactory; -import io.quarkus.core.deployment.action.ActionBuilder; -import io.quarkus.deployment.Phase; +import io.quarkus.core.Phase; +import io.quarkus.core.deployment.service.ServiceRegistrar; import io.quarkus.deployment.annotations.BuildStep; -import io.quarkus.deployment.annotations.ExecutionTime; -import io.quarkus.deployment.annotations.Record; -import io.quarkus.deployment.builditem.ContextHandlerBuildItem; import io.quarkus.deployment.builditem.ExecutorBuildItem; import io.quarkus.deployment.builditem.LaunchModeBuildItem; -import io.quarkus.deployment.builditem.ShutdownContextBuildItem; -import io.quarkus.deployment.builditem.ThreadFactoryBuildItem; import io.quarkus.deployment.builditem.nativeimage.RuntimeInitializedClassBuildItem; import io.quarkus.runtime.ExecutorRecorder; +import io.quarkus.runtime.LaunchMode; +import io.quarkus.runtime.QuarkusContextHandler; +import io.quarkus.runtime.ThreadPoolConfig; /** - * + * Registers the main thread pool as a service. */ public class ThreadPoolSetup { @BuildStep - @Record(value = ExecutionTime.RUNTIME_INIT) - public ExecutorBuildItem createExecutor(ActionBuilder action, ExecutorRecorder recorder, - ShutdownContextBuildItem shutdownContextBuildItem, - LaunchModeBuildItem launchModeBuildItem, - Optional threadFactoryBuildItem, - Optional contextBuildItem) { - ScheduledExecutorService proxy = recorder.setupRunTime(shutdownContextBuildItem, - launchModeBuildItem.getLaunchMode(), - threadFactoryBuildItem.map(ThreadFactoryBuildItem::getThreadFactory).orElse(null), - contextBuildItem.map(ContextHandlerBuildItem::contextHandler).orElse(null)); - action.aliasRecorderValue(ScheduledExecutorService.class, proxy, Phase.INFRASTRUCTURE); - return new ExecutorBuildItem(proxy); + public ExecutorBuildItem createExecutor(ServiceRegistrar reg, + LaunchModeBuildItem launchModeBuildItem) { + LaunchMode launchMode = launchModeBuildItem.getLaunchMode(); + reg + .forService(ScheduledExecutorService.class) + .atPhase(Phase.INFRASTRUCTURE) + .require(ThreadPoolConfig.class) + .request(ThreadFactory.class) + .request(QuarkusContextHandler.class) + .onStart((ctx, config, threadFactoryOpt, contextHandlerOpt) -> { + ScheduledExecutorService executor = ExecutorRecorder.setupRunTime( + config, + launchMode, + threadFactoryOpt.orElse(null), + contextHandlerOpt.orElse(null)); + ctx.onStop(() -> ExecutorRecorder.shutdownExecutor(config, launchMode)); + return executor; + }); + return new ExecutorBuildItem(reg.getRecorderProxy(ScheduledExecutorService.class)); } @BuildStep diff --git a/core/runtime/src/main/java/io/quarkus/runtime/ExecutorRecorder.java b/core/runtime/src/main/java/io/quarkus/runtime/ExecutorRecorder.java index 6f0689f77fef3..753fdd43b9ddc 100644 --- a/core/runtime/src/main/java/io/quarkus/runtime/ExecutorRecorder.java +++ b/core/runtime/src/main/java/io/quarkus/runtime/ExecutorRecorder.java @@ -15,149 +15,164 @@ import org.jboss.threads.JBossExecutors; import org.jboss.threads.JBossThreadFactory; -import io.quarkus.runtime.annotations.Recorder; import io.quarkus.runtime.util.NoopShutdownScheduledExecutorService; import io.smallrye.common.cpu.ProcessorInfo; /** - * + * Creates and manages the main executor service for blocking tasks. */ -@Recorder public class ExecutorRecorder { private static final Logger log = Logger.getLogger("io.quarkus.thread-pool"); private static volatile Executor current; + private static volatile EnhancedQueueExecutor underlying; - private final RuntimeValue threadPoolConfig; - - public ExecutorRecorder(RuntimeValue threadPoolConfig) { - this.threadPoolConfig = threadPoolConfig; + private ExecutorRecorder() { } - public ScheduledExecutorService setupRunTime(ShutdownContext shutdownContext, + /** + * Create and configure the main executor service. + *

+ * In production and test modes, the returned executor wraps the underlying + * {@link EnhancedQueueExecutor} in a {@link NoopShutdownScheduledExecutorService} + * to prevent application code from shutting it down prematurely. + * + * @param threadPoolConfig the thread pool configuration + * @param launchMode the current launch mode + * @param threadFactory the thread factory, or {@code null} for the default + * @param contextHandler the context handler, or {@code null} for none + * @return the configured executor service + */ + public static ScheduledExecutorService setupRunTime(ThreadPoolConfig threadPoolConfig, LaunchMode launchMode, ThreadFactory threadFactory, ContextHandler contextHandler) { - final EnhancedQueueExecutor underlying = createExecutor(threadPoolConfig.getValue(), threadFactory, contextHandler); - if (launchMode == LaunchMode.DEVELOPMENT) { - shutdownContext.addLastShutdownTask(new Runnable() { - @Override - public void run() { - for (Runnable i : underlying.shutdownNow()) { - Thread thread = new Thread(i, "Shutdown task thread"); - thread.setDaemon(true); - thread.start(); - } - current = null; - - } - }); - } else { - Runnable shutdownTask = createShutdownTask(threadPoolConfig.getValue(), underlying); - shutdownContext.addLastShutdownTask(shutdownTask); - } - if (threadPoolConfig.getValue().prefill()) { - underlying.prestartAllCoreThreads(); + final EnhancedQueueExecutor eqe = createExecutor(threadPoolConfig, threadFactory, contextHandler); + if (threadPoolConfig.prefill()) { + eqe.prestartAllCoreThreads(); } - ScheduledExecutorService managed = underlying; - // In prod and test mode, we wrap the ExecutorService and the shutdown() and shutdownNow() are deliberately not delegated - // This is to prevent the application and other extensions from shutting down the executor service - // The problem was described in https://github.com/quarkusio/quarkus/issues/16833#issuecomment-1917042589 - // and https://github.com/quarkusio/quarkus/issues/43228 - // For example, the Vertx instance is closed before io.quarkus.runtime.ExecutorRecorder.createShutdownTask() is used - // And when it's closed the underlying worker thread pool (which is in the prod mode backed by the ExecutorBuildItem) is closed as well - // As a result the quarkus.thread-pool.shutdown-interrupt config property and logic defined in ExecutorRecorder.createShutdownTask() is completely ignored + underlying = eqe; + ScheduledExecutorService managed = eqe; + // In prod and test mode, wrap so that application code cannot shut down the executor prematurely + // (see https://github.com/quarkusio/quarkus/issues/16833#issuecomment-1917042589 + // and https://github.com/quarkusio/quarkus/issues/43228) if (launchMode != LaunchMode.DEVELOPMENT) { - managed = new NoopShutdownScheduledExecutorService(underlying); + managed = new NoopShutdownScheduledExecutorService(eqe); } current = managed; return managed; } - private static Runnable createShutdownTask(ThreadPoolConfig threadPoolConfig, EnhancedQueueExecutor executor) { - return new Runnable() { - @Override - public void run() { - executor.shutdown(); - final Duration shutdownTimeout = threadPoolConfig.shutdownTimeout(); - final Optional optionalInterval = threadPoolConfig.shutdownCheckInterval(); - long remaining = shutdownTimeout.toNanos(); - final long interval = optionalInterval.orElse(Duration.ofNanos(Long.MAX_VALUE)).toNanos(); - long intervalRemaining = interval; - long interruptRemaining = threadPoolConfig.shutdownInterrupt().toNanos(); - - long start = System.nanoTime(); - int loop = 1; - for (;;) { - // This log can be very useful when debugging problems - log.debugf("loop: %s, remaining: %s, intervalRemaining: %s, interruptRemaining: %s", loop++, remaining, - intervalRemaining, interruptRemaining); - try { - if (!executor.awaitTermination(Math.min(remaining, intervalRemaining), TimeUnit.NANOSECONDS)) { - long end = System.nanoTime(); - long elapsed = Math.max(0, end - start); - start = end; - intervalRemaining -= elapsed; - remaining -= elapsed; - interruptRemaining -= elapsed; - if (interruptRemaining <= 0) { - executor.shutdown(true); - } - if (remaining <= 0) { - // done waiting - final List runnables = executor.shutdownNow(); - if (!runnables.isEmpty()) { - log.warnf("Thread pool shutdown failed: discarding %d tasks, %d threads still running", - runnables.size(), executor.getActiveCount()); - } else { - log.warnf("Thread pool shutdown failed: %d threads still running", - executor.getActiveCount()); - } - break; - } - if (intervalRemaining <= 0) { - intervalRemaining = interval; - // do some probing - final int queueSize = executor.getQueueSize(); - final Thread[] runningThreads = executor.getRunningThreads(); - if (queueSize <= 0) { - log.infof("Awaiting thread pool shutdown; %d thread(s) running", - runningThreads.length); - } else { - log.infof("Awaiting thread pool shutdown; %d thread(s) running with %d task(s) waiting", - runningThreads.length, queueSize); - } - // make sure no threads are stuck in {@code exit()} - int realWaiting = runningThreads.length; - for (Thread thr : runningThreads) { - final StackTraceElement[] stackTrace = thr.getStackTrace(); - for (int i = 0; i < stackTrace.length && i < 8; i++) { - if (stackTrace[i].getClassName().equals("java.lang.System") - && stackTrace[i].getMethodName().equals("exit")) { - final Throwable t = new Throwable(); - t.setStackTrace(stackTrace); - log.errorf(t, "Thread %s is blocked in System.exit(); pooled (Executor) threads " - + "should never call this method because it never returns, thus preventing " - + "the thread pool from shutting down in a timely manner. This is the " - + "stack trace of the call", thr.getName()); - // don't bother waiting for exit() to return - realWaiting--; - break; - } - } - } - if (realWaiting == 0) { - // just exit - executor.shutdownNow(); + /** + * Shut down the executor synchronously. + *

+ * In development mode, performs an immediate {@code shutdownNow()}. + * In production and test modes, performs an orderly shutdown with + * configurable timeout and interrupt behavior. + * + * @param threadPoolConfig the thread pool configuration (for timeout/interrupt settings) + * @param launchMode the current launch mode + */ + public static void shutdownExecutor(ThreadPoolConfig threadPoolConfig, LaunchMode launchMode) { + final EnhancedQueueExecutor eqe = underlying; + if (eqe == null) { + return; + } + + if (launchMode == LaunchMode.DEVELOPMENT) { + for (Runnable i : eqe.shutdownNow()) { + Thread thread = new Thread(i, "Shutdown task thread"); + thread.setDaemon(true); + thread.start(); + } + } else { + performOrderlyShutdown(eqe, threadPoolConfig); + } + underlying = null; + current = null; + } + + /** + * Perform an orderly shutdown of the executor, blocking until terminated + * or the configured timeout expires. + * + * @param executor the executor to shut down + * @param threadPoolConfig the configuration with timeout and interrupt settings + */ + private static void performOrderlyShutdown(EnhancedQueueExecutor executor, ThreadPoolConfig threadPoolConfig) { + executor.shutdown(); + final Duration shutdownTimeout = threadPoolConfig.shutdownTimeout(); + final Optional optionalInterval = threadPoolConfig.shutdownCheckInterval(); + long remaining = shutdownTimeout.toNanos(); + final long interval = optionalInterval.orElse(Duration.ofNanos(Long.MAX_VALUE)).toNanos(); + long intervalRemaining = interval; + long interruptRemaining = threadPoolConfig.shutdownInterrupt().toNanos(); + + long start = System.nanoTime(); + int loop = 1; + for (;;) { + log.debugf("loop: %s, remaining: %s, intervalRemaining: %s, interruptRemaining: %s", loop++, remaining, + intervalRemaining, interruptRemaining); + try { + if (!executor.awaitTermination(Math.min(remaining, intervalRemaining), TimeUnit.NANOSECONDS)) { + long end = System.nanoTime(); + long elapsed = Math.max(0, end - start); + start = end; + intervalRemaining -= elapsed; + remaining -= elapsed; + interruptRemaining -= elapsed; + if (interruptRemaining <= 0) { + executor.shutdown(true); + } + if (remaining <= 0) { + final List runnables = executor.shutdownNow(); + if (!runnables.isEmpty()) { + log.warnf("Thread pool shutdown failed: discarding %d tasks, %d threads still running", + runnables.size(), executor.getActiveCount()); + } else { + log.warnf("Thread pool shutdown failed: %d threads still running", + executor.getActiveCount()); + } + break; + } + if (intervalRemaining <= 0) { + intervalRemaining = interval; + final int queueSize = executor.getQueueSize(); + final Thread[] runningThreads = executor.getRunningThreads(); + if (queueSize <= 0) { + log.infof("Awaiting thread pool shutdown; %d thread(s) running", + runningThreads.length); + } else { + log.infof("Awaiting thread pool shutdown; %d thread(s) running with %d task(s) waiting", + runningThreads.length, queueSize); + } + // make sure no threads are stuck in exit() + int realWaiting = runningThreads.length; + for (Thread thr : runningThreads) { + final StackTraceElement[] stackTrace = thr.getStackTrace(); + for (int i = 0; i < stackTrace.length && i < 8; i++) { + if (stackTrace[i].getClassName().equals("java.lang.System") + && stackTrace[i].getMethodName().equals("exit")) { + final Throwable t = new Throwable(); + t.setStackTrace(stackTrace); + log.errorf(t, "Thread %s is blocked in System.exit(); pooled (Executor) threads " + + "should never call this method because it never returns, thus preventing " + + "the thread pool from shutting down in a timely manner. This is the " + + "stack trace of the call", thr.getName()); + realWaiting--; break; } } - } else { - return; } - } catch (InterruptedException ignored) { + if (realWaiting == 0) { + executor.shutdownNow(); + break; + } } + } else { + return; } + } catch (InterruptedException ignored) { } - }; + } } private static EnhancedQueueExecutor createExecutor(ThreadPoolConfig threadPoolConfig, ThreadFactory threadFactory, @@ -193,10 +208,21 @@ private static EnhancedQueueExecutor createExecutor(ThreadPoolConfig threadPoolC return builder.build(); } + /** + * Get the maximum pool size from the configuration. + * + * @param threadPoolConfig the thread pool configuration + * @return the maximum pool size + */ public static int getMaxSize(ThreadPoolConfig threadPoolConfig) { return threadPoolConfig.maxThreads().orElseGet(MaxThreadsCalculator.INSTANCE); } + /** + * Calculate the default maximum number of threads. + * + * @return the default max thread count + */ public static int calculateMaxThreads() { return MaxThreadsCalculator.INSTANCE.getAsInt(); } @@ -222,6 +248,11 @@ private static class Holder { } } + /** + * Get the current executor. + * + * @return the current executor, or {@code null} if not yet initialized + */ public static Executor getCurrent() { return current; } From 97ccdf9494cfcd4cdbd3bacaf562670076f901f5 Mon Sep 17 00:00:00 2001 From: "David M. Lloyd" Date: Tue, 1 Sep 2026 13:38:09 -0500 Subject: [PATCH 030/254] Service conversion: Logging --- .../logging/LoggingResourceProcessor.java | 238 +++- .../runtime/logging/AdditionalLogHandler.java | 17 + .../runtime/logging/InheritableLevel.java | 46 +- .../io/quarkus/runtime/logging/LogBanner.java | 26 + .../runtime/logging/LogCleanupFilter.java | 4 +- .../runtime/logging/LoggingCompatBridge.java | 52 + .../logging/LoggingCompatRecorder.java | 71 + .../quarkus/runtime/logging/LoggingSetup.java | 1266 +++++++++++++++++ .../runtime/logging/LoggingSetupRecorder.java | 692 +-------- .../logging/NamedHandlerFormatters.java | 19 + .../runtime/logging/StreamingLogHandler.java | 17 + 11 files changed, 1729 insertions(+), 719 deletions(-) create mode 100644 core/runtime/src/main/java/io/quarkus/runtime/logging/AdditionalLogHandler.java create mode 100644 core/runtime/src/main/java/io/quarkus/runtime/logging/LogBanner.java create mode 100644 core/runtime/src/main/java/io/quarkus/runtime/logging/LoggingCompatBridge.java create mode 100644 core/runtime/src/main/java/io/quarkus/runtime/logging/LoggingCompatRecorder.java create mode 100644 core/runtime/src/main/java/io/quarkus/runtime/logging/LoggingSetup.java create mode 100644 core/runtime/src/main/java/io/quarkus/runtime/logging/NamedHandlerFormatters.java create mode 100644 core/runtime/src/main/java/io/quarkus/runtime/logging/StreamingLogHandler.java diff --git a/core/deployment/src/main/java/io/quarkus/deployment/logging/LoggingResourceProcessor.java b/core/deployment/src/main/java/io/quarkus/deployment/logging/LoggingResourceProcessor.java index 88d6028971fe9..e242a0cf34009 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/logging/LoggingResourceProcessor.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/logging/LoggingResourceProcessor.java @@ -21,6 +21,7 @@ import java.util.function.Consumer; import java.util.function.Supplier; import java.util.logging.ConsoleHandler; +import java.util.logging.ErrorManager; import java.util.logging.Filter; import java.util.logging.Formatter; import java.util.logging.Handler; @@ -47,6 +48,7 @@ import org.jboss.logmanager.ExtLogRecord; import org.jboss.logmanager.LogContextInitializer; import org.jboss.logmanager.LogManager; +import org.jboss.logmanager.errormanager.OnlyOnceErrorManager; import com.oracle.svm.core.annotate.Alias; import com.oracle.svm.core.annotate.Substitute; @@ -56,6 +58,8 @@ import io.quarkus.bootstrap.logging.InitialConfigurator; import io.quarkus.bootstrap.model.ApplicationModel; import io.quarkus.bootstrap.workspace.WorkspaceModule; +import io.quarkus.core.Phase; +import io.quarkus.core.deployment.service.ServiceRegistrar; import io.quarkus.deployment.ApplicationArchive; import io.quarkus.deployment.GeneratedClassGizmo2Adaptor; import io.quarkus.deployment.IsProduction; @@ -83,10 +87,8 @@ import io.quarkus.deployment.builditem.LogNamedHandlerFormatBuildItem; import io.quarkus.deployment.builditem.LogSocketFormatBuildItem; import io.quarkus.deployment.builditem.LogSyslogFormatBuildItem; -import io.quarkus.deployment.builditem.NamedLogHandlersBuildItem; import io.quarkus.deployment.builditem.RunTimeConfigurationDefaultBuildItem; import io.quarkus.deployment.builditem.ServiceStartBuildItem; -import io.quarkus.deployment.builditem.ShutdownListenerBuildItem; import io.quarkus.deployment.builditem.StreamingLogHandlerBuildItem; import io.quarkus.deployment.builditem.SystemPropertyBuildItem; import io.quarkus.deployment.builditem.nativeimage.NativeImageSystemPropertyBuildItem; @@ -104,7 +106,6 @@ import io.quarkus.deployment.pkg.builditem.CurateOutcomeBuildItem; import io.quarkus.deployment.pkg.builditem.OutputTargetBuildItem; import io.quarkus.deployment.pkg.steps.NativeOrNativeSourcesBuild; -import io.quarkus.deployment.recording.RecorderContext; import io.quarkus.deployment.util.JandexUtil; import io.quarkus.dev.console.CurrentAppExceptionHighlighter; import io.quarkus.dev.spi.DevModeType; @@ -130,10 +131,14 @@ import io.quarkus.runtime.logging.InheritableLevel; import io.quarkus.runtime.logging.LogBuildTimeConfig; import io.quarkus.runtime.logging.LogBuildTimeConfig.CategoryBuildTimeConfig; +import io.quarkus.runtime.logging.LogCleanupFilter; import io.quarkus.runtime.logging.LogCleanupFilterElement; import io.quarkus.runtime.logging.LogFilterFactory; import io.quarkus.runtime.logging.LogMetricsHandlerRecorder; import io.quarkus.runtime.logging.LogRuntimeConfig; +import io.quarkus.runtime.logging.LoggingCompatBridge; +import io.quarkus.runtime.logging.LoggingCompatRecorder; +import io.quarkus.runtime.logging.LoggingSetup; import io.quarkus.runtime.logging.LoggingSetupRecorder; import io.quarkus.runtime.logging.NamedHandlerType; import io.smallrye.config.SmallRyeConfig; @@ -248,13 +253,12 @@ void miscSetup( @BuildStep @Record(ExecutionTime.RUNTIME_INIT) LoggingSetupBuildItem setupLoggingRuntimeInit( - final RecorderContext context, - final LoggingSetupRecorder recorder, + final LoggingCompatRecorder compat, + final ServiceRegistrar reg, final CombinedIndexBuildItem combinedIndexBuildItem, final LogCategoryMinLevelDefaultsBuildItem categoryMinLevelDefaults, final Optional streamingLogStreamHandlerBuildItem, final List handlerBuildItems, - final List namedHandlerBuildItems, final List consoleFormatItems, final List fileFormatItems, final List syslogFormatItems, @@ -262,55 +266,24 @@ LoggingSetupBuildItem setupLoggingRuntimeInit( final List namedHandlerFormatItems, final Optional possibleBannerBuildItem, final List logStreamBuildItems, - final BuildProducer shutdownListenerBuildItemBuildProducer, final LaunchModeBuildItem launchModeBuildItem, final List logCleanupFilters, final BuildProducer reflectiveClassBuildItemBuildProducer, final BuildProducer serviceProviderBuildItemBuildProducer) { if (!launchModeBuildItem.isAuxiliaryApplication() || launchModeBuildItem.getAuxiliaryDevModeType().orElse(null) == DevModeType.TEST_ONLY) { - final List>> handlers = handlerBuildItems.stream() - .map(LogHandlerBuildItem::getHandlerValue) - .collect(Collectors.toList()); - final List>> namedHandlers = namedHandlerBuildItems.stream() - .map(NamedLogHandlersBuildItem::getNamedHandlersMap).collect(Collectors.toList()); - - ConsoleFormatterBannerBuildItem bannerBuildItem = null; - RuntimeValue>> possibleSupplier = null; - if (possibleBannerBuildItem.isPresent()) { - bannerBuildItem = possibleBannerBuildItem.get(); - } - if (bannerBuildItem != null) { - possibleSupplier = bannerBuildItem.getBannerSupplier(); - } - - // New Dev UI Log Stream - RuntimeValue> streamingDevUiLogHandler = null; - if (streamingLogStreamHandlerBuildItem.isPresent()) { - streamingDevUiLogHandler = streamingLogStreamHandlerBuildItem.get().getHandlerValue(); - } - - boolean alwaysEnableLogStream = false; - if (!logStreamBuildItems.isEmpty()) { - alwaysEnableLogStream = true; - } - - List>> possibleConsoleFormatters = consoleFormatItems.stream() - .map(LogConsoleFormatBuildItem::getFormatterValue).collect(Collectors.toList()); - List>> possibleFileFormatters = fileFormatItems.stream() - .map(LogFileFormatBuildItem::getFormatterValue).collect(Collectors.toList()); - List>> possibleSyslogFormatters = syslogFormatItems.stream() - .map(LogSyslogFormatBuildItem::getFormatterValue).collect(Collectors.toList()); - List>> possibleSocketFormatters = socketFormatItems.stream() - .map(LogSocketFormatBuildItem::getFormatterValue).collect(Collectors.toList()); - - List>>>> namedHandlerFormatters = namedHandlerFormatItems - .stream() - .map(LogNamedHandlerFormatBuildItem::getNamedFormattersValue).collect(Collectors.toList()); - - context.registerSubstitution(InheritableLevel.ActualLevel.class, String.class, InheritableLevel.Substitution.class); - context.registerSubstitution(InheritableLevel.Inherited.class, String.class, InheritableLevel.Substitution.class); + final LaunchMode launchMode = launchModeBuildItem.getLaunchMode(); + final boolean includeFilters = true; + final boolean alwaysEnableLogStream = !logStreamBuildItems.isEmpty(); + // Captured by the finalizer service. BUILD_AND_RUN_TIME_FIXED config mappings are capturable, and + // InheritableLevel is Constable, so the min-level defaults map can be captured directly once copied + // into an immutable map. + final LogBuildTimeConfig buildTimeConfig = ConfigProvider.getConfig().unwrap(SmallRyeConfig.class) + .getConfigMapping(LogBuildTimeConfig.class); + final Map categoryDefaultMinLevels = Map.copyOf(categoryMinLevelDefaults.content); + + // ── @LoggingFilter named-filter services ── DiscoveredLogComponents discoveredLogComponents = discoverLogComponents(combinedIndexBuildItem.getIndex()); if (!discoveredLogComponents.getNameToFilterClass().isEmpty()) { reflectiveClassBuildItemBuildProducer.produce( @@ -318,16 +291,165 @@ LoggingSetupBuildItem setupLoggingRuntimeInit( EMPTY_STRING_ARRAY)).reason(getClass().getName()).build()); serviceProviderBuildItemBuildProducer .produce(ServiceProviderBuildItem.allProvidersFromClassPath(LogFilterFactory.class.getName())); + discoveredLogComponents.getNameToFilterClass().forEach((name, className) -> reg + .forService(Filter.class, name) + .atPhase(Phase.LOGGING) + .onStart(ctx -> { + try { + return LogFilterFactory.load().create(className); + } catch (Exception e) { + throw new RuntimeException("Unable to create instance of Logging Filter '" + className + "'", + e); + } + })); } - shutdownListenerBuildItemBuildProducer.produce(new ShutdownListenerBuildItem( - recorder.initializeLogging(discoveredLogComponents, - categoryMinLevelDefaults.content, alwaysEnableLogStream, - streamingDevUiLogHandler, handlers, namedHandlers, - possibleConsoleFormatters, possibleFileFormatters, possibleSyslogFormatters, - possibleSocketFormatters, namedHandlerFormatters, - possibleSupplier, launchModeBuildItem.getLaunchMode(), true))); + // ── shutdown notifier + shared cleanup filter ── + reg.forService(LoggingSetup.ShutdownNotifier.class) + .atPhase(Phase.LOGGING) + .onStart(ctx -> { + LoggingSetup.ShutdownNotifier notifier = new LoggingSetup.ShutdownNotifier(); + ctx.onStop(notifier::markShutdown); + return notifier; + }); + reg.forService(LogCleanupFilter.class) + .atPhase(Phase.LOGGING) + .require(LoggingSetup.ShutdownNotifier.class) + .require(LogRuntimeConfig.class) + .onStart((ctx, notifier, config) -> LoggingSetup.buildCleanupFilter(config, notifier)); + + // ── RECORDER COMPAT: bridge extension formatter build items to Formatter services ── + if (!consoleFormatItems.isEmpty()) { + reg.aliasRecorderValue(Formatter.class, "console", compat.resolveFormatter(consoleFormatItems.stream() + .map(LogConsoleFormatBuildItem::getFormatterValue).collect(Collectors.toList())), Phase.LOGGING); + } + if (!fileFormatItems.isEmpty()) { + reg.aliasRecorderValue(Formatter.class, "file", compat.resolveFormatter(fileFormatItems.stream() + .map(LogFileFormatBuildItem::getFormatterValue).collect(Collectors.toList())), Phase.LOGGING); + } + if (!syslogFormatItems.isEmpty()) { + reg.aliasRecorderValue(Formatter.class, "syslog", compat.resolveFormatter(syslogFormatItems.stream() + .map(LogSyslogFormatBuildItem::getFormatterValue).collect(Collectors.toList())), Phase.LOGGING); + } + if (!socketFormatItems.isEmpty()) { + reg.aliasRecorderValue(Formatter.class, "socket", compat.resolveFormatter(socketFormatItems.stream() + .map(LogSocketFormatBuildItem::getFormatterValue).collect(Collectors.toList())), Phase.LOGGING); + } + // ── RECORDER COMPAT: bundle remaining extension contributions into a single bridge holder ── + RuntimeValue> streamingRv = streamingLogStreamHandlerBuildItem + .map(StreamingLogHandlerBuildItem::getHandlerValue).orElse(null); + RuntimeValue>> bannerRv = possibleBannerBuildItem + .map(ConsoleFormatterBannerBuildItem::getBannerSupplier).orElse(null); + List>>>> namedFormatterValues = namedHandlerFormatItems + .stream().map(LogNamedHandlerFormatBuildItem::getNamedFormattersValue).collect(Collectors.toList()); + List>> additionalHandlerValues = handlerBuildItems.stream() + .map(LogHandlerBuildItem::getHandlerValue).collect(Collectors.toList()); + reg.aliasRecorderValue(LoggingCompatBridge.class, + compat.buildBridge(streamingRv, bannerRv, namedFormatterValues, additionalHandlerValues), Phase.LOGGING); + + // ── console: raw build (exposes the local error manager), shared error manager, wrapped handler ── + reg.forService(LoggingSetup.ConsoleHandlerResult.class) + .atPhase(Phase.LOGGING) + .require(LogRuntimeConfig.class) + .require(ConsoleRuntimeConfig.class) + .require(LogCleanupFilter.class) + .require(LoggingCompatBridge.class) + .consumeAll(Filter.class) + .request(Formatter.class, "console") + .optional() + .onStart((ctx, config, consoleRt, cleanup, bridge, namedFilters, consoleFmt) -> { + if (!config.console().enabled()) { + return Optional.empty(); + } + Supplier bannerSupplier = bridge.banner() == null ? null : bridge.banner().supplier(); + return Optional.of(LoggingSetup.buildConsoleHandler(config.console(), consoleRt, + new OnlyOnceErrorManager(), cleanup, namedFilters, consoleFmt.orElse(null), bannerSupplier, + includeFilters)); + }); + reg.forService(ErrorManager.class) + .atPhase(Phase.LOGGING) + .request(LoggingSetup.ConsoleHandlerResult.class) + .onStart((ctx, consoleResult) -> consoleResult + .map(result -> result.handler().getLocalErrorManager()) + .orElseGet(LoggingSetup::discardingErrorManager)); + reg.forService(Handler.class, "console") + .atPhase(Phase.LOGGING) + .require(LogRuntimeConfig.class) + .request(LoggingSetup.ConsoleHandlerResult.class) + .optional() + .onStart((ctx, config, consoleResult) -> consoleResult + .map(result -> LoggingSetup.wrapConsoleHandler(result, config.console(), launchMode))); + + // ── file / syslog / socket root handler services ── + reg.forService(Handler.class, "file") + .atPhase(Phase.LOGGING) + .require(LogRuntimeConfig.class) + .require(ErrorManager.class) + .require(LogCleanupFilter.class) + .consumeAll(Filter.class) + .request(Formatter.class, "file") + .optional() + .onStart((ctx, config, errorManager, cleanup, namedFilters, fmt) -> config.file().enabled() + ? Optional.ofNullable(LoggingSetup.configureFileHandler(config.file(), errorManager, cleanup, + namedFilters, fmt.orElse(null), includeFilters)) + : Optional.empty()); + reg.forService(Handler.class, "syslog") + .atPhase(Phase.LOGGING) + .require(LogRuntimeConfig.class) + .require(ErrorManager.class) + .require(LogCleanupFilter.class) + .consumeAll(Filter.class) + .request(Formatter.class, "syslog") + .optional() + .onStart((ctx, config, errorManager, cleanup, namedFilters, fmt) -> config.syslog().enabled() + ? Optional.ofNullable(LoggingSetup.configureSyslogHandler(config.syslog(), errorManager, cleanup, + namedFilters, fmt.orElse(null), includeFilters)) + : Optional.empty()); + reg.forService(Handler.class, "socket") + .atPhase(Phase.LOGGING) + .require(LogRuntimeConfig.class) + .require(ErrorManager.class) + .require(LogCleanupFilter.class) + .consumeAll(Filter.class) + .request(Formatter.class, "socket") + .optional() + .onStart((ctx, config, errorManager, cleanup, namedFilters, fmt) -> config.socket().enabled() + ? Optional.ofNullable(LoggingSetup.configureSocketHandler(config.socket(), errorManager, cleanup, + namedFilters, fmt.orElse(null), includeFilters)) + : Optional.empty()); + + // ── dev/test exception-reporting handler ── + if (launchMode.isDevOrTest()) { + reg.forService(Handler.class, "dev-exception-reporting") + .atPhase(Phase.LOGGING) + .onStart(ctx -> LoggingSetup.createExceptionReportingHandler()); + } + + // ── finalizer: whole-application logging configuration ── + reg.forService("io.quarkus.logging.configuration") + .atPhase(Phase.LOGGING) + .consumeAll(Handler.class) + .consumeAll(Filter.class) + .request(Formatter.class, "console") + .request(Formatter.class, "file") + .request(Formatter.class, "syslog") + .request(Formatter.class, "socket") + .require(LoggingCompatBridge.class) + .require(LoggingSetup.ShutdownNotifier.class) + .require(LogCleanupFilter.class) + .require(ErrorManager.class) + .require(LogRuntimeConfig.class) + .require(ConsoleRuntimeConfig.class) + .onStart((ctx, handlers, namedFilters, consoleFmt, fileFmt, syslogFmt, socketFmt, bridge, notifier, + cleanup, errorManager, config, consoleRt) -> LoggingSetup.configureRuntimeLogging(buildTimeConfig, + config, consoleRt, categoryDefaultMinLevels, notifier, cleanup, errorManager, namedFilters, + handlers.values(), bridge.additionalHandlers(), bridge.streaming(), + consoleFmt.orElse(null), fileFmt.orElse(null), syslogFmt.orElse(null), + socketFmt.orElse(null), bridge.namedHandlerFormatters(), bridge.banner(), launchMode, + alwaysEnableLogStream, includeFilters)); + + // ── build-time logging (runs now, during the deployment build) ── List additionalLogCleanupFilters = new ArrayList<>(logCleanupFilters.size()); for (LogCleanupFilterBuildItem i : logCleanupFilters) { LogCleanupFilterElement filterElement = i.getFilterElement(); @@ -337,12 +459,10 @@ LoggingSetupBuildItem setupLoggingRuntimeInit( : filterElement.getTargetLevel(), filterElement.getMessageStarts())); } - SmallRyeConfig config = ConfigProvider.getConfig().unwrap(SmallRyeConfig.class); LogBuildTimeConfig logBuildTimeConfig = config.getConfigMapping(LogBuildTimeConfig.class); LogRuntimeConfig logRuntimeConfigInBuild = config.getConfigMapping(LogRuntimeConfig.class); ConsoleRuntimeConfig consoleRuntimeConfig = config.getConfigMapping(ConsoleRuntimeConfig.class); - initializeBuildTimeLogging(logRuntimeConfigInBuild, logBuildTimeConfig, consoleRuntimeConfig, categoryMinLevelDefaults.content, additionalLogCleanupFilters, launchModeBuildItem.getLaunchMode()); // Build time logging is terminated before the application is started, after dev services are started. @@ -581,7 +701,7 @@ private static boolean allRootMinLevelOrHigher( Set allConfiguredCategoryNames = new LinkedHashSet<>(categories.keySet()); allConfiguredCategoryNames.addAll(categoryMinLevelDefaults.keySet()); for (String categoryName : allConfiguredCategoryNames) { - InheritableLevel categoryMinLevel = LoggingSetupRecorder.getLogLevelNoInheritance(categoryName, categories, + InheritableLevel categoryMinLevel = LoggingSetup.getLogLevelNoInheritance(categoryName, categories, CategoryBuildTimeConfig::minLevel, categoryMinLevelDefaults); if (!categoryMinLevel.isInherited() && categoryMinLevel.getLevel().intValue() < rootMinLogLevel) { return false; @@ -606,7 +726,7 @@ private static void generateMinLevelCompute(Map mc.body(b0 -> { for (Map.Entry entry : categories.entrySet()) { final String category = entry.getKey(); - final int categoryLevelIntValue = LoggingSetupRecorder + final int categoryLevelIntValue = LoggingSetup .getLogLevel(category, categories, CategoryBuildTimeConfig::minLevel, categoryMinLevelDefaults, rootMinLevel) diff --git a/core/runtime/src/main/java/io/quarkus/runtime/logging/AdditionalLogHandler.java b/core/runtime/src/main/java/io/quarkus/runtime/logging/AdditionalLogHandler.java new file mode 100644 index 0000000000000..3ab452c781683 --- /dev/null +++ b/core/runtime/src/main/java/io/quarkus/runtime/logging/AdditionalLogHandler.java @@ -0,0 +1,17 @@ +package io.quarkus.runtime.logging; + +import java.util.logging.Handler; + +/** + * A wrapper carrying an extension-contributed root log handler as a service value. + *

+ * RECORDER COMPAT: this type exists only to bridge legacy {@code LogHandlerBuildItem} contributions (which + * carry a {@code RuntimeValue>}) into the logging service graph. The logging configuration + * service consumes all services of this type, installs the shared error manager and cleanup filter on each, + * and attaches them to the root logger. It can be removed once all handler-contributing extensions produce + * {@link Handler} services directly. + * + * @param handler the contributed handler (must not be {@code null}) + */ +public record AdditionalLogHandler(Handler handler) { +} diff --git a/core/runtime/src/main/java/io/quarkus/runtime/logging/InheritableLevel.java b/core/runtime/src/main/java/io/quarkus/runtime/logging/InheritableLevel.java index 57dfc2bcc8082..f3592e96110b8 100644 --- a/core/runtime/src/main/java/io/quarkus/runtime/logging/InheritableLevel.java +++ b/core/runtime/src/main/java/io/quarkus/runtime/logging/InheritableLevel.java @@ -1,7 +1,16 @@ package io.quarkus.runtime.logging; +import java.lang.constant.ClassDesc; +import java.lang.constant.Constable; +import java.lang.constant.ConstantDesc; +import java.lang.constant.ConstantDescs; +import java.lang.constant.DirectMethodHandleDesc; +import java.lang.constant.DynamicConstantDesc; +import java.lang.constant.MethodHandleDesc; +import java.lang.constant.MethodTypeDesc; import java.util.Locale; import java.util.NoSuchElementException; +import java.util.Optional; import java.util.logging.Level; import org.jboss.logmanager.LogContext; @@ -11,7 +20,16 @@ /** * A level that may be inheritable. */ -public abstract class InheritableLevel { +public abstract class InheritableLevel implements Constable { + + /** The descriptor of this class, used to describe instances as dynamic constants. */ + private static final ClassDesc CD_INHERITABLE_LEVEL = ClassDesc.of("io.quarkus.runtime.logging.InheritableLevel"); + + /** A method handle descriptor for {@link #of(String)}, used to reconstruct instances as dynamic constants. */ + private static final DirectMethodHandleDesc OF_STRING = MethodHandleDesc.ofMethod( + DirectMethodHandleDesc.Kind.STATIC, CD_INHERITABLE_LEVEL, "of", + MethodTypeDesc.of(CD_INHERITABLE_LEVEL, ConstantDescs.CD_String)); + InheritableLevel() { } @@ -41,6 +59,22 @@ public final boolean equals(Object obj) { public abstract int hashCode(); + /** + * {@return a description of this level as a dynamic constant} The constant reconstructs the level at + * resolution time by invoking {@link #of(String)} with {@link #describeArg()}, which allows an + * {@code InheritableLevel} to be captured directly (for example, in a service action). + */ + @Override + public final Optional describeConstable() { + return Optional.of(DynamicConstantDesc.ofNamed(ConstantDescs.BSM_INVOKE, "level", CD_INHERITABLE_LEVEL, + OF_STRING, describeArg())); + } + + /** + * {@return the string that, when passed to {@link #of(String)}, reconstructs this level} + */ + abstract String describeArg(); + public static final class ActualLevel extends InheritableLevel { final Level level; @@ -60,6 +94,11 @@ public String toString() { return level.toString(); } + @Override + String describeArg() { + return level.toString(); + } + public boolean equals(final InheritableLevel other) { return other instanceof ActualLevel && level.equals(((ActualLevel) other).level); } @@ -87,6 +126,11 @@ public String toString() { return "inherited"; } + @Override + String describeArg() { + return "inherit"; + } + public boolean equals(final InheritableLevel other) { return other instanceof Inherited; } diff --git a/core/runtime/src/main/java/io/quarkus/runtime/logging/LogBanner.java b/core/runtime/src/main/java/io/quarkus/runtime/logging/LogBanner.java new file mode 100644 index 0000000000000..5822a8a543d6e --- /dev/null +++ b/core/runtime/src/main/java/io/quarkus/runtime/logging/LogBanner.java @@ -0,0 +1,26 @@ +package io.quarkus.runtime.logging; + +import java.util.function.Supplier; + +import io.smallrye.common.constraint.Assert; + +/** + * A wrapper carrying the console banner supplier as a service value. + *

+ * This exists so that the banner can flow through the logging service dependency graph as a service of a + * dedicated type, rather than as a service typed directly on {@link Supplier} (which is too generic to use as + * a service key). + * + * @param supplier the banner text supplier (must not be {@code null}) + */ +public record LogBanner(Supplier supplier) { + + /** + * Construct a new instance. + * + * @param supplier the banner text supplier (must not be {@code null}) + */ + public LogBanner { + Assert.checkNotNullParam("supplier", supplier); + } +} diff --git a/core/runtime/src/main/java/io/quarkus/runtime/logging/LogCleanupFilter.java b/core/runtime/src/main/java/io/quarkus/runtime/logging/LogCleanupFilter.java index f22039c0d37b6..5cf56ec470b49 100644 --- a/core/runtime/src/main/java/io/quarkus/runtime/logging/LogCleanupFilter.java +++ b/core/runtime/src/main/java/io/quarkus/runtime/logging/LogCleanupFilter.java @@ -15,10 +15,10 @@ public class LogCleanupFilter implements Filter { final Map filterElements = new HashMap<>(); public static final String SHUTDOWN_MESSAGE = " [Error Occurred After Shutdown]"; - private final LoggingSetupRecorder.ShutdownNotifier shutdownNotifier; + private final LoggingSetup.ShutdownNotifier shutdownNotifier; public LogCleanupFilter(Collection filterElements, - LoggingSetupRecorder.ShutdownNotifier shutdownNotifier) { + LoggingSetup.ShutdownNotifier shutdownNotifier) { this.shutdownNotifier = shutdownNotifier; for (LogCleanupFilterElement element : filterElements) { this.filterElements.put(element.getLoggerName(), element); diff --git a/core/runtime/src/main/java/io/quarkus/runtime/logging/LoggingCompatBridge.java b/core/runtime/src/main/java/io/quarkus/runtime/logging/LoggingCompatBridge.java new file mode 100644 index 0000000000000..2bd8b570f757b --- /dev/null +++ b/core/runtime/src/main/java/io/quarkus/runtime/logging/LoggingCompatBridge.java @@ -0,0 +1,52 @@ +package io.quarkus.runtime.logging; + +import java.util.List; + +/** + * A holder bundling the extension-contributed logging inputs into a single service value. + *

+ * RECORDER COMPAT: this type exists only to carry legacy build-item contributions (the Dev UI streaming + * handler, the console banner, the per-named-handler formatters, and additional root handlers) into the + * logging configuration service as a single dependency during the migration period. Once the contributing + * extensions produce services directly, this holder and its consumers can be removed. + *

+ * It is an interface (rather than a record) so that it can be produced by a recorder method and published as a + * service value via {@code ActionBuilder.aliasRecorderValue()}, which requires a proxyable type. + */ +public interface LoggingCompatBridge { + + /** + * {@return the Dev UI streaming handler, or {@code null} if none} + */ + StreamingLogHandler streaming(); + + /** + * {@return the console banner, or {@code null} if none} + */ + LogBanner banner(); + + /** + * {@return the per-named-handler formatters, or {@code null} if none} + */ + NamedHandlerFormatters namedHandlerFormatters(); + + /** + * {@return the extension-contributed root handlers (never {@code null}, may be empty)} + */ + List additionalHandlers(); + + /** + * The default implementation of {@link LoggingCompatBridge}. + * + * @param streaming the Dev UI streaming handler, or {@code null} if none + * @param banner the console banner, or {@code null} if none + * @param namedHandlerFormatters the per-named-handler formatters, or {@code null} if none + * @param additionalHandlers the extension-contributed root handlers (never {@code null}, may be empty) + */ + record Impl( + StreamingLogHandler streaming, + LogBanner banner, + NamedHandlerFormatters namedHandlerFormatters, + List additionalHandlers) implements LoggingCompatBridge { + } +} diff --git a/core/runtime/src/main/java/io/quarkus/runtime/logging/LoggingCompatRecorder.java b/core/runtime/src/main/java/io/quarkus/runtime/logging/LoggingCompatRecorder.java new file mode 100644 index 0000000000000..e4a311806ad75 --- /dev/null +++ b/core/runtime/src/main/java/io/quarkus/runtime/logging/LoggingCompatRecorder.java @@ -0,0 +1,71 @@ +package io.quarkus.runtime.logging; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Supplier; +import java.util.logging.Formatter; +import java.util.logging.Handler; + +import io.quarkus.runtime.RuntimeValue; +import io.quarkus.runtime.annotations.Recorder; + +/** + * Transitional recorder that unwraps legacy logging build-item values (which are delivered as + * {@link RuntimeValue}-wrapped optionals and maps) so that they can be published into the logging service + * graph via {@code ActionBuilder.aliasRecorderValue()}. + *

+ * RECORDER COMPAT: this entire recorder exists only to bridge extension-contributed logging build items into + * the service scheme during the migration period. Each method here can be removed once the corresponding + * contributing extensions produce services directly. + */ +@Recorder +public class LoggingCompatRecorder { + + /** + * Resolve the effective formatter from a list of extension-contributed candidate formatters, using the + * last present one (matching the legacy behavior). Returns {@code null} if none is present. + * + * @param possibleFormatters the candidate formatters (must not be {@code null}) + * @return the resolved formatter, or {@code null} if none is present + */ + public Formatter resolveFormatter(List>> possibleFormatters) { + Formatter formatter = null; + for (RuntimeValue> value : possibleFormatters) { + Optional val = value.getValue(); + if (val.isPresent()) { + formatter = val.get(); + } + } + return formatter; + } + + /** + * Bundle the extension-contributed logging inputs into a single {@link LoggingCompatBridge} holder, + * unwrapping the {@link RuntimeValue}-wrapped values. + * + * @param streamingHandler the Dev UI streaming handler value, or {@code null} if none + * @param banner the console banner supplier value, or {@code null} if none + * @param namedHandlerFormatters the contributed named-handler formatter maps (must not be {@code null}) + * @param additionalHandlers the contributed root handler values (must not be {@code null}) + * @return the bundled bridge holder (never {@code null}) + */ + public LoggingCompatBridge buildBridge( + RuntimeValue> streamingHandler, + RuntimeValue>> banner, + List>>>> namedHandlerFormatters, + List>> additionalHandlers) { + StreamingLogHandler streaming = streamingHandler == null ? null + : streamingHandler.getValue().map(StreamingLogHandler::new).orElse(null); + LogBanner logBanner = banner == null ? null + : banner.getValue().map(LogBanner::new).orElse(null); + NamedHandlerFormatters formatters = namedHandlerFormatters.isEmpty() ? null + : new NamedHandlerFormatters(LoggingSetup.mergeNamedHandlerFormatters(namedHandlerFormatters)); + List additional = new ArrayList<>(additionalHandlers.size()); + for (RuntimeValue> handler : additionalHandlers) { + handler.getValue().ifPresent(h -> additional.add(new AdditionalLogHandler(h))); + } + return new LoggingCompatBridge.Impl(streaming, logBanner, formatters, List.copyOf(additional)); + } +} diff --git a/core/runtime/src/main/java/io/quarkus/runtime/logging/LoggingSetup.java b/core/runtime/src/main/java/io/quarkus/runtime/logging/LoggingSetup.java new file mode 100644 index 0000000000000..ad16f19e12356 --- /dev/null +++ b/core/runtime/src/main/java/io/quarkus/runtime/logging/LoggingSetup.java @@ -0,0 +1,1266 @@ +package io.quarkus.runtime.logging; + +import static io.smallrye.common.net.HostName.getQualifiedHostName; +import static io.smallrye.common.os.Process.getProcessName; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Optional; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.logging.ErrorManager; +import java.util.logging.Filter; +import java.util.logging.Formatter; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogManager; +import java.util.logging.LogRecord; + +import org.jboss.logmanager.ExtFormatter; +import org.jboss.logmanager.ExtHandler; +import org.jboss.logmanager.ExtLogRecord; +import org.jboss.logmanager.LogContext; +import org.jboss.logmanager.LogContextInitializer; +import org.jboss.logmanager.Logger; +import org.jboss.logmanager.filters.AllFilter; +import org.jboss.logmanager.formatters.ColorPatternFormatter; +import org.jboss.logmanager.formatters.PatternFormatter; +import org.jboss.logmanager.formatters.TextBannerFormatter; +import org.jboss.logmanager.handlers.AsyncHandler; +import org.jboss.logmanager.handlers.ConsoleHandler; +import org.jboss.logmanager.handlers.FileHandler; +import org.jboss.logmanager.handlers.PeriodicSizeRotatingFileHandler; +import org.jboss.logmanager.handlers.SizeRotatingFileHandler; +import org.jboss.logmanager.handlers.SocketHandler; +import org.jboss.logmanager.handlers.SyslogHandler; + +import io.quarkus.bootstrap.logging.InitialConfigurator; +import io.quarkus.dev.console.CurrentAppExceptionHighlighter; +import io.quarkus.dev.console.TerminalUtils; +import io.quarkus.dev.testing.ExceptionReporting; +import io.quarkus.runtime.LaunchMode; +import io.quarkus.runtime.RuntimeValue; +import io.quarkus.runtime.configuration.MemorySize; +import io.quarkus.runtime.console.ConsoleRuntimeConfig; +import io.quarkus.runtime.logging.LogBuildTimeConfig.CategoryBuildTimeConfig; +import io.quarkus.runtime.logging.LogRuntimeConfig.CategoryConfig; +import io.quarkus.runtime.logging.LogRuntimeConfig.ConsoleConfig; +import io.quarkus.runtime.logging.LogRuntimeConfig.FileConfig; +import io.quarkus.runtime.logging.LogRuntimeConfig.SocketConfig; +import io.quarkus.runtime.shutdown.ShutdownListener; + +/** + * Construction helpers for the individual {@link Handler}s used by the Quarkus logging subsystem. + *

+ * These helpers are shared between the runtime logging service actions, the build-time logging + * initialization path, and the legacy {@link LoggingSetupRecorder}. Each helper is a pure factory: it + * builds and configures a handler from the given configuration and collaborators, and never mutates any + * shared global state. + *

+ * The primary handler factory methods take a fully resolved {@link Formatter} (which may be {@code null} to + * request the default formatter for the handler type). The overloads that accept a list of + * {@link RuntimeValue}-wrapped candidate formatters exist only to support the legacy bytecode recorder + * scheme and are marked accordingly. + */ +public final class LoggingSetup { + + private static final org.jboss.logging.Logger log = org.jboss.logging.Logger + .getLogger(LoggingSetup.class.getPackageName()); + + /** + * A shared error manager that silently discards all errors. Used as the default error manager for + * non-console handlers when the console handler is disabled, since in that case there is no reasonable + * path for error reporting. + */ + private static final ErrorManager DISCARDING_ERROR_MANAGER = new ErrorManager() { + @Override + public void error(final String msg, final Exception ex, final int code) { + // discard: there is no reasonable path for error reporting + } + }; + + private LoggingSetup() { + } + + /** + * {@return the shared error manager that silently discards all errors} + * This is used as the default error manager for non-console handlers when the console handler is + * disabled. + */ + public static ErrorManager discardingErrorManager() { + return DISCARDING_ERROR_MANAGER; + } + + /** + * Build and configure a console handler using the given resolved formatter. + * + * @param config the console handler configuration (must not be {@code null}) + * @param consoleRuntimeConfig the console runtime configuration (must not be {@code null}) + * @param defaultErrorManager the error manager to install on the handler (must not be {@code null}) + * @param cleanupFilter the log cleanup filter to apply (must not be {@code null}) + * @param namedFilters the map of named filters, keyed by filter name (must not be {@code null}) + * @param formatter the resolved formatter to use, or {@code null} to build the default formatter + * @param bannerSupplier the banner supplier to prepend to the default formatter, or {@code null} for none + * @param launchMode the current launch mode (must not be {@code null}) + * @param includeFilters {@code true} if named filters should be applied + * @return the configured console handler (not {@code null}) + */ + public static Handler configureConsoleHandler( + final ConsoleConfig config, + final ConsoleRuntimeConfig consoleRuntimeConfig, + final ErrorManager defaultErrorManager, + final LogCleanupFilter cleanupFilter, + final Map namedFilters, + Formatter formatter, + final Supplier bannerSupplier, + LaunchMode launchMode, + boolean includeFilters) { + ConsoleHandlerResult result = buildConsoleHandler(config, consoleRuntimeConfig, defaultErrorManager, cleanupFilter, + namedFilters, formatter, bannerSupplier, includeFilters); + return wrapConsoleHandler(result, config, launchMode); + } + + /** + * The result of building a raw console handler: the handler itself and whether color output is enabled + * (which governs whether the dev/test exception-highlighting wrapper is applied by + * {@link #wrapConsoleHandler(ConsoleHandlerResult, ConsoleConfig, LaunchMode)}). + * + * @param handler the raw console handler (never {@code null}) + * @param color {@code true} if color output is enabled for the handler + */ + public record ConsoleHandlerResult(ConsoleHandler handler, boolean color) { + } + + /** + * Build a raw console handler, without any async or color-highlighting wrapping. + *

+ * The returned {@link ConsoleHandler} exposes {@link ConsoleHandler#getLocalErrorManager()}, which is used + * as the default error manager for other handlers. Apply async and dev-mode wrapping via + * {@link #wrapConsoleHandler(ConsoleHandlerResult, ConsoleConfig, LaunchMode)} before attaching the handler + * to a logger. + * + * @param config the console handler configuration (must not be {@code null}) + * @param consoleRuntimeConfig the console runtime configuration (must not be {@code null}) + * @param errorManager the error manager to install on the handler (must not be {@code null}) + * @param cleanupFilter the log cleanup filter to apply (must not be {@code null}) + * @param namedFilters the map of named filters, keyed by filter name (must not be {@code null}) + * @param formatter the resolved formatter to use, or {@code null} to build the default formatter + * @param bannerSupplier the banner supplier to prepend to the default formatter, or {@code null} for none + * @param includeFilters {@code true} if named filters should be applied + * @return the raw console handler and its color flag (not {@code null}) + */ + public static ConsoleHandlerResult buildConsoleHandler( + final ConsoleConfig config, + final ConsoleRuntimeConfig consoleRuntimeConfig, + final ErrorManager errorManager, + final LogCleanupFilter cleanupFilter, + final Map namedFilters, + Formatter formatter, + final Supplier bannerSupplier, + boolean includeFilters) { + boolean color = false; + if (formatter == null) { + if (isColorEnabled(consoleRuntimeConfig, config)) { + formatter = new ColorPatternFormatter(config.darken(), config.format()); + color = true; + } else { + formatter = new PatternFormatter(config.format()); + } + if (bannerSupplier != null) { + formatter = new TextBannerFormatter(bannerSupplier, ExtFormatter.wrap(formatter, false)); + } + } + final ConsoleHandler consoleHandler = new ConsoleHandler( + config.stderr() ? ConsoleHandler.Target.SYSTEM_ERR : ConsoleHandler.Target.SYSTEM_OUT, formatter); + consoleHandler.setLevel(config.level()); + consoleHandler.setErrorManager(errorManager); + applyFilter(includeFilters, errorManager, cleanupFilter, config.filter(), namedFilters, consoleHandler); + return new ConsoleHandlerResult(consoleHandler, color); + } + + /** + * Wrap a raw console handler with async and/or dev-mode color-highlighting wrapping, as configured. + * + * @param result the raw console handler and its color flag (must not be {@code null}) + * @param config the console handler configuration (must not be {@code null}) + * @param launchMode the current launch mode (must not be {@code null}) + * @return the wrapped console handler ready to attach to a logger (not {@code null}) + */ + public static Handler wrapConsoleHandler(final ConsoleHandlerResult result, final ConsoleConfig config, + final LaunchMode launchMode) { + final ConsoleHandler consoleHandler = result.handler(); + boolean asyncEnabled = config.async().enabled(); + + Handler handler = asyncEnabled + ? createAsyncHandler(config.async(), config.level(), consoleHandler) + : consoleHandler; + + if (result.color() && launchMode.isDevOrTest() && !asyncEnabled) { + final Handler delegate = handler; + handler = new ExtHandler() { + @Override + protected void doPublish(ExtLogRecord record) { + BiConsumer> formatter = CurrentAppExceptionHighlighter.THROWABLE_FORMATTER; + if (formatter != null) { + formatter.accept(record, delegate::publish); + } else { + delegate.publish(record); + } + } + + @Override + public void flush() { + delegate.flush(); + } + + @Override + public void close() throws SecurityException { + delegate.close(); + } + }; + } + + return handler; + } + + /** + * Build and configure a console handler from legacy recorder-contributed candidate formatters. + *

+ * RECORDER COMPAT: resolves the candidate formatter/banner {@link RuntimeValue}s (warning if more than + * one formatter was contributed) and then delegates to + * {@link #configureConsoleHandler(ConsoleConfig, ConsoleRuntimeConfig, ErrorManager, LogCleanupFilter, Map, Formatter, Supplier, LaunchMode, boolean)}. + * + * @param config the console handler configuration (must not be {@code null}) + * @param consoleRuntimeConfig the console runtime configuration (must not be {@code null}) + * @param defaultErrorManager the error manager to install on the handler (must not be {@code null}) + * @param cleanupFilter the log cleanup filter to apply (must not be {@code null}) + * @param namedFilters the map of named filters, keyed by filter name (must not be {@code null}) + * @param possibleFormatters the candidate formatters contributed by extensions (must not be {@code null}) + * @param possibleBannerSupplier the optional banner supplier, or {@code null} if none + * @param launchMode the current launch mode (must not be {@code null}) + * @param includeFilters {@code true} if named filters should be applied + * @return the configured console handler (not {@code null}) + */ + public static Handler configureConsoleHandler( + final ConsoleConfig config, + final ConsoleRuntimeConfig consoleRuntimeConfig, + final ErrorManager defaultErrorManager, + final LogCleanupFilter cleanupFilter, + final Map namedFilters, + final List>> possibleFormatters, + final RuntimeValue>> possibleBannerSupplier, + LaunchMode launchMode, + boolean includeFilters) { + FormatterChoice choice = resolveFormatterChoice(possibleFormatters); + Supplier bannerSupplier = null; + if (possibleBannerSupplier != null && possibleBannerSupplier.getValue().isPresent()) { + bannerSupplier = possibleBannerSupplier.getValue().get(); + } + Handler handler = configureConsoleHandler(config, consoleRuntimeConfig, defaultErrorManager, cleanupFilter, + namedFilters, choice.formatter(), bannerSupplier, launchMode, includeFilters); + if (choice.multiple()) { + handler.getErrorManager().error("Multiple console formatters were activated", null, ErrorManager.GENERIC_FAILURE); + } + return handler; + } + + /** + * Build and configure a file handler using the given resolved formatter. + * + * @param config the file handler configuration (must not be {@code null}) + * @param errorManager the error manager to install on the handler (must not be {@code null}) + * @param cleanupFilter the log cleanup filter to apply (must not be {@code null}) + * @param namedFilters the map of named filters, keyed by filter name (must not be {@code null}) + * @param formatter the resolved formatter to use, or {@code null} to build the default formatter + * @param includeFilters {@code true} if named filters should be applied + * @return the configured file handler, possibly wrapped in an async handler (not {@code null}) + */ + public static Handler configureFileHandler(final FileConfig config, final ErrorManager errorManager, + final LogCleanupFilter cleanupFilter, Map namedFilters, + Formatter formatter, + final boolean includeFilters) { + FileHandler handler; + FileConfig.RotationConfig rotationConfig = config.rotation(); + if (!rotationConfig.enabled()) { + handler = new FileHandler(); + } else if (rotationConfig.fileSuffix().isPresent()) { + PeriodicSizeRotatingFileHandler periodicSizeRotatingFileHandler = new PeriodicSizeRotatingFileHandler(); + periodicSizeRotatingFileHandler.setSuffix(rotationConfig.fileSuffix().get()); + periodicSizeRotatingFileHandler.setRotateSize(rotationConfig.maxFileSize().asLongValue()); + periodicSizeRotatingFileHandler.setRotateOnBoot(rotationConfig.rotateOnBoot()); + periodicSizeRotatingFileHandler.setMaxBackupIndex(rotationConfig.maxBackupIndex()); + handler = periodicSizeRotatingFileHandler; + } else { + SizeRotatingFileHandler sizeRotatingFileHandler = new SizeRotatingFileHandler( + rotationConfig.maxFileSize().asLongValue(), rotationConfig.maxBackupIndex()); + sizeRotatingFileHandler.setRotateOnBoot(rotationConfig.rotateOnBoot()); + handler = sizeRotatingFileHandler; + } + + if (formatter == null) { + formatter = new PatternFormatter(config.format()); + } + handler.setFormatter(formatter); + + handler.setAppend(true); + try { + handler.setFile(config.path()); + } catch (FileNotFoundException e) { + errorManager.error("Failed to set log file", e, ErrorManager.OPEN_FAILURE); + } + handler.setErrorManager(errorManager); + handler.setLevel(config.level()); + handler.setFilter(cleanupFilter); + if (config.encoding().isPresent()) { + try { + handler.setEncoding(config.encoding().get().name()); + } catch (UnsupportedEncodingException e) { + errorManager.error("Failed to set character encoding", e, ErrorManager.GENERIC_FAILURE); + } + } + applyFilter(includeFilters, errorManager, cleanupFilter, config.filter(), namedFilters, handler); + + if (config.async().enabled()) { + return createAsyncHandler(config.async(), config.level(), handler); + } + return handler; + } + + /** + * Build and configure a file handler from legacy recorder-contributed candidate formatters. + *

+ * RECORDER COMPAT: resolves the candidate formatter {@link RuntimeValue}s (warning if more than one + * formatter was contributed) and then delegates to + * {@link #configureFileHandler(FileConfig, ErrorManager, LogCleanupFilter, Map, Formatter, boolean)}. + * + * @param config the file handler configuration (must not be {@code null}) + * @param errorManager the error manager to install on the handler (must not be {@code null}) + * @param cleanupFilter the log cleanup filter to apply (must not be {@code null}) + * @param namedFilters the map of named filters, keyed by filter name (must not be {@code null}) + * @param possibleFileFormatters the candidate formatters contributed by extensions (must not be {@code null}) + * @param includeFilters {@code true} if named filters should be applied + * @return the configured file handler, possibly wrapped in an async handler (not {@code null}) + */ + public static Handler configureFileHandler(final FileConfig config, final ErrorManager errorManager, + final LogCleanupFilter cleanupFilter, Map namedFilters, + final List>> possibleFileFormatters, + final boolean includeFilters) { + FormatterChoice choice = resolveFormatterChoice(possibleFileFormatters); + Handler handler = configureFileHandler(config, errorManager, cleanupFilter, namedFilters, choice.formatter(), + includeFilters); + if (choice.multiple()) { + handler.getErrorManager().error("Multiple file formatters were activated", null, ErrorManager.GENERIC_FAILURE); + } + return handler; + } + + /** + * Build and configure a syslog handler using the given resolved formatter. + * + * @param config the syslog handler configuration (must not be {@code null}) + * @param errorManager the error manager to install on the handler (must not be {@code null}) + * @param logCleanupFilter the log cleanup filter to apply (must not be {@code null}) + * @param namedFilters the map of named filters, keyed by filter name (must not be {@code null}) + * @param formatter the resolved formatter to use, or {@code null} to build the default formatter + * @param includeFilters {@code true} if named filters should be applied + * @return the configured syslog handler, or {@code null} if it could not be created + */ + public static Handler configureSyslogHandler(final LogRuntimeConfig.SyslogConfig config, + final ErrorManager errorManager, + final LogCleanupFilter logCleanupFilter, + final Map namedFilters, + Formatter formatter, + final boolean includeFilters) { + try { + final SyslogHandler handler = new SyslogHandler(config.endpoint().getHostString(), config.endpoint().getPort()); + handler.setAppName(config.appName().orElse(getProcessName())); + handler.setHostname(config.hostname().orElse(getQualifiedHostName())); + handler.setFacility(config.facility()); + handler.setSyslogType(config.syslogType()); + handler.setProtocol(config.protocol()); + handler.setBlockOnReconnect(config.blockOnReconnect()); + handler.setTruncate(config.truncate()); + handler.setUseCountingFraming(switch (config.useCountingFraming()) { + case PROTOCOL_DEPENDENT -> + config.protocol() == SyslogHandler.Protocol.TCP || config.protocol() == SyslogHandler.Protocol.SSL_TCP; + case TRUE -> true; + case FALSE -> false; + }); + handler.setLevel(config.level()); + if (config.maxLength().isPresent()) { + MemorySize maxLen = config.maxLength().get(); + int maxLenInt; + if (maxLen.compareTo(Integer.MAX_VALUE) > 0) { + errorManager.error( + "Using 2GB as the value of maxLength for SyslogHandler as it is the maximum allowed value", null, + ErrorManager.GENERIC_FAILURE); + maxLenInt = Integer.MAX_VALUE; + } else if (maxLen.compareTo(128) < 0) { + errorManager.error( + "Using 128 as the value of maxLength for SyslogHandler as using a smaller value is not allowed", + null, ErrorManager.GENERIC_FAILURE); + maxLenInt = 128; + } else { + maxLenInt = maxLen.asIntValue(); + } + handler.setMaxLength(maxLenInt); + } + + if (formatter == null) { + formatter = new PatternFormatter(config.format()); + } + handler.setFormatter(formatter); + + handler.setErrorManager(errorManager); + handler.setFilter(logCleanupFilter); + applyFilter(includeFilters, errorManager, logCleanupFilter, config.filter(), namedFilters, handler); + + if (config.async().enabled()) { + return createAsyncHandler(config.async(), config.level(), handler); + } + return handler; + } catch (IOException e) { + errorManager.error("Failed to create syslog handler", e, ErrorManager.OPEN_FAILURE); + return null; + } + } + + /** + * Build and configure a syslog handler from legacy recorder-contributed candidate formatters. + *

+ * RECORDER COMPAT: resolves the candidate formatter {@link RuntimeValue}s (warning if more than one + * formatter was contributed) and then delegates to + * {@link #configureSyslogHandler(LogRuntimeConfig.SyslogConfig, ErrorManager, LogCleanupFilter, Map, Formatter, boolean)}. + * + * @param config the syslog handler configuration (must not be {@code null}) + * @param errorManager the error manager to install on the handler (must not be {@code null}) + * @param logCleanupFilter the log cleanup filter to apply (must not be {@code null}) + * @param namedFilters the map of named filters, keyed by filter name (must not be {@code null}) + * @param possibleSyslogFormatters the candidate formatters contributed by extensions (must not be {@code null}) + * @param includeFilters {@code true} if named filters should be applied + * @return the configured syslog handler, or {@code null} if it could not be created + */ + public static Handler configureSyslogHandler(final LogRuntimeConfig.SyslogConfig config, + final ErrorManager errorManager, + final LogCleanupFilter logCleanupFilter, + final Map namedFilters, + final List>> possibleSyslogFormatters, + final boolean includeFilters) { + FormatterChoice choice = resolveFormatterChoice(possibleSyslogFormatters); + Handler handler = configureSyslogHandler(config, errorManager, logCleanupFilter, namedFilters, choice.formatter(), + includeFilters); + if (handler != null && choice.multiple()) { + handler.getErrorManager().error("Multiple syslog formatters were activated", null, ErrorManager.GENERIC_FAILURE); + } + return handler; + } + + /** + * Build and configure a socket handler using the given resolved formatter. + * + * @param config the socket handler configuration (must not be {@code null}) + * @param errorManager the error manager to install on the handler (must not be {@code null}) + * @param logCleanupFilter the log cleanup filter to apply (must not be {@code null}) + * @param namedFilters the map of named filters, keyed by filter name (must not be {@code null}) + * @param formatter the resolved formatter to use, or {@code null} to build the default formatter + * @param includeFilters {@code true} if named filters should be applied + * @return the configured socket handler, or {@code null} if it could not be created + */ + public static Handler configureSocketHandler(final SocketConfig config, + final ErrorManager errorManager, + final LogCleanupFilter logCleanupFilter, + final Map namedFilters, + Formatter formatter, + final boolean includeFilters) { + try { + final SocketHandler handler = new SocketHandler(config.endpoint().getHostString(), config.endpoint().getPort()); + handler.setProtocol(config.protocol()); + handler.setBlockOnReconnect(config.blockOnReconnect()); + handler.setLevel(config.level()); + + if (formatter == null) { + formatter = new PatternFormatter(config.format()); + } + handler.setFormatter(formatter); + + handler.setErrorManager(errorManager); + handler.setFilter(logCleanupFilter); + applyFilter(includeFilters, errorManager, logCleanupFilter, config.filter(), namedFilters, handler); + + if (config.async().enabled()) { + return createAsyncHandler(config.async(), config.level(), handler); + } + return handler; + } catch (IOException e) { + errorManager.error("Failed to create socket handler", e, ErrorManager.OPEN_FAILURE); + return null; + } + } + + /** + * Build and configure a socket handler from legacy recorder-contributed candidate formatters. + *

+ * RECORDER COMPAT: resolves the candidate formatter {@link RuntimeValue}s (warning if more than one + * formatter was contributed) and then delegates to + * {@link #configureSocketHandler(SocketConfig, ErrorManager, LogCleanupFilter, Map, Formatter, boolean)}. + * + * @param config the socket handler configuration (must not be {@code null}) + * @param errorManager the error manager to install on the handler (must not be {@code null}) + * @param logCleanupFilter the log cleanup filter to apply (must not be {@code null}) + * @param namedFilters the map of named filters, keyed by filter name (must not be {@code null}) + * @param possibleSocketFormatters the candidate formatters contributed by extensions (must not be {@code null}) + * @param includeFilters {@code true} if named filters should be applied + * @return the configured socket handler, or {@code null} if it could not be created + */ + public static Handler configureSocketHandler(final SocketConfig config, + final ErrorManager errorManager, + final LogCleanupFilter logCleanupFilter, + final Map namedFilters, + final List>> possibleSocketFormatters, + final boolean includeFilters) { + FormatterChoice choice = resolveFormatterChoice(possibleSocketFormatters); + Handler handler = configureSocketHandler(config, errorManager, logCleanupFilter, namedFilters, choice.formatter(), + includeFilters); + if (handler != null && choice.multiple()) { + handler.getErrorManager().error("Multiple socket formatters were activated", null, ErrorManager.GENERIC_FAILURE); + } + return handler; + } + + /** + * The result of resolving a list of candidate formatters: the selected formatter (the last present one, + * or {@code null} if none) and whether more than one candidate was present. + *

+ * RECORDER COMPAT: this record, and all of its usages, exist only to support the legacy recorder scheme + * where multiple candidate formatters may be contributed for a single handler type. It can be removed + * together with the list-based {@code configure*Handler} overloads once recorders are gone. + * + * @param formatter the selected formatter, or {@code null} if none was present + * @param multiple {@code true} if more than one candidate formatter was present + */ + private record FormatterChoice(Formatter formatter, boolean multiple) { + } + + /** + * Resolve a list of candidate formatters to a single choice. + *

+ * RECORDER COMPAT: this list-based resolution exists only for the legacy recorder scheme, where + * multiple extensions may each contribute a formatter for the same handler type. In the service scheme, + * a formatter is a uniquely named service, so at most one can exist per handler type. + * + * @param possibleFormatters the candidate formatters (must not be {@code null}) + * @return the formatter choice (not {@code null}) + */ + private static FormatterChoice resolveFormatterChoice(final List>> possibleFormatters) { + Formatter formatter = null; + boolean multiple = false; + for (RuntimeValue> value : possibleFormatters) { + if (formatter != null) { + multiple = true; + } + final Optional val = value.getValue(); + if (val.isPresent()) { + formatter = val.get(); + } + } + return new FormatterChoice(formatter, multiple); + } + + /** + * Apply the appropriate filter to the given handler. When named filters are enabled and the handler + * configuration references a named filter, the handler is given a composite filter that applies both the + * cleanup filter and the named filter; otherwise, only the cleanup filter is applied. + * + * @param includeFilters {@code true} if named filters should be applied + * @param errorManager the error manager to report failures to (must not be {@code null}) + * @param cleanupFilter the log cleanup filter to apply (must not be {@code null}) + * @param filterName the optional configured named filter name (must not be {@code null}) + * @param namedFilters the map of named filters, keyed by filter name (must not be {@code null}) + * @param handler the handler to configure (must not be {@code null}) + */ + private static void applyFilter(boolean includeFilters, ErrorManager errorManager, LogCleanupFilter cleanupFilter, + Optional filterName, Map namedFilters, Handler handler) { + if (filterName.isEmpty() || !includeFilters) { + handler.setFilter(cleanupFilter); + } else { + String name = filterName.get(); + Filter filter = namedFilters.get(name); + if (filter == null) { + errorManager.error("Unable to find named filter '" + name + "'", null, ErrorManager.GENERIC_FAILURE); + handler.setFilter(cleanupFilter); + } else { + handler.setFilter(new AllFilter(List.of(cleanupFilter, filter))); + } + } + } + + /** + * Wrap the given handler in an asynchronous handler. + * + * @param asyncConfig the async configuration (must not be {@code null}) + * @param level the level to set on the async handler (must not be {@code null}) + * @param handler the handler to wrap (must not be {@code null}) + * @return the async handler wrapping the given handler (not {@code null}) + */ + private static AsyncHandler createAsyncHandler(LogRuntimeConfig.AsyncConfig asyncConfig, Level level, Handler handler) { + final AsyncHandler asyncHandler = new AsyncHandler(asyncConfig.queueLength()); + asyncHandler.setOverflowAction(asyncConfig.overflow()); + asyncHandler.addHandler(handler); + asyncHandler.setLevel(level); + return asyncHandler; + } + + /** + * Determine whether color output is enabled for the console. + * + * @param consoleConfig the console runtime configuration (must not be {@code null}) + * @param logConfig the console handler configuration (must not be {@code null}) + * @return {@code true} if color output should be enabled + */ + private static boolean isColorEnabled(ConsoleRuntimeConfig consoleConfig, ConsoleConfig logConfig) { + if (consoleConfig.color().isPresent()) { + return consoleConfig.color().get(); + } + return TerminalUtils.hasColorSupport(); + } + + /** + * Create the map of named filters (from {@code @LoggingFilter}-annotated classes) discovered at build time. + * + * @param discoveredLogComponents the discovered log components (must not be {@code null}) + * @return a map of filter name to filter instance (not {@code null}) + */ + public static Map createNamedFilters(DiscoveredLogComponents discoveredLogComponents) { + if (discoveredLogComponents.getNameToFilterClass().isEmpty()) { + return Map.of(); + } + + Map nameToFilter = new HashMap<>(); + LogFilterFactory logFilterFactory = LogFilterFactory.load(); + discoveredLogComponents.getNameToFilterClass().forEach(new BiConsumer<>() { + @Override + public void accept(String name, String className) { + try { + nameToFilter.put(name, logFilterFactory.create(className)); + } catch (Exception e) { + throw new RuntimeException("Unable to create instance of Logging Filter '" + className + "'", e); + } + } + }); + return nameToFilter; + } + + /** + * Determine whether named handlers need to be created. + * + * @param logRuntimeConfig the log runtime configuration (must not be {@code null}) + * @param additionalNamedHandlers the additional named handlers contributed by extensions (must not be {@code null}) + * @return {@code true} if named handlers should be created + */ + public static boolean shouldCreateNamedHandlers( + LogRuntimeConfig logRuntimeConfig, + List>> additionalNamedHandlers) { + if (!logRuntimeConfig.categories().isEmpty()) { + return true; + } + if (logRuntimeConfig.handlers().isPresent()) { + return !logRuntimeConfig.handlers().get().isEmpty(); + } + return !additionalNamedHandlers.isEmpty(); + } + + /** + * Resolve the effective log level for a category, walking up the category hierarchy until a non-inherited + * level is found or the root is reached. + * + * @param categoryName the category name (must not be {@code null}) + * @param categories the category configurations, keyed by category name (must not be {@code null}) + * @param levelExtractor a function extracting the inheritable level from a category configuration (must not be + * {@code null}) + * @param categoryDefaults default levels per category (must not be {@code null}) + * @param rootMinLevel the root minimum level to use if no configured level is found (must not be {@code null}) + * @param the category configuration type + * @return the resolved level (not {@code null}) + */ + public static Level getLogLevel(String categoryName, Map categories, + Function levelExtractor, Map categoryDefaults, Level rootMinLevel) { + while (true) { + InheritableLevel inheritableLevel = getLogLevelNoInheritance(categoryName, categories, levelExtractor, + categoryDefaults); + if (!inheritableLevel.isInherited()) { + return inheritableLevel.getLevel(); + } + final int lastDotIndex = categoryName.lastIndexOf('.'); + if (lastDotIndex == -1) { + return rootMinLevel; + } + categoryName = categoryName.substring(0, lastDotIndex); + } + } + + /** + * Resolve the configured level for a category without walking up the category hierarchy. + * + * @param categoryName the category name (must not be {@code null}) + * @param categories the category configurations, keyed by category name (must not be {@code null}) + * @param levelExtractor a function extracting the inheritable level from a category configuration (must not be + * {@code null}) + * @param categoryDefaults default levels per category (must not be {@code null}) + * @param the category configuration type + * @return the configured inheritable level, or {@link InheritableLevel.Inherited#INSTANCE} if none is configured + */ + public static InheritableLevel getLogLevelNoInheritance(String categoryName, Map categories, + Function levelExtractor, Map categoryDefaults) { + T categoryConfig = categories.get(categoryName); + InheritableLevel inheritableLevel = null; + if (categoryConfig != null) { + inheritableLevel = levelExtractor.apply(categoryConfig); + } + if (inheritableLevel == null) { + inheritableLevel = categoryDefaults.get(categoryName); + } + if (inheritableLevel == null) { + inheritableLevel = InheritableLevel.Inherited.INSTANCE; + } + return inheritableLevel; + } + + /** + * Merge the per-named-handler formatters contributed by extensions. + *

+ * RECORDER COMPAT: takes {@link RuntimeValue}-wrapped formatter maps contributed by legacy recorders. + * + * @param namedHandlerFormatters the contributed named-handler formatter maps (must not be {@code null}) + * @return the merged map of formatters per handler type and handler name (not {@code null}) + */ + public static Map>> mergeNamedHandlerFormatters( + List>>>> namedHandlerFormatters) { + if (namedHandlerFormatters.isEmpty()) { + return Map.of(); + } + Map>> merged = new EnumMap<>(NamedHandlerType.class); + for (RuntimeValue>>> rv : namedHandlerFormatters) { + rv.getValue().forEach((type, formatters) -> { + Map> typeMap = merged.computeIfAbsent(type, k -> new HashMap<>()); + formatters.forEach((name, formatter) -> { + if (typeMap.putIfAbsent(name, formatter) != null) { + log.warnf("Multiple formatters configured for named %s handler '%s', the last one will be used", type, + name); + typeMap.put(name, formatter); + } + }); + }); + } + return merged; + } + + /** + * Resolve the candidate formatters for a named handler, preferring a formatter configured specifically for + * that handler and falling back to the global formatters for the handler type. + *

+ * RECORDER COMPAT: returns {@link RuntimeValue}-wrapped candidate formatters for the legacy recorder scheme. + * + * @param handlerName the named handler name (must not be {@code null}) + * @param namedFormatters the formatters configured per named handler (must not be {@code null}) + * @param globalFormatters the global formatters for the handler type (must not be {@code null}) + * @return the candidate formatters to use for the named handler (not {@code null}) + */ + public static List>> resolveFormatters( + String handlerName, + Map> namedFormatters, + List>> globalFormatters) { + if (namedFormatters.containsKey(handlerName)) { + return List.of(new RuntimeValue<>(namedFormatters.get(handlerName))); + } + // Fall back to the global formatter (e.g. JSON enabled globally applies to named handlers too). + return globalFormatters; + } + + /** + * Create the named handlers configured under {@code quarkus.log.handler.*}, plus any additional named + * handlers contributed by extensions. + * + * @param config the log runtime configuration (must not be {@code null}) + * @param consoleRuntimeConfig the console runtime configuration (must not be {@code null}) + * @param additionalNamedHandlers additional named handlers contributed by extensions (must not be {@code null}) + * @param namedHandlerFormatters formatters configured per named handler (must not be {@code null}) + * @param possibleConsoleFormatters the global console formatters (must not be {@code null}) + * @param possibleFileFormatters the global file formatters (must not be {@code null}) + * @param possibleSyslogFormatters the global syslog formatters (must not be {@code null}) + * @param possibleSocketFormatters the global socket formatters (must not be {@code null}) + * @param errorManager the error manager to install on the handlers (must not be {@code null}) + * @param cleanupFilter the log cleanup filter to apply (must not be {@code null}) + * @param namedFilters the map of named filters, keyed by filter name (must not be {@code null}) + * @param launchMode the current launch mode (must not be {@code null}) + * @param shutdownHandler the shutdown notifier (must not be {@code null}) + * @param includeFilters {@code true} if named filters should be applied + * @return the map of named handlers, keyed by handler name (not {@code null}) + */ + public static Map createNamedHandlers( + LogRuntimeConfig config, ConsoleRuntimeConfig consoleRuntimeConfig, + List>> additionalNamedHandlers, + Map>> namedHandlerFormatters, + List>> possibleConsoleFormatters, + List>> possibleFileFormatters, + List>> possibleSyslogFormatters, + List>> possibleSocketFormatters, + ErrorManager errorManager, LogCleanupFilter cleanupFilter, + Map namedFilters, LaunchMode launchMode, + ShutdownNotifier shutdownHandler, boolean includeFilters) { + Map> namedConsoleFormatters = namedHandlerFormatters.getOrDefault(NamedHandlerType.CONSOLE, + Map.of()); + Map> namedFileFormatters = namedHandlerFormatters.getOrDefault(NamedHandlerType.FILE, + Map.of()); + Map> namedSyslogFormatters = namedHandlerFormatters.getOrDefault(NamedHandlerType.SYSLOG, + Map.of()); + Map> namedSocketFormatters = namedHandlerFormatters.getOrDefault(NamedHandlerType.SOCKET, + Map.of()); + + Map namedHandlers = new HashMap<>(); + for (Entry consoleConfigEntry : config.consoleHandlers().entrySet()) { + ConsoleConfig namedConsoleConfig = consoleConfigEntry.getValue(); + if (!namedConsoleConfig.enabled()) { + continue; + } + final Handler consoleHandler = configureConsoleHandler(namedConsoleConfig, consoleRuntimeConfig, + errorManager, cleanupFilter, namedFilters, + resolveFormatters(consoleConfigEntry.getKey(), namedConsoleFormatters, possibleConsoleFormatters), + null, launchMode, includeFilters); + addToNamedHandlers(namedHandlers, consoleHandler, consoleConfigEntry.getKey()); + } + for (Entry fileConfigEntry : config.fileHandlers().entrySet()) { + FileConfig namedFileConfig = fileConfigEntry.getValue(); + if (!namedFileConfig.enabled()) { + continue; + } + final Handler fileHandler = configureFileHandler(namedFileConfig, errorManager, cleanupFilter, + namedFilters, + resolveFormatters(fileConfigEntry.getKey(), namedFileFormatters, possibleFileFormatters), includeFilters); + addToNamedHandlers(namedHandlers, fileHandler, fileConfigEntry.getKey()); + } + for (Entry sysLogConfigEntry : config.syslogHandlers().entrySet()) { + LogRuntimeConfig.SyslogConfig namedSyslogConfig = sysLogConfigEntry.getValue(); + if (!namedSyslogConfig.enabled()) { + continue; + } + final Handler syslogHandler = configureSyslogHandler(namedSyslogConfig, errorManager, cleanupFilter, + namedFilters, + resolveFormatters(sysLogConfigEntry.getKey(), namedSyslogFormatters, possibleSyslogFormatters), + includeFilters); + if (syslogHandler != null) { + addToNamedHandlers(namedHandlers, syslogHandler, sysLogConfigEntry.getKey()); + } + } + for (Entry socketConfigEntry : config.socketHandlers().entrySet()) { + SocketConfig namedSocketConfig = socketConfigEntry.getValue(); + if (!namedSocketConfig.enabled()) { + continue; + } + final Handler socketHandler = configureSocketHandler(namedSocketConfig, errorManager, cleanupFilter, + namedFilters, + resolveFormatters(socketConfigEntry.getKey(), namedSocketFormatters, possibleSocketFormatters), + includeFilters); + if (socketHandler != null) { + addToNamedHandlers(namedHandlers, socketHandler, socketConfigEntry.getKey()); + } + } + + Map additionalNamedHandlersMap; + if (additionalNamedHandlers.isEmpty()) { + additionalNamedHandlersMap = Map.of(); + } else { + additionalNamedHandlersMap = new HashMap<>(); + for (RuntimeValue> runtimeValue : additionalNamedHandlers) { + runtimeValue.getValue().forEach( + new AdditionalNamedHandlersConsumer(additionalNamedHandlersMap, errorManager, + cleanupFilter.filterElements.values(), shutdownHandler)); + } + } + + namedHandlers.putAll(additionalNamedHandlersMap); + + return namedHandlers; + } + + /** + * Add a handler to the map of named handlers, registering a close task for it, and failing if a handler + * with the same name already exists. + * + * @param namedHandlers the map of named handlers to add to (must not be {@code null}) + * @param handler the handler to add (must not be {@code null}) + * @param handlerName the handler name (must not be {@code null}) + */ + public static void addToNamedHandlers(Map namedHandlers, Handler handler, String handlerName) { + if (namedHandlers.containsKey(handlerName)) { + throw new RuntimeException(String.format("Only one handler can be configured with the same name '%s'", + handlerName)); + } + namedHandlers.put(handlerName, handler); + InitialConfigurator.DELAYED_HANDLER.addLoggingCloseTask(new Runnable() { + @Override + public void run() { + handler.close(); + } + }); + } + + /** + * Link the named handlers configured on a category to that category's logger, registering a removal task + * for each linked handler. + * + * @param categoryConfig the category configuration (must not be {@code null}) + * @param namedHandlers the map of named handlers, keyed by handler name (must not be {@code null}) + * @param categoryLogger the category logger to link the handlers to (must not be {@code null}) + * @param errorManager the error manager to report missing handler links to (must not be {@code null}) + * @param checkHandlerLinks {@code true} if a missing handler link should be reported as an error + */ + public static void addNamedHandlersToCategory( + CategoryConfig categoryConfig, Map namedHandlers, + Logger categoryLogger, + ErrorManager errorManager, + boolean checkHandlerLinks) { + for (String categoryNamedHandler : categoryConfig.handlers().get()) { + Handler handler = namedHandlers.get(categoryNamedHandler); + if (handler != null) { + categoryLogger.addHandler(handler); + InitialConfigurator.DELAYED_HANDLER.addLoggingCloseTask(new Runnable() { + @Override + public void run() { + categoryLogger.removeHandler(handler); + } + }); + } else if (checkHandlerLinks) { + errorManager.error(String.format("Handler with name '%s' is linked to a category but not configured.", + categoryNamedHandler), null, ErrorManager.GENERIC_FAILURE); + } + } + } + + /** + * Configure the per-category loggers: their levels (with minimum-level promotion), parent-handler usage, + * and linked named handlers. + * + * @param buildConfig the log build-time configuration (must not be {@code null}) + * @param categoryDefaultMinLevels default minimum levels per category (must not be {@code null}) + * @param categories the category configurations, keyed by category name (must not be {@code null}) + * @param logContext the log context (must not be {@code null}) + * @param errorManager the error manager to report missing handler links to (must not be {@code null}) + * @param namedHandlers the map of named handlers, keyed by handler name (must not be {@code null}) + * @param checkHandlerLinks {@code true} if a missing handler link should be reported as an error + */ + public static void setUpCategoryLoggers( + final LogBuildTimeConfig buildConfig, + final Map categoryDefaultMinLevels, + final Map categories, + final LogContext logContext, + final ErrorManager errorManager, + final Map namedHandlers, + final boolean checkHandlerLinks) { + + for (Entry entry : categories.entrySet()) { + String categoryName = entry.getKey(); + CategoryConfig categoryConfig = entry.getValue(); + InheritableLevel categoryLevel = categoryConfig.level(); + + Level logLevel = getLogLevel(categoryName, categories, CategoryConfig::level, Map.of(), buildConfig.minLevel()); + Level minLogLevel = getLogLevel(categoryName, buildConfig.categories(), CategoryBuildTimeConfig::minLevel, + categoryDefaultMinLevels, buildConfig.minLevel()); + if (logLevel.intValue() < minLogLevel.intValue()) { + String category = entry.getKey(); + log.warnf( + "Log level %s for category '%s' set below minimum logging level %s, promoting it to %s. " + + "Set the build time configuration property 'quarkus.log.category.\"%s\".min-level' to '%s' to avoid this warning", + logLevel, category, minLogLevel, minLogLevel, category, logLevel); + + categoryLevel = InheritableLevel.of(minLogLevel.toString()); + } + + Logger categoryLogger = logContext.getLogger(categoryName); + if (!categoryLevel.isInherited()) { + categoryLogger.setLevel(categoryLevel.getLevel()); + } + categoryLogger.setUseParentHandlers(categoryConfig.useParentHandlers()); + if (categoryConfig.handlers().isPresent()) { + addNamedHandlersToCategory(categoryConfig, namedHandlers, categoryLogger, errorManager, checkHandlerLinks); + } + } + } + + /** + * Add the named handlers linked to the root category (via {@code quarkus.log.handlers}) to the given list + * of effective root handlers. + * + * @param handlerNames the optional list of root handler names (must not be {@code null}) + * @param namedHandlers the map of named handlers, keyed by handler name (must not be {@code null}) + * @param effectiveHandlers the list of effective root handlers to add to (must not be {@code null}) + * @param errorManager the error manager to report missing handler links to (must not be {@code null}) + */ + public static void addNamedHandlersToRootHandlers(Optional> handlerNames, Map namedHandlers, + ArrayList effectiveHandlers, ErrorManager errorManager) { + if (handlerNames.isEmpty()) { + return; + } + if (handlerNames.get().isEmpty()) { + return; + } + for (String namedHandler : handlerNames.get()) { + Handler handler = namedHandlers.get(namedHandler); + if (handler != null) { + effectiveHandlers.add(handler); + } else { + errorManager.error(String.format("Handler with name '%s' is linked to a category but not configured.", + namedHandler), null, ErrorManager.GENERIC_FAILURE); + } + } + } + + /** + * A shutdown listener that tracks whether shutdown has begun, so that the log cleanup filter can stop + * suppressing records once the application is shutting down. + */ + public static class ShutdownNotifier implements ShutdownListener { + volatile boolean shutdown; + + @Override + public void shutdown(ShutdownNotification notification) { + shutdown = true; + notification.done(); + } + + /** + * Mark that shutdown has begun. Used by the logging configuration service's stop handler so that the + * cleanup filter stops suppressing records once the application is shutting down. + */ + public void markShutdown() { + shutdown = true; + } + } + + /** + * Create the dev/test exception-reporting handler, which forwards thrown exceptions from log records to + * {@link ExceptionReporting} (used by the continuous testing and dev-mode error reporting infrastructure). + * + * @return the exception-reporting handler (not {@code null}) + */ + public static Handler createExceptionReportingHandler() { + return new ExtHandler() { + @Override + protected void doPublish(ExtLogRecord record) { + if (record.getThrown() != null) { + ExceptionReporting.notifyException(record.getThrown()); + } + } + + @Override + public void flush() { + } + + @Override + public void close() throws SecurityException { + } + }; + } + + /** + * Build the shared log cleanup filter from the runtime {@code quarkus.log.filter.*} configuration. + * + * @param config the log runtime configuration (must not be {@code null}) + * @param notifier the shutdown notifier the filter consults during shutdown (must not be {@code null}) + * @return the cleanup filter (not {@code null}) + */ + public static LogCleanupFilter buildCleanupFilter(LogRuntimeConfig config, ShutdownNotifier notifier) { + Map filters = config.filters(); + List filterElements; + if (filters.isEmpty()) { + filterElements = List.of(); + } else { + filterElements = new ArrayList<>(filters.size()); + filters.forEach((loggerName, filterConfig) -> filterElements + .add(new LogCleanupFilterElement(loggerName, filterConfig.targetLevel(), filterConfig.ifStartsWith()))); + } + return new LogCleanupFilter(filterElements, notifier); + } + + /** + * Perform the final, whole-application logging configuration at runtime: set the root level, build the + * named handlers and category loggers, assemble the effective root handler set from the handlers produced + * by the individual handler services, and install them on the delayed handler. + *

+ * The individual root handlers (console, file, syslog, socket, and the dev/test exception-reporting + * handler) are produced by their own services and passed in via {@code serviceRootHandlers}; they are + * already fully configured. Extension-contributed handlers, the streaming handler, and the per-named-handler + * formatters are passed in via the {@code // RECORDER COMPAT} bridge wrappers. + * + * @param buildConfig the log build-time configuration (must not be {@code null}) + * @param config the log runtime configuration (must not be {@code null}) + * @param consoleRuntimeConfig the console runtime configuration (must not be {@code null}) + * @param categoryDefaultMinLevels default minimum levels per category (must not be {@code null}) + * @param notifier the shutdown notifier (must not be {@code null}) + * @param cleanupFilter the shared cleanup filter (must not be {@code null}) + * @param errorManager the shared default error manager (must not be {@code null}) + * @param namedFilters the map of named filters, keyed by filter name (must not be {@code null}) + * @param serviceRootHandlers the enabled root handlers produced by the handler services (must not be {@code null}) + * @param additionalHandlers extension-contributed root handlers (must not be {@code null}) + * @param streamingHandler the Dev UI streaming handler, or {@code null} if none + * @param consoleFormatter the global console formatter for named-handler fallback, or {@code null} + * @param fileFormatter the global file formatter for named-handler fallback, or {@code null} + * @param syslogFormatter the global syslog formatter for named-handler fallback, or {@code null} + * @param socketFormatter the global socket formatter for named-handler fallback, or {@code null} + * @param namedHandlerFormatters the per-named-handler formatters, or {@code null} if none + * @param banner the console banner, or {@code null} if none + * @param launchMode the current launch mode (must not be {@code null}) + * @param enableWebStream {@code true} if the web log stream is always enabled + * @param includeFilters {@code true} if named filters should be applied + */ + public static void configureRuntimeLogging( + final LogBuildTimeConfig buildConfig, + final LogRuntimeConfig config, + final ConsoleRuntimeConfig consoleRuntimeConfig, + final Map categoryDefaultMinLevels, + final ShutdownNotifier notifier, + final LogCleanupFilter cleanupFilter, + final ErrorManager errorManager, + final Map namedFilters, + final Collection serviceRootHandlers, + final Collection additionalHandlers, + final StreamingLogHandler streamingHandler, + final Formatter consoleFormatter, + final Formatter fileFormatter, + final Formatter syslogFormatter, + final Formatter socketFormatter, + final NamedHandlerFormatters namedHandlerFormatters, + final LogBanner banner, + final LaunchMode launchMode, + final boolean enableWebStream, + final boolean includeFilters) { + + LogContext logContext = LogContext.getLogContext(); + Logger rootLogger = logContext.getLogger(""); + + if (config.level().intValue() < buildConfig.minLevel().intValue()) { + log.warnf( + "Root log level %s set below minimum logging level %s, promoting it to %s. Set the build time configuration property 'quarkus.log.min-level' to '%s' to avoid this warning", + config.level(), buildConfig.minLevel(), buildConfig.minLevel(), config.level()); + rootLogger.setLevel(buildConfig.minLevel()); + } else { + rootLogger.setLevel(config.level()); + } + + // apply the cleanup filter to the handlers already installed on the root logger + for (Handler handler : LogManager.getLogManager().getLogger("").getHandlers()) { + handler.setFilter(cleanupFilter); + } + + ArrayList handlers = new ArrayList<>( + serviceRootHandlers.size() + additionalHandlers.size() + 2); + // handlers produced by the individual handler services (console, file, syslog, socket, dev-exception); + // these are already fully configured by their own services + handlers.addAll(serviceRootHandlers); + + // the Dev UI streaming handler (RECORDER COMPAT bridge), configured here to match the previous behavior + if ((launchMode.isDevOrTest() || enableWebStream) && streamingHandler != null) { + Handler handler = streamingHandler.handler(); + handler.setErrorManager(errorManager); + handler.setFilter(new LogCleanupFilter(cleanupFilter.filterElements.values(), notifier)); + if (banner != null) { + String header = "\n" + banner.supplier().get(); + handler.publish(new LogRecord(Level.INFO, header)); + } + handlers.add(handler); + } + + // extension-contributed root handlers (RECORDER COMPAT bridge), configured here to match previous behavior + for (AdditionalLogHandler additionalHandler : additionalHandlers) { + Handler handler = additionalHandler.handler(); + handler.setErrorManager(errorManager); + handler.setFilter(cleanupFilter); + handlers.add(handler); + } + + Map>> mergedNamedHandlerFormatters = namedHandlerFormatters == null + ? Map.of() + : namedHandlerFormatters.formatters(); + + Map namedHandlers = shouldCreateNamedHandlers(config, List.of()) + ? createNamedHandlers(config, consoleRuntimeConfig, List.of(), mergedNamedHandlerFormatters, + toFormatterList(consoleFormatter), toFormatterList(fileFormatter), toFormatterList(syslogFormatter), + toFormatterList(socketFormatter), errorManager, cleanupFilter, namedFilters, launchMode, notifier, + includeFilters) + : Map.of(); + + if (!config.categories().isEmpty()) { + setUpCategoryLoggers(buildConfig, categoryDefaultMinLevels, config.categories(), logContext, errorManager, + namedHandlers, true); + } + + addNamedHandlersToRootHandlers(config.handlers(), namedHandlers, handlers, errorManager); + InitialConfigurator.DELAYED_HANDLER.setAutoFlush(false); + InitialConfigurator.DELAYED_HANDLER.setHandlers(handlers.toArray(LogContextInitializer.NO_HANDLERS)); + } + + /** + * Adapt a single resolved formatter to the list-of-candidate-formatters shape expected by + * {@link #createNamedHandlers}. + *

+ * RECORDER COMPAT: exists only to feed resolved formatter service values into the legacy list-based + * named-handler construction; removable once {@code createNamedHandlers} takes resolved formatters. + * + * @param formatter the resolved formatter, or {@code null} + * @return a singleton candidate list, or an empty list if {@code formatter} is {@code null} + */ + private static List>> toFormatterList(Formatter formatter) { + return formatter == null ? List.of() : List.of(new RuntimeValue<>(Optional.of(formatter))); + } + + /** + * A consumer that adds extension-contributed named handlers to a map, installing the shared error manager + * and a fresh cleanup filter on each. + *

+ * RECORDER COMPAT: consumes named handlers contributed by legacy recorders via build items. + */ + static class AdditionalNamedHandlersConsumer implements BiConsumer { + private final Map additionalNamedHandlersMap; + private final ErrorManager errorManager; + private final Collection filterElements; + + private final ShutdownNotifier shutdownNotifier; + + /** + * Construct a new instance. + * + * @param additionalNamedHandlersMap the map to add handlers to (must not be {@code null}) + * @param errorManager the error manager to install on each handler (must not be {@code null}) + * @param filterElements the cleanup filter elements to apply to each handler (must not be {@code null}) + * @param shutdownNotifier the shutdown notifier for the cleanup filter (must not be {@code null}) + */ + public AdditionalNamedHandlersConsumer(Map additionalNamedHandlersMap, ErrorManager errorManager, + Collection filterElements, ShutdownNotifier shutdownNotifier) { + this.additionalNamedHandlersMap = additionalNamedHandlersMap; + this.errorManager = errorManager; + this.filterElements = filterElements; + this.shutdownNotifier = shutdownNotifier; + } + + @Override + public void accept(String name, Handler handler) { + Handler previous = additionalNamedHandlersMap.putIfAbsent(name, handler); + if (previous != null) { + throw new IllegalStateException(String.format( + "Duplicate key %s (attempted merging values %s and %s)", + name, previous, handler)); + } + handler.setErrorManager(errorManager); + handler.setFilter(new LogCleanupFilter(filterElements, shutdownNotifier)); + } + } +} diff --git a/core/runtime/src/main/java/io/quarkus/runtime/logging/LoggingSetupRecorder.java b/core/runtime/src/main/java/io/quarkus/runtime/logging/LoggingSetupRecorder.java index 183186f7f8b6d..f30822da312a1 100644 --- a/core/runtime/src/main/java/io/quarkus/runtime/logging/LoggingSetupRecorder.java +++ b/core/runtime/src/main/java/io/quarkus/runtime/logging/LoggingSetupRecorder.java @@ -1,16 +1,9 @@ package io.quarkus.runtime.logging; -import static io.smallrye.common.net.HostName.getQualifiedHostName; -import static io.smallrye.common.os.Process.getProcessName; import static java.util.Collections.emptyList; import static java.util.Collections.emptyMap; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.UnsupportedEncodingException; import java.util.ArrayList; -import java.util.Collection; -import java.util.EnumMap; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -19,8 +12,6 @@ import java.util.Optional; import java.util.Set; import java.util.function.BiConsumer; -import java.util.function.Consumer; -import java.util.function.Function; import java.util.function.Supplier; import java.util.logging.ErrorManager; import java.util.logging.Filter; @@ -32,46 +23,39 @@ import org.eclipse.microprofile.config.ConfigProvider; import org.eclipse.microprofile.config.spi.ConfigSource; -import org.jboss.logmanager.ExtFormatter; import org.jboss.logmanager.ExtHandler; import org.jboss.logmanager.ExtLogRecord; import org.jboss.logmanager.LogContext; import org.jboss.logmanager.LogContextInitializer; import org.jboss.logmanager.Logger; import org.jboss.logmanager.errormanager.OnlyOnceErrorManager; -import org.jboss.logmanager.filters.AllFilter; -import org.jboss.logmanager.formatters.ColorPatternFormatter; import org.jboss.logmanager.formatters.PatternFormatter; -import org.jboss.logmanager.formatters.TextBannerFormatter; -import org.jboss.logmanager.handlers.AsyncHandler; import org.jboss.logmanager.handlers.ConsoleHandler; -import org.jboss.logmanager.handlers.FileHandler; -import org.jboss.logmanager.handlers.PeriodicSizeRotatingFileHandler; -import org.jboss.logmanager.handlers.SizeRotatingFileHandler; -import org.jboss.logmanager.handlers.SocketHandler; -import org.jboss.logmanager.handlers.SyslogHandler; import io.quarkus.bootstrap.logging.InitialConfigurator; -import io.quarkus.dev.console.CurrentAppExceptionHighlighter; -import io.quarkus.dev.console.TerminalUtils; import io.quarkus.dev.testing.ExceptionReporting; import io.quarkus.runtime.ImageMode; import io.quarkus.runtime.LaunchMode; import io.quarkus.runtime.RuntimeValue; import io.quarkus.runtime.annotations.Recorder; -import io.quarkus.runtime.configuration.MemorySize; import io.quarkus.runtime.configuration.QuarkusConfigBuilderCustomizer; import io.quarkus.runtime.console.ConsoleRuntimeConfig; -import io.quarkus.runtime.logging.LogBuildTimeConfig.CategoryBuildTimeConfig; import io.quarkus.runtime.logging.LogRuntimeConfig.CategoryConfig; import io.quarkus.runtime.logging.LogRuntimeConfig.CleanupFilterConfig; -import io.quarkus.runtime.logging.LogRuntimeConfig.ConsoleConfig; -import io.quarkus.runtime.logging.LogRuntimeConfig.FileConfig; -import io.quarkus.runtime.logging.LogRuntimeConfig.SocketConfig; import io.quarkus.runtime.shutdown.ShutdownListener; import io.smallrye.config.SmallRyeConfig; import io.smallrye.config.SmallRyeConfigBuilder; +/** + * Runtime entry points for legacy recorder-driven logging initialization. + *

+ * The reusable handler-construction and orchestration logic now lives in {@link LoggingSetup}; this recorder + * retains only the entry points that are still driven from bytecode recording or reflective fallbacks: + * {@link #handleFailedStart()} (the failure fallback), {@link #initializeLogging} (invoked by the fallback), + * {@link #initializeBuildTimeLogging} (invoked statically at build time), and + * {@link #initializeLoggingForImageBuild()} (native image build). As logging is migrated to the service + * scheme, these entry points shrink further. + */ @Recorder public class LoggingSetupRecorder { private static final org.jboss.logging.Logger log = org.jboss.logging.Logger.getLogger(LoggingSetupRecorder.class); @@ -150,7 +134,7 @@ public ShutdownListener initializeLogging( LogBuildTimeConfig buildConfig = logBuildTimeConfig; LogRuntimeConfig config = logRuntimeConfig.getValue(); - ShutdownNotifier shutdownNotifier = new ShutdownNotifier(); + LoggingSetup.ShutdownNotifier shutdownNotifier = new LoggingSetup.ShutdownNotifier(); Map categories = config.categories(); LogContext logContext = LogContext.getLogContext(); Logger rootLogger = logContext.getLogger(""); @@ -183,12 +167,13 @@ public void accept(String loggerName, CleanupFilterConfig config) { handler.setFilter(cleanupFiler); } - Map namedFilters = createNamedFilters(discoveredLogComponents); + Map namedFilters = LoggingSetup.createNamedFilters(discoveredLogComponents); ArrayList handlers = new ArrayList<>( 3 + additionalHandlers.size() + (config.handlers().isPresent() ? config.handlers().get().size() : 0)); if (config.console().enabled()) { - Handler consoleHandler = configureConsoleHandler(config.console(), consoleRuntimeConfig.getValue(), errorManager, + Handler consoleHandler = LoggingSetup.configureConsoleHandler(config.console(), consoleRuntimeConfig.getValue(), + errorManager, cleanupFiler, namedFilters, possibleConsoleFormatters, possibleBannerSupplier, launchMode, includeFilters); errorManager = consoleHandler.getErrorManager(); @@ -214,12 +199,14 @@ public void close() throws SecurityException { } if (config.file().enabled()) { - handlers.add(configureFileHandler(config.file(), errorManager, cleanupFiler, namedFilters, possibleFileFormatters, + handlers.add(LoggingSetup.configureFileHandler(config.file(), errorManager, cleanupFiler, namedFilters, + possibleFileFormatters, includeFilters)); } if (config.syslog().enabled()) { - Handler syslogHandler = configureSyslogHandler(config.syslog(), errorManager, cleanupFiler, namedFilters, + Handler syslogHandler = LoggingSetup.configureSyslogHandler(config.syslog(), errorManager, cleanupFiler, + namedFilters, possibleSyslogFormatters, includeFilters); if (syslogHandler != null) { handlers.add(syslogHandler); @@ -227,7 +214,7 @@ public void close() throws SecurityException { } if (config.socket().enabled()) { - final Handler socketHandler = configureSocketHandler(config.socket(), errorManager, cleanupFiler, + final Handler socketHandler = LoggingSetup.configureSocketHandler(config.socket(), errorManager, cleanupFiler, namedFilters, possibleSocketFormatters, includeFilters); if (socketHandler != null) { handlers.add(socketHandler); @@ -250,11 +237,11 @@ public void close() throws SecurityException { handlers.add(handler); } - Map>> mergedNamedHandlerFormatters = mergeNamedHandlerFormatters( - namedHandlerFormatters); + Map>> mergedNamedHandlerFormatters = LoggingSetup + .mergeNamedHandlerFormatters(namedHandlerFormatters); - Map namedHandlers = shouldCreateNamedHandlers(config, additionalNamedHandlers) - ? createNamedHandlers(config, consoleRuntimeConfig.getValue(), additionalNamedHandlers, + Map namedHandlers = LoggingSetup.shouldCreateNamedHandlers(config, additionalNamedHandlers) + ? LoggingSetup.createNamedHandlers(config, consoleRuntimeConfig.getValue(), additionalNamedHandlers, mergedNamedHandlerFormatters, possibleConsoleFormatters, possibleFileFormatters, possibleSyslogFormatters, possibleSocketFormatters, errorManager, cleanupFiler, namedFilters, launchMode, @@ -268,14 +255,16 @@ public void close() throws SecurityException { additionalNamedHandlersMap = new HashMap<>(); for (RuntimeValue> runtimeValue : additionalNamedHandlers) { runtimeValue.getValue().forEach( - new AdditionalNamedHandlersConsumer(additionalNamedHandlersMap, errorManager, filterElements, + new LoggingSetup.AdditionalNamedHandlersConsumer(additionalNamedHandlersMap, errorManager, + filterElements, shutdownNotifier)); } } namedHandlers.putAll(additionalNamedHandlersMap); - setUpCategoryLoggers(buildConfig, categoryDefaultMinLevels, categories, logContext, errorManager, namedHandlers, + LoggingSetup.setUpCategoryLoggers(buildConfig, categoryDefaultMinLevels, categories, logContext, errorManager, + namedHandlers, true); } @@ -288,32 +277,12 @@ public void close() throws SecurityException { handlers.add(handler); } } - addNamedHandlersToRootHandlers(config.handlers(), namedHandlers, handlers, errorManager); + LoggingSetup.addNamedHandlersToRootHandlers(config.handlers(), namedHandlers, handlers, errorManager); InitialConfigurator.DELAYED_HANDLER.setAutoFlush(false); InitialConfigurator.DELAYED_HANDLER.setHandlers(handlers.toArray(LogContextInitializer.NO_HANDLERS)); return shutdownNotifier; } - private static Map createNamedFilters(DiscoveredLogComponents discoveredLogComponents) { - if (discoveredLogComponents.getNameToFilterClass().isEmpty()) { - return emptyMap(); - } - - Map nameToFilter = new HashMap<>(); - LogFilterFactory logFilterFactory = LogFilterFactory.load(); - discoveredLogComponents.getNameToFilterClass().forEach(new BiConsumer<>() { - @Override - public void accept(String name, String className) { - try { - nameToFilter.put(name, logFilterFactory.create(className)); - } catch (Exception e) { - throw new RuntimeException("Unable to create instance of Logging Filter '" + className + "'", e); - } - } - }); - return nameToFilter; - } - /** * WARNING: this method is part of the recorder but is actually called statically at build time. * You may not push RuntimeValue's to it. @@ -326,7 +295,7 @@ public static void initializeBuildTimeLogging( final List additionalLogCleanupFilters, final LaunchMode launchMode) { - ShutdownNotifier dummy = new ShutdownNotifier(); + LoggingSetup.ShutdownNotifier dummy = new LoggingSetup.ShutdownNotifier(); Map categories = config.categories(); LogContext logContext = LogContext.getLogContext(); @@ -349,277 +318,25 @@ public static void initializeBuildTimeLogging( ArrayList handlers = new ArrayList<>(3); if (config.console().enabled()) { - Handler consoleHandler = configureConsoleHandler(config.console(), consoleConfig, errorManager, logCleanupFilter, + Handler consoleHandler = LoggingSetup.configureConsoleHandler(config.console(), consoleConfig, errorManager, + logCleanupFilter, emptyMap(), emptyList(), new RuntimeValue<>(Optional.empty()), launchMode, false); errorManager = consoleHandler.getErrorManager(); handlers.add(consoleHandler); } - Map namedHandlers = createNamedHandlers(config, consoleConfig, emptyList(), + Map namedHandlers = LoggingSetup.createNamedHandlers(config, consoleConfig, emptyList(), emptyMap(), emptyList(), emptyList(), emptyList(), emptyList(), errorManager, logCleanupFilter, emptyMap(), launchMode, dummy, false); - setUpCategoryLoggers(buildConfig, categoryDefaultMinLevels, categories, logContext, errorManager, namedHandlers, false); + LoggingSetup.setUpCategoryLoggers(buildConfig, categoryDefaultMinLevels, categories, logContext, errorManager, + namedHandlers, false); - addNamedHandlersToRootHandlers(config.handlers(), namedHandlers, handlers, errorManager); + LoggingSetup.addNamedHandlersToRootHandlers(config.handlers(), namedHandlers, handlers, errorManager); InitialConfigurator.DELAYED_HANDLER.setAutoFlush(false); InitialConfigurator.DELAYED_HANDLER.setBuildTimeHandlers(handlers.toArray(LogContextInitializer.NO_HANDLERS)); } - private boolean shouldCreateNamedHandlers( - LogRuntimeConfig logRuntimeConfig, - List>> additionalNamedHandlers) { - if (!logRuntimeConfig.categories().isEmpty()) { - return true; - } - if (logRuntimeConfig.handlers().isPresent()) { - return !logRuntimeConfig.handlers().get().isEmpty(); - } - return !additionalNamedHandlers.isEmpty(); - } - - public static Level getLogLevel(String categoryName, Map categories, - Function levelExtractor, Map categoryDefaults, Level rootMinLevel) { - while (true) { - InheritableLevel inheritableLevel = getLogLevelNoInheritance(categoryName, categories, levelExtractor, - categoryDefaults); - if (!inheritableLevel.isInherited()) { - return inheritableLevel.getLevel(); - } - final int lastDotIndex = categoryName.lastIndexOf('.'); - if (lastDotIndex == -1) { - return rootMinLevel; - } - categoryName = categoryName.substring(0, lastDotIndex); - } - } - - public static InheritableLevel getLogLevelNoInheritance(String categoryName, Map categories, - Function levelExtractor, Map categoryDefaults) { - T categoryConfig = categories.get(categoryName); - InheritableLevel inheritableLevel = null; - if (categoryConfig != null) { - inheritableLevel = levelExtractor.apply(categoryConfig); - } - if (inheritableLevel == null) { - inheritableLevel = categoryDefaults.get(categoryName); - } - if (inheritableLevel == null) { - inheritableLevel = InheritableLevel.Inherited.INSTANCE; - } - return inheritableLevel; - } - - private static Map>> mergeNamedHandlerFormatters( - List>>>> namedHandlerFormatters) { - if (namedHandlerFormatters.isEmpty()) { - return emptyMap(); - } - Map>> merged = new EnumMap<>(NamedHandlerType.class); - for (RuntimeValue>>> rv : namedHandlerFormatters) { - rv.getValue().forEach((type, formatters) -> { - Map> typeMap = merged.computeIfAbsent(type, k -> new HashMap<>()); - formatters.forEach((name, formatter) -> { - if (typeMap.putIfAbsent(name, formatter) != null) { - log.warnf("Multiple formatters configured for named %s handler '%s', the last one will be used", type, - name); - typeMap.put(name, formatter); - } - }); - }); - } - return merged; - } - - private static List>> resolveFormatters( - String handlerName, - Map> namedFormatters, - List>> globalFormatters) { - if (namedFormatters.containsKey(handlerName)) { - return List.of(new RuntimeValue<>(namedFormatters.get(handlerName))); - } - // Fall back to the global formatter (e.g. JSON enabled globally applies to named handlers too). - return globalFormatters; - } - - private static Map createNamedHandlers( - LogRuntimeConfig config, ConsoleRuntimeConfig consoleRuntimeConfig, - List>> additionalNamedHandlers, - Map>> namedHandlerFormatters, - List>> possibleConsoleFormatters, - List>> possibleFileFormatters, - List>> possibleSyslogFormatters, - List>> possibleSocketFormatters, - ErrorManager errorManager, LogCleanupFilter cleanupFilter, - Map namedFilters, LaunchMode launchMode, - ShutdownNotifier shutdownHandler, boolean includeFilters) { - Map> namedConsoleFormatters = namedHandlerFormatters.getOrDefault(NamedHandlerType.CONSOLE, - emptyMap()); - Map> namedFileFormatters = namedHandlerFormatters.getOrDefault(NamedHandlerType.FILE, - emptyMap()); - Map> namedSyslogFormatters = namedHandlerFormatters.getOrDefault(NamedHandlerType.SYSLOG, - emptyMap()); - Map> namedSocketFormatters = namedHandlerFormatters.getOrDefault(NamedHandlerType.SOCKET, - emptyMap()); - - Map namedHandlers = new HashMap<>(); - for (Entry consoleConfigEntry : config.consoleHandlers().entrySet()) { - ConsoleConfig namedConsoleConfig = consoleConfigEntry.getValue(); - if (!namedConsoleConfig.enabled()) { - continue; - } - final Handler consoleHandler = configureConsoleHandler(namedConsoleConfig, consoleRuntimeConfig, - errorManager, cleanupFilter, namedFilters, - resolveFormatters(consoleConfigEntry.getKey(), namedConsoleFormatters, possibleConsoleFormatters), - null, launchMode, includeFilters); - addToNamedHandlers(namedHandlers, consoleHandler, consoleConfigEntry.getKey()); - } - for (Entry fileConfigEntry : config.fileHandlers().entrySet()) { - FileConfig namedFileConfig = fileConfigEntry.getValue(); - if (!namedFileConfig.enabled()) { - continue; - } - final Handler fileHandler = configureFileHandler(namedFileConfig, errorManager, cleanupFilter, namedFilters, - resolveFormatters(fileConfigEntry.getKey(), namedFileFormatters, possibleFileFormatters), includeFilters); - addToNamedHandlers(namedHandlers, fileHandler, fileConfigEntry.getKey()); - } - for (Entry sysLogConfigEntry : config.syslogHandlers().entrySet()) { - LogRuntimeConfig.SyslogConfig namedSyslogConfig = sysLogConfigEntry.getValue(); - if (!namedSyslogConfig.enabled()) { - continue; - } - final Handler syslogHandler = configureSyslogHandler(namedSyslogConfig, errorManager, cleanupFilter, - namedFilters, - resolveFormatters(sysLogConfigEntry.getKey(), namedSyslogFormatters, possibleSyslogFormatters), - includeFilters); - if (syslogHandler != null) { - addToNamedHandlers(namedHandlers, syslogHandler, sysLogConfigEntry.getKey()); - } - } - for (Entry socketConfigEntry : config.socketHandlers().entrySet()) { - SocketConfig namedSocketConfig = socketConfigEntry.getValue(); - if (!namedSocketConfig.enabled()) { - continue; - } - final Handler socketHandler = configureSocketHandler(namedSocketConfig, errorManager, cleanupFilter, - namedFilters, - resolveFormatters(socketConfigEntry.getKey(), namedSocketFormatters, possibleSocketFormatters), - includeFilters); - if (socketHandler != null) { - addToNamedHandlers(namedHandlers, socketHandler, socketConfigEntry.getKey()); - } - } - - Map additionalNamedHandlersMap; - if (additionalNamedHandlers.isEmpty()) { - additionalNamedHandlersMap = emptyMap(); - } else { - additionalNamedHandlersMap = new HashMap<>(); - for (RuntimeValue> runtimeValue : additionalNamedHandlers) { - runtimeValue.getValue().forEach( - new AdditionalNamedHandlersConsumer(additionalNamedHandlersMap, errorManager, - cleanupFilter.filterElements.values(), shutdownHandler)); - } - } - - namedHandlers.putAll(additionalNamedHandlersMap); - - return namedHandlers; - } - - private static void addToNamedHandlers(Map namedHandlers, Handler handler, String handlerName) { - if (namedHandlers.containsKey(handlerName)) { - throw new RuntimeException(String.format("Only one handler can be configured with the same name '%s'", - handlerName)); - } - namedHandlers.put(handlerName, handler); - InitialConfigurator.DELAYED_HANDLER.addLoggingCloseTask(new Runnable() { - @Override - public void run() { - handler.close(); - } - }); - } - - private static void addNamedHandlersToCategory( - CategoryConfig categoryConfig, Map namedHandlers, - Logger categoryLogger, - ErrorManager errorManager, - boolean checkHandlerLinks) { - for (String categoryNamedHandler : categoryConfig.handlers().get()) { - Handler handler = namedHandlers.get(categoryNamedHandler); - if (handler != null) { - categoryLogger.addHandler(handler); - InitialConfigurator.DELAYED_HANDLER.addLoggingCloseTask(new Runnable() { - @Override - public void run() { - categoryLogger.removeHandler(handler); - } - }); - } else if (checkHandlerLinks) { - errorManager.error(String.format("Handler with name '%s' is linked to a category but not configured.", - categoryNamedHandler), null, ErrorManager.GENERIC_FAILURE); - } - } - } - - private static void setUpCategoryLoggers( - final LogBuildTimeConfig buildConfig, - final Map categoryDefaultMinLevels, - final Map categories, - final LogContext logContext, - final ErrorManager errorManager, - final Map namedHandlers, - final boolean checkHandlerLinks) { - - for (Entry entry : categories.entrySet()) { - String categoryName = entry.getKey(); - CategoryConfig categoryConfig = entry.getValue(); - InheritableLevel categoryLevel = categoryConfig.level(); - - Level logLevel = getLogLevel(categoryName, categories, CategoryConfig::level, emptyMap(), buildConfig.minLevel()); - Level minLogLevel = getLogLevel(categoryName, buildConfig.categories(), CategoryBuildTimeConfig::minLevel, - categoryDefaultMinLevels, buildConfig.minLevel()); - if (logLevel.intValue() < minLogLevel.intValue()) { - String category = entry.getKey(); - log.warnf( - "Log level %s for category '%s' set below minimum logging level %s, promoting it to %s. " + - "Set the build time configuration property 'quarkus.log.category.\"%s\".min-level' to '%s' to avoid this warning", - logLevel, category, minLogLevel, minLogLevel, category, logLevel); - - categoryLevel = InheritableLevel.of(minLogLevel.toString()); - } - - Logger categoryLogger = logContext.getLogger(categoryName); - if (!categoryLevel.isInherited()) { - categoryLogger.setLevel(categoryLevel.getLevel()); - } - categoryLogger.setUseParentHandlers(categoryConfig.useParentHandlers()); - if (categoryConfig.handlers().isPresent()) { - addNamedHandlersToCategory(categoryConfig, namedHandlers, categoryLogger, errorManager, checkHandlerLinks); - } - } - } - - private static void addNamedHandlersToRootHandlers(Optional> handlerNames, Map namedHandlers, - ArrayList effectiveHandlers, ErrorManager errorManager) { - if (handlerNames.isEmpty()) { - return; - } - if (handlerNames.get().isEmpty()) { - return; - } - for (String namedHandler : handlerNames.get()) { - Handler handler = namedHandlers.get(namedHandler); - if (handler != null) { - effectiveHandlers.add(handler); - } else { - errorManager.error(String.format("Handler with name '%s' is linked to a category but not configured.", - namedHandler), null, ErrorManager.GENERIC_FAILURE); - } - } - } - public void initializeLoggingForImageBuild() { if (ImageMode.current() == ImageMode.NATIVE_BUILD) { final ConsoleHandler handler = new ConsoleHandler(new PatternFormatter( @@ -629,343 +346,4 @@ public void initializeLoggingForImageBuild() { InitialConfigurator.DELAYED_HANDLER.setHandlers(new Handler[] { handler }); } } - - private static Handler configureConsoleHandler( - final ConsoleConfig config, - final ConsoleRuntimeConfig consoleRuntimeConfig, - final ErrorManager defaultErrorManager, - final LogCleanupFilter cleanupFilter, - final Map namedFilters, - final List>> possibleFormatters, - final RuntimeValue>> possibleBannerSupplier, - LaunchMode launchMode, - boolean includeFilters) { - Formatter formatter = null; - boolean formatterWarning = false; - - for (RuntimeValue> value : possibleFormatters) { - if (formatter != null) { - formatterWarning = true; - } - final Optional val = value.getValue(); - if (val.isPresent()) { - formatter = val.get(); - } - } - boolean color = false; - if (formatter == null) { - Supplier bannerSupplier = null; - if (possibleBannerSupplier != null && possibleBannerSupplier.getValue().isPresent()) { - bannerSupplier = possibleBannerSupplier.getValue().get(); - } - if (isColorEnabled(consoleRuntimeConfig, config)) { - formatter = new ColorPatternFormatter(config.darken(), config.format()); - color = true; - } else { - formatter = new PatternFormatter(config.format()); - } - if (bannerSupplier != null) { - formatter = new TextBannerFormatter(bannerSupplier, ExtFormatter.wrap(formatter, false)); - } - } - final ConsoleHandler consoleHandler = new ConsoleHandler( - config.stderr() ? ConsoleHandler.Target.SYSTEM_ERR : ConsoleHandler.Target.SYSTEM_OUT, formatter); - consoleHandler.setLevel(config.level()); - consoleHandler.setErrorManager(defaultErrorManager); - applyFilter(includeFilters, defaultErrorManager, cleanupFilter, config.filter(), namedFilters, consoleHandler); - - boolean asyncEnabled = config.async().enabled(); - - Handler handler = asyncEnabled - ? createAsyncHandler(config.async(), config.level(), consoleHandler) - : consoleHandler; - - if (color && launchMode.isDevOrTest() && !asyncEnabled) { - final Handler delegate = handler; - handler = new ExtHandler() { - @Override - protected void doPublish(ExtLogRecord record) { - BiConsumer> formatter = CurrentAppExceptionHighlighter.THROWABLE_FORMATTER; - if (formatter != null) { - formatter.accept(record, delegate::publish); - } else { - delegate.publish(record); - } - } - - @Override - public void flush() { - delegate.flush(); - } - - @Override - public void close() throws SecurityException { - delegate.close(); - } - }; - } - - if (formatterWarning) { - handler.getErrorManager().error("Multiple console formatters were activated", null, ErrorManager.GENERIC_FAILURE); - } - - return handler; - } - - private static Handler configureFileHandler(final FileConfig config, final ErrorManager errorManager, - final LogCleanupFilter cleanupFilter, Map namedFilters, - final List>> possibleFileFormatters, - final boolean includeFilters) { - FileHandler handler; - FileConfig.RotationConfig rotationConfig = config.rotation(); - if (!rotationConfig.enabled()) { - handler = new FileHandler(); - } else if (rotationConfig.fileSuffix().isPresent()) { - PeriodicSizeRotatingFileHandler periodicSizeRotatingFileHandler = new PeriodicSizeRotatingFileHandler(); - periodicSizeRotatingFileHandler.setSuffix(rotationConfig.fileSuffix().get()); - periodicSizeRotatingFileHandler.setRotateSize(rotationConfig.maxFileSize().asLongValue()); - periodicSizeRotatingFileHandler.setRotateOnBoot(rotationConfig.rotateOnBoot()); - periodicSizeRotatingFileHandler.setMaxBackupIndex(rotationConfig.maxBackupIndex()); - handler = periodicSizeRotatingFileHandler; - } else { - SizeRotatingFileHandler sizeRotatingFileHandler = new SizeRotatingFileHandler( - rotationConfig.maxFileSize().asLongValue(), rotationConfig.maxBackupIndex()); - sizeRotatingFileHandler.setRotateOnBoot(rotationConfig.rotateOnBoot()); - handler = sizeRotatingFileHandler; - } - - Formatter formatter = null; - boolean formatterWarning = false; - for (RuntimeValue> value : possibleFileFormatters) { - if (formatter != null) { - formatterWarning = true; - } - final Optional val = value.getValue(); - if (val.isPresent()) { - formatter = val.get(); - } - } - if (formatter == null) { - formatter = new PatternFormatter(config.format()); - } - handler.setFormatter(formatter); - - handler.setAppend(true); - try { - handler.setFile(config.path()); - } catch (FileNotFoundException e) { - errorManager.error("Failed to set log file", e, ErrorManager.OPEN_FAILURE); - } - handler.setErrorManager(errorManager); - handler.setLevel(config.level()); - handler.setFilter(cleanupFilter); - if (config.encoding().isPresent()) { - try { - handler.setEncoding(config.encoding().get().name()); - } catch (UnsupportedEncodingException e) { - errorManager.error("Failed to set character encoding", e, ErrorManager.GENERIC_FAILURE); - } - } - applyFilter(includeFilters, errorManager, cleanupFilter, config.filter(), namedFilters, handler); - - if (formatterWarning) { - handler.getErrorManager().error("Multiple file formatters were activated", null, ErrorManager.GENERIC_FAILURE); - } - - if (config.async().enabled()) { - return createAsyncHandler(config.async(), config.level(), handler); - } - return handler; - } - - private static void applyFilter(boolean includeFilters, ErrorManager errorManager, LogCleanupFilter cleanupFilter, - Optional filterName, Map namedFilters, Handler handler) { - if (filterName.isEmpty() || !includeFilters) { - handler.setFilter(cleanupFilter); - } else { - String name = filterName.get(); - Filter filter = namedFilters.get(name); - if (filter == null) { - errorManager.error("Unable to find named filter '" + name + "'", null, ErrorManager.GENERIC_FAILURE); - handler.setFilter(cleanupFilter); - } else { - handler.setFilter(new AllFilter(List.of(cleanupFilter, filter))); - } - } - } - - private static Handler configureSyslogHandler(final LogRuntimeConfig.SyslogConfig config, final ErrorManager errorManager, - final LogCleanupFilter logCleanupFilter, - final Map namedFilters, - final List>> possibleSyslogFormatters, - final boolean includeFilters) { - try { - final SyslogHandler handler = new SyslogHandler(config.endpoint().getHostString(), config.endpoint().getPort()); - handler.setAppName(config.appName().orElse(getProcessName())); - handler.setHostname(config.hostname().orElse(getQualifiedHostName())); - handler.setFacility(config.facility()); - handler.setSyslogType(config.syslogType()); - handler.setProtocol(config.protocol()); - handler.setBlockOnReconnect(config.blockOnReconnect()); - handler.setTruncate(config.truncate()); - handler.setUseCountingFraming(switch (config.useCountingFraming()) { - case PROTOCOL_DEPENDENT -> - config.protocol() == SyslogHandler.Protocol.TCP || config.protocol() == SyslogHandler.Protocol.SSL_TCP; - case TRUE -> true; - case FALSE -> false; - }); - handler.setLevel(config.level()); - if (config.maxLength().isPresent()) { - MemorySize maxLen = config.maxLength().get(); - int maxLenInt; - if (maxLen.compareTo(Integer.MAX_VALUE) > 0) { - errorManager.error( - "Using 2GB as the value of maxLength for SyslogHandler as it is the maximum allowed value", null, - ErrorManager.GENERIC_FAILURE); - maxLenInt = Integer.MAX_VALUE; - } else if (maxLen.compareTo(128) < 0) { - errorManager.error( - "Using 128 as the value of maxLength for SyslogHandler as using a smaller value is not allowed", - null, ErrorManager.GENERIC_FAILURE); - maxLenInt = 128; - } else { - maxLenInt = maxLen.asIntValue(); - } - handler.setMaxLength(maxLenInt); - } - - Formatter formatter = null; - boolean formatterWarning = false; - for (RuntimeValue> value : possibleSyslogFormatters) { - if (formatter != null) { - formatterWarning = true; - } - final Optional val = value.getValue(); - if (val.isPresent()) { - formatter = val.get(); - } - } - if (formatter == null) { - formatter = new PatternFormatter(config.format()); - } - handler.setFormatter(formatter); - - handler.setErrorManager(errorManager); - handler.setFilter(logCleanupFilter); - applyFilter(includeFilters, errorManager, logCleanupFilter, config.filter(), namedFilters, handler); - - if (formatterWarning) { - handler.getErrorManager().error("Multiple syslog formatters were activated", null, - ErrorManager.GENERIC_FAILURE); - } - - if (config.async().enabled()) { - return createAsyncHandler(config.async(), config.level(), handler); - } - return handler; - } catch (IOException e) { - errorManager.error("Failed to create syslog handler", e, ErrorManager.OPEN_FAILURE); - return null; - } - } - - private static Handler configureSocketHandler(final LogRuntimeConfig.SocketConfig config, - final ErrorManager errorManager, - final LogCleanupFilter logCleanupFilter, - final Map namedFilters, - final List>> possibleSocketFormatters, - final boolean includeFilters) { - try { - final SocketHandler handler = new SocketHandler(config.endpoint().getHostString(), config.endpoint().getPort()); - handler.setProtocol(config.protocol()); - handler.setBlockOnReconnect(config.blockOnReconnect()); - handler.setLevel(config.level()); - - Formatter formatter = null; - boolean formatterWarning = false; - for (RuntimeValue> value : possibleSocketFormatters) { - if (formatter != null) { - formatterWarning = true; - } - final Optional val = value.getValue(); - if (val.isPresent()) { - formatter = val.get(); - } - } - if (formatter == null) { - formatter = new PatternFormatter(config.format()); - } - handler.setFormatter(formatter); - - handler.setErrorManager(errorManager); - handler.setFilter(logCleanupFilter); - applyFilter(includeFilters, errorManager, logCleanupFilter, config.filter(), namedFilters, handler); - - if (formatterWarning) { - handler.getErrorManager().error("Multiple socket formatters were activated", null, - ErrorManager.GENERIC_FAILURE); - } - - if (config.async().enabled()) { - return createAsyncHandler(config.async(), config.level(), handler); - } - return handler; - } catch (IOException e) { - errorManager.error("Failed to create socket handler", e, ErrorManager.OPEN_FAILURE); - return null; - } - } - - private static AsyncHandler createAsyncHandler(LogRuntimeConfig.AsyncConfig asyncConfig, Level level, Handler handler) { - final AsyncHandler asyncHandler = new AsyncHandler(asyncConfig.queueLength()); - asyncHandler.setOverflowAction(asyncConfig.overflow()); - asyncHandler.addHandler(handler); - asyncHandler.setLevel(level); - return asyncHandler; - } - - private static boolean isColorEnabled(ConsoleRuntimeConfig consoleConfig, ConsoleConfig logConfig) { - if (consoleConfig.color().isPresent()) { - return consoleConfig.color().get(); - } - return TerminalUtils.hasColorSupport(); - } - - private static class AdditionalNamedHandlersConsumer implements BiConsumer { - private final Map additionalNamedHandlersMap; - private final ErrorManager errorManager; - private final Collection filterElements; - - private final ShutdownNotifier shutdownNotifier; - - public AdditionalNamedHandlersConsumer(Map additionalNamedHandlersMap, ErrorManager errorManager, - Collection filterElements, ShutdownNotifier shutdownNotifier) { - this.additionalNamedHandlersMap = additionalNamedHandlersMap; - this.errorManager = errorManager; - this.filterElements = filterElements; - this.shutdownNotifier = shutdownNotifier; - } - - @Override - public void accept(String name, Handler handler) { - Handler previous = additionalNamedHandlersMap.putIfAbsent(name, handler); - if (previous != null) { - throw new IllegalStateException(String.format( - "Duplicate key %s (attempted merging values %s and %s)", - name, previous, handler)); - } - handler.setErrorManager(errorManager); - handler.setFilter(new LogCleanupFilter(filterElements, shutdownNotifier)); - } - } - - public static class ShutdownNotifier implements ShutdownListener { - volatile boolean shutdown; - - @Override - public void shutdown(ShutdownNotification notification) { - shutdown = true; - notification.done(); - } - } } diff --git a/core/runtime/src/main/java/io/quarkus/runtime/logging/NamedHandlerFormatters.java b/core/runtime/src/main/java/io/quarkus/runtime/logging/NamedHandlerFormatters.java new file mode 100644 index 0000000000000..ecfba06c76d5f --- /dev/null +++ b/core/runtime/src/main/java/io/quarkus/runtime/logging/NamedHandlerFormatters.java @@ -0,0 +1,19 @@ +package io.quarkus.runtime.logging; + +import java.util.Map; +import java.util.Optional; +import java.util.logging.Formatter; + +/** + * A wrapper carrying the per-named-handler formatters as a service value. + *

+ * RECORDER COMPAT: this type exists only to bridge legacy {@code LogNamedHandlerFormatBuildItem} contributions + * (which carry a {@code RuntimeValue>>>}) into the + * logging service graph. The logging configuration service consumes a service of this type when building the + * named handlers. It can be removed once formatter-contributing extensions produce {@link Formatter} services + * directly. + * + * @param formatters the formatters keyed by handler type and handler name (must not be {@code null}) + */ +public record NamedHandlerFormatters(Map>> formatters) { +} diff --git a/core/runtime/src/main/java/io/quarkus/runtime/logging/StreamingLogHandler.java b/core/runtime/src/main/java/io/quarkus/runtime/logging/StreamingLogHandler.java new file mode 100644 index 0000000000000..ed7cf3c150ade --- /dev/null +++ b/core/runtime/src/main/java/io/quarkus/runtime/logging/StreamingLogHandler.java @@ -0,0 +1,17 @@ +package io.quarkus.runtime.logging; + +import java.util.logging.Handler; + +/** + * A wrapper carrying the Dev UI streaming log handler as a service value. + *

+ * RECORDER COMPAT: this type exists only to bridge the legacy {@code StreamingLogHandlerBuildItem} (which + * carries a {@code RuntimeValue>}) into the logging service graph. The logging + * configuration service optionally consumes a service of this type and installs it with a cleanup filter and + * the startup banner, matching the previous behavior. It can be removed once the Dev UI extension produces a + * {@link Handler} service directly. + * + * @param handler the streaming handler (must not be {@code null}) + */ +public record StreamingLogHandler(Handler handler) { +} From d8ca8c5e3911a83db52760d65a2b791c1ee56746 Mon Sep 17 00:00:00 2001 From: "David M. Lloyd" Date: Wed, 8 Jul 2026 11:39:10 -0500 Subject: [PATCH 031/254] Service conversion: ArC extension --- .../arc/deployment/ArcContainerBuildItem.java | 18 --- .../quarkus/arc/deployment/ArcProcessor.java | 87 +++++++--- .../deployment/LifecycleEventsBuildStep.java | 44 +++-- .../MicroProfileConfigProcessor.java | 14 +- .../deployment/SyntheticBeanBuildItem.java | 85 +++++++++- .../deployment/SyntheticBeansProcessor.java | 113 ++++++++++++- .../init/InitializationTaskProcessor.java | 15 +- .../InterceptedStaticMethodsProcessor.java | 27 +++- .../SyntheticBeanBuildItemProxyTest.java | 4 +- .../SyntheticBeanServiceValueTest.java | 149 +++++++++++++++++ .../arc/test/unused/UnusedExclusionTest.java | 2 +- .../io/quarkus/arc/runtime/ArcRecorder.java | 152 +++++++++++++----- .../InterceptedStaticMethodsRecorder.java | 23 --- .../runtime/MicroProfileConfigRecorder.java | 49 +++--- 14 files changed, 617 insertions(+), 165 deletions(-) delete mode 100644 extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/ArcContainerBuildItem.java create mode 100644 extensions/arc/deployment/src/test/java/io/quarkus/arc/test/synthetic/SyntheticBeanServiceValueTest.java delete mode 100644 extensions/arc/runtime/src/main/java/io/quarkus/arc/runtime/InterceptedStaticMethodsRecorder.java diff --git a/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/ArcContainerBuildItem.java b/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/ArcContainerBuildItem.java deleted file mode 100644 index 141708a48a8bf..0000000000000 --- a/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/ArcContainerBuildItem.java +++ /dev/null @@ -1,18 +0,0 @@ -package io.quarkus.arc.deployment; - -import io.quarkus.arc.ArcContainer; -import io.quarkus.builder.item.SimpleBuildItem; - -final class ArcContainerBuildItem extends SimpleBuildItem { - - private final ArcContainer container; - - ArcContainerBuildItem(ArcContainer container) { - this.container = container; - } - - public ArcContainer getContainer() { - return container; - } - -} diff --git a/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/ArcProcessor.java b/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/ArcProcessor.java index 7192b9e82251d..5a67668a61f63 100644 --- a/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/ArcProcessor.java +++ b/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/ArcProcessor.java @@ -1,6 +1,5 @@ package io.quarkus.arc.deployment; -import static io.quarkus.deployment.annotations.ExecutionTime.RUNTIME_INIT; import static io.quarkus.deployment.annotations.ExecutionTime.STATIC_INIT; import java.lang.reflect.Modifier; @@ -15,6 +14,7 @@ import java.util.Optional; import java.util.Set; import java.util.concurrent.ExecutorService; +import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; import java.util.function.Predicate; @@ -34,8 +34,11 @@ import org.jboss.jandex.Type; import org.jboss.logging.Logger; +import io.quarkus.arc.Arc; import io.quarkus.arc.ArcContainer; +import io.quarkus.arc.ArcInitConfig; import io.quarkus.arc.AsyncObserverExceptionHandler; +import io.quarkus.arc.CurrentContextFactory; import io.quarkus.arc.deployment.BeanRegistrationPhaseBuildItem.BeanConfiguratorBuildItem; import io.quarkus.arc.deployment.ContextRegistrationPhaseBuildItem.ContextConfiguratorBuildItem; import io.quarkus.arc.deployment.ObserverRegistrationPhaseBuildItem.ObserverConfiguratorBuildItem; @@ -71,6 +74,8 @@ import io.quarkus.arc.runtime.context.ArcContextProvider; import io.quarkus.arc.shutdown.ArcShutdownListener; import io.quarkus.bootstrap.BootstrapDebug; +import io.quarkus.core.Phase; +import io.quarkus.core.deployment.service.ServiceRegistrar; import io.quarkus.deployment.Capabilities; import io.quarkus.deployment.Capability; import io.quarkus.deployment.Feature; @@ -90,7 +95,6 @@ import io.quarkus.deployment.builditem.GeneratedServiceProviderBuildItem; import io.quarkus.deployment.builditem.LaunchModeBuildItem; import io.quarkus.deployment.builditem.LiveReloadBuildItem; -import io.quarkus.deployment.builditem.ShutdownContextBuildItem; import io.quarkus.deployment.builditem.ShutdownListenerBuildItem; import io.quarkus.deployment.builditem.TestClassPredicateBuildItem; import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; @@ -653,28 +657,70 @@ public void registerSubclass(DotName beanClassName, String subclassName) { // PHASE 6 - initialize the container @BuildStep @Consume(ResourcesGeneratedPhaseBuildItem.class) - @Record(STATIC_INIT) - public ArcContainerBuildItem initializeContainer(ArcConfig config, ArcRecorder recorder, - ShutdownContextBuildItem shutdown, Optional currentContextFactory, - LaunchModeBuildItem launchMode) - throws Exception { - ArcContainer container = recorder.initContainer(shutdown, - currentContextFactory.isPresent() ? currentContextFactory.get().getFactory() : null, - config.strictCompatibility(), launchMode.isTest()); - return new ArcContainerBuildItem(container); + public void initializeContainer(ArcConfig config, ServiceRegistrar reg, + Optional currentContextFactory, + LaunchModeBuildItem launchMode) { + boolean strictCompat = config.strictCompatibility(); + boolean testMode = launchMode.isTest(); + reg + .forService(ArcContainer.class) + .atPhase(Phase.STATIC_INIT) + .afterBuildItem(ResourcesGeneratedPhaseBuildItem.class) + .after("io.quarkus.core.last-shutdown-tasks") + .request(CurrentContextFactory.class) + .onStart((ctx, factoryOpt) -> { + ArcInitConfig.Builder b = ArcInitConfig.builder() + .setCurrentContextFactory(factoryOpt.orElse(null)) + .setStrictCompatibility(strictCompat) + .setTestMode(testMode); + ArcContainer container = Arc.initialize(b.build()); + ctx.onStop(() -> { + try { + Arc.shutdown(); + } finally { + // Synthetic bean providers must stay available for the whole of bean destruction + // (destroyers / @PreDestroy callbacks may resolve synthetic beans), so tear them + // down only now, after the container has been fully destroyed. + ArcRecorder.clearSyntheticBeans(); + } + }); + return container; + }); + } + + @BuildStep + // Ordered after the bean deployment is fully processed (as the ArcContainer service is), so that + // BeanContainerBuildItem and everything else derived from PreBeanContainerBuildItem are only available + // once bean registration has completed, which downstream consumers of BeanContainerBuildItem rely upon. + @Consume(ResourcesGeneratedPhaseBuildItem.class) + public PreBeanContainerBuildItem createBeanContainer(ServiceRegistrar reg) { + // BeanContainer is a static-init service wrapping the ArcContainer service. This replaces the + // former recorder-created-and-aliased value, so consumers can require(BeanContainer.class) and + // its value flows through the service graph. + reg + .forService(BeanContainer.class) + .atPhase(Phase.STATIC_INIT) + .require(ArcContainer.class) + .onStart((ctx, container) -> ArcRecorder.createBeanContainer(container)); + // Hand out a bare recorder proxy for legacy consumers: the listener-firing step below, + // PreBeanContainerBuildItem consumers, and the downstream BeanContainerBuildItem recorders. + // For a static-init service, getRecorderProxy also retains the value across the phase boundary. + return new PreBeanContainerBuildItem(reg.getRecorderProxy(BeanContainer.class)); } @BuildStep @Record(STATIC_INIT) - public PreBeanContainerBuildItem notifyBeanContainerListeners(ArcContainerBuildItem container, - List beanContainerListenerBuildItems, ArcRecorder recorder) throws Exception { - BeanContainer beanContainer = recorder.initBeanContainer(container.getContainer(), + public void notifyBeanContainerListeners(PreBeanContainerBuildItem preBeanContainer, + List beanContainerListenerBuildItems, ArcRecorder recorder) { + // Fire any BeanContainerListeners contributed by extensions that still use the legacy build item. + // Consuming PreBeanContainerBuildItem orders this recorder step after the BeanContainer service + // (which produces that item), so the recorder proxy resolves to the initialized container at runtime. + recorder.fireBeanContainerListeners(preBeanContainer.getValue(), beanContainerListenerBuildItems.stream().map(BeanContainerListenerBuildItem::getBeanContainerListener) .collect(Collectors.toList())); - return new PreBeanContainerBuildItem(beanContainer); } - @Record(RUNTIME_INIT) + @Record(STATIC_INIT) @BuildStep public void signalBeanContainerReady(JvmStartupOptimizerArchiveRecorder recorder, PreBeanContainerBuildItem bi, Optional jvmStartupOptimizerArchiveRequested, @@ -694,9 +740,12 @@ List marker() { } @BuildStep - @Record(value = RUNTIME_INIT) - void setupExecutor(ExecutorBuildItem executor, ArcRecorder recorder) { - recorder.initExecutor(executor.getExecutorProxy()); + void setupExecutor(ExecutorBuildItem executor, ServiceRegistrar reg) { + reg + .forService("io.quarkus.arc.executor") + .atPhase(Phase.INFRASTRUCTURE) + .require(ScheduledExecutorService.class) + .onStart((ctx, exec) -> Arc.setExecutor(exec)); } @BuildStep diff --git a/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/LifecycleEventsBuildStep.java b/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/LifecycleEventsBuildStep.java index 126e244012393..c22f166b5c771 100644 --- a/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/LifecycleEventsBuildStep.java +++ b/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/LifecycleEventsBuildStep.java @@ -1,28 +1,54 @@ package io.quarkus.arc.deployment; -import static io.quarkus.deployment.annotations.ExecutionTime.RUNTIME_INIT; - import java.util.List; +import io.quarkus.arc.Arc; import io.quarkus.arc.runtime.ArcRecorder; +import io.quarkus.core.Phase; +import io.quarkus.core.deployment.service.ServiceRegistrar; import io.quarkus.deployment.annotations.BuildStep; -import io.quarkus.deployment.annotations.Record; import io.quarkus.deployment.builditem.ApplicationStartBuildItem; import io.quarkus.deployment.builditem.LaunchModeBuildItem; import io.quarkus.deployment.builditem.ServiceStartBuildItem; -import io.quarkus.deployment.builditem.ShutdownContextBuildItem; +import io.quarkus.runtime.Application; +import io.quarkus.runtime.LaunchMode; +import io.quarkus.runtime.StartupEvent; public class LifecycleEventsBuildStep { @BuildStep - @Record(RUNTIME_INIT) - ApplicationStartBuildItem startupEvent(ArcRecorder recorder, + ApplicationStartBuildItem startupEvent(ServiceRegistrar reg, List startList, BeanContainerBuildItem beanContainer, - ShutdownContextBuildItem shutdown, LaunchModeBuildItem launchMode, ArcConfig config) { - recorder.handleLifecycleEvents(shutdown, launchMode.getLaunchMode(), - config.test().disableApplicationLifecycleObservers()); + LaunchMode mode = launchMode.getLaunchMode(); + boolean disableObservers = config.test().disableApplicationLifecycleObservers(); + + reg + .forService("io.quarkus.arc.startup-event") + .after("io.quarkus.arc.executor") + .afterBuildItem(SyntheticBeansRuntimeInitBuildItem.class) + .afterBuildItem(ServiceStartBuildItem.class) + .onStart(ctx -> { + List> mockBeanClasses = ArcRecorder.computeMockBeanClasses(mode, disableObservers); + ArcRecorder.fireLifecycleEvent(new StartupEvent(), mockBeanClasses); + ctx.onStop(() -> ArcRecorder.performShutdown(mockBeanClasses)); + }); + + reg + .forService("io.quarkus.arc.shutdown-gateway") + .atPhase(Phase.INFRASTRUCTURE) + .after("io.quarkus.core.last-runtime-shutdown-tasks") + .before("io.quarkus.arc.startup-event") + .onStart(ctx -> { + ctx.onStop(() -> { + Application app = Application.currentApplication(); + if (app == null || !app.hasStaticGraph()) { + Arc.shutdown(); + } + }); + }); + return new ApplicationStartBuildItem(); } diff --git a/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/MicroProfileConfigProcessor.java b/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/MicroProfileConfigProcessor.java index 6a9f1bab5fcb7..aa5aed67b41b8 100644 --- a/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/MicroProfileConfigProcessor.java +++ b/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/MicroProfileConfigProcessor.java @@ -3,6 +3,7 @@ import static io.quarkus.deployment.annotations.ExecutionTime.RUNTIME_INIT; import static io.quarkus.deployment.annotations.ExecutionTime.STATIC_INIT; import static java.util.stream.Collectors.toCollection; +import static java.util.stream.Collectors.toMap; import static org.eclipse.microprofile.config.inject.ConfigProperties.UNCONFIGURED_PREFIX; import static org.jboss.jandex.AnnotationInstance.create; import static org.jboss.jandex.AnnotationTarget.Kind.CLASS; @@ -47,6 +48,8 @@ import io.quarkus.arc.runtime.ConfigPropertyCreator; import io.quarkus.arc.runtime.MicroProfileConfigRecorder; import io.quarkus.arc.runtime.MicroProfileConfigRecorder.ConfigValidationMetadata; +import io.quarkus.core.Phase; +import io.quarkus.core.deployment.service.ServiceRegistrar; import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; import io.quarkus.deployment.annotations.Record; @@ -352,9 +355,8 @@ void activeConfigPropertiesInjectionPoints( * expected {@link jakarta.enterprise.inject.spi.DeploymentException}. */ @BuildStep - @Record(RUNTIME_INIT) void registerConfigClasses( - MicroProfileConfigRecorder recorder, + ServiceRegistrar reg, Optional configPropertiesRegistrar, BuildProducer serviceStart) { @@ -362,7 +364,13 @@ void registerConfigClasses( return; } - recorder.registerConfigProperties(configPropertiesRegistrar.get().getConfigProperties()); + Map> configProperties = Map.copyOf(configPropertiesRegistrar.get().getConfigProperties() + .entrySet().stream().map(e -> Map.entry(e.getKey(), Set.copyOf(e.getValue()))) + .collect(toMap(Map.Entry::getKey, Map.Entry::getValue))); + reg + .forService("io.quarkus.arc.config-properties") + .atPhase(Phase.CONFIG) + .onStart(ctx -> MicroProfileConfigRecorder.registerConfigProperties(configProperties)); // Ensure that @ConfigProperties are registered before Startup events serviceStart.produce(new ServiceStartBuildItem("microprofile-config-properties")); diff --git a/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/SyntheticBeanBuildItem.java b/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/SyntheticBeanBuildItem.java index 4db81a4a70dfe..c5c652f238e17 100644 --- a/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/SyntheticBeanBuildItem.java +++ b/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/SyntheticBeanBuildItem.java @@ -1,5 +1,6 @@ package io.quarkus.arc.deployment; +import java.util.List; import java.util.Objects; import java.util.Set; import java.util.function.Consumer; @@ -15,6 +16,7 @@ import io.quarkus.arc.processor.BeanConfiguratorBase; import io.quarkus.arc.processor.BeanRegistrar; import io.quarkus.builder.item.MultiBuildItem; +import io.quarkus.core.deployment.service.ServiceRegistrar; import io.quarkus.deployment.annotations.ExecutionTime; import io.quarkus.deployment.recording.BytecodeRecorderImpl.ReturnedProxy; import io.quarkus.runtime.RuntimeValue; @@ -113,7 +115,7 @@ boolean isStaticInit() { boolean hasRecorderInstance() { return configurator.supplier != null || configurator.runtimeValue != null || configurator.fun != null - || configurator.runtimeProxy != null; + || configurator.runtimeProxy != null || configurator.serviceType != null; } boolean hasCheckActiveSupplier() { @@ -129,6 +131,8 @@ public static class ExtendedBeanConfigurator extends BeanConfiguratorBase supplier; private RuntimeValue runtimeValue; private Function, ?> fun; + private Class serviceType; + private List serviceNameParts; private boolean staticInit; private Supplier checkActive; @@ -144,11 +148,15 @@ public static class ExtendedBeanConfigurator extends BeanConfiguratorBase + * At runtime, the service value registered under the given type (with no name) + * is retrieved from the startup context and used as the bean instance. + *

+ * The service must be defined (via {@link ServiceRegistrar#forService(Class) + * ServiceRegistrar.forService()}) before or during the same build step. + * + * @param serviceType the service type (must not be {@code null}) + * @return self + */ + public ExtendedBeanConfigurator serviceValue(Class serviceType) { + return serviceValue(serviceType, List.of()); + } + + /** + * The contextual bean instance is sourced from a named service in the service graph. + *

+ * At runtime, the service value registered under the given type and name + * is retrieved from the startup context and used as the bean instance. + * + * @param serviceType the service type (must not be {@code null}) + * @param serviceName the service name (may be {@code null} for an unnamed service) + * @return self + */ + public ExtendedBeanConfigurator serviceValue(Class serviceType, String serviceName) { + return serviceValue(serviceType, serviceName == null ? List.of() : List.of(serviceName)); + } + + /** + * The contextual bean instance is sourced from a named service in the service graph, + * using a multi-part service name. + *

+ * At runtime, the service value registered under the given type and name parts + * is retrieved from the startup context and used as the bean instance. + * + * @param serviceType the service type (must not be {@code null}) + * @param serviceNameParts the service name parts (must not be {@code null}) + * @return self + */ + public ExtendedBeanConfigurator serviceValue(Class serviceType, List serviceNameParts) { + checkMultipleCreationMethods(); + this.serviceType = Objects.requireNonNull(serviceType); + this.serviceNameParts = List.copyOf(serviceNameParts); + return this; + } + /** * A synthetic bean whose instance is produced through a recorder is initialized during * {@link ExecutionTime#STATIC_INIT} by default. @@ -291,8 +347,27 @@ Supplier getCheckActive() { return checkActive; } + /** + * Return the service type for service-value beans, or {@code null} if not set. + * + * @return the service type, or {@code null} + */ + Class getServiceType() { + return serviceType; + } + + /** + * Return the service name parts for service-value beans, or {@code null} if not set. + * + * @return the service name parts, or {@code null} + */ + List getServiceNameParts() { + return serviceNameParts; + } + private void checkMultipleCreationMethods() { - if (runtimeProxy == null && runtimeValue == null && supplier == null && fun == null) { + if (runtimeProxy == null && runtimeValue == null && supplier == null && fun == null + && serviceType == null) { return; } throw new IllegalStateException("It is not possible to specify multiple creation methods"); diff --git a/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/SyntheticBeansProcessor.java b/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/SyntheticBeansProcessor.java index 2ecd47a458122..66f40cdb8510f 100644 --- a/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/SyntheticBeansProcessor.java +++ b/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/SyntheticBeansProcessor.java @@ -1,8 +1,10 @@ package io.quarkus.arc.deployment; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Supplier; @@ -15,12 +17,17 @@ import io.quarkus.arc.processor.BeanConfigurator; import io.quarkus.arc.processor.BeanConfiguratorBase; import io.quarkus.arc.runtime.ArcRecorder; +import io.quarkus.core.deployment.service.impl.LambdaTransliterator; +import io.quarkus.core.deployment.service.impl.ServiceValueRetentionBuildItem; import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; import io.quarkus.deployment.annotations.ExecutionTime; import io.quarkus.deployment.annotations.Produce; import io.quarkus.deployment.annotations.Record; +import io.quarkus.deployment.builditem.MainBytecodeRecorderBuildItem; import io.quarkus.deployment.builditem.ServiceStartBuildItem; +import io.quarkus.deployment.builditem.StaticBytecodeRecorderBuildItem; +import io.quarkus.deployment.recording.BytecodeRecorderImpl; import io.quarkus.gizmo2.Const; import io.quarkus.gizmo2.Expr; import io.quarkus.gizmo2.FieldVar; @@ -28,20 +35,23 @@ import io.quarkus.gizmo2.creator.BlockCreator; import io.quarkus.gizmo2.desc.FieldDesc; import io.quarkus.gizmo2.desc.MethodDesc; +import io.quarkus.runtime.RuntimeValue; public class SyntheticBeansProcessor { @Record(ExecutionTime.STATIC_INIT) @BuildStep void initStatic(ArcRecorder recorder, List syntheticBeans, - BeanRegistrationPhaseBuildItem beanRegistration, BuildProducer configurators) { + BeanRegistrationPhaseBuildItem beanRegistration, BuildProducer configurators, + BuildProducer staticRecorderProducer) { Map, ?>> creationFunctions = new LinkedHashMap<>(); Map> checkActiveSuppliers = new LinkedHashMap<>(); for (SyntheticBeanBuildItem bean : syntheticBeans) { if (bean.hasRecorderInstance() && bean.isStaticInit()) { - configureSyntheticBean(recorder, creationFunctions, checkActiveSuppliers, beanRegistration, bean); + configureSyntheticBean(recorder, staticRecorderProducer, null, creationFunctions, checkActiveSuppliers, + beanRegistration, bean); } } // Init the map of bean instances @@ -51,15 +61,18 @@ void initStatic(ArcRecorder recorder, List syntheticBean @Record(ExecutionTime.RUNTIME_INIT) @Produce(SyntheticBeansRuntimeInitBuildItem.class) @BuildStep - ServiceStartBuildItem initRuntime(ArcRecorder recorder, List syntheticBeans, - BeanRegistrationPhaseBuildItem beanRegistration, BuildProducer configurators) { + ServiceStartBuildItem initRuntime(ArcRecorder recorder, + List syntheticBeans, + BeanRegistrationPhaseBuildItem beanRegistration, BuildProducer configurators, + BuildProducer runtimeRecorderProducer) { Map, ?>> creationFunctions = new LinkedHashMap<>(); Map> checkActiveSuppliers = new LinkedHashMap<>(); for (SyntheticBeanBuildItem bean : syntheticBeans) { if (bean.hasRecorderInstance() && !bean.isStaticInit()) { - configureSyntheticBean(recorder, creationFunctions, checkActiveSuppliers, beanRegistration, bean); + configureSyntheticBean(recorder, null, runtimeRecorderProducer, creationFunctions, checkActiveSuppliers, + beanRegistration, bean); } } recorder.initRuntimeSupplierBeans(creationFunctions, checkActiveSuppliers); @@ -72,17 +85,67 @@ void initRegular(List syntheticBeans, for (SyntheticBeanBuildItem bean : syntheticBeans) { if (!bean.hasRecorderInstance()) { - configureSyntheticBean(null, null, null, beanRegistration, bean); + configureSyntheticBean(null, null, null, null, null, beanRegistration, bean); + } + } + } + + /** + * Emit retention markers for CDI service-value bean keys. + * These keys are consumed lazily after startup and must not be cleared + * by the between-phase or post-startup {@code retainServiceValues()} calls. + * They are self-draining: each bean's creation function calls + * {@code removeServiceValue()} on first access. + */ + @BuildStep + void trackServiceValueRetention(List syntheticBeans, + BuildProducer retentionProducer) { + Set keys = new HashSet<>(); + for (SyntheticBeanBuildItem bean : syntheticBeans) { + Class serviceType = bean.configurator().getServiceType(); + if (serviceType != null) { + List nameParts = bean.configurator().getServiceNameParts(); + keys.add(LambdaTransliterator.serviceKey(serviceType, nameParts)); } } + if (!keys.isEmpty()) { + retentionProducer.produce(new ServiceValueRetentionBuildItem(keys, true)); + } } + /** + * Configure a single synthetic bean, handling both recorder-based and + * service-value-based creation methods. + * + * @param recorder the Arc recorder (null for regular/non-recorder beans) + * @param staticRecorderProducer producer for static-init recorder items (null if not static-init) + * @param runtimeRecorderProducer producer for runtime-init recorder items (null if not runtime-init) + * @param creationFunctions map to populate with creation functions (null for regular beans) + * @param checkActiveSuppliers map to populate with check-active suppliers (null for regular beans) + * @param beanRegistration the bean registration phase + * @param bean the synthetic bean build item + */ private void configureSyntheticBean(ArcRecorder recorder, + BuildProducer staticRecorderProducer, + BuildProducer runtimeRecorderProducer, Map, ?>> creationFunctions, Map> checkActiveSuppliers, BeanRegistrationPhaseBuildItem beanRegistration, SyntheticBeanBuildItem bean) { String name = bean.name(); - if (bean.configurator().getRuntimeValue() != null) { + if (bean.configurator().getServiceType() != null) { + // Bridge from service graph: the CDI bean reads the service value directly + // from the startup context using the service key. No wrapper action needed — + // the creation function runs lazily on first CDI access, after all startup + // steps (including the service action) have completed. + Class serviceType = bean.configurator().getServiceType(); + List serviceNameParts = bean.configurator().getServiceNameParts(); + String serviceKey = serviceType.getName() + ":" + String.join("/", serviceNameParts); + + // the creation function reads from the startup context lazily on first CDI access; + // null is passed for StartupContext at build time — the recorder emits code that + // passes the deploy method's StartupContext parameter at runtime + creationFunctions.put(name, recorder.createServiceValueFunction(serviceKey, null)); + } else if (bean.configurator().getRuntimeValue() != null) { creationFunctions.put(name, recorder.createFunction(bean.configurator().getRuntimeValue())); } else if (bean.configurator().getSupplier() != null) { creationFunctions.put(name, recorder.createFunction(bean.configurator().getSupplier())); @@ -137,6 +200,42 @@ public void accept(BeanConfiguratorBase.CheckActiveGeneration cag) { }; } + /** + * A {@link RuntimeValue} subclass that implements {@link BytecodeRecorderImpl.ReturnedProxy}. + *

+ * At deployment time, this proxy is passed to recorder methods. The recorder sees + * it as a {@code ReturnedProxy} and generates bytecode that retrieves the actual + * {@code RuntimeValue} wrapper from the startup context at the stored key. + * + * @param the service type + */ + private static final class ServiceRuntimeValueProxy extends RuntimeValue + implements BytecodeRecorderImpl.ReturnedProxy { + private final String rvKey; + private final boolean staticInit; + + /** + * Construct a new instance. + * + * @param rvKey the startup context key for the {@code RuntimeValue} wrapper + * @param staticInit whether this proxy targets the static-init phase + */ + ServiceRuntimeValueProxy(String rvKey, boolean staticInit) { + this.rvKey = rvKey; + this.staticInit = staticInit; + } + + @Override + public String __returned$proxy$key() { + return rvKey; + } + + @Override + public boolean __static$$init() { + return staticInit; + } + } + private String createMessage(String description, String name, SyntheticBeanBuildItem bean) { StringBuilder builder = new StringBuilder(); builder.append(description); diff --git a/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/init/InitializationTaskProcessor.java b/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/init/InitializationTaskProcessor.java index 194ad9ba1e73a..84f939803a742 100644 --- a/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/init/InitializationTaskProcessor.java +++ b/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/init/InitializationTaskProcessor.java @@ -3,11 +3,12 @@ import java.util.List; import io.quarkus.arc.deployment.SyntheticBeansRuntimeInitBuildItem; +import io.quarkus.core.Phase; +import io.quarkus.core.deployment.service.ServiceRegistrar; import io.quarkus.deployment.annotations.BuildStep; import io.quarkus.deployment.annotations.Consume; -import io.quarkus.deployment.annotations.ExecutionTime; -import io.quarkus.deployment.annotations.Record; import io.quarkus.deployment.builditem.InitTaskCompletedBuildItem; +import io.quarkus.runtime.init.InitRuntimeConfig; import io.quarkus.runtime.init.InitializationTaskRecorder; /** @@ -19,9 +20,13 @@ public class InitializationTaskProcessor { @BuildStep @Consume(SyntheticBeansRuntimeInitBuildItem.class) - @Record(ExecutionTime.RUNTIME_INIT) - void startApplicationInitializer(InitializationTaskRecorder recorder, + void startApplicationInitializer(ServiceRegistrar reg, List initTaskCompletedBuildItems) { - recorder.exitIfNeeded(); + reg + .forService("io.quarkus.arc.init-task") + .atPhase(Phase.INIT) + .require(InitRuntimeConfig.class) + .afterBuildItem(SyntheticBeansRuntimeInitBuildItem.class) + .onStart((ctx, config) -> InitializationTaskRecorder.exitIfNeeded(config)); } } diff --git a/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/staticmethods/InterceptedStaticMethodsProcessor.java b/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/staticmethods/InterceptedStaticMethodsProcessor.java index 4b58220a631dc..6b1d66e1bb6db 100644 --- a/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/staticmethods/InterceptedStaticMethodsProcessor.java +++ b/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/staticmethods/InterceptedStaticMethodsProcessor.java @@ -1,6 +1,5 @@ package io.quarkus.arc.deployment.staticmethods; -import static io.quarkus.deployment.annotations.ExecutionTime.STATIC_INIT; import static org.jboss.jandex.gizmo2.Jandex2Gizmo.classDescOf; import java.lang.constant.ClassDesc; @@ -54,12 +53,13 @@ import io.quarkus.arc.processor.AnnotationLiteralProcessor; import io.quarkus.arc.processor.BeanProcessor; import io.quarkus.arc.processor.InterceptorInfo; -import io.quarkus.arc.runtime.InterceptedStaticMethodsRecorder; +import io.quarkus.arc.runtime.ArcRecorder; +import io.quarkus.core.Phase; +import io.quarkus.core.deployment.service.ServiceRegistrar; import io.quarkus.deployment.GeneratedClassGizmo2Adaptor; import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; import io.quarkus.deployment.annotations.Produce; -import io.quarkus.deployment.annotations.Record; import io.quarkus.deployment.builditem.BytecodeTransformerBuildItem; import io.quarkus.deployment.builditem.GeneratedClassBuildItem; import io.quarkus.deployment.builditem.GeneratedResourceBuildItem; @@ -88,6 +88,8 @@ public class InterceptedStaticMethodsProcessor { private static final Logger LOGGER = Logger.getLogger(InterceptedStaticMethodsProcessor.class); + private static final String INITIALIZER_CLASS_NAME = "io.quarkus.arc.runtime.InterceptedStaticMethodsInitializer"; + static final MethodDesc INTERCEPTED_STATIC_METHODS_REGISTER = MethodDesc.of(InterceptedStaticMethods.class, "register", void.class, String.class, InterceptedMethodMetadata.class); @@ -199,7 +201,7 @@ void processInterceptedStaticMethods(BeanArchiveIndexBuildItem beanArchiveIndex, generatedServiceProviders, new Predicate() { @Override public boolean test(String name) { - if (InterceptedStaticMethodsRecorder.INITIALIZER_CLASS_NAME.equals(name)) { + if (INITIALIZER_CLASS_NAME.equals(name)) { return true; } @@ -267,7 +269,7 @@ public boolean test(String name) { } // Generate a global initializer that calls all other initializers; this initializer must be loaded by the runtime ClassLoader - gizmo.class_(InterceptedStaticMethodsRecorder.INITIALIZER_CLASS_NAME, cc -> { + gizmo.class_(INITIALIZER_CLASS_NAME, cc -> { cc.final_(); cc.staticInitializer(bc -> { @@ -480,14 +482,23 @@ public ClassVisitor apply(String className, ClassVisitor outputClassVisitor) { } - @Record(STATIC_INIT) @BuildStep void callInitializer(BeanContainerBuildItem beanContainer, List interceptedStaticMethods, - InterceptedStaticMethodsRecorder recorder) { + ServiceRegistrar reg) { if (interceptedStaticMethods.isEmpty()) { return; } - recorder.callInitializer(); + reg + .forService("io.quarkus.arc.intercepted-static-methods.init") + .atPhase(Phase.STATIC_INIT) + .require(io.quarkus.arc.ArcContainer.class) + .onStart((ctx, container) -> { + ClassLoader cl = Thread.currentThread().getContextClassLoader(); + if (cl == null) { + cl = ArcRecorder.class.getClassLoader(); + } + Class.forName(INITIALIZER_CLASS_NAME, true, cl); + }); } } diff --git a/extensions/arc/deployment/src/test/java/io/quarkus/arc/test/synthetic/SyntheticBeanBuildItemProxyTest.java b/extensions/arc/deployment/src/test/java/io/quarkus/arc/test/synthetic/SyntheticBeanBuildItemProxyTest.java index 782b98707d4f4..a11535d53a9fe 100644 --- a/extensions/arc/deployment/src/test/java/io/quarkus/arc/test/synthetic/SyntheticBeanBuildItemProxyTest.java +++ b/extensions/arc/deployment/src/test/java/io/quarkus/arc/test/synthetic/SyntheticBeanBuildItemProxyTest.java @@ -44,7 +44,7 @@ public void accept(BuildChainBuilder builder) { public void execute(BuildContext context) { BytecodeRecorderImpl bytecodeRecorder = new BytecodeRecorderImpl(true, TestRecorder.class.getSimpleName(), - "test", "" + TestRecorder.class.hashCode(), true); + "test", "" + TestRecorder.class.hashCode(), context.getStepId(), true); // We need to use reflection due to some class loading problems Object recorderProxy = bytecodeRecorder.getRecordingProxy(TestRecorder.class); try { @@ -76,7 +76,7 @@ public void execute(BuildContext context) { } catch (Exception e) { throw new RuntimeException(e); } - context.produce(new StaticBytecodeRecorderBuildItem(bytecodeRecorder)); + context.produce(new StaticBytecodeRecorderBuildItem(bytecodeRecorder, context.getStepId())); } }).produces(StaticBytecodeRecorderBuildItem.class).produces(SyntheticBeanBuildItem.class).build(); } diff --git a/extensions/arc/deployment/src/test/java/io/quarkus/arc/test/synthetic/SyntheticBeanServiceValueTest.java b/extensions/arc/deployment/src/test/java/io/quarkus/arc/test/synthetic/SyntheticBeanServiceValueTest.java new file mode 100644 index 0000000000000..66dc95d26a00c --- /dev/null +++ b/extensions/arc/deployment/src/test/java/io/quarkus/arc/test/synthetic/SyntheticBeanServiceValueTest.java @@ -0,0 +1,149 @@ +package io.quarkus.arc.test.synthetic; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.Method; +import java.util.function.Consumer; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Vetoed; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.arc.Arc; +import io.quarkus.arc.InstanceHandle; +import io.quarkus.arc.deployment.SyntheticBeanBuildItem; +import io.quarkus.builder.BuildChainBuilder; +import io.quarkus.builder.BuildContext; +import io.quarkus.builder.BuildStep; +import io.quarkus.core.deployment.service.impl.TransliteratedAction; +import io.quarkus.deployment.builditem.StaticBytecodeRecorderBuildItem; +import io.quarkus.deployment.recording.BytecodeRecorderImpl; +import io.quarkus.runtime.annotations.Recorder; +import io.quarkus.test.QuarkusExtensionTest; + +/** + * Tests the {@link SyntheticBeanBuildItem.ExtendedBeanConfigurator#serviceValue(Class)} API, + * which bridges a service graph value into a CDI synthetic bean. + *

+ * The test manually populates a service value in the startup context using a + * {@link BytecodeRecorderImpl} and an {@link TransliteratedAction.AliasService}, + * then verifies that a {@code SyntheticBeanBuildItem} with {@code serviceValue()} + * produces a CDI bean whose instance is the service value. + */ +public class SyntheticBeanServiceValueTest { + + @RegisterExtension + static final QuarkusExtensionTest config = new QuarkusExtensionTest() + .withApplicationRoot(root -> root.addClasses(SyntheticBeanServiceValueTest.class, SynthBean.class)) + .addBuildChainCustomizer(buildCustomizer()); + + static Consumer buildCustomizer() { + return new Consumer() { + + @Override + public void accept(BuildChainBuilder builder) { + builder.addBuildStep(new BuildStep() { + + @Override + public void execute(BuildContext context) { + // Use a BytecodeRecorderImpl to produce a SynthBean via a @Recorder method. + // This stores the value in StartupContext under a recorder proxy key. + BytecodeRecorderImpl bytecodeRecorder = new BytecodeRecorderImpl(true, + TestRecorder.class.getSimpleName(), + "test", "" + TestRecorder.class.hashCode(), context.getStepId(), true); + // Reflection is needed due to classloading constraints in the test framework + Object recorderProxy = bytecodeRecorder.getRecordingProxy(TestRecorder.class); + try { + Method create = recorderProxy.getClass().getDeclaredMethod("create", String.class); + Object proxy = create.invoke(recorderProxy, "service-value"); + + // Get the auto-assigned recorder proxy key + String recorderKey = ((BytecodeRecorderImpl.ReturnedProxy) proxy) + .__returned$proxy$key(); + + // Alias the recorder value to the service key so it appears + // as a service graph value in StartupContext + String serviceKey = SynthBean.class.getName() + ":"; + TransliteratedAction alias = new TransliteratedAction.AliasService( + serviceKey, true, recorderKey, context.getStepId()); + + // Produce items in order: recorder bytecode first, then alias + context.produce(new StaticBytecodeRecorderBuildItem(bytecodeRecorder)); + context.produce(new StaticBytecodeRecorderBuildItem(alias)); + + // Produce the synthetic bean backed by the service value + context.produce(SyntheticBeanBuildItem.configure(SynthBean.class) + .scope(ApplicationScoped.class) + .serviceValue(SynthBean.class) + .unremovable() + .done()); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + }).produces(StaticBytecodeRecorderBuildItem.class).produces(SyntheticBeanBuildItem.class).build(); + } + }; + } + + /** + * Recorder that produces {@link SynthBean} instances during static init. + */ + @Recorder + public static class TestRecorder { + + /** + * Create a new {@link SynthBean} with the given value. + * + * @param val the value to set + * @return a new {@code SynthBean} + */ + public SynthBean create(String val) { + SynthBean bean = new SynthBean(); + bean.setValue(val); + return bean; + } + } + + @Test + public void testServiceValueBean() { + InstanceHandle handle = Arc.container().instance(SynthBean.class); + assertThat(handle.isAvailable()).isTrue(); + assertThat(handle.get().getValue()).isEqualTo("service-value"); + } + + /** + * A simple bean class used as the synthetic bean type. + */ + @Vetoed + public static class SynthBean { + + private String value; + + /** + * Construct a new instance. + */ + public SynthBean() { + } + + /** + * Get the value. + * + * @return the value + */ + public String getValue() { + return value; + } + + /** + * Set the value. + * + * @param value the value + */ + public void setValue(String value) { + this.value = value; + } + } +} diff --git a/extensions/arc/deployment/src/test/java/io/quarkus/arc/test/unused/UnusedExclusionTest.java b/extensions/arc/deployment/src/test/java/io/quarkus/arc/test/unused/UnusedExclusionTest.java index 82c6595676c40..3b20b48b346d6 100644 --- a/extensions/arc/deployment/src/test/java/io/quarkus/arc/test/unused/UnusedExclusionTest.java +++ b/extensions/arc/deployment/src/test/java/io/quarkus/arc/test/unused/UnusedExclusionTest.java @@ -51,7 +51,7 @@ public void execute(BuildContext context) { BeanContainer beanContainer = context.consume(BeanContainerBuildItem.class).getValue(); BytecodeRecorderImpl bytecodeRecorder = new BytecodeRecorderImpl(true, TestRecorder.class.getSimpleName(), - "test", "" + TestRecorder.class.hashCode(), true); + "test", "" + TestRecorder.class.hashCode(), context.getStepId(), true); // We need to use reflection due to some class loading problems Object recorderProxy = bytecodeRecorder.getRecordingProxy(TestRecorder.class); try { diff --git a/extensions/arc/runtime/src/main/java/io/quarkus/arc/runtime/ArcRecorder.java b/extensions/arc/runtime/src/main/java/io/quarkus/arc/runtime/ArcRecorder.java index 3c3411c0b5ca5..118ec94be8cd0 100644 --- a/extensions/arc/runtime/src/main/java/io/quarkus/arc/runtime/ArcRecorder.java +++ b/extensions/arc/runtime/src/main/java/io/quarkus/arc/runtime/ArcRecorder.java @@ -28,6 +28,7 @@ import io.quarkus.runtime.RuntimeValue; import io.quarkus.runtime.ShutdownContext; import io.quarkus.runtime.ShutdownEvent; +import io.quarkus.runtime.StartupContext; import io.quarkus.runtime.StartupEvent; import io.quarkus.runtime.annotations.Recorder; import io.quarkus.runtime.test.TestApplicationClassPredicate; @@ -51,13 +52,7 @@ public ArcContainer initContainer(ShutdownContext shutdown, RuntimeValue listeners) - throws Exception { + /** + * Create the {@link BeanContainer} that wraps the given {@link ArcContainer}. + *

+ * This is invoked directly from the {@code BeanContainer} static-init service action (see + * {@code ArcProcessor#createBeanContainer}), not recorded as bytecode; it is therefore a plain + * {@code static} factory rather than a recorder method. It lives here so it can access the + * package-private {@link BeanContainerImpl} constructor. + * + * @param container the running Arc container (must not be {@code null}) + * @return the bean container (never {@code null}) + */ + public static BeanContainer createBeanContainer(ArcContainer container) { if (container == null) { throw new IllegalArgumentException("Arc container was null"); } - BeanContainer beanContainer = new BeanContainerImpl(container); + return new BeanContainerImpl(container); + } + + /** + * Notify the given legacy {@link BeanContainerListener}s that the {@link BeanContainer} has been created. + *

+ * Retained as a recorder method for the coexistence period so that extensions which still produce + * {@code BeanContainerListenerBuildItem}s continue to work; converted extensions should instead define + * their own static-init services that depend on {@code BeanContainer}. + * + * @param beanContainer the bean container the listeners are notified about (must not be {@code null}) + * @param listeners the listeners to notify (must not be {@code null}) + */ + public void fireBeanContainerListeners(BeanContainer beanContainer, List listeners) { for (BeanContainerListener listener : listeners) { long start = System.currentTimeMillis(); listener.created(beanContainer); LOG.debugf("Bean container listener %s finished in %s ms", listener.getClass().getName(), System.currentTimeMillis() - start); } - return beanContainer; } + /** + * @deprecated handled by the Arc lifecycle runtime service; retained for compatibility + */ + @Deprecated(forRemoval = true) public void handleLifecycleEvents(ShutdownContext context, LaunchMode launchMode, boolean disableApplicationLifecycleObservers) { - ArcContainerImpl container = ArcContainerImpl.instance(); - List> mockBeanClasses; - - // If needed then mock all app observers in the test mode - if (launchMode == LaunchMode.TEST && disableApplicationLifecycleObservers) { - Predicate predicate = container - .select(TestApplicationClassPredicate.class).get(); - mockBeanClasses = new ArrayList<>(); - for (InjectableBean bean : container.getBeans()) { - // Mock observers for all application class beans - if (bean.getKind() == Kind.CLASS && predicate.test(bean.getBeanClass().getName())) { - mockBeanClasses.add(bean.getBeanClass()); - } - } - } else { - mockBeanClasses = Collections.emptyList(); - } - - fireLifecycleEvent(container, new StartupEvent(), mockBeanClasses); - - context.addShutdownTask(new Runnable() { - @Override - public void run() { - fireLifecycleEvent(container, new ShutdownEvent(ApplicationLifecycleManager.shutdownReason), mockBeanClasses); - } - }); + List> mockBeanClasses = computeMockBeanClasses(launchMode, disableApplicationLifecycleObservers); + fireLifecycleEvent(new StartupEvent(), mockBeanClasses); + context.addShutdownTask(() -> performShutdown(mockBeanClasses)); } public Function, Object> createFunction(RuntimeValue value) { @@ -149,6 +147,31 @@ public Object apply(SyntheticCreationalContext t) { }; } + /** + * Create a creation function for a service-value-backed synthetic bean. + * The function reads and removes the service value from the startup context + * on first CDI access, ensuring the service action has already populated + * the value. Removing drains the map so it does not persist after startup. + * + * @param serviceKey the startup context key for the service value + * @param startupContext the startup context + * @return a function that reads and removes the service value on demand + */ + public Function, Object> createServiceValueFunction( + String serviceKey, StartupContext startupContext) { + return new Function, Object>() { + @Override + public Object apply(SyntheticCreationalContext t) { + Object value = startupContext.removeServiceValue(serviceKey); + if (value == null) { + throw new IllegalStateException("Service '" + serviceKey + + "' has not been initialized; the CDI bean was accessed before the service action ran"); + } + return value; + } + }; + } + public void initTestApplicationClassPredicate(Set applicationBeanClasses) { PreloadedTestApplicationClassPredicate predicate = Arc.requireContainer() .instance(PreloadedTestApplicationClassPredicate.class) @@ -156,7 +179,38 @@ public void initTestApplicationClassPredicate(Set applicationBeanClasses predicate.setApplicationBeanClasses(applicationBeanClasses); } - private void fireLifecycleEvent(ArcContainerImpl container, Object event, List> mockBeanClasses) { + /** + * Compute the list of bean classes whose observers should be mocked in test mode. + * Returns an empty list if mocking is not applicable. + * + * @param launchMode the launch mode + * @param disableApplicationLifecycleObservers whether test observers are disabled + * @return the list of bean classes to mock (never {@code null}) + */ + public static List> computeMockBeanClasses(LaunchMode launchMode, boolean disableApplicationLifecycleObservers) { + if (launchMode == LaunchMode.TEST && disableApplicationLifecycleObservers) { + ArcContainerImpl container = ArcContainerImpl.instance(); + Predicate predicate = container + .select(TestApplicationClassPredicate.class).get(); + List> mockBeanClasses = new ArrayList<>(); + for (InjectableBean bean : container.getBeans()) { + if (bean.getKind() == Kind.CLASS && predicate.test(bean.getBeanClass().getName())) { + mockBeanClasses.add(bean.getBeanClass()); + } + } + return mockBeanClasses; + } + return Collections.emptyList(); + } + + /** + * Fire a CDI lifecycle event, temporarily mocking observers for the given bean classes. + * + * @param event the event to fire + * @param mockBeanClasses bean classes whose observers should be mocked (may be empty) + */ + public static void fireLifecycleEvent(Object event, List> mockBeanClasses) { + ArcContainerImpl container = ArcContainerImpl.instance(); if (!mockBeanClasses.isEmpty()) { for (Class beanClass : mockBeanClasses) { container.mockObserversFor(beanClass, true); @@ -170,4 +224,26 @@ private void fireLifecycleEvent(ArcContainerImpl container, Object event, List> mockBeanClasses) { + fireLifecycleEvent(new ShutdownEvent(ApplicationLifecycleManager.shutdownReason), mockBeanClasses); + } + + /** + * Tear down the synthetic bean provider maps. This must run only after the Arc container has been fully + * destroyed: bean destruction (including {@code @PreDestroy} callbacks and {@link BeanDestroyer}s) may + * resolve synthetic beans, so the providers have to remain available until destruction completes. It is + * invoked from the {@code ArcContainer} static-init service's {@code onStop} handler, after + * {@code Arc.shutdown()}. + */ + public static void clearSyntheticBeans() { + syntheticBeanProviders = null; + } + } diff --git a/extensions/arc/runtime/src/main/java/io/quarkus/arc/runtime/InterceptedStaticMethodsRecorder.java b/extensions/arc/runtime/src/main/java/io/quarkus/arc/runtime/InterceptedStaticMethodsRecorder.java deleted file mode 100644 index 86a993c174a39..0000000000000 --- a/extensions/arc/runtime/src/main/java/io/quarkus/arc/runtime/InterceptedStaticMethodsRecorder.java +++ /dev/null @@ -1,23 +0,0 @@ -package io.quarkus.arc.runtime; - -import io.quarkus.runtime.annotations.Recorder; - -@Recorder -public class InterceptedStaticMethodsRecorder { - - // This class is generated and calls all generated static interceptor initializers to register metadata in the InterceptedStaticMethods class - public static final String INITIALIZER_CLASS_NAME = "io.quarkus.arc.runtime.InterceptedStaticMethodsInitializer"; - - public void callInitializer() { - ClassLoader cl = Thread.currentThread().getContextClassLoader(); - if (cl == null) { - cl = InterceptedStaticMethodsRecorder.class.getClassLoader(); - } - try { - Class.forName(INITIALIZER_CLASS_NAME, true, cl); - } catch (ClassNotFoundException e) { - throw new IllegalStateException(e); - } - } - -} diff --git a/extensions/arc/runtime/src/main/java/io/quarkus/arc/runtime/MicroProfileConfigRecorder.java b/extensions/arc/runtime/src/main/java/io/quarkus/arc/runtime/MicroProfileConfigRecorder.java index 05c0a1f112da6..ce5171342bf90 100644 --- a/extensions/arc/runtime/src/main/java/io/quarkus/arc/runtime/MicroProfileConfigRecorder.java +++ b/extensions/arc/runtime/src/main/java/io/quarkus/arc/runtime/MicroProfileConfigRecorder.java @@ -76,7 +76,12 @@ public void validateConfigProperties(Set properties) { } } - public void registerConfigProperties(final Map> configProperties) { + /** + * Register {@code @ConfigProperties} config classes with SmallRye Config. + * + * @param configProperties map of class name to set of prefixes + */ + public static void registerConfigProperties(final Map> configProperties) { try { Set configClasses = new HashSet<>(); for (Entry> entry : configProperties.entrySet()) { @@ -92,32 +97,22 @@ public void registerConfigProperties(final Map> configProper } } - private Class load(String className, ClassLoader cl) { - switch (className) { - case "boolean": - return boolean.class; - case "byte": - return byte.class; - case "short": - return short.class; - case "int": - return int.class; - case "long": - return long.class; - case "float": - return float.class; - case "double": - return double.class; - case "char": - return char.class; - case "void": - return void.class; - default: - try { - return Class.forName(className, true, cl); - } catch (ClassNotFoundException e) { - throw new IllegalStateException("Unable to load the config property type: " + className, e); - } + private static Class load(String className, ClassLoader cl) { + try { + return switch (className) { + case "boolean" -> boolean.class; + case "byte" -> byte.class; + case "short" -> short.class; + case "int" -> int.class; + case "long" -> long.class; + case "float" -> float.class; + case "double" -> double.class; + case "char" -> char.class; + case "void" -> void.class; + default -> Class.forName(className, true, cl); + }; + } catch (ClassNotFoundException e) { + throw new IllegalStateException("Unable to load the config property type: " + className, e); } } From e550ab4ee6b3c5b99d9e41558d7546bc2d49a035 Mon Sep 17 00:00:00 2001 From: "David M. Lloyd" Date: Wed, 8 Jul 2026 11:39:27 -0500 Subject: [PATCH 032/254] Service conversion: Mutiny --- .../mutiny/deployment/MutinyProcessor.java | 59 +++++++++++++------ .../mutiny/runtime/MutinyInfrastructure.java | 34 ++++++++--- 2 files changed, 65 insertions(+), 28 deletions(-) diff --git a/extensions/mutiny/deployment/src/main/java/io/quarkus/mutiny/deployment/MutinyProcessor.java b/extensions/mutiny/deployment/src/main/java/io/quarkus/mutiny/deployment/MutinyProcessor.java index d31cbfbfd3050..f281ba28c0c73 100644 --- a/extensions/mutiny/deployment/src/main/java/io/quarkus/mutiny/deployment/MutinyProcessor.java +++ b/extensions/mutiny/deployment/src/main/java/io/quarkus/mutiny/deployment/MutinyProcessor.java @@ -1,35 +1,56 @@ package io.quarkus.mutiny.deployment; -import java.util.Optional; - -import org.jboss.threads.ContextHandler; +import java.util.concurrent.ScheduledExecutorService; +import io.quarkus.core.Phase; +import io.quarkus.core.deployment.service.ServiceRegistrar; import io.quarkus.deployment.annotations.BuildStep; -import io.quarkus.deployment.annotations.ExecutionTime; -import io.quarkus.deployment.annotations.Record; -import io.quarkus.deployment.builditem.ContextHandlerBuildItem; import io.quarkus.deployment.builditem.ExecutorBuildItem; -import io.quarkus.deployment.builditem.ShutdownContextBuildItem; import io.quarkus.mutiny.runtime.MutinyInfrastructure; +/** + * Build steps for configuring the Mutiny infrastructure. + */ public class MutinyProcessor { + /** + * Configure the Mutiny infrastructure with the application's executor at runtime. + *

+ * The {@code executorBuildItem} parameter is consumed (but not used directly) to ensure + * that the build step which aliases the executor into the service graph runs before this one. + * + * @param reg the service registrar + * @param executorBuildItem consumed for build-step ordering (the executor value is obtained + * via the service graph) + * @return a build item signaling that Mutiny runtime initialization is complete + */ @BuildStep - @Record(ExecutionTime.RUNTIME_INIT) - public MutinyRuntimeInitBuildItem runtimeInit(ExecutorBuildItem executorBuildItem, - MutinyInfrastructure recorder, - ShutdownContextBuildItem shutdownContext, - Optional contextHandler) { - ContextHandler handler = contextHandler.map(ContextHandlerBuildItem::contextHandler).orElse(null); - recorder.configureMutinyInfrastructure(executorBuildItem.getExecutorProxy(), shutdownContext, handler); + @SuppressWarnings("unused") + MutinyRuntimeInitBuildItem runtimeInit(ServiceRegistrar reg, ExecutorBuildItem executorBuildItem) { + reg + .forService("io.quarkus.mutiny.runtime-init") + .atPhase(Phase.INFRASTRUCTURE) + .require(ScheduledExecutorService.class) + .onStart((ctx, executor) -> MutinyInfrastructure.configureMutinyInfrastructure(executor)); + return new MutinyRuntimeInitBuildItem(); } + /** + * Configure Mutiny's dropped exception handler, thread blocking checker, + * and operator logger at static init time. + * + * @param reg the service registrar + */ @BuildStep - @Record(ExecutionTime.STATIC_INIT) - public void buildTimeInit(MutinyInfrastructure recorder) { - recorder.configureDroppedExceptionHandler(); - recorder.configureThreadBlockingChecker(); - recorder.configureOperatorLogger(); + void buildTimeInit(ServiceRegistrar reg) { + reg + .forService("io.quarkus.mutiny.static-init") + .atPhase(Phase.STATIC_INIT) + .onStart(ctx -> { + MutinyInfrastructure.configureDroppedExceptionHandler(); + MutinyInfrastructure.configureThreadBlockingChecker(); + MutinyInfrastructure.configureOperatorLogger(); + }); } } diff --git a/extensions/mutiny/runtime/src/main/java/io/quarkus/mutiny/runtime/MutinyInfrastructure.java b/extensions/mutiny/runtime/src/main/java/io/quarkus/mutiny/runtime/MutinyInfrastructure.java index e8d3621723410..b33e2261910be 100644 --- a/extensions/mutiny/runtime/src/main/java/io/quarkus/mutiny/runtime/MutinyInfrastructure.java +++ b/extensions/mutiny/runtime/src/main/java/io/quarkus/mutiny/runtime/MutinyInfrastructure.java @@ -5,25 +5,35 @@ import java.util.function.Consumer; import org.jboss.logging.Logger; -import org.jboss.threads.ContextHandler; -import io.quarkus.runtime.ShutdownContext; -import io.quarkus.runtime.annotations.Recorder; import io.smallrye.mutiny.infrastructure.Infrastructure; -@Recorder +/** + * Utility methods for configuring the Mutiny {@link Infrastructure} during application startup. + */ public class MutinyInfrastructure { + /** + * The prefix used by Vert.x event loop thread names. + */ public static final String VERTX_EVENT_LOOP_THREAD_PREFIX = "vert.x-eventloop-thread-"; - public void configureMutinyInfrastructure(ScheduledExecutorService executor, ShutdownContext shutdownContext, - ContextHandler contextHandler) { + /** + * Configure the Mutiny infrastructure to use the given executor as the default executor. + * The default worker pool is shut down first to prevent a leak. + * + * @param executor the executor to use as the default executor (must not be {@code null}) + */ + public static void configureMutinyInfrastructure(ScheduledExecutorService executor) { // Mutiny leaks a ScheduledExecutorService if we don't do this Infrastructure.getDefaultWorkerPool().shutdown(); Infrastructure.setDefaultExecutor(executor); } - public void configureDroppedExceptionHandler() { + /** + * Configure the dropped exception handler to log exceptions that Mutiny could not deliver. + */ + public static void configureDroppedExceptionHandler() { Logger logger = Logger.getLogger(MutinyInfrastructure.class); Infrastructure.setDroppedExceptionHandler(new Consumer() { @Override @@ -33,7 +43,10 @@ public void accept(Throwable throwable) { }); } - public void configureThreadBlockingChecker() { + /** + * Configure the thread blocking checker to prevent blocking on Vert.x event loop threads. + */ + public static void configureThreadBlockingChecker() { Infrastructure.setCanCallerThreadBeBlockedSupplier(new BooleanSupplier() { @Override public boolean getAsBoolean() { @@ -50,7 +63,10 @@ public boolean getAsBoolean() { }); } - public void configureOperatorLogger() { + /** + * Configure the operator logger for Mutiny pipeline debugging. + */ + public static void configureOperatorLogger() { Logger logger = Logger.getLogger(MutinyInfrastructure.class); Infrastructure.setOperatorLogger(new Infrastructure.OperatorLogger() { @Override From 4f4f99175c49d984cda8ee1b670ef5b757bc72a9 Mon Sep 17 00:00:00 2001 From: "David M. Lloyd" Date: Wed, 8 Jul 2026 11:39:45 -0500 Subject: [PATCH 033/254] Service conversion: Narayana STM --- .../stm/deployment/NarayanaSTMProcessor.java | 13 ++++++------- .../narayana/stm/runtime/NarayanaSTMRecorder.java | 13 ------------- 2 files changed, 6 insertions(+), 20 deletions(-) delete mode 100644 extensions/narayana-stm/runtime/src/main/java/io/quarkus/narayana/stm/runtime/NarayanaSTMRecorder.java diff --git a/extensions/narayana-stm/deployment/src/main/java/io/quarkus/narayana/stm/deployment/NarayanaSTMProcessor.java b/extensions/narayana-stm/deployment/src/main/java/io/quarkus/narayana/stm/deployment/NarayanaSTMProcessor.java index 75dd5218c5ca6..9781d9ad60e45 100644 --- a/extensions/narayana-stm/deployment/src/main/java/io/quarkus/narayana/stm/deployment/NarayanaSTMProcessor.java +++ b/extensions/narayana-stm/deployment/src/main/java/io/quarkus/narayana/stm/deployment/NarayanaSTMProcessor.java @@ -1,7 +1,5 @@ package io.quarkus.narayana.stm.deployment; -import static io.quarkus.deployment.annotations.ExecutionTime.RUNTIME_INIT; - import java.util.ArrayList; import java.util.Collection; @@ -15,15 +13,16 @@ import org.jboss.logging.Logger; import org.jboss.stm.annotations.Transactional; +import com.arjuna.ats.arjuna.common.arjPropertyManager; import com.arjuna.ats.internal.arjuna.coordinator.CheckedActionFactoryImple; import com.arjuna.ats.internal.arjuna.objectstore.ShadowNoFileLockStore; import com.arjuna.ats.internal.arjuna.utils.SocketProcessId; import com.arjuna.ats.txoj.Lock; +import io.quarkus.core.deployment.service.ServiceRegistrar; import io.quarkus.deployment.Feature; import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; -import io.quarkus.deployment.annotations.Record; import io.quarkus.deployment.builditem.CombinedIndexBuildItem; import io.quarkus.deployment.builditem.FeatureBuildItem; import io.quarkus.deployment.builditem.nativeimage.NativeImageProxyDefinitionBuildItem; @@ -31,7 +30,6 @@ import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; import io.quarkus.deployment.builditem.nativeimage.ReflectiveHierarchyBuildItem; import io.quarkus.deployment.builditem.nativeimage.RuntimeInitializedClassBuildItem; -import io.quarkus.narayana.stm.runtime.NarayanaSTMRecorder; class NarayanaSTMProcessor { private static final Logger log = Logger.getLogger(NarayanaSTMProcessor.class.getName()); @@ -59,10 +57,11 @@ public NativeImageSystemPropertyBuildItem substrateSystemPropertyBuildItem() { // the software transactional memory implementation does not require a TSM // so disable it at runtime @BuildStep() - @Record(RUNTIME_INIT) - public void configureRuntimeProperties(NarayanaSTMRecorder recorder, + public void configureRuntimeProperties(ServiceRegistrar reg, BuildProducer runtimeInit) { - recorder.disableTransactionStatusManager(); + reg + .forService("io.quarkus.narayana-stm.tsm-disable") + .onStart(ctx -> arjPropertyManager.getCoordinatorEnvironmentBean().setTransactionStatusManagerEnable(false)); runtimeInit.produce(new RuntimeInitializedClassBuildItem(SocketProcessId.class.getName())); runtimeInit.produce(new RuntimeInitializedClassBuildItem(Lock.class.getName())); } diff --git a/extensions/narayana-stm/runtime/src/main/java/io/quarkus/narayana/stm/runtime/NarayanaSTMRecorder.java b/extensions/narayana-stm/runtime/src/main/java/io/quarkus/narayana/stm/runtime/NarayanaSTMRecorder.java deleted file mode 100644 index 990808dfb0cb2..0000000000000 --- a/extensions/narayana-stm/runtime/src/main/java/io/quarkus/narayana/stm/runtime/NarayanaSTMRecorder.java +++ /dev/null @@ -1,13 +0,0 @@ -package io.quarkus.narayana.stm.runtime; - -import com.arjuna.ats.arjuna.common.arjPropertyManager; - -import io.quarkus.runtime.annotations.Recorder; - -@Recorder -public class NarayanaSTMRecorder { - public void disableTransactionStatusManager() { - arjPropertyManager.getCoordinatorEnvironmentBean() - .setTransactionStatusManagerEnable(false); - } -} From 72fbcff020f07e90845cd5369193959e60792da3 Mon Sep 17 00:00:00 2001 From: "David M. Lloyd" Date: Wed, 8 Jul 2026 11:40:05 -0500 Subject: [PATCH 034/254] Service conversion: Elytron Security Common --- .../QuarkusSecurityCommonProcessor.java | 25 +++++++++++-------- .../common/runtime/ElytronCommonRecorder.java | 17 +++++++------ 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/extensions/elytron-security-common/deployment/src/main/java/io/quarkus/elytron/security/common/deployment/QuarkusSecurityCommonProcessor.java b/extensions/elytron-security-common/deployment/src/main/java/io/quarkus/elytron/security/common/deployment/QuarkusSecurityCommonProcessor.java index 329c792fc3a4c..ff4b9730172f9 100644 --- a/extensions/elytron-security-common/deployment/src/main/java/io/quarkus/elytron/security/common/deployment/QuarkusSecurityCommonProcessor.java +++ b/extensions/elytron-security-common/deployment/src/main/java/io/quarkus/elytron/security/common/deployment/QuarkusSecurityCommonProcessor.java @@ -1,9 +1,9 @@ package io.quarkus.elytron.security.common.deployment; +import io.quarkus.core.Phase; +import io.quarkus.core.deployment.service.ServiceRegistrar; import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; -import io.quarkus.deployment.annotations.ExecutionTime; -import io.quarkus.deployment.annotations.Record; import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; import io.quarkus.deployment.builditem.nativeimage.RuntimeInitializedClassBuildItem; import io.quarkus.elytron.security.common.BcryptUtil; @@ -17,20 +17,23 @@ RuntimeInitializedClassBuildItem runtimeInitBcryptUtil() { } @BuildStep - @Record(ExecutionTime.STATIC_INIT) - public void registerPasswordProvider(ElytronCommonRecorder recorder) { - recorder.registerPasswordProvider(); + public void registerPasswordProvider(ServiceRegistrar reg) { + reg + .forService("io.quarkus.elytron-security-common.password-provider") + .atPhase(Phase.STATIC_INIT) + .onStart(ctx -> ElytronCommonRecorder.registerPasswordProvider()); } /** - * Graal VM now seems to lose providers registered at static init - * - * We re-register at runtime (which is a no-op in JVM mode) + * Graal VM now seems to lose providers registered at static init. + * We re-register at runtime (which is a no-op in JVM mode). */ @BuildStep - @Record(ExecutionTime.RUNTIME_INIT) - public void registerPasswordProviderForNative(ElytronCommonRecorder recorder) { - recorder.registerPasswordProvider(); + public void registerPasswordProviderForNative(ServiceRegistrar reg) { + reg + .forService("io.quarkus.elytron-security-common.password-provider-native") + .atPhase(Phase.INFRASTRUCTURE) + .onStart(ctx -> ElytronCommonRecorder.registerPasswordProvider()); } /** diff --git a/extensions/elytron-security-common/runtime/src/main/java/io/quarkus/elytron/security/common/runtime/ElytronCommonRecorder.java b/extensions/elytron-security-common/runtime/src/main/java/io/quarkus/elytron/security/common/runtime/ElytronCommonRecorder.java index faaf6149642d6..739b3431a2eeb 100644 --- a/extensions/elytron-security-common/runtime/src/main/java/io/quarkus/elytron/security/common/runtime/ElytronCommonRecorder.java +++ b/extensions/elytron-security-common/runtime/src/main/java/io/quarkus/elytron/security/common/runtime/ElytronCommonRecorder.java @@ -4,18 +4,19 @@ import org.wildfly.security.password.WildFlyElytronPasswordProvider; -import io.quarkus.runtime.annotations.Recorder; - /** - * The runtime security recorder class that provides methods for creating RuntimeValues for the deployment security objects. + * Runtime security utilities for the Elytron security common extension. */ -@Recorder public class ElytronCommonRecorder { - public void registerPasswordProvider() { - //we don't remove this, as there is no correct place where it can be removed - //as continuous testing can be running alongside the dev mode app, but there is - //only ever one provider + /** + * Register the WildFly Elytron password provider if not already present. + *

+ * We don't remove this, as there is no correct place where it can be removed — + * continuous testing can be running alongside the dev mode app, but there is + * only ever one provider. + */ + public static void registerPasswordProvider() { WildFlyElytronPasswordProvider provider = new WildFlyElytronPasswordProvider(); if (Security.getProvider(provider.getName()) == null) { Security.addProvider(provider); From b28a9746a50bb94f32bc792fcf8c0156c559bdaa Mon Sep 17 00:00:00 2001 From: "David M. Lloyd" Date: Wed, 8 Jul 2026 11:40:27 -0500 Subject: [PATCH 035/254] Service conversion: Websockets --- .../ServerWebSocketContainerBuildItem.java | 17 ++--- ...verWebSocketContainerFactoryBuildItem.java | 16 ++--- .../WebSocketDeploymentInfoBuildItem.java | 17 ++--- .../deployment/WebsocketClientProcessor.java | 67 +++++++++++++------ .../client/runtime/WebsocketCoreRecorder.java | 19 +++--- .../deployment/ServerWebSocketProcessor.java | 33 ++++++--- .../runtime/WebsocketServerRecorder.java | 14 ++-- 7 files changed, 101 insertions(+), 82 deletions(-) diff --git a/extensions/websockets/client/deployment/src/main/java/io/quarkus/websockets/client/deployment/ServerWebSocketContainerBuildItem.java b/extensions/websockets/client/deployment/src/main/java/io/quarkus/websockets/client/deployment/ServerWebSocketContainerBuildItem.java index 6395347a89935..bd8b6c521c4b2 100644 --- a/extensions/websockets/client/deployment/src/main/java/io/quarkus/websockets/client/deployment/ServerWebSocketContainerBuildItem.java +++ b/extensions/websockets/client/deployment/src/main/java/io/quarkus/websockets/client/deployment/ServerWebSocketContainerBuildItem.java @@ -1,18 +1,11 @@ package io.quarkus.websockets.client.deployment; import io.quarkus.builder.item.SimpleBuildItem; -import io.quarkus.runtime.RuntimeValue; -import io.undertow.websockets.ServerWebSocketContainer; +/** + * Marker build item indicating that a + * {@link io.undertow.websockets.ServerWebSocketContainer} service has been + * registered for static-init. + */ public final class ServerWebSocketContainerBuildItem extends SimpleBuildItem { - - private final RuntimeValue container; - - public ServerWebSocketContainerBuildItem(RuntimeValue container) { - this.container = container; - } - - public RuntimeValue getContainer() { - return container; - } } diff --git a/extensions/websockets/client/deployment/src/main/java/io/quarkus/websockets/client/deployment/ServerWebSocketContainerFactoryBuildItem.java b/extensions/websockets/client/deployment/src/main/java/io/quarkus/websockets/client/deployment/ServerWebSocketContainerFactoryBuildItem.java index 2e4f0d7237bad..12e1d1105136d 100644 --- a/extensions/websockets/client/deployment/src/main/java/io/quarkus/websockets/client/deployment/ServerWebSocketContainerFactoryBuildItem.java +++ b/extensions/websockets/client/deployment/src/main/java/io/quarkus/websockets/client/deployment/ServerWebSocketContainerFactoryBuildItem.java @@ -1,17 +1,11 @@ package io.quarkus.websockets.client.deployment; import io.quarkus.builder.item.SimpleBuildItem; -import io.quarkus.websockets.client.runtime.ServerWebSocketContainerFactory; +/** + * Marker build item indicating that the server module has registered a + * {@link io.quarkus.websockets.client.runtime.ServerWebSocketContainerFactory} + * service. When absent, the client module registers a default factory. + */ public final class ServerWebSocketContainerFactoryBuildItem extends SimpleBuildItem { - - private final ServerWebSocketContainerFactory factory; - - public ServerWebSocketContainerFactoryBuildItem(ServerWebSocketContainerFactory factory) { - this.factory = factory; - } - - public ServerWebSocketContainerFactory getFactory() { - return factory; - } } diff --git a/extensions/websockets/client/deployment/src/main/java/io/quarkus/websockets/client/deployment/WebSocketDeploymentInfoBuildItem.java b/extensions/websockets/client/deployment/src/main/java/io/quarkus/websockets/client/deployment/WebSocketDeploymentInfoBuildItem.java index daadfba2e5c96..707540d62f822 100644 --- a/extensions/websockets/client/deployment/src/main/java/io/quarkus/websockets/client/deployment/WebSocketDeploymentInfoBuildItem.java +++ b/extensions/websockets/client/deployment/src/main/java/io/quarkus/websockets/client/deployment/WebSocketDeploymentInfoBuildItem.java @@ -1,18 +1,11 @@ package io.quarkus.websockets.client.deployment; import io.quarkus.builder.item.SimpleBuildItem; -import io.quarkus.runtime.RuntimeValue; -import io.undertow.websockets.WebSocketDeploymentInfo; +/** + * Marker build item indicating that a + * {@link io.undertow.websockets.WebSocketDeploymentInfo} service has been + * registered for static-init. + */ public final class WebSocketDeploymentInfoBuildItem extends SimpleBuildItem { - - private final RuntimeValue info; - - public WebSocketDeploymentInfoBuildItem(RuntimeValue info) { - this.info = info; - } - - public RuntimeValue getInfo() { - return info; - } } diff --git a/extensions/websockets/client/deployment/src/main/java/io/quarkus/websockets/client/deployment/WebsocketClientProcessor.java b/extensions/websockets/client/deployment/src/main/java/io/quarkus/websockets/client/deployment/WebsocketClientProcessor.java index c5a2399a978c9..a011af33dc55b 100644 --- a/extensions/websockets/client/deployment/src/main/java/io/quarkus/websockets/client/deployment/WebsocketClientProcessor.java +++ b/extensions/websockets/client/deployment/src/main/java/io/quarkus/websockets/client/deployment/WebsocketClientProcessor.java @@ -6,6 +6,7 @@ import java.util.List; import java.util.Optional; import java.util.Set; +import java.util.concurrent.ScheduledExecutorService; import jakarta.websocket.ClientEndpoint; import jakarta.websocket.ClientEndpointConfig; @@ -24,11 +25,12 @@ import io.quarkus.arc.deployment.BeanContainerBuildItem; import io.quarkus.arc.deployment.BeanDefiningAnnotationBuildItem; +import io.quarkus.arc.runtime.BeanContainer; +import io.quarkus.core.Phase; +import io.quarkus.core.deployment.service.ServiceRegistrar; import io.quarkus.deployment.Feature; import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; -import io.quarkus.deployment.annotations.ExecutionTime; -import io.quarkus.deployment.annotations.Record; import io.quarkus.deployment.builditem.CombinedIndexBuildItem; import io.quarkus.deployment.builditem.ExecutorBuildItem; import io.quarkus.deployment.builditem.FeatureBuildItem; @@ -36,9 +38,9 @@ import io.quarkus.deployment.builditem.ServiceStartBuildItem; import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; import io.quarkus.deployment.builditem.nativeimage.ServiceProviderBuildItem; -import io.quarkus.runtime.RuntimeValue; import io.quarkus.undertow.deployment.ServletContextAttributeBuildItem; import io.quarkus.websockets.client.runtime.DisableLoggingFeature; +import io.quarkus.websockets.client.runtime.ServerWebSocketContainerFactory; import io.quarkus.websockets.client.runtime.WebsocketCoreRecorder; import io.undertow.websockets.DefaultContainerConfigurator; import io.undertow.websockets.ServerWebSocketContainer; @@ -79,9 +81,8 @@ void scanForAnnotatedEndpoints(CombinedIndexBuildItem indexBuildItem, } @BuildStep - @Record(ExecutionTime.STATIC_INIT) public ServerWebSocketContainerBuildItem deploy(final CombinedIndexBuildItem indexBuildItem, - WebsocketCoreRecorder recorder, + ServiceRegistrar reg, BuildProducer reflection, List annotatedEndpoints, BeanContainerBuildItem beanContainerBuildItem, @@ -129,18 +130,44 @@ public ServerWebSocketContainerBuildItem deploy(final CombinedIndexBuildItem ind ReflectiveClassBuildItem.builder(ClientEndpointConfig.Configurator.class.getName()).methods().fields() .build()); - RuntimeValue deploymentInfo = recorder.createDeploymentInfo(annotated, endpoints, config, - websocketConfig.maxFrameSize(), - websocketConfig.dispatchToWorker()); - infoBuildItemBuildProducer.produce(new WebSocketDeploymentInfoBuildItem(deploymentInfo)); - RuntimeValue serverContainer = recorder.createServerContainer( - beanContainerBuildItem.getValue(), - deploymentInfo, - factoryBuildItem.map(ServerWebSocketContainerFactoryBuildItem::getFactory).orElse(null)); + // extract build-time config values into locals (BUILD_TIME config cannot be captured directly) + int maxFrameSize = websocketConfig.maxFrameSize(); + boolean dispatchToWorker = websocketConfig.dispatchToWorker(); + + // convert annotated/endpoints/config to immutable sets for lambda capture + Set capturedAnnotated = Set.copyOf(annotated); + Set capturedEndpoints = Set.copyOf(endpoints); + Set capturedConfig = Set.copyOf(config); + + reg + .forService(WebSocketDeploymentInfo.class) + .atPhase(Phase.STATIC_INIT) + .onStart(ctx -> WebsocketCoreRecorder.createDeploymentInfo( + capturedAnnotated, capturedEndpoints, capturedConfig, + maxFrameSize, dispatchToWorker)); + infoBuildItemBuildProducer.produce(new WebSocketDeploymentInfoBuildItem()); + + // if the server module didn't register a factory, register the default + if (factoryBuildItem.isEmpty()) { + reg + .forService(ServerWebSocketContainerFactory.class) + .atPhase(Phase.STATIC_INIT) + .onStart(ctx -> (ServerWebSocketContainerFactory) ServerWebSocketContainer::new); + } + + reg + .forService(ServerWebSocketContainer.class) + .atPhase(Phase.STATIC_INIT) + .require(WebSocketDeploymentInfo.class) + .require(ServerWebSocketContainerFactory.class) + .require(BeanContainer.class) + .onStart((ctx, info, factory, beanContainer) -> WebsocketCoreRecorder.createServerContainer(beanContainer, info, + factory)); + servletContextAttributeBuildItemBuildProducer - .produce(new ServletContextAttributeBuildItem(ServerContainer.class.getName(), serverContainer)); - return new ServerWebSocketContainerBuildItem( - serverContainer); + .produce(new ServletContextAttributeBuildItem(ServerContainer.class.getName(), + reg.staticInitServiceAsRuntimeValue(ServerWebSocketContainer.class))); + return new ServerWebSocketContainerBuildItem(); } public static void registerCodersForReflection(BuildProducer reflection, @@ -166,9 +193,11 @@ static void registerForReflection(BuildProducer reflec } @BuildStep - @Record(ExecutionTime.RUNTIME_INIT) - ServiceStartBuildItem setupWorker(WebsocketCoreRecorder recorder, ExecutorBuildItem exec) { - recorder.setupWorker(exec.getExecutorProxy()); + ServiceStartBuildItem setupWorker(ServiceRegistrar reg, ExecutorBuildItem exec) { + reg + .forService("io.quarkus.websocket.worker-setup") + .require(ScheduledExecutorService.class) + .onStart((ctx, executor) -> WebsocketCoreRecorder.setupWorker(executor)); return new ServiceStartBuildItem("Websockets"); } diff --git a/extensions/websockets/client/runtime/src/main/java/io/quarkus/websockets/client/runtime/WebsocketCoreRecorder.java b/extensions/websockets/client/runtime/src/main/java/io/quarkus/websockets/client/runtime/WebsocketCoreRecorder.java index 2c8e271d864ee..33b7871a112c1 100644 --- a/extensions/websockets/client/runtime/src/main/java/io/quarkus/websockets/client/runtime/WebsocketCoreRecorder.java +++ b/extensions/websockets/client/runtime/src/main/java/io/quarkus/websockets/client/runtime/WebsocketCoreRecorder.java @@ -19,8 +19,6 @@ import io.quarkus.arc.Arc; import io.quarkus.arc.ManagedContext; import io.quarkus.arc.runtime.BeanContainer; -import io.quarkus.runtime.RuntimeValue; -import io.quarkus.runtime.annotations.Recorder; import io.quarkus.security.identity.CurrentIdentityAssociation; import io.quarkus.vertx.core.runtime.VertxCoreRecorder; import io.undertow.websockets.ServerWebSocketContainer; @@ -33,17 +31,19 @@ import io.undertow.websockets.util.ObjectIntrospecter; import io.vertx.core.internal.VertxInternal; -@Recorder public class WebsocketCoreRecorder { private static final Logger log = Logger.getLogger(WebsocketCoreRecorder.class); - public void setupWorker(Executor executor) { + private WebsocketCoreRecorder() { + } + + public static void setupWorker(Executor executor) { ExecutorSupplier.executor = executor; } @SuppressWarnings("unchecked") - public RuntimeValue createDeploymentInfo(Set annotatedEndpoints, Set endpoints, + public static WebSocketDeploymentInfo createDeploymentInfo(Set annotatedEndpoints, Set endpoints, Set serverApplicationConfigClasses, int maxFrameSize, boolean dispatchToWorker) { WebSocketDeploymentInfo container = new WebSocketDeploymentInfo(); container.setMaxFrameSize(maxFrameSize); @@ -107,13 +107,12 @@ public RuntimeValue createDeploymentInfo(Set an container.addEndpoint(endpoint); } } - return new RuntimeValue<>(container); + return container; } - public RuntimeValue createServerContainer(BeanContainer beanContainer, - RuntimeValue infoVal, ServerWebSocketContainerFactory serverContainerFactory) + public static ServerWebSocketContainer createServerContainer(BeanContainer beanContainer, + WebSocketDeploymentInfo info, ServerWebSocketContainerFactory serverContainerFactory) throws DeploymentException { - WebSocketDeploymentInfo info = infoVal.getValue(); ClassLoader cl = Thread.currentThread().getContextClassLoader(); ManagedContext requestContext = Arc.container().requestContext(); if (serverContainerFactory == null) { @@ -213,7 +212,7 @@ public Principal get() { container.addEndpoint(i); } UndertowContainerProvider.setDefaultContainer(container); - return new RuntimeValue<>(container); + return container; } } diff --git a/extensions/websockets/server/deployment/src/main/java/io/quarkus/websockets/deployment/ServerWebSocketProcessor.java b/extensions/websockets/server/deployment/src/main/java/io/quarkus/websockets/deployment/ServerWebSocketProcessor.java index 6551a352dd5af..a19460c75a205 100644 --- a/extensions/websockets/server/deployment/src/main/java/io/quarkus/websockets/deployment/ServerWebSocketProcessor.java +++ b/extensions/websockets/server/deployment/src/main/java/io/quarkus/websockets/deployment/ServerWebSocketProcessor.java @@ -16,11 +16,11 @@ import org.jboss.jandex.IndexView; import io.quarkus.arc.deployment.BeanDefiningAnnotationBuildItem; +import io.quarkus.core.Phase; +import io.quarkus.core.deployment.service.ServiceRegistrar; import io.quarkus.deployment.Feature; import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; -import io.quarkus.deployment.annotations.ExecutionTime; -import io.quarkus.deployment.annotations.Record; import io.quarkus.deployment.builditem.CombinedIndexBuildItem; import io.quarkus.deployment.builditem.FeatureBuildItem; import io.quarkus.deployment.builditem.IndexDependencyBuildItem; @@ -28,13 +28,17 @@ import io.quarkus.deployment.builditem.nativeimage.ReflectiveHierarchyBuildItem; import io.quarkus.deployment.util.JandexUtil; import io.quarkus.vertx.http.deployment.FilterBuildItem; +import io.quarkus.vertx.http.runtime.RouteHandler; import io.quarkus.vertx.http.runtime.security.SecurityHandlerPriorities; import io.quarkus.websockets.client.deployment.AnnotatedWebsocketEndpointBuildItem; import io.quarkus.websockets.client.deployment.ServerWebSocketContainerBuildItem; import io.quarkus.websockets.client.deployment.ServerWebSocketContainerFactoryBuildItem; import io.quarkus.websockets.client.deployment.WebSocketDeploymentInfoBuildItem; import io.quarkus.websockets.client.deployment.WebsocketClientProcessor; +import io.quarkus.websockets.client.runtime.ServerWebSocketContainerFactory; import io.quarkus.websockets.runtime.WebsocketServerRecorder; +import io.undertow.websockets.ServerWebSocketContainer; +import io.undertow.websockets.WebSocketDeploymentInfo; public class ServerWebSocketProcessor { @@ -92,15 +96,17 @@ void scanForCodecs(CombinedIndexBuildItem index, } @BuildStep - @Record(ExecutionTime.STATIC_INIT) - public ServerWebSocketContainerFactoryBuildItem factory(WebsocketServerRecorder recorder) { - return new ServerWebSocketContainerFactoryBuildItem(recorder.createFactory()); + public ServerWebSocketContainerFactoryBuildItem factory(ServiceRegistrar reg) { + reg + .forService(ServerWebSocketContainerFactory.class) + .atPhase(Phase.STATIC_INIT) + .onStart(ctx -> WebsocketServerRecorder.createFactory()); + return new ServerWebSocketContainerFactoryBuildItem(); } @BuildStep - @Record(ExecutionTime.RUNTIME_INIT) public FilterBuildItem deploy(final CombinedIndexBuildItem indexBuildItem, - WebsocketServerRecorder recorder, + ServiceRegistrar reg, BuildProducer reflection, Optional webSocketDeploymentInfoBuildItem, Optional serverWebSocketContainerBuildItem) throws Exception { @@ -111,11 +117,16 @@ public FilterBuildItem deploy(final CombinedIndexBuildItem indexBuildItem, final IndexView index = indexBuildItem.getIndex(); WebsocketClientProcessor.registerCodersForReflection(reflection, index.getAnnotations(SERVER_ENDPOINT)); + reg + .forService(RouteHandler.class, "io.quarkus.websocket.filter") + .require(WebSocketDeploymentInfo.class) + .require(ServerWebSocketContainer.class) + .onStart((ctx, info, container) -> WebsocketServerRecorder.createHandler(info, container)::handle); + int priority = 1 + SecurityHandlerPriorities.AUTHORIZATION; - return new FilterBuildItem( - recorder.createHandler(webSocketDeploymentInfoBuildItem.get().getInfo(), - serverWebSocketContainerBuildItem.get().getContainer()), - priority); + RouteHandler handler = reg.getRecorderProxy(RouteHandler.class, + "io.quarkus.websocket.filter"); + return new FilterBuildItem(handler, priority); } @BuildStep diff --git a/extensions/websockets/server/runtime/src/main/java/io/quarkus/websockets/runtime/WebsocketServerRecorder.java b/extensions/websockets/server/runtime/src/main/java/io/quarkus/websockets/runtime/WebsocketServerRecorder.java index d5ddec395d3db..daa68135c2ed1 100644 --- a/extensions/websockets/server/runtime/src/main/java/io/quarkus/websockets/runtime/WebsocketServerRecorder.java +++ b/extensions/websockets/server/runtime/src/main/java/io/quarkus/websockets/runtime/WebsocketServerRecorder.java @@ -13,8 +13,6 @@ import org.jboss.logging.Logger; import io.netty.channel.EventLoopGroup; -import io.quarkus.runtime.RuntimeValue; -import io.quarkus.runtime.annotations.Recorder; import io.quarkus.vertx.http.runtime.security.QuarkusHttpUser; import io.quarkus.websockets.client.runtime.ServerWebSocketContainerFactory; import io.quarkus.websockets.client.runtime.WebSocketPrincipal; @@ -31,14 +29,16 @@ import io.vertx.core.net.impl.ConnectionBase; import io.vertx.ext.web.RoutingContext; -@Recorder public class WebsocketServerRecorder { private static final Logger log = Logger.getLogger(WebsocketCoreRecorder.class); - public Handler createHandler(RuntimeValue info, - RuntimeValue container) throws DeploymentException { - return new VertxWebSocketHandler(container.getValue(), info.getValue()) { + private WebsocketServerRecorder() { + } + + public static Handler createHandler(WebSocketDeploymentInfo info, + ServerWebSocketContainer container) throws DeploymentException { + return new VertxWebSocketHandler(container, info) { @Override protected VertxWebSocketHttpExchange createHttpExchange(RoutingContext event) { return new QuarkusVertxWebSocketHttpExchange(executor, event); @@ -46,7 +46,7 @@ protected VertxWebSocketHttpExchange createHttpExchange(RoutingContext event) { }; } - public ServerWebSocketContainerFactory createFactory() { + public static ServerWebSocketContainerFactory createFactory() { return new ServerWebSocketContainerFactory() { @Override public ServerWebSocketContainer create(ObjectIntrospecter objectIntrospecter, ClassLoader classLoader, From eaa8418c10b1314148b8f20707ea3d4dcf707173 Mon Sep 17 00:00:00 2001 From: "David M. Lloyd" Date: Tue, 14 Jul 2026 12:11:36 -0500 Subject: [PATCH 036/254] Service conversion: Proxy registry --- .../deployment/ProxyRegistryProcessor.java | 23 ++++++------ .../runtime/ProxyConfigurationRecorder.java | 36 ++++++++----------- 2 files changed, 28 insertions(+), 31 deletions(-) diff --git a/extensions/proxy-registry/deployment/src/main/java/io/quarkus/proxy/deployment/ProxyRegistryProcessor.java b/extensions/proxy-registry/deployment/src/main/java/io/quarkus/proxy/deployment/ProxyRegistryProcessor.java index 556d1d7514adc..82b974b3c999b 100644 --- a/extensions/proxy-registry/deployment/src/main/java/io/quarkus/proxy/deployment/ProxyRegistryProcessor.java +++ b/extensions/proxy-registry/deployment/src/main/java/io/quarkus/proxy/deployment/ProxyRegistryProcessor.java @@ -1,37 +1,40 @@ package io.quarkus.proxy.deployment; -import java.util.function.Supplier; - import jakarta.inject.Singleton; import io.quarkus.arc.deployment.SyntheticBeanBuildItem; +import io.quarkus.core.Phase; +import io.quarkus.core.deployment.service.ServiceRegistrar; import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; -import io.quarkus.deployment.annotations.ExecutionTime; -import io.quarkus.deployment.annotations.Record; import io.quarkus.proxy.ProxyConfigurationRegistry; import io.quarkus.proxy.runtime.ProxyConfigurationRecorder; import io.quarkus.proxy.runtime.ProxyConfigurationRegistryImpl; +import io.quarkus.proxy.runtime.config.ProxyConfig; public class ProxyRegistryProcessor { @BuildStep - @Record(ExecutionTime.RUNTIME_INIT) void initializeProxyConfigurationRegistry( - ProxyConfigurationRecorder recorder, + ServiceRegistrar reg, BuildProducer registry, BuildProducer syntheticBeans) { - Supplier supplier = recorder.init(); + reg + .forService(ProxyConfigurationRegistry.class) + .atPhase(Phase.INFRASTRUCTURE) + .require(ProxyConfig.class) + .onStart((ctx, config) -> ProxyConfigurationRecorder.createRegistry(config)); - registry.produce(new ProxyRegistryBuildItem(supplier)); + // temporary bridge: supplier-based build item for unconverted recorder consumers + registry.produce(new ProxyRegistryBuildItem( + reg.serviceAsRecorderSupplier(ProxyConfigurationRegistry.class))); syntheticBeans.produce(SyntheticBeanBuildItem.create(ProxyConfigurationRegistryImpl.class) .addType(ProxyConfigurationRegistry.class) - .supplier(supplier) + .runtimeValue(reg.serviceAsRuntimeValue(ProxyConfigurationRegistry.class)) .scope(Singleton.class) .unremovable() .setRuntimeInit() .done()); - } } diff --git a/extensions/proxy-registry/runtime/src/main/java/io/quarkus/proxy/runtime/ProxyConfigurationRecorder.java b/extensions/proxy-registry/runtime/src/main/java/io/quarkus/proxy/runtime/ProxyConfigurationRecorder.java index c457d28ceba3f..c72f46cdb5715 100644 --- a/extensions/proxy-registry/runtime/src/main/java/io/quarkus/proxy/runtime/ProxyConfigurationRecorder.java +++ b/extensions/proxy-registry/runtime/src/main/java/io/quarkus/proxy/runtime/ProxyConfigurationRecorder.java @@ -4,28 +4,29 @@ import java.util.Map; import java.util.Optional; import java.util.StringJoiner; -import java.util.function.Supplier; import io.quarkus.credentials.CredentialsProvider; import io.quarkus.credentials.runtime.CredentialsProviderFinder; import io.quarkus.proxy.ProxyConfiguration; import io.quarkus.proxy.ProxyConfigurationRegistry; import io.quarkus.proxy.runtime.config.ProxyConfig; -import io.quarkus.runtime.RuntimeValue; -import io.quarkus.runtime.annotations.Recorder; -@Recorder +/** + * Factory for creating a {@link ProxyConfigurationRegistry} from runtime configuration. + */ public class ProxyConfigurationRecorder { - private final RuntimeValue runtimeConfig; - public ProxyConfigurationRecorder(RuntimeValue runtimeConfig) { - this.runtimeConfig = runtimeConfig; + private ProxyConfigurationRecorder() { } - public Supplier init() { - ProxyConfig proxyConfig = runtimeConfig.getValue(); - - Optional defaultConfig = build("quarkus.proxy.", proxyConfig.defaultProxyConfig()); + /** + * Create a {@link ProxyConfigurationRegistry} from the given configuration. + * + * @param proxyConfig the proxy configuration (must not be {@code null}) + * @return the registry (never {@code null}) + */ + public static ProxyConfigurationRegistry createRegistry(ProxyConfig proxyConfig) { + Optional defaultConfig = buildConfig("quarkus.proxy.", proxyConfig.defaultProxyConfig()); Map namedConfigs = new HashMap<>(); for (Map.Entry entry : proxyConfig.namedProxyConfigs().entrySet()) { @@ -36,24 +37,17 @@ public Supplier init() { + " from your configuration."); } - Optional namedConfig = build("quarkus.proxy.\"" + name + "\".", entry.getValue()); + Optional namedConfig = buildConfig("quarkus.proxy.\"" + name + "\".", entry.getValue()); if (namedConfig.isPresent()) { namedConfigs.put(name, namedConfig.get()); } } - ProxyConfigurationRegistry registry = new ProxyConfigurationRegistryImpl(namedConfigs, defaultConfig); - - return new Supplier() { - @Override - public ProxyConfigurationRegistry get() { - return registry; - } - }; + return new ProxyConfigurationRegistryImpl(namedConfigs, defaultConfig); } // `prefix` is only used in error messages - private Optional build(String prefix, ProxyConfig.NamedProxyConfig config) { + private static Optional buildConfig(String prefix, ProxyConfig.NamedProxyConfig config) { if (config.host().isEmpty()) { StringJoiner badValues = new StringJoiner(", "); if (config.port().isPresent()) { From 4c17fe11d3482c31c816d8ebf65bc8574af3c879 Mon Sep 17 00:00:00 2001 From: "David M. Lloyd" Date: Tue, 14 Jul 2026 12:11:49 -0500 Subject: [PATCH 037/254] Service conversion: TLS registry --- .../tls/deployment/CertificatesProcessor.java | 70 +++++++++++------- .../tls/BuildTimeRegistrationTest.java | 5 +- ...rder.java => CertificateRegistryImpl.java} | 71 ++++++++----------- .../tls/runtime/VertxCertificateHolder.java | 4 +- 4 files changed, 80 insertions(+), 70 deletions(-) rename extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/{CertificateRecorder.java => CertificateRegistryImpl.java} (85%) diff --git a/extensions/tls-registry/deployment/src/main/java/io/quarkus/tls/deployment/CertificatesProcessor.java b/extensions/tls-registry/deployment/src/main/java/io/quarkus/tls/deployment/CertificatesProcessor.java index e817386c4d22f..28003f50647aa 100644 --- a/extensions/tls-registry/deployment/src/main/java/io/quarkus/tls/deployment/CertificatesProcessor.java +++ b/extensions/tls-registry/deployment/src/main/java/io/quarkus/tls/deployment/CertificatesProcessor.java @@ -2,9 +2,11 @@ import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.function.Supplier; +import java.util.stream.Collectors; import jakarta.inject.Singleton; @@ -14,24 +16,28 @@ import io.quarkus.arc.deployment.SyntheticBeanBuildItem; import io.quarkus.arc.deployment.UnremovableBeanBuildItem; import io.quarkus.bootstrap.classloading.QuarkusClassLoader; +import io.quarkus.core.Phase; +import io.quarkus.core.deployment.service.ServiceRegistrar; import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; import io.quarkus.deployment.annotations.ExecutionTime; import io.quarkus.deployment.annotations.Record; -import io.quarkus.deployment.builditem.ShutdownContextBuildItem; import io.quarkus.runtime.configuration.ConfigurationException; import io.quarkus.tls.KeyStoreFactory; import io.quarkus.tls.KeyStoreProvider; +import io.quarkus.tls.TlsConfiguration; import io.quarkus.tls.TlsConfigurationRegistry; import io.quarkus.tls.TrustStoreFactory; import io.quarkus.tls.TrustStoreProvider; import io.quarkus.tls.deployment.spi.TlsCertificateBuildItem; import io.quarkus.tls.deployment.spi.TlsRegistryBuildItem; -import io.quarkus.tls.runtime.CertificateRecorder; +import io.quarkus.tls.runtime.CertificateRegistryImpl; import io.quarkus.tls.runtime.LetsEncryptRecorder; +import io.quarkus.tls.runtime.config.TlsConfig; import io.quarkus.vertx.deployment.VertxBuildItem; import io.quarkus.vertx.http.deployment.spi.RouteBuildItem; import io.smallrye.common.annotation.Identifier; +import io.vertx.core.Vertx; public class CertificatesProcessor { @@ -46,36 +52,50 @@ public UnremovableBeanBuildItem unremovableBeans() { } @BuildStep - @Record(ExecutionTime.RUNTIME_INIT) public TlsRegistryBuildItem initializeCertificate( Optional vertx, - BeanDiscoveryFinishedBuildItem beadDiscovery, - CertificateRecorder recorder, + BeanDiscoveryFinishedBuildItem beanDiscovery, + ServiceRegistrar reg, BuildProducer syntheticBeans, - List otherCertificates, - ShutdownContextBuildItem shutdown) { - - if (vertx.isPresent()) { - var providerBucketNames = getProviderBucketNames(beadDiscovery); - recorder.validateCertificates(providerBucketNames, vertx.get().getVertx(), shutdown); - } - - for (TlsCertificateBuildItem certificate : otherCertificates) { - recorder.register(certificate.name, certificate.supplier); - } - - Supplier supplier = recorder.getSupplier(); - - SyntheticBeanBuildItem.ExtendedBeanConfigurator configurator = SyntheticBeanBuildItem + List otherCertificates) { + + Set providerBucketNames = vertx.isPresent() + ? Set.copyOf(getProviderBucketNames(beanDiscovery)) + : Set.of(); + + // capture additional certificate suppliers for the lambda; + // suppliers must be records or other capturable types + Map> capturedCerts = Map.copyOf( + otherCertificates.stream().collect(Collectors.toMap(c -> c.name, c -> c.supplier))); + + reg + .forService(TlsConfigurationRegistry.class) + .atPhase(Phase.INFRASTRUCTURE) + .require(TlsConfig.class) + .request(Vertx.class) + .onStart((ctx, tlsConfig, vertxOpt) -> { + CertificateRegistryImpl registry = new CertificateRegistryImpl(tlsConfig); + vertxOpt.ifPresent(v -> { + registry.validateCertificates(providerBucketNames, (Vertx) v); + ctx.onStop(registry::closeReloader); + }); + for (var entry : capturedCerts.entrySet()) { + registry.register(entry.getKey(), entry.getValue().get()); + } + return registry; + }); + + // temporary bridge: supplier for unconverted consumers + syntheticBeans.produce(SyntheticBeanBuildItem .configure(TlsConfigurationRegistry.class) - .supplier(supplier) + .runtimeValue(reg.serviceAsRuntimeValue(TlsConfigurationRegistry.class)) .scope(Singleton.class) .unremovable() - .setRuntimeInit(); - - syntheticBeans.produce(configurator.done()); + .setRuntimeInit() + .done()); - return new TlsRegistryBuildItem(supplier); + return new TlsRegistryBuildItem( + reg.serviceAsRecorderSupplier(TlsConfigurationRegistry.class)); } @Record(ExecutionTime.RUNTIME_INIT) diff --git a/extensions/tls-registry/deployment/src/test/java/io/quarkus/tls/BuildTimeRegistrationTest.java b/extensions/tls-registry/deployment/src/test/java/io/quarkus/tls/BuildTimeRegistrationTest.java index f5991f0a1da2f..a838cfddf878f 100644 --- a/extensions/tls-registry/deployment/src/test/java/io/quarkus/tls/BuildTimeRegistrationTest.java +++ b/extensions/tls-registry/deployment/src/test/java/io/quarkus/tls/BuildTimeRegistrationTest.java @@ -35,8 +35,9 @@ public class BuildTimeRegistrationTest { @RegisterExtension static final QuarkusExtensionTest config = new QuarkusExtensionTest().setArchiveProducer( () -> ShrinkWrap.create(JavaArchive.class) + .addClass(MyCertificateSupplier.class) .add(new StringAsset(configuration), "application.properties")) - .addBuildChainCustomizer(buildCustomizer());; + .addBuildChainCustomizer(buildCustomizer()); @Inject TlsConfigurationRegistry registry; @@ -65,7 +66,7 @@ public void execute(BuildContext context) { }; } - public static class MyCertificateSupplier implements Supplier { + public record MyCertificateSupplier() implements Supplier { @Override public TlsConfiguration get() { diff --git a/extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/CertificateRecorder.java b/extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/CertificateRegistryImpl.java similarity index 85% rename from extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/CertificateRecorder.java rename to extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/CertificateRegistryImpl.java index c6a10d7968755..24110aa2d42aa 100644 --- a/extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/CertificateRecorder.java +++ b/extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/CertificateRegistryImpl.java @@ -5,7 +5,6 @@ import java.util.Optional; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; -import java.util.function.Supplier; import jakarta.enterprise.inject.AmbiguousResolutionException; import jakarta.enterprise.inject.Default; @@ -14,9 +13,6 @@ import io.quarkus.arc.Arc; import io.quarkus.arc.InstanceHandle; -import io.quarkus.runtime.RuntimeValue; -import io.quarkus.runtime.ShutdownContext; -import io.quarkus.runtime.annotations.Recorder; import io.quarkus.tls.KeyStoreAndKeyCertOptions; import io.quarkus.tls.KeyStoreFactory; import io.quarkus.tls.KeyStoreProvider; @@ -34,19 +30,27 @@ import io.smallrye.common.annotation.Identifier; import io.vertx.core.Vertx; -@Recorder -public class CertificateRecorder implements TlsConfigurationRegistry { +/** + * Implementation of {@link TlsConfigurationRegistry} that validates and manages + * TLS certificate configurations. + */ +public class CertificateRegistryImpl implements TlsConfigurationRegistry { - private static final Logger LOGGER = Logger.getLogger(CertificateRecorder.class); + private static final Logger LOGGER = Logger.getLogger(CertificateRegistryImpl.class); private final Map certificates = new ConcurrentHashMap<>(); private volatile TlsCertificateUpdater reloader; private volatile Vertx vertx; - private final RuntimeValue runtimeConfig; + private final TlsConfig config; - public CertificateRecorder(final RuntimeValue runtimeConfig) { - this.runtimeConfig = runtimeConfig; + /** + * Construct a new instance. + * + * @param config the TLS configuration (must not be {@code null}) + */ + public CertificateRegistryImpl(TlsConfig config) { + this.config = config; } /** @@ -59,17 +63,14 @@ public CertificateRecorder(final RuntimeValue runtimeConfig) { * {@link KeyStoreProvider} or {@link TrustStoreProvider} beans * @param vertx the Vert.x instance */ - public void validateCertificates(Set providerBucketNames, - RuntimeValue vertx, - ShutdownContext shutdownContext) { - this.vertx = vertx.getValue(); + public void validateCertificates(Set providerBucketNames, Vertx vertx) { + this.vertx = vertx; // Verify the default config - if (runtimeConfig.getValue().defaultCertificateConfig().isPresent()) { - verifyCertificateConfig(runtimeConfig.getValue().defaultCertificateConfig().get(), vertx.getValue(), - TlsConfig.DEFAULT_NAME); + if (config.defaultCertificateConfig().isPresent()) { + verifyCertificateConfig(config.defaultCertificateConfig().get(), vertx, TlsConfig.DEFAULT_NAME); } - var bucketNames = new HashSet<>(runtimeConfig.getValue().namedCertificateConfig().keySet()); + var bucketNames = new HashSet<>(config.namedCertificateConfig().keySet()); bucketNames.addAll(providerBucketNames); // Verify the named configs @@ -84,17 +85,18 @@ public void validateCertificates(Set providerBucketNames, "The TLS configuration name " + TlsConfig.JAVA_NET_SSL_TLS_CONFIGURATION_NAME + " is reserved for providing access to default SunJSSE keystore; neither Quarkus extensions nor end users can adjust or override it"); } - verifyCertificateConfig(runtimeConfig.getValue().namedCertificateConfig().get(name), vertx.getValue(), name); + verifyCertificateConfig(config.namedCertificateConfig().get(name), vertx, name); } + } - shutdownContext.addShutdownTask(new Runnable() { - @Override - public void run() { - if (reloader != null) { - reloader.close(); - } - } - }); + /** + * Close the certificate reloader, if one was started. + * Called from the service's {@code onStop} handler. + */ + public void closeReloader() { + if (reloader != null) { + reloader.close(); + } } private void verifyCertificateConfig(TlsBucketConfig config, Vertx vertx, String name) { @@ -196,7 +198,7 @@ public Optional get(String name) { if (TlsConfig.JAVA_NET_SSL_TLS_CONFIGURATION_NAME.equals(name)) { final TlsConfiguration result = certificates.computeIfAbsent(TlsConfig.JAVA_NET_SSL_TLS_CONFIGURATION_NAME, k -> { final TrustStoreAndTrustOptions ts = JavaxNetSslTrustStoreProvider.getTrustStore(vertx); - return new VertxCertificateHolder(vertx, k, runtimeConfig.getValue().namedCertificateConfig().get(k), null, ts); + return new VertxCertificateHolder(vertx, k, config.namedCertificateConfig().get(k), null, ts); }); return Optional.ofNullable(result); } @@ -227,19 +229,6 @@ public void register(String name, TlsConfiguration configuration) { certificates.put(name, configuration); } - public Supplier getSupplier() { - return new Supplier() { - @Override - public TlsConfigurationRegistry get() { - return CertificateRecorder.this; - } - }; - } - - public void register(String name, Supplier supplier) { - register(name, supplier.get()); - } - static InstanceHandle lookupProvider(Class type, String bucketName) { var container = Arc.container(); var qualifier = TlsConfig.DEFAULT_NAME.equals(bucketName) diff --git a/extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/VertxCertificateHolder.java b/extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/VertxCertificateHolder.java index 1c3dc32645c5d..00674badb3908 100644 --- a/extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/VertxCertificateHolder.java +++ b/extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/VertxCertificateHolder.java @@ -291,7 +291,7 @@ public boolean reload() { // Reload keystore if (keyStore != null) { try { - keyStoreUpdateResult = CertificateRecorder.getKeyStore(config, vertx, name); + keyStoreUpdateResult = CertificateRegistryImpl.getKeyStore(config, vertx, name); } catch (Exception e) { return false; } @@ -300,7 +300,7 @@ public boolean reload() { // Reload truststore if (trustStore != null) { try { - trustStoreUpdateResult = CertificateRecorder.getTrustStore(config, vertx, name); + trustStoreUpdateResult = CertificateRegistryImpl.getTrustStore(config, vertx, name); } catch (Exception e) { return false; } From 868a6d0b9d729a25705cef054540095e464f9b3c Mon Sep 17 00:00:00 2001 From: "David M. Lloyd" Date: Wed, 8 Jul 2026 15:09:11 -0500 Subject: [PATCH 038/254] Service conversion: Vert.x & Vert.x HTTP --- .../deployment/HttpSecurityProcessor.java | 126 +++++++++++--- .../ManagementInterfaceSecurityProcessor.java | 61 +++++-- .../vertx/http/runtime/RouteHandler.java | 11 ++ .../ManagementSecurityRecorder.java | 35 ++-- .../security/HttpSecurityConfiguration.java | 9 +- .../security/HttpSecurityRecorder.java | 75 ++++----- .../core/deployment/CoreVertxBuildItem.java | 5 +- .../IgnoredContextLocalDataKeysBuildItem.java | 29 +++- .../core/deployment/VertxCoreProcessor.java | 159 ++++++++++++++---- .../vertx/deployment/VertxProcessor.java | 14 +- .../java/io/quarkus/vertx/VertxSupplier.java | 18 ++ .../vertx/core/runtime/VertxCoreRecorder.java | 65 ++++--- 12 files changed, 428 insertions(+), 179 deletions(-) create mode 100644 extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/RouteHandler.java create mode 100644 extensions/vertx/runtime/src/main/java/io/quarkus/vertx/VertxSupplier.java diff --git a/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/HttpSecurityProcessor.java b/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/HttpSecurityProcessor.java index cb691797b7b34..30662dc92f35b 100644 --- a/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/HttpSecurityProcessor.java +++ b/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/HttpSecurityProcessor.java @@ -51,9 +51,13 @@ import io.quarkus.arc.deployment.GeneratedBeanBuildItem; import io.quarkus.arc.deployment.GeneratedBeanGizmoAdaptor; import io.quarkus.arc.deployment.SyntheticBeanBuildItem; +import io.quarkus.arc.deployment.SyntheticBeansRuntimeInitBuildItem; import io.quarkus.arc.deployment.UnremovableBeanBuildItem; import io.quarkus.arc.processor.BeanInfo; +import io.quarkus.builder.item.EmptyBuildItem; import io.quarkus.builder.item.SimpleBuildItem; +import io.quarkus.core.Phase; +import io.quarkus.core.deployment.service.ServiceRegistrar; import io.quarkus.deployment.Capabilities; import io.quarkus.deployment.Capability; import io.quarkus.deployment.annotations.BuildProducer; @@ -89,6 +93,7 @@ import io.quarkus.tls.deployment.spi.TlsRegistryBuildItem; import io.quarkus.vertx.core.deployment.IgnoredContextLocalDataKeysBuildItem; import io.quarkus.vertx.core.runtime.security.VertxBlockingSecurityExecutor; +import io.quarkus.vertx.http.runtime.RouteHandler; import io.quarkus.vertx.http.runtime.VertxHttpBuildTimeConfig; import io.quarkus.vertx.http.runtime.cors.CORSConfig; import io.quarkus.vertx.http.runtime.management.ManagementInterfaceBuildTimeConfig; @@ -99,8 +104,10 @@ import io.quarkus.vertx.http.runtime.security.FormAuthenticationMechanism; import io.quarkus.vertx.http.runtime.security.HttpAuthenticator; import io.quarkus.vertx.http.runtime.security.HttpAuthorizer; +import io.quarkus.vertx.http.runtime.security.HttpSecurityConfiguration; import io.quarkus.vertx.http.runtime.security.HttpSecurityRecorder; import io.quarkus.vertx.http.runtime.security.HttpSecurityRecorder.AuthenticationHandler; +import io.quarkus.vertx.http.runtime.security.HttpSecurityUtils; import io.quarkus.vertx.http.runtime.security.MtlsAuthenticationMechanism; import io.quarkus.vertx.http.runtime.security.PathMatchingHttpSecurityPolicy; import io.quarkus.vertx.http.runtime.security.SecurityHandlerPriorities; @@ -141,10 +148,18 @@ void makeRequiredBeansUnremovable(BuildProducer unremo } } - @Record(ExecutionTime.RUNTIME_INIT) + // NOTE: registering the synthetic bean and defining its service action are deliberately kept in two + // separate build steps (this one and defineFormAuthService below); they must not be merged. + // This step produces a SyntheticBeanBuildItem, which ARC's synthetic-bean pipeline consumes to build + // the bean container, so it is *upstream* of BeanContainerBuildItem. defineFormAuthService consumes + // HttpSecurityInitCompleteBuildItem, which initializeHttpSecurity produces after consuming + // BeanContainerBuildItem, so it is *downstream* of the container. A single step doing both would be + // both upstream and downstream of the container build, forming a build-step cycle through the synthetic + // bean pipeline. The basic-auth path (initBasicAuth / defineBasicAuthService) is split for the same + // reason. See HttpSecurityInitCompleteBuildItem. @BuildStep void registerFormAuthMechanism(BuildProducer syntheticBeanProducer, - VertxHttpBuildTimeConfig buildTimeConfig, HttpSecurityRecorder recorder) { + VertxHttpBuildTimeConfig buildTimeConfig) { if (buildTimeConfig.auth().form()) { syntheticBeanProducer.produce(SyntheticBeanBuildItem .configure(FormAuthenticationMechanism.class) @@ -152,11 +167,24 @@ void registerFormAuthMechanism(BuildProducer syntheticBe .scope(Singleton.class) .setRuntimeInit() .unremovable() - .supplier(recorder.createFormAuthMechanism()) + .serviceValue(FormAuthenticationMechanism.class) .done()); } } + // Kept separate from registerFormAuthMechanism to avoid a build-step cycle through ARC's synthetic bean + // pipeline; see the note on registerFormAuthMechanism above. + @Consume(HttpSecurityInitCompleteBuildItem.class) // service ordering: remove when bytecode recorders are removed + @Produce(ServiceStartBuildItem.class) + @BuildStep + void defineFormAuthService(ServiceRegistrar reg, VertxHttpBuildTimeConfig buildTimeConfig) { + if (buildTimeConfig.auth().form()) { + reg + .forService(FormAuthenticationMechanism.class) + .onStart(ctx -> HttpSecurityConfiguration.get().getFormAuthenticationMechanism()); + } + } + @BuildStep AdditionalBeanBuildItem initMtlsClientAuth(VertxHttpBuildTimeConfig buildTimeConfig) { if (isMtlsClientAuthenticationEnabled(buildTimeConfig)) { @@ -168,10 +196,13 @@ AdditionalBeanBuildItem initMtlsClientAuth(VertxHttpBuildTimeConfig buildTimeCon @Consume(HttpSecurityConfigSetupCompleteBuildItem.class) @BuildStep - @Record(ExecutionTime.RUNTIME_INIT) - void setMtlsCertificateRoleProperties(HttpSecurityRecorder recorder, Capabilities capabilities) { + void setMtlsCertificateRoleProperties(ServiceRegistrar reg, Capabilities capabilities) { if (capabilities.isPresent(Capability.SECURITY)) { - recorder.setMtlsCertificateRoleProperties(); + reg + .forService("io.quarkus.vertx.http.security.mtls-cert-roles") + .require(io.quarkus.vertx.http.runtime.VertxHttpConfig.class) + .afterBuildItem(SyntheticBeansRuntimeInitBuildItem.class) + .onStart((ctx, httpConfig) -> HttpSecurityRecorder.setMtlsCertificateRoleProperties(httpConfig)); } } @@ -212,8 +243,7 @@ void detectBasicAuthImplicitlyRequired( } @BuildStep(onlyIf = IsApplicationBasicAuthRequired.class) - @Record(ExecutionTime.RUNTIME_INIT) - SyntheticBeanBuildItem initBasicAuth(HttpSecurityRecorder recorder, + SyntheticBeanBuildItem initBasicAuth( VertxHttpBuildTimeConfig httpBuildTimeConfig, BuildProducer securityInformationProducer) { @@ -225,7 +255,7 @@ SyntheticBeanBuildItem initBasicAuth(HttpSecurityRecorder recorder, .configure(BasicAuthenticationMechanism.class) .types(io.quarkus.vertx.http.runtime.security.HttpAuthenticationMechanism.class) .scope(Singleton.class) - .supplier(recorder.basicAuthenticationMechanismBean()) + .serviceValue(BasicAuthenticationMechanism.class) .setRuntimeInit() .unremovable(); if (makeBasicAuthMechDefaultBean(httpBuildTimeConfig)) { @@ -235,6 +265,19 @@ SyntheticBeanBuildItem initBasicAuth(HttpSecurityRecorder recorder, return configurator.done(); } + @Consume(HttpSecurityInitCompleteBuildItem.class) // service ordering: remove when bytecode recorders are removed + @Produce(ServiceStartBuildItem.class) + @BuildStep + void defineBasicAuthService(ServiceRegistrar reg, VertxHttpBuildTimeConfig httpBuildTimeConfig, + ManagementInterfaceBuildTimeConfig managementBuildTimeConfig) { + if (applicationBasicAuthRequired(httpBuildTimeConfig, managementBuildTimeConfig)) { + reg + .forService(BasicAuthenticationMechanism.class) + .afterBuildItem(SyntheticBeansRuntimeInitBuildItem.class) + .onStart(ctx -> HttpSecurityConfiguration.get().getBasicAuthenticationMechanism()); + } + } + private static boolean makeBasicAuthMechDefaultBean(VertxHttpBuildTimeConfig httpBuildTimeConfig) { return !httpBuildTimeConfig.auth().form() && !isMtlsClientAuthenticationEnabled(httpBuildTimeConfig) && !httpBuildTimeConfig.auth().basic().orElse(false); @@ -258,9 +301,8 @@ private static boolean applicationBasicAuthRequired(VertxHttpBuildTimeConfig htt } @BuildStep - @Record(ExecutionTime.STATIC_INIT) void setupAuthenticationMechanisms( - HttpSecurityRecorder recorder, + ServiceRegistrar reg, BuildProducer filterBuildItemBuildProducer, BuildProducer beanProducer, Optional authenticationHandlerBuildItem, @@ -279,26 +321,43 @@ void setupAuthenticationMechanisms( .produce(AdditionalBeanBuildItem.builder().setUnremovable().addBeanClass(HttpAuthenticator.class) .addBeanClass(HttpAuthorizer.class).build()); beanProducer.produce(AdditionalBeanBuildItem.unremovableOf(PathMatchingHttpSecurityPolicy.class)); + + // authentication filter: the AuthenticationHandler IS a Handler + reg + .forService(RouteHandler.class, "io.quarkus.vertx.http.security.authentication-filter") + .atPhase(Phase.STATIC_INIT) + .require(AuthenticationHandler.class) + .onStart((ctx, handler) -> handler::handle); + RouteHandler authHandler = reg.getRecorderProxy(RouteHandler.class, + "io.quarkus.vertx.http.security.authentication-filter"); filterBuildItemBuildProducer - .produce(new FilterBuildItem( - recorder.getHttpAuthenticatorHandler(authenticationHandlerBuildItem.get().handler), - SecurityHandlerPriorities.AUTHENTICATION)); + .produce(new FilterBuildItem(authHandler, SecurityHandlerPriorities.AUTHENTICATION)); + + // authorization filter + reg + .forService(RouteHandler.class, "io.quarkus.vertx.http.security.permission-check-filter") + .atPhase(Phase.STATIC_INIT) + .onStart(ctx -> HttpSecurityRecorder.permissionCheckHandler()::handle); + RouteHandler permHandler = reg.getRecorderProxy(RouteHandler.class, + "io.quarkus.vertx.http.security.permission-check-filter"); filterBuildItemBuildProducer - .produce(new FilterBuildItem(recorder.permissionCheckHandler(), SecurityHandlerPriorities.AUTHORIZATION)); + .produce(new FilterBuildItem(permHandler, SecurityHandlerPriorities.AUTHORIZATION)); } } @BuildStep - @Record(ExecutionTime.STATIC_INIT) - void createHttpAuthenticationHandler(HttpSecurityRecorder recorder, Capabilities capabilities, + void createHttpAuthenticationHandler(ServiceRegistrar reg, Capabilities capabilities, VertxHttpBuildTimeConfig httpBuildTimeConfig, BuildProducer authenticationHandlerProducer) { if (capabilities.isPresent(Capability.SECURITY)) { - var authConfig = httpBuildTimeConfig.auth(); - authenticationHandlerProducer.produce( - new HttpAuthenticationHandlerBuildItem( - recorder.authenticationMechanismHandler(authConfig.proactive(), - authConfig.propagateSecurityIdentity()))); + boolean proactive = httpBuildTimeConfig.auth().proactive(); + boolean propagateSecurityIdentity = httpBuildTimeConfig.auth().propagateSecurityIdentity(); + reg + .forService(AuthenticationHandler.class) + .atPhase(Phase.STATIC_INIT) + .onStart(ctx -> new AuthenticationHandler(proactive, propagateSecurityIdentity)); + authenticationHandlerProducer.produce(new HttpAuthenticationHandlerBuildItem( + reg.staticInitServiceAsRuntimeValue(AuthenticationHandler.class))); } } @@ -332,7 +391,11 @@ void prepareCsrfConfigBuilder(Capabilities capabilities, Optional getPolicyTargetEndpointCandidates(AnnotationTarget target, @@ -993,4 +1055,18 @@ private HttpSecurityConfigSetupCompleteBuildItem(RuntimeValue progra this.programmaticCorsConfig = programmaticCorsConfig; } } + + /** + * Ordering-only build item produced by {@link #initializeHttpSecurity} to signal that + * {@link HttpSecurityConfiguration} is initialized and service actions that depend on it + * may now safely run. + *

+ * This is separate from {@link HttpSecurityConfigSetupCompleteBuildItem} to avoid creating + * a build-step cycle through ARC's synthetic bean pipeline. + *

+ * This build item should be removed once bytecode recorders are removed and service + * dependency ordering is used instead. + */ + static final class HttpSecurityInitCompleteBuildItem extends EmptyBuildItem { + } } diff --git a/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/ManagementInterfaceSecurityProcessor.java b/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/ManagementInterfaceSecurityProcessor.java index 185fc6c3389fb..6cfa6bb50e583 100644 --- a/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/ManagementInterfaceSecurityProcessor.java +++ b/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/ManagementInterfaceSecurityProcessor.java @@ -7,16 +7,19 @@ import io.quarkus.arc.deployment.AdditionalBeanBuildItem; import io.quarkus.arc.deployment.BeanContainerBuildItem; import io.quarkus.arc.deployment.SyntheticBeanBuildItem; +import io.quarkus.arc.runtime.BeanContainer; import io.quarkus.builder.item.SimpleBuildItem; +import io.quarkus.core.Phase; +import io.quarkus.core.deployment.service.ServiceRegistrar; import io.quarkus.deployment.Capabilities; import io.quarkus.deployment.Capability; import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; -import io.quarkus.deployment.annotations.ExecutionTime; import io.quarkus.deployment.annotations.Produce; -import io.quarkus.deployment.annotations.Record; import io.quarkus.runtime.RuntimeValue; import io.quarkus.vertx.http.deployment.HttpSecurityProcessor.IsApplicationBasicAuthRequired; +import io.quarkus.vertx.http.runtime.RouteHandler; +import io.quarkus.vertx.http.runtime.management.ManagementConfig; import io.quarkus.vertx.http.runtime.management.ManagementInterfaceBuildTimeConfig; import io.quarkus.vertx.http.runtime.management.ManagementSecurityRecorder; import io.quarkus.vertx.http.runtime.security.BasicAuthenticationMechanism; @@ -29,16 +32,19 @@ public class ManagementInterfaceSecurityProcessor { @BuildStep(onlyIfNot = IsApplicationBasicAuthRequired.class) - @Record(ExecutionTime.STATIC_INIT) SyntheticBeanBuildItem initBasicAuth( - ManagementSecurityRecorder recorder, + ServiceRegistrar reg, ManagementInterfaceBuildTimeConfig managementBuildTimeConfig) { if (managementBuildTimeConfig.auth().basic().orElse(false)) { + reg + .forService(BasicAuthenticationMechanism.class, "management") + .atPhase(Phase.STATIC_INIT) + .onStart(ctx -> new BasicAuthenticationMechanism(null, false)); SyntheticBeanBuildItem.ExtendedBeanConfigurator configurator = SyntheticBeanBuildItem .configure(BasicAuthenticationMechanism.class) .types(HttpAuthenticationMechanism.class) .scope(Singleton.class) - .supplier(recorder.setupBasicAuth()); + .serviceValue(BasicAuthenticationMechanism.class, "management"); return configurator.done(); } @@ -46,9 +52,8 @@ SyntheticBeanBuildItem initBasicAuth( } @BuildStep - @Record(ExecutionTime.STATIC_INIT) void setupAuthenticationMechanisms( - ManagementSecurityRecorder recorder, + ServiceRegistrar reg, BuildProducer filterBuildItemBuildProducer, BuildProducer beanProducer, Optional managementAuthenticationHandlerBuildItem) { @@ -58,36 +63,58 @@ void setupAuthenticationMechanisms( .addBeanClass(HttpAuthenticator.class) .addBeanClass(ManagementPathMatchingHttpSecurityPolicy.class) .addBeanClass(ManagementInterfaceHttpAuthorizer.class).build()); + + reg + .forService(RouteHandler.class, "io.quarkus.vertx.http.management.authentication-filter") + .atPhase(Phase.STATIC_INIT) + .require(AuthenticationHandler.class, "management") + .onStart((ctx, handler) -> handler::handle); + RouteHandler authHandler = reg.getRecorderProxy(RouteHandler.class, + "io.quarkus.vertx.http.management.authentication-filter"); filterBuildItemBuildProducer - .produce(new ManagementInterfaceFilterBuildItem( - recorder.getAuthenticationHandler(managementAuthenticationHandlerBuildItem.get().handler), + .produce(new ManagementInterfaceFilterBuildItem(authHandler, ManagementInterfaceFilterBuildItem.AUTHENTICATION)); + + reg + .forService(RouteHandler.class, "io.quarkus.vertx.http.management.permission-check-filter") + .atPhase(Phase.STATIC_INIT) + .onStart(ctx -> ManagementSecurityRecorder.permissionCheckHandler()::handle); + RouteHandler permHandler = reg.getRecorderProxy(RouteHandler.class, + "io.quarkus.vertx.http.management.permission-check-filter"); filterBuildItemBuildProducer - .produce(new ManagementInterfaceFilterBuildItem(recorder.permissionCheckHandler(), + .produce(new ManagementInterfaceFilterBuildItem(permHandler, ManagementInterfaceFilterBuildItem.AUTHORIZATION)); } } @BuildStep - @Record(ExecutionTime.STATIC_INIT) void createManagementAuthMechHandler( - ManagementSecurityRecorder recorder, Capabilities capabilities, + ServiceRegistrar reg, Capabilities capabilities, ManagementInterfaceBuildTimeConfig managementBuildTimeConfig, BuildProducer managementAuthMechHandlerProducer) { if (managementBuildTimeConfig.auth().enabled() && capabilities.isPresent(Capability.SECURITY)) { + boolean proactive = managementBuildTimeConfig.auth().proactive(); + reg + .forService(AuthenticationHandler.class, "management") + .atPhase(Phase.STATIC_INIT) + .onStart(ctx -> new AuthenticationHandler(proactive)); managementAuthMechHandlerProducer.produce(new ManagementAuthenticationHandlerBuildItem( - recorder.managementAuthenticationHandler(managementBuildTimeConfig.auth().proactive()))); + reg.staticInitServiceAsRuntimeValue(AuthenticationHandler.class, "management"))); } } @Produce(PreRouterFinalizationBuildItem.class) - @Record(ExecutionTime.RUNTIME_INIT) @BuildStep void initializeAuthMechanismHandler(Optional managementAuthenticationHandler, - ManagementSecurityRecorder recorder, BeanContainerBuildItem containerBuildItem) { + ServiceRegistrar reg, BeanContainerBuildItem containerBuildItem) { if (managementAuthenticationHandler.isPresent()) { - recorder.initializeAuthenticationHandler(managementAuthenticationHandler.get().handler, - containerBuildItem.getValue()); + reg + .forService("io.quarkus.vertx.http.management.init-auth-handler") + .require(AuthenticationHandler.class, "management") + .require(BeanContainer.class) + .require(ManagementConfig.class) + .onStart((ctx, handler, beanContainer, config) -> ManagementSecurityRecorder + .initializeAuthenticationHandler(handler, beanContainer, config)); } } diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/RouteHandler.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/RouteHandler.java new file mode 100644 index 0000000000000..3e0855a851705 --- /dev/null +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/RouteHandler.java @@ -0,0 +1,11 @@ +package io.quarkus.vertx.http.runtime; + +import io.vertx.core.Handler; +import io.vertx.ext.web.RoutingContext; + +/** + * A non-generic Handler for Vert.x RoutingContext to support + * ActionBuilder service registration without generic types. + */ +public interface RouteHandler extends Handler { +} diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/management/ManagementSecurityRecorder.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/management/ManagementSecurityRecorder.java index a204445573dd6..1ad202ef376be 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/management/ManagementSecurityRecorder.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/management/ManagementSecurityRecorder.java @@ -1,13 +1,10 @@ package io.quarkus.vertx.http.runtime.management; -import java.util.function.Supplier; - import jakarta.enterprise.inject.spi.CDI; import io.quarkus.arc.runtime.BeanContainer; import io.quarkus.runtime.RuntimeValue; import io.quarkus.runtime.annotations.Recorder; -import io.quarkus.vertx.http.runtime.security.BasicAuthenticationMechanism; import io.quarkus.vertx.http.runtime.security.HttpSecurityRecorder.AuthenticationHandler; import io.quarkus.vertx.http.runtime.security.ManagementInterfaceHttpAuthorizer; import io.quarkus.vertx.http.runtime.security.ManagementPathMatchingHttpSecurityPolicy; @@ -23,20 +20,20 @@ public ManagementSecurityRecorder(final RuntimeValue managemen this.managementConfig = managementConfig; } - public RuntimeValue managementAuthenticationHandler(boolean proactiveAuthentication) { - return new RuntimeValue<>(new AuthenticationHandler(proactiveAuthentication)); - } - - public Handler getAuthenticationHandler(RuntimeValue handlerRuntimeValue) { - return handlerRuntimeValue.getValue(); - } - - public void initializeAuthenticationHandler(RuntimeValue handler, BeanContainer beanContainer) { - handler.getValue().init(beanContainer.beanInstance(ManagementPathMatchingHttpSecurityPolicy.class), - RolesMapping.of(managementConfig.getValue().auth().rolesMapping())); + /** + * Initialize the management authentication handler with security policy and roles mapping. + * + * @param handler the authentication handler + * @param beanContainer the bean container + * @param managementConfig the management configuration + */ + public static void initializeAuthenticationHandler(AuthenticationHandler handler, BeanContainer beanContainer, + ManagementConfig managementConfig) { + handler.init(beanContainer.beanInstance(ManagementPathMatchingHttpSecurityPolicy.class), + RolesMapping.of(managementConfig.auth().rolesMapping())); } - public Handler permissionCheckHandler() { + public static Handler permissionCheckHandler() { return new Handler() { private volatile ManagementInterfaceHttpAuthorizer authorizer; @@ -50,12 +47,4 @@ public void handle(RoutingContext event) { }; } - public Supplier setupBasicAuth() { - return new Supplier() { - @Override - public BasicAuthenticationMechanism get() { - return new BasicAuthenticationMechanism(null, false); - } - }; - } } diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityConfiguration.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityConfiguration.java index aa6e95a1fffac..d2814494046e9 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityConfiguration.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityConfiguration.java @@ -136,7 +136,8 @@ default PolicyMappingConfig.AppliesTo getAppliesTo() { } } - BasicAuthenticationMechanism getBasicAuthenticationMechanism() { + // not a public API — this class is subject to change + public BasicAuthenticationMechanism getBasicAuthenticationMechanism() { for (HttpAuthenticationMechanism additionalMechanism : additionalMechanisms) { if (additionalMechanism.getClass() == BasicAuthenticationMechanism.class) { return (BasicAuthenticationMechanism) additionalMechanism; @@ -146,7 +147,8 @@ BasicAuthenticationMechanism getBasicAuthenticationMechanism() { httpConfig.auth().basicPriority()); } - FormAuthenticationMechanism getFormAuthenticationMechanism() { + // not a public API — this class is subject to change + public FormAuthenticationMechanism getFormAuthenticationMechanism() { for (HttpAuthenticationMechanism additionalMechanism : additionalMechanisms) { if (additionalMechanism.getClass() == FormAuthenticationMechanism.class) { return (FormAuthenticationMechanism) additionalMechanism; @@ -214,7 +216,8 @@ public int compare(HttpAuthenticationMechanism mech1, HttpAuthenticationMechanis return result; } - static HttpSecurityConfiguration get() { + // not a public API — this class is subject to change + public static HttpSecurityConfiguration get() { return get(null, null); } diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityRecorder.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityRecorder.java index ed5666d683c3e..cca6c792fe379 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityRecorder.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityRecorder.java @@ -14,7 +14,6 @@ import java.nio.file.Path; import java.util.HashMap; import java.util.HashSet; -import java.util.List; import java.util.Map; import java.util.Properties; import java.util.Set; @@ -74,22 +73,13 @@ public HttpSecurityRecorder(final RuntimeValue httpConfig, Vert this.httpBuildTimeConfig = httpBuildTimeConfig; } - public RuntimeValue authenticationMechanismHandler(boolean proactiveAuthentication, - boolean propagateRoutingContext) { - return new RuntimeValue<>(new AuthenticationHandler(proactiveAuthentication, propagateRoutingContext)); - } - - public Handler getHttpAuthenticatorHandler(RuntimeValue handlerRuntimeValue) { - return handlerRuntimeValue.getValue(); - } - public void initializeHttpAuthenticatorHandler(RuntimeValue handlerRuntimeValue, BeanContainer beanContainer) { handlerRuntimeValue.getValue().init(beanContainer.beanInstance(PathMatchingHttpSecurityPolicy.class), HttpSecurityConfiguration.get().rolesMapping()); } - public Handler permissionCheckHandler() { + public static Handler permissionCheckHandler() { return new Handler() { volatile HttpAuthorizer authorizer; @@ -194,13 +184,13 @@ public RuntimeValue prepareHttpSecurityConfiguration(ShutdownContext return new RuntimeValue<>(config.getCorsConfig()); } - public Supplier createFormAuthMechanism() { - return new Supplier() { - @Override - public FormAuthenticationMechanism get() { - return HttpSecurityConfiguration.get().getFormAuthenticationMechanism(); - } - }; + /** + * Get the form authentication mechanism instance. + * + * @return the form authentication mechanism + */ + public static FormAuthenticationMechanism getFormAuthMechanism() { + return HttpSecurityConfiguration.get().getFormAuthenticationMechanism(); } public static abstract class DefaultAuthFailureHandler implements BiConsumer { @@ -235,12 +225,7 @@ public void accept(Boolean aBoolean) { proceed(authenticationFailedException); } } - }, new Consumer() { - @Override - public void accept(Throwable throwable) { - event.fail(throwable); - } - }); + }, event::fail); } else if (throwable instanceof AuthenticationCompletionException) { log.debug("Authentication has failed, returning HTTP status 401"); event.response().setStatusCode(401); @@ -279,10 +264,10 @@ private static HttpAuthenticator getAuthenticator(RoutingContext event) { } public static Throwable extractRootCause(Throwable throwable) { - while ((throwable instanceof CompletionException && throwable.getCause() != null) || - (throwable instanceof CompositeException)) { - if (throwable instanceof CompositeException) { - throwable = ((CompositeException) throwable).getCauses().get(0); + while (throwable instanceof CompletionException && throwable.getCause() != null || + throwable instanceof CompositeException) { + if (throwable instanceof CompositeException ce) { + throwable = ce.getCauses().get(0); } else { throwable = throwable.getCause(); } @@ -312,7 +297,13 @@ public static final class AuthenticationHandler implements Handler> getSecurityIdentityContextKeySupplier() { - return new RuntimeValue<>(List.of(HttpSecurityUtils.ROUTING_CONTEXT_ATTRIBUTE)); - } - public Consumer createEagerSecurityInterceptor( Function> interceptorCreator, String annotationValue) { return interceptorCreator.apply(annotationValue); @@ -573,13 +564,13 @@ private static Set parseRoles(String value) { return Set.copyOf(roles); } - public Supplier basicAuthenticationMechanismBean() { - return new Supplier<>() { - @Override - public BasicAuthenticationMechanism get() { - return HttpSecurityConfiguration.get().getBasicAuthenticationMechanism(); - } - }; + /** + * Get the basic authentication mechanism instance. + * + * @return the basic authentication mechanism + */ + public static BasicAuthenticationMechanism getBasicAuthMechanism() { + return HttpSecurityConfiguration.get().getBasicAuthenticationMechanism(); } } diff --git a/extensions/vertx/deployment/src/main/java/io/quarkus/vertx/core/deployment/CoreVertxBuildItem.java b/extensions/vertx/deployment/src/main/java/io/quarkus/vertx/core/deployment/CoreVertxBuildItem.java index 1e2dffcafbba5..3224e2e423385 100644 --- a/extensions/vertx/deployment/src/main/java/io/quarkus/vertx/core/deployment/CoreVertxBuildItem.java +++ b/extensions/vertx/deployment/src/main/java/io/quarkus/vertx/core/deployment/CoreVertxBuildItem.java @@ -3,13 +3,14 @@ import java.util.function.Supplier; import io.quarkus.builder.item.SimpleBuildItem; +import io.quarkus.vertx.VertxSupplier; import io.vertx.core.Vertx; public final class CoreVertxBuildItem extends SimpleBuildItem { - private final Supplier vertx; + private final VertxSupplier vertx; - public CoreVertxBuildItem(Supplier vertx) { + public CoreVertxBuildItem(VertxSupplier vertx) { this.vertx = vertx; } diff --git a/extensions/vertx/deployment/src/main/java/io/quarkus/vertx/core/deployment/IgnoredContextLocalDataKeysBuildItem.java b/extensions/vertx/deployment/src/main/java/io/quarkus/vertx/core/deployment/IgnoredContextLocalDataKeysBuildItem.java index e0a13576877a4..798e97340e013 100644 --- a/extensions/vertx/deployment/src/main/java/io/quarkus/vertx/core/deployment/IgnoredContextLocalDataKeysBuildItem.java +++ b/extensions/vertx/deployment/src/main/java/io/quarkus/vertx/core/deployment/IgnoredContextLocalDataKeysBuildItem.java @@ -14,13 +14,34 @@ */ public final class IgnoredContextLocalDataKeysBuildItem extends MultiBuildItem { - private final RuntimeValue> ignoredKeysSupplier; + private final List ignoredKeys; + /** + * Construct a new instance with a plain list of ignored keys. + * + * @param ignoredKeys the list of context local data keys to ignore (must not be {@code null}) + */ + public IgnoredContextLocalDataKeysBuildItem(List ignoredKeys) { + this.ignoredKeys = List.copyOf(Objects.requireNonNull(ignoredKeys)); + } + + /** + * Construct a new instance from a {@link RuntimeValue} containing the ignored keys. + * + * @param ignoredKeysSupplier a runtime value wrapping the list of keys (must not be {@code null}) + * @deprecated Use {@link #IgnoredContextLocalDataKeysBuildItem(List)} instead. + */ + @Deprecated(forRemoval = true) public IgnoredContextLocalDataKeysBuildItem(RuntimeValue> ignoredKeysSupplier) { - this.ignoredKeysSupplier = Objects.requireNonNull(ignoredKeysSupplier); + this(Objects.requireNonNull(ignoredKeysSupplier).getValue()); } - RuntimeValue> getIgnoredKeysSupplier() { - return ignoredKeysSupplier; + /** + * Get the list of context local data keys that should not be propagated. + * + * @return the ignored keys (not {@code null}) + */ + public List ignoredKeys() { + return ignoredKeys; } } diff --git a/extensions/vertx/deployment/src/main/java/io/quarkus/vertx/core/deployment/VertxCoreProcessor.java b/extensions/vertx/deployment/src/main/java/io/quarkus/vertx/core/deployment/VertxCoreProcessor.java index 275074fb91002..ccf98534f2cef 100644 --- a/extensions/vertx/deployment/src/main/java/io/quarkus/vertx/core/deployment/VertxCoreProcessor.java +++ b/extensions/vertx/deployment/src/main/java/io/quarkus/vertx/core/deployment/VertxCoreProcessor.java @@ -10,8 +10,9 @@ import java.util.HashSet; import java.util.List; import java.util.Set; +import java.util.concurrent.ThreadFactory; import java.util.function.Consumer; -import java.util.function.Supplier; +import java.util.function.IntSupplier; import java.util.logging.Filter; import java.util.logging.LogRecord; import java.util.regex.Matcher; @@ -24,11 +25,15 @@ import org.jboss.logging.Logger; import org.jboss.logmanager.Level; import org.jboss.logmanager.LogManager; +import org.jboss.threads.ContextHandler; +import io.quarkus.arc.ArcContainer; import io.quarkus.arc.deployment.AdditionalBeanBuildItem; import io.quarkus.arc.deployment.SyntheticBeanBuildItem; import io.quarkus.arc.deployment.UnremovableBeanBuildItem; import io.quarkus.bootstrap.classloading.QuarkusClassLoader; +import io.quarkus.core.Phase; +import io.quarkus.core.deployment.service.ServiceRegistrar; import io.quarkus.deployment.Capabilities; import io.quarkus.deployment.Capability; import io.quarkus.deployment.IsDevelopment; @@ -53,19 +58,26 @@ import io.quarkus.deployment.util.ServiceUtil; import io.quarkus.mutiny.deployment.MutinyRuntimeInitBuildItem; import io.quarkus.netty.deployment.EventLoopSupplierBuildItem; +import io.quarkus.runtime.IOThreadDetector; +import io.quarkus.runtime.LaunchMode; +import io.quarkus.runtime.QuarkusContextHandler; import io.quarkus.runtime.configuration.ConfigurationException; import io.quarkus.vertx.VertxOptionsCustomizer; +import io.quarkus.vertx.VertxSupplier; import io.quarkus.vertx.core.runtime.VertxCoreRecorder; import io.quarkus.vertx.core.runtime.VertxLogDelegateFactory; import io.quarkus.vertx.core.runtime.config.NativeTransportMode; import io.quarkus.vertx.core.runtime.config.NativeTransportType; import io.quarkus.vertx.core.runtime.config.VertxBuildTimeConfig; +import io.quarkus.vertx.core.runtime.config.VertxConfiguration; import io.quarkus.vertx.core.runtime.context.SafeVertxContextInterceptor; import io.quarkus.vertx.deployment.VertxBuildConfig; import io.quarkus.vertx.deployment.spi.VertxBootstrapConsumerBuildItem; import io.quarkus.vertx.deployment.spi.VertxOptionsConsumerBuildItem; import io.quarkus.vertx.mdc.provider.LateBoundMDCProvider; +import io.quarkus.vertx.runtime.jackson.QuarkusJacksonFactory; import io.vertx.core.AbstractVerticle; +import io.vertx.core.Context; import io.vertx.core.Vertx; import io.vertx.core.VertxOptions; import io.vertx.core.impl.SysProps; @@ -110,7 +122,19 @@ NativeImageConfigBuildItem build(BuildProducer reflect @BuildStep @Record(ExecutionTime.RUNTIME_INIT) - EventLoopCountBuildItem eventLoopCount(VertxCoreRecorder recorder) { + EventLoopCountBuildItem eventLoopCount(VertxCoreRecorder recorder, ServiceRegistrar reg) { + // service registration for the dependency graph + reg + .forService(IntSupplier.class, "io.quarkus.vertx.event-loop-count") + .atPhase(Phase.INFRASTRUCTURE) + .require(VertxConfiguration.class) + .onStart((ctx, config) -> { + int threads = config.eventLoopsPoolSize().isPresent() + ? config.eventLoopsPoolSize().getAsInt() + : VertxCoreRecorder.calculateDefaultIOThreads(); + return () -> threads; + }); + // recorder bridge for the build item (until consumers are converted) return new EventLoopCountBuildItem(recorder.calculateEventLoopThreads()); } @@ -131,15 +155,66 @@ LogCategoryBuildItem preventLoggerContention() { } @BuildStep - @Record(ExecutionTime.STATIC_INIT) - IOThreadDetectorBuildItem ioThreadDetector(VertxCoreRecorder recorder) { - return new IOThreadDetectorBuildItem(recorder.detector()); + IOThreadDetectorBuildItem ioThreadDetector(ServiceRegistrar reg) { + reg + .forService(IOThreadDetector.class) + .atPhase(Phase.STATIC_INIT) + .onStart(ctx -> Context::isOnEventLoopThread); + return new IOThreadDetectorBuildItem( + reg.getRecorderProxy(IOThreadDetector.class)); } + /** + * Register a static-init service whose stop handler cleans up Netty's + * {@code InternalThreadLocalMap} from the main thread. + *

+ * Netty attaches a thread-local map to non-Netty threads (like the main + * thread) whenever they interact with Netty. This map holds strong + * references to classes loaded by the QuarkusClassLoader, preventing + * classloader garbage collection in dev/test mode. + *

+ * The cleanup must run after all runtime-init services have + * stopped — including ArC, whose bean destruction callbacks may touch + * Netty. By placing the cleanup in a static-init service, it runs + * during the static-init graph's stop cascade, which executes after + * the entire runtime-init graph has stopped. + *

+ * Future considerations: + *

    + *
  • Implicit stop-ordering from synthetic bean registration: + * When a service registers a synthetic CDI bean, the framework could + * automatically infer a stop-ordering edge: ArC must destroy that + * bean's dependency subgraph before the backing service stops. This + * would eliminate the need for this workaround.
  • + *
  • Service-scoped CDI contexts: Instead of + * {@code @ApplicationScoped}, service-backed synthetic beans could + * live in a custom scope tied to the service graph. The scope would + * end when the backing service stops, triggering bean destruction + * at the right time.
  • + *
+ */ @BuildStep - @Record(ExecutionTime.RUNTIME_INIT) - void configureLogging(VertxCoreRecorder recorder) { - recorder.configureQuarkusLoggerFactory(); + void registerNettyThreadLocalCleanup(ServiceRegistrar reg) { + reg + .forService("io.quarkus.vertx.netty-thread-local-cleanup") + .atPhase(Phase.STATIC_INIT) + .before(IOThreadDetector.class) + .before(ArcContainer.class) + .onStart(ctx -> { + ctx.onStop(() -> { + Logger.getLogger("io.quarkus.vertx.netty-cleanup") + .info("Cleaning up Netty InternalThreadLocalMap"); + io.netty.util.internal.InternalThreadLocalMap.remove(); + }); + }); + } + + @BuildStep + void configureLogging(ServiceRegistrar reg) { + reg + .forService("io.quarkus.vertx.configure-logging") + .atPhase(Phase.LOGGING) + .onStart(ctx -> VertxCoreRecorder.configureQuarkusLoggerFactory()); } @BuildStep @@ -147,6 +222,7 @@ void configureLogging(VertxCoreRecorder recorder) { @Record(value = ExecutionTime.RUNTIME_INIT) CoreVertxBuildItem build( VertxCoreRecorder recorder, + ServiceRegistrar reg, LaunchModeBuildItem launchMode, ShutdownContextBuildItem shutdown, List vertxBootstrapConsumers, @@ -172,9 +248,15 @@ CoreVertxBuildItem build( List vertxServiceProviderClassNames = loadServiceClassNames(VertxServiceProvider.class); List verticleFactoryClassNames = loadServiceClassNames(VerticleFactory.class); - Supplier vertx = recorder.configureVertx(launchMode.getLaunchMode(), shutdown, bootstrapCustomizer, + VertxSupplier vertx = recorder.configureVertx(launchMode.getLaunchMode(), shutdown, bootstrapCustomizer, optionsCustomizer, vertxServiceProviderClassNames, verticleFactoryClassNames, executorBuildItem.getExecutorProxy()); + reg.aliasRecorderValue(VertxSupplier.class, vertx, Phase.INFRASTRUCTURE); + reg + .forService(Vertx.class) + .atPhase(Phase.INFRASTRUCTURE) + .require(VertxSupplier.class) + .onStart((ctx, supplier) -> supplier.get()); syntheticBeans.produce(SyntheticBeanBuildItem.configure(Vertx.class) .types(Vertx.class) .scope(Singleton.class) @@ -192,8 +274,7 @@ CoreVertxBuildItem build( } @BuildStep - @Record(ExecutionTime.RUNTIME_INIT) - void detectNativeTransports(VertxBuildTimeConfig buildTimeConfig, VertxCoreRecorder recorder) { + void detectNativeTransports(VertxBuildTimeConfig buildTimeConfig, ServiceRegistrar reg) { Set detected = new HashSet<>(); if (QuarkusClassLoader.isClassPresentAtRuntime("io.netty.channel.epoll.EpollMode")) { @@ -228,7 +309,11 @@ void detectNativeTransports(VertxBuildTimeConfig buildTimeConfig, VertxCoreRecor log.debugf("Detected native transport(s) on classpath: %s", detected); } - recorder.setDetectedNativeTransports(detected); + Set capturedDetected = Set.copyOf(detected); + reg + .forService("io.quarkus.vertx.detect-native-transports") + .atPhase(Phase.INFRASTRUCTURE) + .onStart(ctx -> VertxCoreRecorder.setDetectedNativeTransports(capturedDetected)); } @BuildStep @@ -252,34 +337,42 @@ void doNotRemoveVertxOptionsCustomizers(BuildProducer } @BuildStep - @Record(ExecutionTime.RUNTIME_INIT) - ThreadFactoryBuildItem createVertxThreadFactory(VertxCoreRecorder recorder, LaunchModeBuildItem launchMode) { - return new ThreadFactoryBuildItem(recorder.createThreadFactory(launchMode.getLaunchMode())); + ThreadFactoryBuildItem createVertxThreadFactory(ServiceRegistrar reg, LaunchModeBuildItem launchMode) { + LaunchMode mode = launchMode.getLaunchMode(); + reg + .forService(ThreadFactory.class) + .atPhase(Phase.INFRASTRUCTURE) + .onStart(ctx -> VertxCoreRecorder.createThreadFactory(mode)); + return new ThreadFactoryBuildItem( + reg.getRecorderProxy(ThreadFactory.class)); } @BuildStep - @Record(ExecutionTime.RUNTIME_INIT) - void dontPropagateCdiContext(BuildProducer ignoredContextKeysProducer, - VertxCoreRecorder recorder, VertxBuildConfig buildConfig) { - if (buildConfig.customizeArcContext()) { - ignoredContextKeysProducer - .produce(new IgnoredContextLocalDataKeysBuildItem(recorder.getIgnoredArcContextKeysSupplier())); - } - } - - @BuildStep - @Record(ExecutionTime.RUNTIME_INIT) - ContextHandlerBuildItem createVertxContextHandlers(VertxCoreRecorder recorder, + ContextHandlerBuildItem createVertxContextHandlers(ServiceRegistrar reg, + VertxBuildConfig buildConfig, List ignoredKeysSuppliersItems) { - var ignoredKeysSuppliers = ignoredKeysSuppliersItems.stream() - .map(IgnoredContextLocalDataKeysBuildItem::getIgnoredKeysSupplier).toList(); - return new ContextHandlerBuildItem(recorder.executionContextHandler(ignoredKeysSuppliers)); + List extensionIgnoredKeys = List.of(ignoredKeysSuppliersItems.stream() + .flatMap(item -> item.ignoredKeys().stream()) + .toArray(String[]::new)); + boolean includeArcKeys = buildConfig.customizeArcContext(); + reg + .forService(QuarkusContextHandler.class) + .atPhase(Phase.INFRASTRUCTURE) + .onStart(ctx -> { + ContextHandler delegate = VertxCoreRecorder.executionContextHandler(extensionIgnoredKeys, + includeArcKeys); + return QuarkusContextHandler.of(delegate); + }); + return new ContextHandlerBuildItem( + reg.getRecorderProxy(QuarkusContextHandler.class)); } @BuildStep(onlyIf = IsDevelopment.class) - @Record(ExecutionTime.RUNTIME_INIT) - public void resetMapper(VertxCoreRecorder recorder, ShutdownContextBuildItem shutdown) { - recorder.resetMapper(shutdown); + public void resetMapper(ServiceRegistrar reg) { + reg + .forService("io.quarkus.vertx.reset-mapper") + .atPhase(Phase.INFRASTRUCTURE) + .onStart(ctx -> ctx.onStop(QuarkusJacksonFactory::reset)); } private void handleBlockingWarningsInDevOrTestMode() { diff --git a/extensions/vertx/deployment/src/main/java/io/quarkus/vertx/deployment/VertxProcessor.java b/extensions/vertx/deployment/src/main/java/io/quarkus/vertx/deployment/VertxProcessor.java index 013ec2bf9c1b8..d47103be14942 100644 --- a/extensions/vertx/deployment/src/main/java/io/quarkus/vertx/deployment/VertxProcessor.java +++ b/extensions/vertx/deployment/src/main/java/io/quarkus/vertx/deployment/VertxProcessor.java @@ -20,6 +20,7 @@ import org.jboss.jandex.Type.Kind; import org.jboss.logging.Logger; +import io.quarkus.arc.CurrentContextFactory; import io.quarkus.arc.deployment.AdditionalBeanBuildItem; import io.quarkus.arc.deployment.AutoAddScopeBuildItem; import io.quarkus.arc.deployment.BeanRegistrationPhaseBuildItem; @@ -37,6 +38,8 @@ import io.quarkus.arc.processor.KotlinUtils; import io.quarkus.arc.spi.NonBlockingProvider; import io.quarkus.bootstrap.classloading.QuarkusClassLoader; +import io.quarkus.core.Phase; +import io.quarkus.core.deployment.service.ServiceRegistrar; import io.quarkus.deployment.Capabilities; import io.quarkus.deployment.Capability; import io.quarkus.deployment.Feature; @@ -65,6 +68,7 @@ import io.quarkus.vertx.core.deployment.CoreVertxBuildItem; import io.quarkus.vertx.deployment.spi.EventConsumerInvokerCustomizerBuildItem; import io.quarkus.vertx.runtime.EventConsumerInfo; +import io.quarkus.vertx.runtime.VertxCurrentContextFactory; import io.quarkus.vertx.runtime.VertxEventBusConsumerRecorder; import io.quarkus.vertx.runtime.VertxNonBlockingProvider; import io.quarkus.vertx.runtime.VertxProducer; @@ -133,11 +137,15 @@ VertxBuildItem build(CoreVertxBuildItem vertx, VertxEventBusConsumerRecorder rec } @BuildStep - @Record(ExecutionTime.STATIC_INIT) void currentContextFactory(BuildProducer currentContextFactory, - VertxBuildConfig buildConfig, VertxEventBusConsumerRecorder recorder) { + VertxBuildConfig buildConfig, ServiceRegistrar reg) { if (buildConfig.customizeArcContext()) { - currentContextFactory.produce(new CurrentContextFactoryBuildItem(recorder.currentContextFactory())); + reg + .forService(CurrentContextFactory.class) + .atPhase(Phase.STATIC_INIT) + .onStart(ctx -> new VertxCurrentContextFactory()); + currentContextFactory.produce(new CurrentContextFactoryBuildItem( + reg.staticInitServiceAsRuntimeValue(CurrentContextFactory.class))); } } diff --git a/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/VertxSupplier.java b/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/VertxSupplier.java new file mode 100644 index 0000000000000..92d3fddff402b --- /dev/null +++ b/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/VertxSupplier.java @@ -0,0 +1,18 @@ +package io.quarkus.vertx; + +import java.util.function.Supplier; + +import io.vertx.core.Vertx; + +/** + * A typed supplier of the Quarkus-managed {@link Vertx} instance. + *

+ * This interface exists to provide a non-generic service type for the + * Vert.x supplier in the service dependency graph, since {@code Supplier} + * loses its type parameter at runtime due to erasure. + *

+ * The supplier performs lazy initialization: the {@link Vertx} instance is + * created on the first call to {@link #get()} and cached thereafter. + */ +public interface VertxSupplier extends Supplier { +} diff --git a/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/core/runtime/VertxCoreRecorder.java b/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/core/runtime/VertxCoreRecorder.java index 6bf0bc0d866c3..878365fb09b56 100644 --- a/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/core/runtime/VertxCoreRecorder.java +++ b/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/core/runtime/VertxCoreRecorder.java @@ -25,7 +25,6 @@ import org.jboss.threads.ContextHandler; import io.netty.channel.EventLoopGroup; -import io.netty.util.concurrent.FastThreadLocal; import io.quarkus.arc.Arc; import io.quarkus.arc.InstanceHandle; import io.quarkus.runtime.ExecutorRecorder; @@ -36,6 +35,7 @@ import io.quarkus.runtime.ThreadPoolConfig; import io.quarkus.runtime.annotations.Recorder; import io.quarkus.runtime.shutdown.ShutdownConfig; +import io.quarkus.vertx.VertxSupplier; import io.quarkus.vertx.core.runtime.config.AddressResolverConfiguration; import io.quarkus.vertx.core.runtime.config.NativeTransportMode; import io.quarkus.vertx.core.runtime.config.NativeTransportType; @@ -77,7 +77,7 @@ public class VertxCoreRecorder { private static final Logger LOGGER = Logger.getLogger(VertxCoreRecorder.class.getName()); public static final String VERTX_CACHE = "vertx-cache"; - static volatile VertxSupplier vertx; + static volatile VertxSupplierImpl vertx; static volatile int blockingThreadPoolSize; @@ -92,7 +92,7 @@ public class VertxCoreRecorder { * * @param transports the set of transport, empty if none. */ - public void setDetectedNativeTransports(Set transports) { + public static void setDetectedNativeTransports(Set transports) { detectedNativeTransports = transports; } @@ -133,7 +133,7 @@ public VertxCoreRecorder(VertxBuildTimeConfig btConfig, RuntimeValue configureVertx(LaunchMode launchMode, ShutdownContext shutdown, + public VertxSupplier configureVertx(LaunchMode launchMode, ShutdownContext shutdown, List> bootstrapCustomizers, List> optionsCustomizers, List vertxServiceProviderClassNames, List verticleFactoryClassNames, ExecutorService executorProxy) { @@ -141,7 +141,7 @@ public Supplier configureVertx(LaunchMode launchMode, ShutdownContext shu QuarkusExecutorFactory.sharedExecutor = executorProxy; if (launchMode != LaunchMode.DEVELOPMENT) { - vertx = new VertxSupplier(launchMode, vertxConfig.getValue(), new ArrayList<>(bootstrapCustomizers), + vertx = new VertxSupplierImpl(launchMode, vertxConfig.getValue(), new ArrayList<>(bootstrapCustomizers), new ArrayList<>(optionsCustomizers), threadPoolConfig.getValue(), shutdown, vertxServiceProviderClassNames, verticleFactoryClassNames); // we need this to be part of the last shutdown tasks because closing it early (basically before Arc) @@ -156,7 +156,7 @@ public void run() { }); } else { if (vertx == null) { - vertx = new VertxSupplier(launchMode, vertxConfig.getValue(), new ArrayList<>(bootstrapCustomizers), + vertx = new VertxSupplierImpl(launchMode, vertxConfig.getValue(), new ArrayList<>(bootstrapCustomizers), new ArrayList<>(optionsCustomizers), threadPoolConfig.getValue(), shutdown, vertxServiceProviderClassNames, verticleFactoryClassNames); @@ -240,11 +240,16 @@ public void handle(AsyncResult event) { devModeThreads.clear(); currentDevModeNewThreadCreationClassLoader = null; } + // Release the closed dev-mode Vert.x. If left set, this static holds the closed + // VertxImpl (and its now-dead BlockedThreadChecker thread, whose stale context + // classloader pins the per-session runtime QuarkusClassLoader), leaking a classloader + // per dev-mode test class and eventually exhausting metaspace on JDK 21. + vertx = null; } } } - public static Supplier getVertx() { + public static VertxSupplier getVertx() { return vertx; } @@ -629,7 +634,7 @@ private static File getRandomDirectory(File tmp) { return cache; } - private static int calculateDefaultIOThreads() { + public static int calculateDefaultIOThreads() { //we only allow one event loop per 10mb of ram at the most //it's hard to say what this number should be, but it is also obvious //that for constrained environments we don't want a lot of event loops @@ -647,8 +652,6 @@ private static int calculateDefaultIOThreads() { void destroy() { if (vertx != null && vertx.v != null) { - // Netty attaches a ThreadLocal to the main thread that can leak the QuarkusClassLoader which can be problematic in dev or test mode - FastThreadLocal.destroy(); CountDownLatch latch = new CountDownLatch(1); AtomicReference problem = new AtomicReference<>(); vertx.v.close().onComplete(new Handler>() { @@ -734,6 +737,11 @@ public EventLoopGroup get() { }; } + /** + * @deprecated retained for {@code EventLoopCountBuildItem} compatibility; + * use the {@code IntSupplier} service {@code "io.quarkus.vertx.event-loop-count"} instead + */ + @Deprecated(forRemoval = true) public Supplier calculateEventLoopThreads() { int threads; if (vertxConfig.getValue().eventLoopsPoolSize().isPresent()) { @@ -749,7 +757,7 @@ public Integer get() { }; } - public ThreadFactory createThreadFactory(LaunchMode launchMode) { + public static ThreadFactory createThreadFactory(LaunchMode launchMode) { Optional nonDevModeTccl = setupThreadFactoryTccl(launchMode); AtomicInteger threadCount = new AtomicInteger(0); return new ThreadFactory() { @@ -782,20 +790,22 @@ private static void setNewThreadTccl(VertxThread thread) { thread.setContextClassLoader(cl); } - public RuntimeValue> getIgnoredArcContextKeysSupplier() { - final VertxCurrentContextFactory currentContextFactory = (VertxCurrentContextFactory) Arc.container() - .getCurrentContextFactory(); - return new RuntimeValue<>(currentContextFactory.keys()); - } - - public ContextHandler executionContextHandler(List>> ignoredKeysSuppliers) { + /** + * Create a {@link ContextHandler} that propagates Vert.x context and MDC across thread boundaries. + * + * @param extensionIgnoredKeys context-local data keys that should not be propagated + * @param includeArcKeys whether to include Arc context factory keys in the ignored set + * @return the context handler + */ + public static ContextHandler executionContextHandler(List extensionIgnoredKeys, boolean includeArcKeys) { final List ignoredKeys; - if (ignoredKeysSuppliers.isEmpty()) { + if (extensionIgnoredKeys.isEmpty() && !includeArcKeys) { ignoredKeys = null; } else { - ignoredKeys = new ArrayList<>(); - for (RuntimeValue> ignoredKeysSupplier : ignoredKeysSuppliers) { - ignoredKeys.addAll(ignoredKeysSupplier.getValue()); + ignoredKeys = new ArrayList<>(extensionIgnoredKeys); + if (includeArcKeys) { + ignoredKeys.addAll( + ((VertxCurrentContextFactory) Arc.container().getCurrentContextFactory()).keys()); } } return new ContextHandler() { @@ -868,21 +878,22 @@ private boolean containsIgnoredKey(List keys, Map localC }; } - public static Supplier recoverFailedStart(VertxConfiguration config, ThreadPoolConfig threadPoolConfig) { - return vertx = new VertxSupplier(LaunchMode.DEVELOPMENT, config, Collections.emptyList(), Collections.emptyList(), + public static VertxSupplier recoverFailedStart(VertxConfiguration config, + ThreadPoolConfig threadPoolConfig) { + return vertx = new VertxSupplierImpl(LaunchMode.DEVELOPMENT, config, Collections.emptyList(), Collections.emptyList(), threadPoolConfig, null, List.of(), List.of()); } - public void configureQuarkusLoggerFactory() { + public static void configureQuarkusLoggerFactory() { String loggerClassName = System.getProperty(LOGGER_FACTORY_NAME_SYS_PROP); if (loggerClassName == null) { System.setProperty(LOGGER_FACTORY_NAME_SYS_PROP, VertxLogDelegateFactory.class.getName()); } } - static class VertxSupplier implements Supplier { + static class VertxSupplierImpl implements VertxSupplier { final LaunchMode launchMode; final VertxConfiguration config; final VertxCustomizer customizer; @@ -892,7 +903,7 @@ static class VertxSupplier implements Supplier { final List verticleFactoryClassNames; Vertx v; - VertxSupplier(LaunchMode launchMode, VertxConfiguration config, + VertxSupplierImpl(LaunchMode launchMode, VertxConfiguration config, List> bootstrapCustomizers, List> optionCustomizers, ThreadPoolConfig threadPoolConfig, From bde4ad78f95525cbf20b4832af2e6d4ca6f65bb2 Mon Sep 17 00:00:00 2001 From: "David M. Lloyd" Date: Fri, 25 Sep 2026 16:25:09 -0500 Subject: [PATCH 039/254] Service conversion: Undertow --- .../deployment/UndertowBuildStep.java | 85 ++++++++++++++----- .../runtime/UndertowDeploymentRecorder.java | 37 +++----- .../runtime/UndertowVertxHandler.java | 11 +++ 3 files changed, 88 insertions(+), 45 deletions(-) create mode 100644 extensions/undertow/runtime/src/main/java/io/quarkus/undertow/runtime/UndertowVertxHandler.java diff --git a/extensions/undertow/deployment/src/main/java/io/quarkus/undertow/deployment/UndertowBuildStep.java b/extensions/undertow/deployment/src/main/java/io/quarkus/undertow/deployment/UndertowBuildStep.java index 489f11a39374c..5d3a018e8ee59 100644 --- a/extensions/undertow/deployment/src/main/java/io/quarkus/undertow/deployment/UndertowBuildStep.java +++ b/extensions/undertow/deployment/src/main/java/io/quarkus/undertow/deployment/UndertowBuildStep.java @@ -1,6 +1,5 @@ package io.quarkus.undertow.deployment; -import static io.quarkus.deployment.annotations.ExecutionTime.RUNTIME_INIT; import static io.quarkus.deployment.annotations.ExecutionTime.STATIC_INIT; import static io.undertow.servlet.api.SecurityInfo.EmptyRoleSemantic.AUTHENTICATE; import static io.undertow.servlet.api.SecurityInfo.EmptyRoleSemantic.DENY; @@ -17,6 +16,7 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.concurrent.ScheduledExecutorService; import java.util.function.Consumer; import java.util.stream.Collectors; @@ -28,6 +28,7 @@ import jakarta.inject.Inject; import jakarta.servlet.ServletContainerInitializer; import jakarta.servlet.ServletContext; +import jakarta.servlet.ServletException; import jakarta.servlet.SessionTrackingMode; import jakarta.servlet.annotation.HandlesTypes; import jakarta.servlet.annotation.MultipartConfig; @@ -89,6 +90,9 @@ import io.quarkus.arc.deployment.CustomScopeBuildItem; import io.quarkus.arc.deployment.SyntheticBeanBuildItem; import io.quarkus.arc.processor.AnnotationsTransformer; +import io.quarkus.arc.runtime.BeanContainer; +import io.quarkus.core.Phase; +import io.quarkus.core.deployment.service.ServiceRegistrar; import io.quarkus.deployment.Capabilities; import io.quarkus.deployment.Capability; import io.quarkus.deployment.Feature; @@ -118,15 +122,19 @@ import io.quarkus.undertow.runtime.QuarkusIdentityManager; import io.quarkus.undertow.runtime.ServletHttpSecurityPolicy; import io.quarkus.undertow.runtime.ServletProducer; +import io.quarkus.undertow.runtime.ServletRuntimeConfig; import io.quarkus.undertow.runtime.ServletSecurityInfoProxy; import io.quarkus.undertow.runtime.ServletSecurityInfoSubstitution; import io.quarkus.undertow.runtime.UndertowDeploymentRecorder; import io.quarkus.undertow.runtime.UndertowHandlersConfServletExtension; +import io.quarkus.undertow.runtime.UndertowVertxHandler; import io.quarkus.vertx.http.deployment.DefaultRouteBuildItem; import io.quarkus.vertx.http.deployment.HttpRootPathBuildItem; import io.quarkus.vertx.http.deployment.RouteBuildItem; import io.quarkus.vertx.http.runtime.VertxHttpBuildTimeConfig; +import io.quarkus.vertx.http.runtime.VertxHttpConfig; import io.undertow.servlet.api.DeploymentInfo; +import io.undertow.servlet.api.DeploymentManager; import io.undertow.servlet.api.FilterInfo; import io.undertow.servlet.api.HttpMethodSecurityInfo; import io.undertow.servlet.api.SecurityConstraint; @@ -135,8 +143,6 @@ import io.undertow.servlet.api.ServletSessionConfig; import io.undertow.servlet.api.WebResourceCollection; import io.undertow.servlet.handlers.DefaultServlet; -import io.vertx.core.Handler; -import io.vertx.ext.web.RoutingContext; //TODO: break this up, it is getting too big public class UndertowBuildStep { @@ -179,24 +185,54 @@ private boolean jacksonOnClasspath(CurateOutcomeBuildItem curateOutcomeBuildItem } @BuildStep - @Record(RUNTIME_INIT) public ServiceStartBuildItem boot( - UndertowDeploymentRecorder recorder, + ServiceRegistrar reg, ServletDeploymentManagerBuildItem servletDeploymentManagerBuildItem, List wrappers, - ShutdownContextBuildItem shutdown, Consumer undertowProducer, BuildProducer routeProducer, ExecutorBuildItem executorBuildItem, ServletContextPathBuildItem servletContextPathBuildItem, Capabilities capabilities) throws Exception { - if (capabilities.isPresent(Capability.SECURITY)) { - recorder.setupSecurity(servletDeploymentManagerBuildItem.getDeploymentManager()); - } - Handler ut = recorder.startUndertow(shutdown, executorBuildItem.getExecutorProxy(), - servletDeploymentManagerBuildItem.getDeploymentManager(), - wrappers.stream().map(HttpHandlerWrapperBuildItem::getValue).collect(Collectors.toList())); + boolean hasSecurity = capabilities.isPresent(Capability.SECURITY); + var handlerWrappers = wrappers.stream() + .map(HttpHandlerWrapperBuildItem::getValue) + .toList(); + + reg.forService(UndertowVertxHandler.class, "undertow") + .require(DeploymentManager.class) + .require(BeanContainer.class) + .require(ScheduledExecutorService.class) + .require(ServletRuntimeConfig.class) + .require(VertxHttpBuildTimeConfig.class) + .require(VertxHttpConfig.class) + .after("io.quarkus.arc.shutdown-gateway") + .onStart((ctx, manager, beanContainer, executor, servletRuntimeConfig, httpBuildTimeConfig, + httpRuntimeConfig) -> { + if (hasSecurity) { + UndertowDeploymentRecorder.setupSecurity(manager); + } + + ctx.onStop(() -> { + try { + manager.stop(); + } catch (ServletException e) { + // ignore / log + } + manager.undeploy(); + }); + + return UndertowDeploymentRecorder.startUndertow( + executor, + manager, + handlerWrappers, + servletRuntimeConfig, + httpBuildTimeConfig, + httpRuntimeConfig); + }); + + UndertowVertxHandler ut = reg.getRecorderProxy(UndertowVertxHandler.class, "undertow"); if (servletContextPathBuildItem.getServletContextPath().equals("/")) { undertowProducer.accept(new DefaultRouteBuildItem(ut)); @@ -345,7 +381,7 @@ private boolean hasSecurityCapability(final Capabilities capabilities) { public void addTypedAnnotations( BuildProducer annotationsTransformer) { - annotationsTransformer.produce(new io.quarkus.arc.deployment.AnnotationsTransformerBuildItem( + annotationsTransformer.produce(new AnnotationsTransformerBuildItem( new AnnotationsTransformer() { @Override @@ -377,7 +413,9 @@ private AnnotationInstance createTypedAnnotationInstance(ClassInfo clazz) { @Record(STATIC_INIT) @BuildStep() - public ServletDeploymentManagerBuildItem build(List servlets, + public ServletDeploymentManagerBuildItem build( + ServiceRegistrar reg, + List servlets, List filters, List listeners, List initParams, @@ -696,14 +734,17 @@ public ServletDeploymentManagerBuildItem build(List servlets, knownClasses = loggingDecorateBuildItem.get().getKnowClasses(); } - return new ServletDeploymentManagerBuildItem( - recorder.bootServletContainer(deployment, - bc.getValue(), - launchMode.getLaunchMode(), - shutdownContext, - logBuildTimeConfig.decorateStacktraces(), - scrMainJava, - knownClasses)); + DeploymentManager deploymentManager = recorder.bootServletContainer(deployment, + bc.getValue(), + launchMode.getLaunchMode(), + shutdownContext, + logBuildTimeConfig.decorateStacktraces(), + scrMainJava, + knownClasses); + + reg.aliasRecorderValue(DeploymentManager.class, deploymentManager, Phase.STATIC_INIT); + + return new ServletDeploymentManagerBuildItem(deploymentManager); } diff --git a/extensions/undertow/runtime/src/main/java/io/quarkus/undertow/runtime/UndertowDeploymentRecorder.java b/extensions/undertow/runtime/src/main/java/io/quarkus/undertow/runtime/UndertowDeploymentRecorder.java index 158fa9068b6b5..d39d2e4fab92e 100644 --- a/extensions/undertow/runtime/src/main/java/io/quarkus/undertow/runtime/UndertowDeploymentRecorder.java +++ b/extensions/undertow/runtime/src/main/java/io/quarkus/undertow/runtime/UndertowDeploymentRecorder.java @@ -27,7 +27,6 @@ import jakarta.servlet.Servlet; import jakarta.servlet.ServletContainerInitializer; import jakarta.servlet.ServletContext; -import jakarta.servlet.ServletException; import jakarta.servlet.ServletRequest; import jakarta.servlet.ServletResponse; import jakarta.servlet.SessionTrackingMode; @@ -356,24 +355,16 @@ public void addServletInitParameter(RuntimeValue info, String na info.getValue().addInitParameter(name, value); } - public void setupSecurity(DeploymentManager manager) { + public static void setupSecurity(DeploymentManager manager) { CDI.current().select(ServletHttpSecurityPolicy.class).get().setDeployment(manager.getDeployment()); } - public Handler startUndertow(ShutdownContext shutdown, ExecutorService executorService, - DeploymentManager manager, List wrappers) throws Exception { - shutdown.addShutdownTask(new Runnable() { - @Override - public void run() { - try { - manager.stop(); - } catch (ServletException e) { - log.error("Failed to stop deployment", e); - } - manager.undeploy(); - } - }); + public static UndertowVertxHandler startUndertow(ExecutorService executorService, + DeploymentManager manager, List wrappers, + ServletRuntimeConfig servletRuntimeConfig, + VertxHttpBuildTimeConfig httpBuildTimeConfig, + VertxHttpConfig httpRuntimeConfig) throws Exception { HttpHandler main = manager.getDeployment().getHandler(); for (HandlerWrapper i : wrappers) { main = i.wrap(main); @@ -389,14 +380,14 @@ public void run() { DefaultExchangeHandler defaultHandler = new DefaultExchangeHandler(ROOT_HANDLER); UndertowBufferAllocator allocator = new UndertowBufferAllocator( - servletRuntimeConfig.getValue().directBuffers().orElse(DEFAULT_DIRECT_BUFFERS), - servletRuntimeConfig.getValue().bufferSize() + servletRuntimeConfig.directBuffers().orElse(DEFAULT_DIRECT_BUFFERS), + servletRuntimeConfig.bufferSize() .orElse(MemorySize.of(DEFAULT_BUFFER_SIZE)).asIntValue()); UndertowOptionMap.Builder undertowOptions = UndertowOptionMap.builder(); - undertowOptions.set(UndertowOptions.MAX_PARAMETERS, servletRuntimeConfig.getValue().maxParameters()); + undertowOptions.set(UndertowOptions.MAX_PARAMETERS, servletRuntimeConfig.maxParameters()); undertowOptions.set(UndertowOptions.RECORD_REQUEST_START_TIME, - servletRuntimeConfig.getValue().recordRequestStartTime()); + servletRuntimeConfig.recordRequestStartTime()); UndertowOptionMap undertowOptionMap = undertowOptions.getMap(); Set compressMediaTypes = httpBuildTimeConfig.enableCompression() @@ -404,7 +395,7 @@ public void run() { : Collections.emptySet(); Set disallowedMethods; - Optional> configured = servletRuntimeConfig.getValue().disallowedMethods(); + Optional> configured = servletRuntimeConfig.disallowedMethods(); if (configured.isPresent()) { Set normalized = new HashSet<>(); for (String method : configured.get()) { @@ -417,7 +408,7 @@ public void run() { disallowedMethods = Collections.emptySet(); } - return new Handler() { + return new UndertowVertxHandler() { @Override public void handle(RoutingContext event) { if (!event.request().isEnded()) { @@ -457,11 +448,11 @@ public void handle(Void result) { }); } - Optional maxBodySize = httpRuntimeConfig.getValue().limits().maxBodySize(); + Optional maxBodySize = httpRuntimeConfig.limits().maxBodySize(); if (maxBodySize.isPresent()) { exchange.setMaxEntitySize(maxBodySize.get().asLongValue()); } - Duration readTimeout = httpRuntimeConfig.getValue().readTimeout(); + Duration readTimeout = httpRuntimeConfig.readTimeout(); exchange.setReadTimeout(readTimeout.toMillis()); exchange.setUndertowOptions(undertowOptionMap); diff --git a/extensions/undertow/runtime/src/main/java/io/quarkus/undertow/runtime/UndertowVertxHandler.java b/extensions/undertow/runtime/src/main/java/io/quarkus/undertow/runtime/UndertowVertxHandler.java new file mode 100644 index 0000000000000..49d78d43f476f --- /dev/null +++ b/extensions/undertow/runtime/src/main/java/io/quarkus/undertow/runtime/UndertowVertxHandler.java @@ -0,0 +1,11 @@ +package io.quarkus.undertow.runtime; + +import io.vertx.core.Handler; +import io.vertx.ext.web.RoutingContext; + +/** + * A non-generic marker interface extending Handler<RoutingContext> so it can be registered + * as a service type in the Quarkus ServiceGraph without violating the non-generic constraint. + */ +public interface UndertowVertxHandler extends Handler { +} From de25bcb7701260cd206eeb48f87e000c0700e110 Mon Sep 17 00:00:00 2001 From: "David M. Lloyd" Date: Tue, 25 Aug 2026 13:13:49 -0500 Subject: [PATCH 040/254] Modify OIDC DB token state manager to not use bean container listeners --- .../OidcDbTokenStateManagerProcessor.java | 20 ++++++++++++++----- .../OidcDbTokenStateManagerInitializer.java | 2 +- .../OidcDbTokenStateManagerRecorder.java | 8 -------- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/extensions/oidc-db-token-state-manager/deployment/src/main/java/io/quarkus/oidc/db/token/state/manager/OidcDbTokenStateManagerProcessor.java b/extensions/oidc-db-token-state-manager/deployment/src/main/java/io/quarkus/oidc/db/token/state/manager/OidcDbTokenStateManagerProcessor.java index 3d57ec571571d..f34a5efc531a6 100644 --- a/extensions/oidc-db-token-state-manager/deployment/src/main/java/io/quarkus/oidc/db/token/state/manager/OidcDbTokenStateManagerProcessor.java +++ b/extensions/oidc-db-token-state-manager/deployment/src/main/java/io/quarkus/oidc/db/token/state/manager/OidcDbTokenStateManagerProcessor.java @@ -15,10 +15,12 @@ import io.quarkus.arc.deployment.AdditionalBeanBuildItem; import io.quarkus.arc.deployment.BeanContainerBuildItem; -import io.quarkus.arc.deployment.BeanContainerListenerBuildItem; import io.quarkus.arc.deployment.SyntheticBeanBuildItem; import io.quarkus.arc.deployment.SyntheticBeansRuntimeInitBuildItem; +import io.quarkus.arc.runtime.BeanContainer; import io.quarkus.builder.item.SimpleBuildItem; +import io.quarkus.core.Phase; +import io.quarkus.core.deployment.service.ServiceRegistrar; import io.quarkus.deployment.Capabilities; import io.quarkus.deployment.annotations.BuildStep; import io.quarkus.deployment.annotations.BuildSteps; @@ -108,9 +110,7 @@ AdditionalBeanBuildItem makeDbTokenStateManagerInitializerBean() { } @BuildStep - @Record(STATIC_INIT) - BeanContainerListenerBuildItem createDbTokenStateInitializerProps(ReactiveSqlClientBuildItem sqlClientBuildItem, - OidcDbTokenStateManagerRecorder recorder) { + void createDbTokenStateInitializerProps(ReactiveSqlClientBuildItem sqlClientBuildItem, ServiceRegistrar reg) { var supportedReactiveSqlClient = switch (sqlClientBuildItem.reactiveClient) { case REACTIVE_PG_CLIENT -> SupportedReactiveSqlClient.POSTGRESQL; case REACTIVE_MYSQL_CLIENT -> SupportedReactiveSqlClient.MYSQL; @@ -119,7 +119,17 @@ BeanContainerListenerBuildItem createDbTokenStateInitializerProps(ReactiveSqlCli case REACTIVE_ORACLE_CLIENT -> SupportedReactiveSqlClient.ORACLE; default -> throw new ConfigurationException("Unknown Reactive Sql Client " + sqlClientBuildItem.reactiveClient); }; - return new BeanContainerListenerBuildItem(recorder.setSupportedReactiveSqlClient(supportedReactiveSqlClient)); + // Formerly a BeanContainerListener; now a static-init service that pushes the + // build-time-selected SQL client into the initializer bean as soon as the bean + // container is available. The value is consumed at StartupEvent (runtime init), + // so the static-init -> runtime-init phase boundary preserves ordering; only a + // BeanContainer dependency is required. + reg + .forService("io.quarkus.oidc.db.token.state.manager.initializer") + .atPhase(Phase.STATIC_INIT) + .require(BeanContainer.class) + .onStart((ctx, container) -> container.beanInstance(OidcDbTokenStateManagerInitializer.class) + .setSupportedReactiveSqlClient(supportedReactiveSqlClient)); } @Consume(SyntheticBeansRuntimeInitBuildItem.class) diff --git a/extensions/oidc-db-token-state-manager/runtime/src/main/java/io/quarkus/oidc/db/token/state/manager/runtime/OidcDbTokenStateManagerInitializer.java b/extensions/oidc-db-token-state-manager/runtime/src/main/java/io/quarkus/oidc/db/token/state/manager/runtime/OidcDbTokenStateManagerInitializer.java index 3a55dc8cb3061..6e0454d9988cc 100644 --- a/extensions/oidc-db-token-state-manager/runtime/src/main/java/io/quarkus/oidc/db/token/state/manager/runtime/OidcDbTokenStateManagerInitializer.java +++ b/extensions/oidc-db-token-state-manager/runtime/src/main/java/io/quarkus/oidc/db/token/state/manager/runtime/OidcDbTokenStateManagerInitializer.java @@ -34,7 +34,7 @@ public final class OidcDbTokenStateManagerInitializer { private volatile Long timerId = null; private volatile SupportedReactiveSqlClient supportedReactiveSqlClient = null; - void setSupportedReactiveSqlClient(SupportedReactiveSqlClient supportedReactiveSqlClient) { + public void setSupportedReactiveSqlClient(SupportedReactiveSqlClient supportedReactiveSqlClient) { this.supportedReactiveSqlClient = supportedReactiveSqlClient; } diff --git a/extensions/oidc-db-token-state-manager/runtime/src/main/java/io/quarkus/oidc/db/token/state/manager/runtime/OidcDbTokenStateManagerRecorder.java b/extensions/oidc-db-token-state-manager/runtime/src/main/java/io/quarkus/oidc/db/token/state/manager/runtime/OidcDbTokenStateManagerRecorder.java index de37985c7ab19..bc17938d0c034 100644 --- a/extensions/oidc-db-token-state-manager/runtime/src/main/java/io/quarkus/oidc/db/token/state/manager/runtime/OidcDbTokenStateManagerRecorder.java +++ b/extensions/oidc-db-token-state-manager/runtime/src/main/java/io/quarkus/oidc/db/token/state/manager/runtime/OidcDbTokenStateManagerRecorder.java @@ -3,8 +3,6 @@ import java.util.function.Supplier; import io.quarkus.arc.runtime.BeanContainer; -import io.quarkus.arc.runtime.BeanContainerListener; -import io.quarkus.oidc.db.token.state.manager.runtime.OidcDbTokenStateManagerInitializer.SupportedReactiveSqlClient; import io.quarkus.runtime.annotations.Recorder; import io.vertx.sqlclient.Pool; @@ -26,10 +24,4 @@ public OidcDbTokenStateManager get() { public void setSqlClientPool(BeanContainer container) { container.beanInstance(OidcDbTokenStateManager.class).setSqlClientPool(container.beanInstance(Pool.class)); } - - /* STATIC INIT */ - public BeanContainerListener setSupportedReactiveSqlClient(SupportedReactiveSqlClient supportedReactiveSqlClient) { - return container -> container.beanInstance(OidcDbTokenStateManagerInitializer.class) - .setSupportedReactiveSqlClient(supportedReactiveSqlClient); - } } From 37313f391ed4c076b818790becc389bf60b53617 Mon Sep 17 00:00:00 2001 From: aldettinger Date: Fri, 2 Oct 2026 16:00:02 +0200 Subject: [PATCH 041/254] Upgrade sshd to 2.20.0 --- bom/application/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bom/application/pom.xml b/bom/application/pom.xml index 3261a54505b0c..baf719970e38c 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -215,7 +215,7 @@ 0.9.6 0.0.17 1.5.0 - 2.19.0 + 2.20.0 0.8.12 1.3.1 4.2.1 From 63d179a20a2caf95b295c71e7c8cafcbe322968f Mon Sep 17 00:00:00 2001 From: Chakravarthy Batna Date: Sat, 3 Oct 2026 23:14:33 +0530 Subject: [PATCH 042/254] Use ECS field names for structured access log fields in ECS format --- docs/src/main/asciidoc/logging.adoc | 20 +++++ ...eJsonFormatterStructuredAccessLogTest.java | 46 ++++++++++++ .../logging/json/runtime/JsonFormatter.java | 74 ++++++++++++++++++- 3 files changed, 139 insertions(+), 1 deletion(-) diff --git a/docs/src/main/asciidoc/logging.adoc b/docs/src/main/asciidoc/logging.adoc index fa29a300e9895..eafae2e9766da 100644 --- a/docs/src/main/asciidoc/logging.adoc +++ b/docs/src/main/asciidoc/logging.adoc @@ -532,6 +532,26 @@ The `accessLog` object contains the following fields: {"timestamp":"...","level":"INFO","loggerName":"io.quarkus.http.access-log","message":"GET /hello HTTP/1.1 200 512 12","accessLog":{"method":"GET","uri":"/hello","status":200,"responseTimeMs":12,"bytesSent":512,"remoteIp":"127.0.0.1","protocol":"HTTP/1.1"}} ---- + +When `log-format` is set to `ecs`, the fields are written as top-level ECS fields instead of the `accessLog` object: ++ +[cols="1,2",options="header"] +|=== +|Access log field |ECS field +|`method` |`http.request.method` +|`uri` |`url.path`, plus `url.query` when the URI has a query string +|`status` |`http.response.status_code` +|`responseTimeMs` |`event.duration`, converted to nanoseconds +|`bytesSent` |`http.response.body.bytes` +|`remoteIp` |`client.ip` +|`protocol` |`http.version`, holding the version alone (e.g. `1.1`) +|=== ++ +.Example: access log with `structured-access-log=true` and `log-format=ecs` +[source,json] +---- +{"@timestamp":"...","log.level":"INFO","log.logger":"io.quarkus.http.access-log","message":"GET /hello HTTP/1.1 200 512 12","http.request.method":"GET","url.path":"/hello","http.response.status_code":200,"event.duration":12000000,"http.response.body.bytes":512,"client.ip":"127.0.0.1","http.version":"1.1"} +---- ++ The option is available for all handler types: `quarkus.log.console.json.access-log.structured`, `quarkus.log.file.json.access-log.structured`, `quarkus.log.syslog.json.access-log.structured`, `quarkus.log.socket.json.access-log.structured`, and named handlers such as `quarkus.log.handler.console..json.access-log.structured`. + TIP: To add custom fields beyond the standard access log fields, use the <>. diff --git a/extensions/logging-json/deployment/src/test/java/io/quarkus/logging/json/ConsoleJsonFormatterStructuredAccessLogTest.java b/extensions/logging-json/deployment/src/test/java/io/quarkus/logging/json/ConsoleJsonFormatterStructuredAccessLogTest.java index 356c61a751393..03ebaf91c3820 100644 --- a/extensions/logging-json/deployment/src/test/java/io/quarkus/logging/json/ConsoleJsonFormatterStructuredAccessLogTest.java +++ b/extensions/logging-json/deployment/src/test/java/io/quarkus/logging/json/ConsoleJsonFormatterStructuredAccessLogTest.java @@ -10,6 +10,7 @@ import org.junit.jupiter.api.extension.RegisterExtension; import io.quarkus.logging.json.runtime.JsonFormatter; +import io.quarkus.logging.json.runtime.JsonLogConfig.JsonConfig.LogFormat; import io.quarkus.test.QuarkusExtensionTest; import tools.jackson.databind.JsonNode; import tools.jackson.databind.ObjectMapper; @@ -39,6 +40,51 @@ public void structuredAccessLogEnabledConfigTest() { assertThat(jsonFormatter.isStructuredAccessLog()).isTrue(); } + @Test + public void ecsAccessLogRecordProducesEcsFieldsTest() throws Exception { + JsonNode node = formatEcsAccessLogRecord("/api/health"); + + assertThat(node.has("accessLog")).isFalse(); + assertThat(node.get("http.request.method").asText()).isEqualTo("GET"); + assertThat(node.get("url.path").asText()).isEqualTo("/api/health"); + assertThat(node.has("url.query")).isFalse(); + assertThat(node.get("client.ip").asText()).isEqualTo("10.0.0.1"); + // ECS expects the version alone, not HTTP/1.1 + assertThat(node.get("http.version").asText()).isEqualTo("1.1"); + + assertThat(node.get("http.response.status_code").isNumber()).isTrue(); + assertThat(node.get("http.response.status_code").asInt()).isEqualTo(200); + assertThat(node.get("http.response.body.bytes").asLong()).isEqualTo(512L); + // ECS defines event.duration in nanoseconds + assertThat(node.get("event.duration").asLong()).isEqualTo(12_000_000L); + } + + @Test + public void ecsAccessLogRecordSplitsQueryFromPathTest() throws Exception { + JsonNode node = formatEcsAccessLogRecord("/api/health?full=true&verbose=1"); + + assertThat(node.get("url.path").asText()).isEqualTo("/api/health"); + assertThat(node.get("url.query").asText()).isEqualTo("full=true&verbose=1"); + } + + private static JsonNode formatEcsAccessLogRecord(String uri) throws Exception { + JsonFormatter formatter = new JsonFormatter(); + formatter.setStructuredAccessLog(true); + formatter.setLogFormat(LogFormat.ECS); + + ExtLogRecord record = new ExtLogRecord(Level.INFO, "GET " + uri + " 200", ACCESS_LOG_LOGGER); + record.setLoggerName(ACCESS_LOG_LOGGER); + record.putMdc("__access__method", "GET"); + record.putMdc("__access__uri", uri); + record.putMdc("__access__status", "200"); + record.putMdc("__access__responseTimeMs", "12"); + record.putMdc("__access__bytesSent", "512"); + record.putMdc("__access__remoteIp", "10.0.0.1"); + record.putMdc("__access__protocol", "HTTP/1.1"); + + return MAPPER.readTree(formatter.format(record)); + } + @Test public void accessLogRecordProducesAccessLogObjectTest() throws Exception { JsonFormatter formatter = new JsonFormatter(); diff --git a/extensions/logging-json/runtime/src/main/java/io/quarkus/logging/json/runtime/JsonFormatter.java b/extensions/logging-json/runtime/src/main/java/io/quarkus/logging/json/runtime/JsonFormatter.java index f82aaec6d621d..6b2b3734c82c3 100644 --- a/extensions/logging-json/runtime/src/main/java/io/quarkus/logging/json/runtime/JsonFormatter.java +++ b/extensions/logging-json/runtime/src/main/java/io/quarkus/logging/json/runtime/JsonFormatter.java @@ -210,7 +210,11 @@ protected void after(final Generator generator, final ExtLogRecord record) throw } if (structuredAccessLog && isAccessLogRecord(record)) { - writeAccessLogObject(generator, record); + if (logFormat == LogFormat.ECS) { + writeEcsAccessLogFields(generator, record); + } else { + writeAccessLogObject(generator, record); + } } JsonLogGenerator jsonLogGenerator = new JsonLogGenerator(generator, this.excludedKeys); @@ -255,6 +259,74 @@ private void writeAccessLogObject(final Generator generator, final ExtLogRecord } } + /** + * Writes the access-log fields as top-level ECS fields rather than as the nested {@code accessLog} object, so + * that they line up with the rest of the record in ECS mode. + */ + private void writeEcsAccessLogFields(final Generator generator, final ExtLogRecord record) throws Exception { + final Map mdc = record.getMdcCopy(); + if (mdc == null || mdc.isEmpty()) { + return; + } + for (Map.Entry entry : mdc.entrySet()) { + if (!entry.getKey().startsWith(ACCESS_LOG_MDC_PREFIX)) { + continue; + } + final String fieldName = entry.getKey().substring(ACCESS_LOG_MDC_PREFIX.length()); + final String value = entry.getValue(); + if (value == null || value.isEmpty()) { + continue; + } + switch (fieldName) { + case "method" -> generator.add("http.request.method", value); + case "uri" -> addUrl(generator, value); + case "status" -> addLong(generator, "http.response.status_code", value); + case "responseTimeMs" -> addEventDuration(generator, value); + case "bytesSent" -> addLong(generator, "http.response.body.bytes", value); + case "remoteIp" -> generator.add("client.ip", value); + case "protocol" -> generator.add("http.version", stripProtocolName(value)); + default -> generator.add(fieldName, value); + } + } + } + + private static void addUrl(final Generator generator, final String uri) throws Exception { + final int queryStart = uri.indexOf('?'); + if (queryStart < 0) { + generator.add("url.path", uri); + return; + } + generator.add("url.path", uri.substring(0, queryStart)); + if (queryStart < uri.length() - 1) { + generator.add("url.query", uri.substring(queryStart + 1)); + } + } + + private static void addLong(final Generator generator, final String key, final String value) throws Exception { + try { + generator.add(key, Long.parseLong(value)); + } catch (NumberFormatException ignored) { + generator.add(key, value); + } + } + + private static void addEventDuration(final Generator generator, final String responseTimeMs) throws Exception { + try { + // ECS defines event.duration in nanoseconds + generator.add("event.duration", Long.parseLong(responseTimeMs) * 1_000_000L); + } catch (NumberFormatException ignored) { + generator.add("event.duration", responseTimeMs); + } + } + + /** + * ECS expects {@code http.version} to hold the version alone, while the access log reports it as HTTP/1.1. + */ + private static String stripProtocolName(final String protocol) { + final int slash = protocol.indexOf('/'); + return slash < 0 ? protocol : protocol.substring(slash + 1); + } + private static boolean isNumericAccessField(final String fieldName) { return "status".equals(fieldName) || "responseTimeMs".equals(fieldName) || "bytesSent".equals(fieldName); } From 3801ca3c797040a49c2c9462ee30db7f91be6711 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Vav=C5=99=C3=ADk?= Date: Sun, 4 Oct 2026 12:46:27 +0200 Subject: [PATCH 043/254] fix(http-security): deduplicate policies matched across normalized paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses https://github.com/quarkusio/quarkus/pull/56860#discussion_r4049241814 by de-duplicating same policies if they were added under the same configuration (HTTP method, policies set, selected auth mechanism). Didn't fix a duplication the same policy shared across paths with differing auth mechanisms or methods producing distinct matchers, which would require more refactoring for scenario which I humbly guess is uncommon. Signed-off-by: Michal Vavřík --- .../CrossPolicyNormalizationBypassTest.java | 46 ++++++++++++++++++- ...bstractPathMatchingHttpSecurityPolicy.java | 7 ++- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/CrossPolicyNormalizationBypassTest.java b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/CrossPolicyNormalizationBypassTest.java index 1f353dac7c190..2ffd341a90439 100644 --- a/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/CrossPolicyNormalizationBypassTest.java +++ b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/CrossPolicyNormalizationBypassTest.java @@ -24,8 +24,11 @@ import io.quarkus.security.test.utils.TestIdentityProvider; import io.quarkus.test.QuarkusUnitTest; import io.quarkus.test.common.http.TestHTTPResource; +import io.quarkus.vertx.http.runtime.security.HttpSecurityPolicy; import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; import io.vertx.ext.web.Router; +import io.vertx.ext.web.client.HttpResponse; import io.vertx.ext.web.client.WebClient; /** @@ -55,7 +58,8 @@ public class CrossPolicyNormalizationBypassTest { @RegisterExtension static QuarkusUnitTest test = new QuarkusUnitTest().setArchiveProducer(() -> ShrinkWrap .create(JavaArchive.class) - .addClasses(TestIdentityController.class, TestIdentityProvider.class, RouteHandler.class) + .addClasses(TestIdentityController.class, TestIdentityProvider.class, RouteHandler.class, + CountingPolicySetup.class) .addAsResource(new StringAsset(APP_PROPS), "application.properties")); private static WebClient client; @@ -129,7 +133,20 @@ public void testOverlappingPolicyBypass(String path) { assurePath(path, 401, null); } - private void assurePath(String path, int expectedStatusCode, String auth) { + @Test + void testPolicyRegisteredUnderTwoPathsRunsOncePerRequest() { + HttpResponse response = assurePath("/alpha/..;/beta/data", 200, null); + String policyInvocationCount = response.getHeader(CountingPolicySetup.POLICY_INVOCATION_COUNT_HEADER); + assertEquals("ok", response.bodyAsString()); + assertEquals("1", policyInvocationCount, "A security policy registered under two paths must run once per request"); + + response = assurePath("/beta/..;/gama/data", 200, null); + policyInvocationCount = response.getHeader(CountingPolicySetup.POLICY_INVOCATION_COUNT_HEADER); + assertEquals("ok", response.bodyAsString()); + assertEquals("1", policyInvocationCount, "A security policy registered under two paths must run once per request"); + } + + private HttpResponse assurePath(String path, int expectedStatusCode, String auth) { var req = getClient().get(url.getPort(), url.getHost(), path); if (auth != null) { req.basicAuthentication(auth, auth); @@ -138,6 +155,31 @@ private void assurePath(String path, int expectedStatusCode, String auth) { await().atMost(REQUEST_TIMEOUT).until(result::isComplete); assertEquals(expectedStatusCode, result.result().statusCode(), "Path: " + path + " (auth=" + auth + ")"); + return result.result(); + } + + @ApplicationScoped + static class CountingPolicySetup { + + private static final String POLICY_INVOCATION_COUNT_HEADER = "X-Policy-Invocation-Count"; + private static final String INVOCATION_COUNT_KEY = "io.quarkus.vertx.http.security.policy-invocation-count"; + private static final HttpSecurityPolicy COUNTING_POLICY = (routingContext, identity, requestContext) -> { + int invocationCount = routingContext. get(INVOCATION_COUNT_KEY, 0) + 1; + routingContext.put(INVOCATION_COUNT_KEY, invocationCount); + routingContext.response().putHeader(POLICY_INVOCATION_COUNT_HEADER, Integer.toString(invocationCount)); + return HttpSecurityPolicy.CheckResult.permit(); + }; + + void registerSharedPolicy(@Observes HttpSecurity httpSecurity) { + httpSecurity.path("/alpha/*", "/beta/*").policy(COUNTING_POLICY); + httpSecurity.path("/gama/*").policy(COUNTING_POLICY); + } + + void registerRoute(@Observes Router router) { + router.route("/alpha/*").order(-1).handler(rc -> rc.response().end("ok")); + router.route("/beta/*").order(-1).handler(rc -> rc.response().end("ok")); + router.route("/gama/*").order(-1).handler(rc -> rc.response().end("ok")); + } } @ApplicationScoped diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/AbstractPathMatchingHttpSecurityPolicy.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/AbstractPathMatchingHttpSecurityPolicy.java index 30bdd282bbb39..d5143feb78228 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/AbstractPathMatchingHttpSecurityPolicy.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/AbstractPathMatchingHttpSecurityPolicy.java @@ -10,6 +10,7 @@ import java.util.Map; import java.util.Set; import java.util.function.Function; +import java.util.stream.Stream; import jakarta.enterprise.inject.Instance; @@ -222,8 +223,10 @@ private static List findHttpMatchers(RoutingContext context, toCheck = routerCheck; } else if (routerCheck.getValue() != null && !routerCheck.getValue().isEmpty() && !toCheck.getMatched().equals(routerCheck.getMatched())) { - List merged = new ArrayList<>(toCheck.getValue()); - merged.addAll(routerCheck.getValue()); + List merged = Stream.concat( + toCheck.getValue().stream(), + routerCheck.getValue().stream()) + .distinct().toList(); toCheck = new PathMatch<>(toCheck.getMatched(), merged); } } From 84e01976cbe8e539fbb12a3883dd52cb2f3e5174 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Vav=C5=99=C3=ADk?= Date: Sun, 4 Oct 2026 20:40:29 +0200 Subject: [PATCH 044/254] chore(tls registry): add capability for extensions relying on its spi MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I am working on https://github.com/quarkusio/quarkus/issues/56358 and we shall rely on both TLS registry runtime and deployment SPI. However, I would like to have a way to validate that when someone requests to produce TLS configuration when TLS registry is not present, I can fail build. Signed-off-by: Michal Vavřík --- .../src/main/java/io/quarkus/deployment/Capability.java | 2 ++ extensions/tls-registry/runtime/pom.xml | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/core/deployment/src/main/java/io/quarkus/deployment/Capability.java b/core/deployment/src/main/java/io/quarkus/deployment/Capability.java index 274226bc25bb6..ffc5cd1120892 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/Capability.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/Capability.java @@ -179,4 +179,6 @@ public interface Capability { String GRPC = QUARKUS_PREFIX + ".grpc"; String WEBSOCKETS_NEXT = QUARKUS_PREFIX + ".websockets.next"; + + String TLS_REGISTRY = QUARKUS_PREFIX + ".tls.registry"; } diff --git a/extensions/tls-registry/runtime/pom.xml b/extensions/tls-registry/runtime/pom.xml index 18d5f7c9fa25b..379def96d1e97 100644 --- a/extensions/tls-registry/runtime/pom.xml +++ b/extensions/tls-registry/runtime/pom.xml @@ -55,6 +55,11 @@ io.quarkus quarkus-extension-maven-plugin + + + io.quarkus.tls.registry + + maven-compiler-plugin From eed93c4dd862343b8e3b37095d06768d4e31f968 Mon Sep 17 00:00:00 2001 From: jnbdz <237008+jnbdz@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:00:16 -0400 Subject: [PATCH 045/254] Document how JVM options that add modules affect the AOT cache The JVM rejects an AOT cache when the modules added with --add-modules differ between the training run and the application run, which is what happens when Flight Recorder is started from the command line. Explain the error, how to train the cache with the same modules, that a cache trained this way is rejected by runs without the option, and how to start a recording with jcmd instead. Relates to #54876 --- docs/src/main/asciidoc/aot.adoc | 38 +++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/docs/src/main/asciidoc/aot.adoc b/docs/src/main/asciidoc/aot.adoc index 7fc6f59774745..6ec0b10e6206f 100644 --- a/docs/src/main/asciidoc/aot.adoc +++ b/docs/src/main/asciidoc/aot.adoc @@ -172,6 +172,44 @@ You can obtain more information about AOT cache usage with the `-Xlog:aot` optio You can collect even more information in an `aot.log` file with `-Xlog:class+load=info,aot+codecache=debug:file=aot.log:level,tags`. ==== +[[aot-add-modules]] +=== Options that add modules, such as Flight Recorder + +The JVM only uses an AOT cache if the application runs with the same `--add-modules` as the training run. +Otherwise it reports an error such as the following and starts the application without the cache: + +[source,text] +---- +[error][aot] Mismatched values for property jdk.module.addmods: jdk.jfr specified during runtime but not during dump time +---- + +Some JVM options add a module implicitly. +The most common one is `-XX:StartFlightRecording`, which adds `jdk.jfr`; enabling the Vector API with `--add-modules=jdk.incubator.vector` has the same effect. + +If the application is always started with such an option, pass the corresponding `--add-modules` to the training run with `quarkus.package.jar.aot.additional-recording-args`: + +[source, bash, subs=attributes+, role="primary asciidoc-tabs-sync-maven"] +.Maven +---- +./mvnw verify -Dquarkus.package.jar.aot.enabled=true -Dquarkus.package.jar.aot.additional-recording-args=--add-modules=jdk.jfr +---- + +[source, bash, subs=attributes+, role="secondary asciidoc-tabs-sync-gradle"] +.Gradle +---- +./gradlew build quarkusIntTest -Dquarkus.package.jar.aot.enabled=true -Dquarkus.package.jar.aot.additional-recording-args=--add-modules=jdk.jfr +---- + +The check works both ways: a cache generated this way is only used when the application is started with `-XX:StartFlightRecording` or `--add-modules=jdk.jfr`, and is rejected otherwise. +A single cache cannot serve both kinds of runs. + +To record only occasionally, keep the default cache and start the recording on the running application instead, which does not affect the cache: + +[source,bash] +---- +jcmd JFR.start +---- + == Manual AOT file generation As explained above, Quarkus takes care of all the heavy lifting when coming to the AOT file generation. However, in cases where more control is needed, users can perform the necessary steps manually. From 1c66f0b24af30d3245eb75c820cb5ccfa1afa60b Mon Sep 17 00:00:00 2001 From: Clement Escoffier Date: Mon, 5 Oct 2026 09:20:30 +0200 Subject: [PATCH 046/254] Bump SmallRye Certificate Generator to version 1.1.3 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 11329c323490c..eb7502919804b 100644 --- a/pom.xml +++ b/pom.xml @@ -96,7 +96,7 @@ 0.27.0 - 1.1.2 + 1.1.3 7.8.0 From 1e264925443369c0f0cf0b6d760aa59f5dc00818 Mon Sep 17 00:00:00 2001 From: Guillaume Smet Date: Mon, 5 Oct 2026 10:02:57 +0200 Subject: [PATCH 047/254] Move to ubuntu-slim for small, administrative tasks See https://github.blog/changelog/2025-10-28-1-vcpu-linux-runner-now-available-in-github-actions-in-public-preview/#runner-details Per discussion with @apupier. --- .github/workflows/ci-actions-incremental.yml | 14 +++++++------- .github/workflows/ci-merge-gate.yml | 2 +- .../workflows/develocity-publish-build-scans.yml | 2 +- .github/workflows/populate-cache.yml | 2 +- .github/workflows/preview-teardown.yml | 2 +- .github/workflows/preview.yml | 2 +- .github/workflows/quarkus-github-bot-lint.yml | 2 +- 7 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci-actions-incremental.yml b/.github/workflows/ci-actions-incremental.yml index 133adbfe55a4c..e4f355f03af68 100644 --- a/.github/workflows/ci-actions-incremental.yml +++ b/.github/workflows/ci-actions-incremental.yml @@ -136,7 +136,7 @@ defaults: jobs: approval: name: "Wait for approval if Dependabot" - runs-on: ubuntu-latest + runs-on: ubuntu-slim # For Dependabot, we require a manual approval before running the CI build environment: name: ${{ github.actor == 'dependabot[bot]' && 'dependabot' || 'ci' }} @@ -150,7 +150,7 @@ jobs: # For more details, see # https://github.community/t/pull-request-attribute-empty-in-workflow-run-event-object-for-pr-from-forked-repo/154682 attach-pr-number: - runs-on: ubuntu-latest + runs-on: ubuntu-slim needs: [ approval ] name: Attach pull request number if: github.event_name == 'pull_request' @@ -167,7 +167,7 @@ jobs: ci-sanity-check: name: "CI Sanity Check" needs: [ approval ] - runs-on: ubuntu-latest + runs-on: ubuntu-slim # Skip main in forks if: github.repository == 'quarkusio/quarkus' || !endsWith(github.ref, '/main') steps: @@ -176,7 +176,7 @@ jobs: configure: name: "Configure jobs" needs: [ approval ] - runs-on: ubuntu-latest + runs-on: ubuntu-slim # Skip main in forks # Skip draft PRs, rerun as soon as its removed if: (github.repository == 'quarkusio/quarkus' || !endsWith(github.ref, '/main')) && ( @@ -517,7 +517,7 @@ jobs: calculate-test-jobs: name: Calculate Test Jobs - runs-on: ubuntu-latest + runs-on: ubuntu-slim needs: initial-build env: SCALPEL_IMPACTED_MODULES: ${{ needs.initial-build.outputs.scalpel_impacted }} @@ -1700,7 +1700,7 @@ jobs: if: ${{ always() && github.repository == 'quarkusio/quarkus' && endsWith(github.ref, '/main') && github.event_name != 'pull_request' && needs.native-tests.result != 'skipped' && needs.native-tests.result != 'cancelled' }} needs: - native-tests - runs-on: ubuntu-latest + runs-on: ubuntu-slim steps: - uses: runs-on/action@efac073ea2507ec18797de3a81704201ade11d9d # v2.3.1 - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 @@ -2059,7 +2059,7 @@ jobs: retention-days: 7 build-report: - runs-on: ubuntu-latest + runs-on: ubuntu-slim name: Build report # needs: [configure,initial-build,jvm-tests,maven-tests,gradle-tests,devtools-tests,kubernetes-tests,quickstarts-tests,platform-tests,tcks-test,native-tests,virtual-thread-native-tests,oracle-test-pilot-jvm-tests,oracle-test-pilot-native-tests] needs: [configure,initial-build,jvm-tests,maven-tests,gradle-tests,devtools-tests,kubernetes-tests,platform-tests,tcks-test,native-tests,virtual-thread-native-tests,oracle-test-pilot-jvm-tests,oracle-test-pilot-native-tests,pqc-tests] diff --git a/.github/workflows/ci-merge-gate.yml b/.github/workflows/ci-merge-gate.yml index 107d7a312b6f0..dc587fc0f4b87 100644 --- a/.github/workflows/ci-merge-gate.yml +++ b/.github/workflows/ci-merge-gate.yml @@ -16,7 +16,7 @@ permissions: jobs: merge-gate: - runs-on: ubuntu-latest + runs-on: ubuntu-slim if: github.event.workflow_run.event == 'pull_request' steps: - name: Update merge gate status diff --git a/.github/workflows/develocity-publish-build-scans.yml b/.github/workflows/develocity-publish-build-scans.yml index 0554b1ab4c3b8..d5d6582e03694 100644 --- a/.github/workflows/develocity-publish-build-scans.yml +++ b/.github/workflows/develocity-publish-build-scans.yml @@ -12,7 +12,7 @@ defaults: jobs: publish-build-scans: if: github.repository == 'quarkusio/quarkus' && github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion != 'cancelled' - runs-on: ubuntu-latest + runs-on: ubuntu-slim permissions: actions: write pull-requests: write diff --git a/.github/workflows/populate-cache.yml b/.github/workflows/populate-cache.yml index db18b1a9c7a2a..8a4c71ce88b52 100644 --- a/.github/workflows/populate-cache.yml +++ b/.github/workflows/populate-cache.yml @@ -22,7 +22,7 @@ defaults: jobs: configure: name: "Configure jobs" - runs-on: ubuntu-latest + runs-on: ubuntu-slim outputs: m2-monthly-branch-cache-key: ${{ steps.cache-key.outputs.m2-monthly-branch-cache-key }} m2-monthly-cache-key: ${{ steps.cache-key.outputs.m2-monthly-cache-key }} diff --git a/.github/workflows/preview-teardown.yml b/.github/workflows/preview-teardown.yml index 9dbfcfe9558a0..cc94c9aaf8544 100644 --- a/.github/workflows/preview-teardown.yml +++ b/.github/workflows/preview-teardown.yml @@ -13,7 +13,7 @@ on: jobs: preview-teardown: - runs-on: ubuntu-latest + runs-on: ubuntu-slim steps: - name: Teardown surge preview id: deploy diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index bbac5693e8825..b6b00f2008e36 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -12,7 +12,7 @@ defaults: jobs: preview: - runs-on: ubuntu-latest + runs-on: ubuntu-slim permissions: actions: read issues: write diff --git a/.github/workflows/quarkus-github-bot-lint.yml b/.github/workflows/quarkus-github-bot-lint.yml index e5cb2f0de439b..cda04e3eb91bd 100644 --- a/.github/workflows/quarkus-github-bot-lint.yml +++ b/.github/workflows/quarkus-github-bot-lint.yml @@ -14,7 +14,7 @@ on: jobs: lint: name: "quarkus-github-bot.yml validation" - runs-on: ubuntu-latest + runs-on: ubuntu-slim steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: install yamllint From e2989e84de645631588f588ad93a5b6ff1aa0f03 Mon Sep 17 00:00:00 2001 From: Arthur Navarro Date: Tue, 29 Sep 2026 11:15:44 +0200 Subject: [PATCH 048/254] docs: add dedicated section for using OpenSSL engine. Adds a standalone section 'Using the OpenSSL engine for classical TLS' detailing how to use ssl-engine property without the focus on PQC. We cover motivations, configuration, platform prerequisites and native limitations. Removes information from PQC section and and replaces it with links to this new section to avoid duplicates. Also updates the PQC doc to reflect latest changes. Co-authored-by: Clement Escoffier --- .../main/asciidoc/tls-registry-reference.adoc | 116 ++++++++++-------- 1 file changed, 68 insertions(+), 48 deletions(-) diff --git a/docs/src/main/asciidoc/tls-registry-reference.adoc b/docs/src/main/asciidoc/tls-registry-reference.adoc index 7963463a2a2e1..449f9365c7900 100644 --- a/docs/src/main/asciidoc/tls-registry-reference.adoc +++ b/docs/src/main/asciidoc/tls-registry-reference.adoc @@ -803,20 +803,11 @@ Use this when you want post-quantum key exchange where available without breakin Clients that do not negotiate a post-quantum group are rejected. Use this when all clients are known to support post-quantum cryptography. -[IMPORTANT] -==== -`pqc-enforcement-policy` does more than enforce post-quantum key exchange: it controls which SSL engine Vert.x selects. -Setting `pqc-enforcement-policy` to `client-negotiated` or `strict` activates the native OpenSSL engine (requires OpenSSL 3.5+), which negotiates post-quantum key exchange groups. -With the default `relaxed` policy, Vert.x uses the JDK SSL engine, which ignores any post-quantum key-exchange groups before JDK 27 — meaning that configuring post-quantum groups without also setting the enforcement policy results in no post-quantum key exchange at all. -On JDK 27+, the JDK natively supports `X25519MLKEM768` and post-quantum key exchange works without changing the enforcement policy. - -Quarkus logs a warning at startup if you configure a post-quantum group with `relaxed` policy to help catch this misconfiguration. -==== - [NOTE] ==== -When using `client-negotiated` or `strict`, `quarkus.tls.key-exchange-groups` must include at least one post-quantum-capable group such as `X25519MLKEM768`. -Vert.x validates this at startup and throws an exception if no compatible engine or group is configured. +When using `client-negotiated`, `quarkus.tls.key-exchange-groups` must include at least one post-quantum-capable group such as `X25519MLKEM768`. +When using `strict`, `quarkus.tls.key-exchange-groups` must include only post-quantum-capable groups. +Vert.x validates this at startup and overrides the specified set of groups if this condition is not respected. It will throw an exception if no compatible engine is available (see <>). ==== [WARNING] @@ -825,30 +816,23 @@ Vert.x validates this at startup and throws an exception if no compatible engine Ensure all clients are upgraded before enabling this mode. ==== -[NOTE] -==== -Policy enforcement (`strict` and `client-negotiated`) is not supported when using the JDK SSL engine. -If you are running on JDK 27+, or on JDK 25+ once the post-quantum backport ships (expected October 2026), you can still enable post-quantum key exchange by setting `quarkus.tls.key-exchange-groups` — the negotiation will work correctly without policy enforcement. -For full enforcement, OpenSSL 3.5+ via the native SSL engine is required. -==== - [IMPORTANT] ==== -Post-quantum enforcement (`client-negotiated` and `strict`) relies on a native OpenSSL 3.5+ library. -Quarkus bundles this library only for **Linux x86_64** and **Linux aarch64**. -In addition, `libapr-1` must be installed on the host OS (see link:https://netty.io/wiki/forked-tomcat-native.html[netty-tcnative prerequisites]). -Other operating systems and architectures (macOS, Windows, Alpine Linux, Linux on RISC-V, etc.) are *not supported*: attempting to use `client-negotiated` or `strict` on an unsupported platform will fail at runtime because the native OpenSSL engine is unavailable. -On those platforms, use `relaxed` with JDK 27+ for post-quantum key exchange. +`pqc-enforcement-policy` does more than enforce post-quantum key exchange: it also controls which SSL engine Vert.x selects. +When `pqc-enforcement-policy` is set to `client-negotiated` or `strict`, Quarkus first checks whether the JDK SSL engine supports post-quantum key exchange. +If it does (JDK 27+), the JDK engine is used; otherwise Quarkus falls back to the native OpenSSL 3.5+ engine. +With the default `relaxed` policy, Vert.x uses the JDK SSL engine, which ignores any post-quantum key-exchange groups before JDK 27. +Thus, configuring post-quantum groups without also setting the enforcement policy may result in no post-quantum key exchange at all. +Quarkus logs a warning at startup if you configure a post-quantum group with `relaxed` policy to help catch this misconfiguration. + +For platform requirements when OpenSSL is used as fallback, see <>. +For a full overview of which engine is used in each configuration, see the <>. ==== +[[pqc-ssl-engine-override]] ===== SSL engine override -By default, Vert.x selects the SSL engine automatically based on the enforcement policy: - -- `client-negotiated` or `strict` activates the native OpenSSL engine (requires OpenSSL 3.5+). -- `relaxed` uses the JDK SSL engine. - -You can override this selection with the `ssl-engine` property: +You can override the automatic engine selection with the `ssl-engine` property: [source,properties] ---- @@ -856,18 +840,61 @@ quarkus.tls.ssl-engine=openssl ---- The supported values are `openssl` (requires netty-tcnative on the classpath) and `jdkssl` (the standard JDK SSL engine). -This property is useful for testing or when you need explicit control over the engine — for example, forcing `jdkssl` on JDK 27+ where the JDK natively supports post-quantum groups. +This property is useful for testing or when you need explicit control over the engine. + +For using the OpenSSL engine on classical (non-PQC) TLS paths, see <>. ===== PQC and native mode -In native mode, quantum-safe TLS is not available using OpenSSL. Thus, the only way to leverage PQC-compliant key exchange groups is to use a JDK SSL engine that supports them. This are not yet available in GraalVM/Mandrel, but should be available in the near future. +The OpenSSL engine is unavailable in native mode (see <>). +The only way to leverage PQC-compliant key exchange groups in native images is to use a JDK SSL engine that supports them; these are not yet available in GraalVM/Mandrel but should be in the near future. ===== PQC in containerized environments -When running in a container with a UBI image as the base, `libapr-1` isn't installed by default. -So you need to install it explicitly. -Without it, netty-tcnative cannot load the native OpenSSL engine at runtime, and PQC enforcement will fail even if OpenSSL 3.5+ is present. +The `libapr-1` requirement and container setup are identical whether OpenSSL is used for PQC or classical TLS. +See <> for installation instructions. -Install `apr` explicitly in your `Containerfile`: +[[pqc-availability-summary]] +===== PQC availability summary + +[cols="1,1,1,1,1,2", options="header"] +|=== +| Mode | Java Runtime | SSL Engine | PQC support | PQC enforcement available | Prerequisites + +| JVM | Any | OpenSSL (netty-tcnative) | Yes | Yes | OpenSSL 3.5+; `libapr-1` (`microdnf install apr` on UBI) +| JVM | < 27 (25*) | JDK SSL | No | No | — +| JVM | 27+ (25+*) | JDK SSL | Yes | Yes | JDK 27+ (or 25+ after backport) +| Native | Any | OpenSSL (netty-tcnative) | — | No | Engine unavailable in native mode +|=== + +_{asterisk} JDK 25 support pending backport (expected October 2026)._ + +[[openssl-engine-classical-tls]] +==== Using the OpenSSL engine for classical TLS + +Quarkus can automatically select the SSL engine based on the Post-Quantum Cryptography configuration (see <>). However, even without post-quantum cryptography, you can configure Quarkus to use the native OpenSSL engine (via netty-tcnative) instead of the JDK SSL engine on classical TLS paths. +The native engine can offer improved handshake throughput and hardware-accelerated cryptography on supported platforms. + +To force the OpenSSL engine for all TLS connections: + +[source,properties] +---- +quarkus.tls.ssl-engine=openssl +---- + +===== Prerequisites + +The native OpenSSL engine has the same platform requirements whether it is used for PQC or classical TLS: + +* **Supported platforms only:** Linux x86_64 and Linux aarch64. + macOS, Windows, Alpine Linux, and other architectures are not supported. + Quarkus bundles netty-tcnative only for the two platforms listed above; the property is silently ignored on unsupported platforms. +* **`libapr-1` must be installed on the host OS.** + Without `libapr-1`, netty-tcnative cannot load the native OpenSSL library at runtime and the engine will not be available. + +[[openssl-engine-prerequisites]] +====== Container setup + +On UBI-based container images, `libapr-1` is not installed by default. Add it explicitly in your `Containerfile`: [source,dockerfile] ---- @@ -902,19 +929,12 @@ ENV JAVA_APP_JAR="/deployments/quarkus-run.jar" ENTRYPOINT [ "/opt/jboss/container/java/run/run-java.sh" ] ---- -===== PQC availability summary +[[openssl-engine-native-mode]] +===== Native mode -[cols="1,1,1,1,1,2", options="header"] -|=== -| Mode | Java Runtime | SSL Engine | PQC support | PQC enforcement available | Prerequisites - -| JVM | Any | OpenSSL (netty-tcnative) | Yes | Yes | OpenSSL 3.5+; `libapr-1` (`microdnf install apr` on UBI) -| JVM | < 27 (25*) | JDK SSL | No | No | — -| JVM | 27+ (25+*) | JDK SSL | Yes | Yes | JDK 27+ (or 25+ after backport) -| Native | Any | OpenSSL (netty-tcnative) | — | No | Engine unavailable in native mode -|=== - -_{asterisk} JDK 25 support pending backport (expected October 2026)._ +The OpenSSL engine is *not supported in native (GraalVM/Mandrel) mode*. +netty-tcnative cannot be loaded inside a native image, so setting `ssl-engine=openssl` in a native application will cause a startup failure. +In native mode, use the JDK SSL engine (the default) or leave `ssl-engine` unset. ==== Certificate Revocation List (CRL) From 332abe9386ff94e35c3219b3be57c29475585c4d Mon Sep 17 00:00:00 2001 From: Jerome Prinet Date: Mon, 5 Oct 2026 11:04:06 +0200 Subject: [PATCH 049/254] Switch Develocity URL from ge.quarkus.io to develocity.quarkus.io --- .github/quarkus-github-bot.yml | 2 +- .github/workflows/develocity-publish-build-scans.yml | 2 +- .mvn/develocity.xml | 2 +- CONTRIBUTING.md | 6 +++--- README.md | 2 +- devtools/gradle/settings.gradle.kts | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/quarkus-github-bot.yml b/.github/quarkus-github-bot.yml index dbb6152451741..a15fee9a00bdd 100644 --- a/.github/quarkus-github-bot.yml +++ b/.github/quarkus-github-bot.yml @@ -7,7 +7,7 @@ similarGitHubHandleCheck: teams: [quarkus-push, quarkus-admin] develocity: enabled: true - url: "https://ge.quarkus.io/" + url: "https://develocity.quarkus.io/" workflows: rules: - allow: diff --git a/.github/workflows/develocity-publish-build-scans.yml b/.github/workflows/develocity-publish-build-scans.yml index 0554b1ab4c3b8..8762ed07e9b9c 100644 --- a/.github/workflows/develocity-publish-build-scans.yml +++ b/.github/workflows/develocity-publish-build-scans.yml @@ -29,7 +29,7 @@ jobs: id: publish uses: gradle/develocity-actions/maven-publish-build-scan@4b276bd81fb55e2fcbae724c48699de57e2156eb # v2.3 with: - develocity-url: 'https://ge.quarkus.io' + develocity-url: 'https://develocity.quarkus.io' develocity-access-key: ${{ secrets.GRADLE_ENTERPRISE_ACCESS_KEY }} - name: Upload JSON file uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/.mvn/develocity.xml b/.mvn/develocity.xml index 723272831ea31..855c457f91f7e 100644 --- a/.mvn/develocity.xml +++ b/.mvn/develocity.xml @@ -1,6 +1,6 @@ - https://ge.quarkus.io + https://develocity.quarkus.io ${version.compiler.plugin} - 2.4.10 + 2.4.20 2.2.10-2.0.2 2.2.0 diff --git a/devtools/gradle/gradle-application-plugin/src/test/java/io/quarkus/gradle/QuarkusPluginTest.java b/devtools/gradle/gradle-application-plugin/src/test/java/io/quarkus/gradle/QuarkusPluginTest.java index db5df941cb1ff..7c2e347de1e46 100644 --- a/devtools/gradle/gradle-application-plugin/src/test/java/io/quarkus/gradle/QuarkusPluginTest.java +++ b/devtools/gradle/gradle-application-plugin/src/test/java/io/quarkus/gradle/QuarkusPluginTest.java @@ -84,7 +84,7 @@ public void shouldMakeQuarkusDevAndQuarkusBuildDependOnClassesTask() { @Test public void shouldNotFailOnProjectDependenciesWithoutMain(@TempDir Path testProjectDir) throws IOException { - var kotlinVersion = System.getProperty("kotlin_version", "2.4.10"); + var kotlinVersion = System.getProperty("kotlin_version", "2.4.20"); var settingFile = testProjectDir.resolve("settings.gradle"); var mppProjectDir = testProjectDir.resolve("mpp"); var quarkusProjectDir = testProjectDir.resolve("quarkus"); diff --git a/devtools/gradle/gradle/libs.versions.toml b/devtools/gradle/gradle/libs.versions.toml index f1b8eca6bb787..a44edad21895e 100644 --- a/devtools/gradle/gradle/libs.versions.toml +++ b/devtools/gradle/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] plugin-publish = "2.2.0" -kotlin = "2.4.10" +kotlin = "2.4.20" smallrye-config = "4.0.0" json-schema-validator = "2.0.1" diff --git a/extensions/schema-registry/confluent/pom.xml b/extensions/schema-registry/confluent/pom.xml index 3fdd47e543d87..f605fcd98ba1b 100644 --- a/extensions/schema-registry/confluent/pom.xml +++ b/extensions/schema-registry/confluent/pom.xml @@ -25,7 +25,7 @@ org.jetbrains.kotlin kotlin-scripting-compiler-embeddable - 2.4.10 + 2.4.20 org.json diff --git a/independent-projects/arc/pom.xml b/independent-projects/arc/pom.xml index bc90932efc300..6cbc7de87a270 100644 --- a/independent-projects/arc/pom.xml +++ b/independent-projects/arc/pom.xml @@ -55,7 +55,7 @@ 3.27.7 1.12.0 6.1.3 - 2.4.10 + 2.4.20 1.11.0 5.21.0 diff --git a/integration-tests/gradle/src/main/resources/conditional-dependencies-kotlin/build.gradle.kts b/integration-tests/gradle/src/main/resources/conditional-dependencies-kotlin/build.gradle.kts index 2ace949ab4f19..d058473130c3a 100644 --- a/integration-tests/gradle/src/main/resources/conditional-dependencies-kotlin/build.gradle.kts +++ b/integration-tests/gradle/src/main/resources/conditional-dependencies-kotlin/build.gradle.kts @@ -1,6 +1,6 @@ plugins { - kotlin("jvm") version "2.4.10" - kotlin("plugin.allopen") version "2.4.10" + kotlin("jvm") version "2.4.20" + kotlin("plugin.allopen") version "2.4.20" id("io.quarkus") } diff --git a/integration-tests/gradle/src/main/resources/grpc-multi-module-no-java/build.gradle.kts b/integration-tests/gradle/src/main/resources/grpc-multi-module-no-java/build.gradle.kts index 7847c84b1891e..0a495c30dfcfa 100644 --- a/integration-tests/gradle/src/main/resources/grpc-multi-module-no-java/build.gradle.kts +++ b/integration-tests/gradle/src/main/resources/grpc-multi-module-no-java/build.gradle.kts @@ -1,6 +1,6 @@ plugins { - kotlin("jvm") version "2.4.10" - kotlin("plugin.allopen") version "2.4.10" + kotlin("jvm") version "2.4.20" + kotlin("plugin.allopen") version "2.4.20" id("io.quarkus") apply false } diff --git a/integration-tests/kafka-json-schema-apicurio2/pom.xml b/integration-tests/kafka-json-schema-apicurio2/pom.xml index adef2a0107862..efb817b85057f 100644 --- a/integration-tests/kafka-json-schema-apicurio2/pom.xml +++ b/integration-tests/kafka-json-schema-apicurio2/pom.xml @@ -29,7 +29,7 @@ org.jetbrains.kotlin kotlin-scripting-compiler-embeddable - 2.4.10 + 2.4.20 org.json From 392a172beaa38dd2d6c7a5a481e4f686dca6f88a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marco=20Sapp=C3=A9=20Griot?= Date: Mon, 5 Oct 2026 12:43:04 +0200 Subject: [PATCH 059/254] Update Narayana LRA to 2.0.1.Final --- bom/application/pom.xml | 2 +- docs/src/main/asciidoc/lra.adoc | 31 +++++++ extensions/narayana-lra/deployment/pom.xml | 15 +++ .../lra/test/LRAJwtPropagationSmokeTest.java | 88 ++++++++++++++++++ .../narayana/lra/test/LRAJwtResource.java | 93 +++++++++++++++++++ extensions/narayana-lra/runtime/pom.xml | 11 +++ 6 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 extensions/narayana-lra/deployment/src/test/java/io/quarkus/narayana/lra/test/LRAJwtPropagationSmokeTest.java create mode 100644 extensions/narayana-lra/deployment/src/test/java/io/quarkus/narayana/lra/test/LRAJwtResource.java diff --git a/bom/application/pom.xml b/bom/application/pom.xml index 74bddb0c31a20..e09fbdc7551c6 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -99,7 +99,7 @@ 7.3.4.Final - 1.2.0.Final + 2.0.1.Final 3.2.1 8.0.0.Final 9.4.7 diff --git a/docs/src/main/asciidoc/lra.adoc b/docs/src/main/asciidoc/lra.adoc index 8ab29c4c147e9..a016671933e24 100644 --- a/docs/src/main/asciidoc/lra.adoc +++ b/docs/src/main/asciidoc/lra.adoc @@ -103,6 +103,37 @@ dependencies. A Quarkus quickstart will be provided to show how to do this, or y take a look at one of the https://github.com/jbosstm/quickstart/tree/master/rts/lra-examples/lra-coordinator[Narayana quickstarts]. Another option would be to run it managed inside a WildFly application server. +== Securing LRA with JWT + +Narayana LRA can forward a JWT bearer token on the Jakarta REST invocations that +flow between participants and the coordinator. This is useful when the services +taking part in an LRA are protected and require an `Authorization: Bearer` +header. + +The feature is opt-in. To propagate the bearer token that arrived on an incoming +request, annotate the `@LRA` method (or its class) with +`io.narayana.lra.PropagateToken`: + +[source,java] +---- +@LRA(LRA.Type.REQUIRES_NEW) // a new LRA is created on method entry +@PropagateToken // forward the inbound bearer token on outgoing LRA calls +@Path("/work") +@PUT +public void doInNewLongRunningAction(@HeaderParam(LRA_HTTP_CONTEXT_HEADER) URI lraId) +{ + ... +} +---- + +For the token to be available, your service must authenticate the incoming +request with a JWT security extension such as `quarkus-smallrye-jwt`. Once +authenticated, the inbound bearer token is propagated automatically on the +outgoing LRA calls; no further configuration is required. + +Issuing a service token to authenticate the services themselves is a +coordinator-side concern and is not handled by the participant extension. + == Handling failures When an LRA is told to finish, i.e. when a method annotated with `@LRA(end = true, ...)` diff --git a/extensions/narayana-lra/deployment/pom.xml b/extensions/narayana-lra/deployment/pom.xml index f4d2dfe51d5ab..d9f193d4225f9 100644 --- a/extensions/narayana-lra/deployment/pom.xml +++ b/extensions/narayana-lra/deployment/pom.xml @@ -49,11 +49,26 @@ quarkus-smallrye-openapi-deployment test + + io.quarkus + quarkus-smallrye-jwt-deployment + test + + + io.quarkus + quarkus-smallrye-jwt-build-deployment + test + io.rest-assured rest-assured test + + org.assertj + assertj-core + test + diff --git a/extensions/narayana-lra/deployment/src/test/java/io/quarkus/narayana/lra/test/LRAJwtPropagationSmokeTest.java b/extensions/narayana-lra/deployment/src/test/java/io/quarkus/narayana/lra/test/LRAJwtPropagationSmokeTest.java new file mode 100644 index 0000000000000..0ce433dbc0480 --- /dev/null +++ b/extensions/narayana-lra/deployment/src/test/java/io/quarkus/narayana/lra/test/LRAJwtPropagationSmokeTest.java @@ -0,0 +1,88 @@ +package io.quarkus.narayana.lra.test; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusExtensionTest; +import io.restassured.RestAssured; +import io.smallrye.jwt.build.Jwt; + +/** + * Smoke tests for the Narayana LRA 2.x JWT bearer-token feature. + *

+ * The feature's {@code @PropagateToken}/{@code BearerTokenResolver} machinery is + * a Narayana extension, not part of the MicroProfile LRA specification + * (MP-LRA does not mandate bearer-token propagation). What these tests validate + * against a specification is MicroProfile JWT (MP-JWT 2.1): a + * valid MP-JWT is produced/verified by quarkus-smallrye-jwt and round-tripped + * through Narayana's CDI resolver ({@link io.narayana.lra.BearerTokenResolver}). + *

+ * The RSA key pair is generated in-memory at startup; no key material is ever + * written to disk or committed. + */ +public class LRAJwtPropagationSmokeTest { + + private static final String ISSUER = "https://lra-jwt-smoke-test"; + + private static final KeyPair KEY_PAIR = generateKeyPair(); + + @RegisterExtension + static final QuarkusExtensionTest config = new QuarkusExtensionTest() + .withApplicationRoot((jar) -> jar.addClasses(LRAJwtResource.class)) + // Do not try to start a coordinator container for these hermetic tests. + .overrideConfigKey("quarkus.lra.devservices.enabled", "false") + // Authenticate every request so a valid bearer token populates the + // CDI JsonWebToken even on endpoints without a security constraint. + .overrideConfigKey("quarkus.http.auth.proactive", "true") + .overrideConfigKey("mp.jwt.verify.issuer", ISSUER) + .overrideConfigKey("mp.jwt.verify.publickey", + Base64.getEncoder().encodeToString(KEY_PAIR.getPublic().getEncoded())) + .overrideConfigKey("smallrye.jwt.sign.key", + Base64.getEncoder().encodeToString(KEY_PAIR.getPrivate().getEncoded())); + + private static KeyPair generateKeyPair() { + try { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(2048); + return generator.generateKeyPair(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("Failed to generate RSA key pair for the LRA JWT smoke test", e); + } + } + + @Test + public void bootsWithJwtFeatureEnabled() { + RestAssured.when().get("/lra-jwt/ping") + .then().statusCode(200) + .body(org.hamcrest.CoreMatchers.is("pong")); + } + + @Test + public void resolvesBearerTokenFromCdi() { + String token = Jwt.issuer(ISSUER).upn("alice").groups("User").sign(); + + String resolved = RestAssured.given() + .header("Authorization", "Bearer " + token) + .when().get("/lra-jwt/resolved") + .then().statusCode(200) + .extract().body().asString(); + + assertThat(resolved).isEqualTo(token); + } + + @Test + public void resolvesToNoTokenWhenUnauthenticated() { + String resolved = RestAssured.when().get("/lra-jwt/resolved") + .then().statusCode(200) + .extract().body().asString(); + + assertThat(resolved).isEmpty(); + } +} diff --git a/extensions/narayana-lra/deployment/src/test/java/io/quarkus/narayana/lra/test/LRAJwtResource.java b/extensions/narayana-lra/deployment/src/test/java/io/quarkus/narayana/lra/test/LRAJwtResource.java new file mode 100644 index 0000000000000..3106976812e16 --- /dev/null +++ b/extensions/narayana-lra/deployment/src/test/java/io/quarkus/narayana/lra/test/LRAJwtResource.java @@ -0,0 +1,93 @@ +package io.quarkus.narayana.lra.test; + +import java.net.URI; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.HeaderParam; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import org.eclipse.microprofile.lra.annotation.Compensate; +import org.eclipse.microprofile.lra.annotation.Complete; +import org.eclipse.microprofile.lra.annotation.ParticipantStatus; +import org.eclipse.microprofile.lra.annotation.ws.rs.LRA; + +import io.narayana.lra.BearerTokenResolver; +import io.narayana.lra.PropagateToken; + +/** + * Test resource exercising the Narayana LRA 2.x JWT bearer-token feature. + */ +@ApplicationScoped +@Path("/lra-jwt") +public class LRAJwtResource { + + /** + * Unauthenticated endpoint used as the boot check: a 200 here proves the + * combined narayana-lra + smallrye-jwt deployment (including the + * {@code @PropagateToken @LRA} endpoint below) started cleanly. + */ + @GET + @Path("/ping") + @Produces(MediaType.TEXT_PLAIN) + public String ping() { + return "pong"; + } + + /** + * Returns the raw JWT that Narayana's {@link BearerTokenResolver} obtains + * from the CDI {@code JsonWebToken} (produced by quarkus-smallrye-jwt) for + * the current request. This is exactly the participant-side path LRA uses + * to capture the token before propagating it to the coordinator. Returns an + * empty string when no token can be resolved (graceful degradation). + */ + @GET + @Path("/resolved") + @Produces(MediaType.TEXT_PLAIN) + public String resolved() { + String token = BearerTokenResolver.resolveFromCdi(); + return token == null ? "" : token; + } + + /** + * Present only so the deployment must process {@code @PropagateToken} on an + * {@code @LRA} endpoint with the MP-JWT API on the classpath. It is not + * invoked by the tests, since starting an LRA would require a running + * coordinator. + */ + @GET + @Path("/lra") + @Produces(MediaType.TEXT_PLAIN) + @LRA(LRA.Type.REQUIRES_NEW) + @PropagateToken + public String inLra() { + return "in-lra"; + } + + /** + * Compensation handler required for {@link #inLra()} to be a valid LRA + * participant. Never invoked by the tests (no coordinator is contacted). + */ + @PUT + @Path("/compensate") + @Produces(MediaType.TEXT_PLAIN) + @Compensate + public Response compensate(@HeaderParam(LRA.LRA_HTTP_CONTEXT_HEADER) URI lraId) { + return Response.ok(ParticipantStatus.Compensated.name()).build(); + } + + /** + * Completion handler for the participant. Never invoked by the tests. + */ + @PUT + @Path("/complete") + @Produces(MediaType.TEXT_PLAIN) + @Complete + public Response complete(@HeaderParam(LRA.LRA_HTTP_CONTEXT_HEADER) URI lraId) { + return Response.ok(ParticipantStatus.Completed.name()).build(); + } +} diff --git a/extensions/narayana-lra/runtime/pom.xml b/extensions/narayana-lra/runtime/pom.xml index 50fb2692678e9..ff01354af9236 100644 --- a/extensions/narayana-lra/runtime/pom.xml +++ b/extensions/narayana-lra/runtime/pom.xml @@ -69,6 +69,17 @@ org.eclipse.microprofile.lra microprofile-lra-api + + + org.eclipse.microprofile.jwt + microprofile-jwt-auth-api + org.eclipse.angus angus-activation From e026cc17b9530ec8bcbfc21cc1a3f0c56889d3ca Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:31:39 +0000 Subject: [PATCH 060/254] Bump org.mockito:mockito-bom from 5.21.0 to 5.24.0 Bumps [org.mockito:mockito-bom](https://github.com/mockito/mockito) from 5.21.0 to 5.24.0. - [Release notes](https://github.com/mockito/mockito/releases) - [Commits](https://github.com/mockito/mockito/compare/v5.21.0...v5.24.0) --- updated-dependencies: - dependency-name: org.mockito:mockito-bom dependency-version: 5.24.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- bom/application/pom.xml | 2 +- independent-projects/arc/pom.xml | 2 +- independent-projects/extension-maven-plugin/pom.xml | 2 +- independent-projects/resteasy-reactive/pom.xml | 2 +- independent-projects/tools/pom.xml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/bom/application/pom.xml b/bom/application/pom.xml index 042787db0d0ef..bf2125d06f3de 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -183,7 +183,7 @@ 7.1.SP1 4.1.SP1 4.1 - 5.21.0 + 5.24.0 5.8.0 2.3.2 26.0.12 diff --git a/independent-projects/arc/pom.xml b/independent-projects/arc/pom.xml index bc90932efc300..031f39dd99ae0 100644 --- a/independent-projects/arc/pom.xml +++ b/independent-projects/arc/pom.xml @@ -57,7 +57,7 @@ 6.1.3 2.4.10 1.11.0 - 5.21.0 + 5.24.0 1.7.0.Final 2.0.1 diff --git a/independent-projects/extension-maven-plugin/pom.xml b/independent-projects/extension-maven-plugin/pom.xml index 1dd8227653caa..c7c5123b9d12d 100644 --- a/independent-projects/extension-maven-plugin/pom.xml +++ b/independent-projects/extension-maven-plugin/pom.xml @@ -40,7 +40,7 @@ 3.1.4 1.6.1 6.1.3 - 5.21.0 + 5.24.0 3.6.0 diff --git a/independent-projects/resteasy-reactive/pom.xml b/independent-projects/resteasy-reactive/pom.xml index 9fa36f8ba2a71..769f7ae2e1a81 100644 --- a/independent-projects/resteasy-reactive/pom.xml +++ b/independent-projects/resteasy-reactive/pom.xml @@ -68,7 +68,7 @@ 3.0.3 4.3.0 1.0.4 - 5.21.0 + 5.24.0 3.13.2 1.3.1 diff --git a/independent-projects/tools/pom.xml b/independent-projects/tools/pom.xml index d9e1105e026f8..268e45c3b4c5b 100644 --- a/independent-projects/tools/pom.xml +++ b/independent-projects/tools/pom.xml @@ -55,7 +55,7 @@ 6.1.3 1.28.0 3.6.3.Final - 5.21.0 + 5.24.0 999-SNAPSHOT 41 3.6.0 From 793d0c64806ebe5514fc5d7fb1142474f14b347a Mon Sep 17 00:00:00 2001 From: Holly Cummins Date: Mon, 5 Oct 2026 17:40:23 +0100 Subject: [PATCH 061/254] Fix up various Roq-incompatibilities and missing Roq-adaptations in sync script --- docs/sync-web-site.sh | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/sync-web-site.sh b/docs/sync-web-site.sh index e633671d82da0..6337cf64b8096 100755 --- a/docs/sync-web-site.sh +++ b/docs/sync-web-site.sh @@ -59,12 +59,15 @@ else if [ "$QUARKUS_RELEASE" == "true" ]; then if [ ! -f ${TARGET_GUIDES}/_attributes-local.adoc ]; then cat < ${TARGET_GUIDES}/_attributes-local.adoc +:idprefix: +:idseparator: - // tag::xref-attributes[] :doc-examples: ./_examples -:generated-dir: ../../../../_generated-doc/${BRANCH} +:generated-dir: ../../../_generated-doc/${BRANCH} :code-examples: {generated-dir}/examples -:imagesdir: ./images +:imagesdir: /version/${BRANCH}/guides/images :includes: ./_includes +:sectanchors: // :quickstarts-clone-url: -b ${BRANCH} https://github.com/quarkusio/quarkus-quickstarts.git :quickstarts-archive-url: https://github.com/quarkusio/quarkus-quickstarts/archive/${BRANCH}.zip @@ -131,10 +134,13 @@ done # Ensure index.html files exist in resource directories echo "Ensuring index.html exists in resource directories..." -for subdir in images javascript assets; do +for subdir in images javascript assets stylesheet; do dir="$TARGET_GUIDES/$subdir" if [ -d "$dir" ] && [ ! -f "$dir/index.html" ]; then rel_path=$(echo "$dir" | sed "s|${TARGET_DIR}/content/||") + # The on-disk "versions" directory is served under the singular "version" + # collection link (site.collections.versions.link=/version/:dir[1]/:name/). + rel_path=$(echo "$rel_path" | sed 's|^versions/|version/|') printf '%s\n' '---' "link: /${rel_path}/" '---' '' > "$dir/index.html" echo "Created index.html for $subdir/" fi From dbea4bb002b052ee7ba62c49ac7b561a059978c0 Mon Sep 17 00:00:00 2001 From: George Gastaldi Date: Fri, 2 Oct 2026 22:56:46 -0300 Subject: [PATCH 062/254] Avoid NPE when the REST Client is invoked from a bare duplicated context Context locals start unset, so a duplicated context that nobody wrote a local to carries no DATA_MAP_LOCAL. ClientRequestContextImpl guarded the new context's map against null but not the caller's, so copying the caller's locals threw: java.lang.NullPointerException: Cannot invoke "java.util.Map.entrySet()" because "m" is null at java.base/java.util.concurrent.ConcurrentHashMap.putAll at org.jboss.resteasy.reactive.client.impl.ClientRequestContextImpl. Code reached from an HTTP request never hit this because the HTTP layer populates the data map first. It affects callers that create their own duplicated context, such as a JCA message endpoint invoking a @RestClient. Go through VertxContext.localContextData() on both sides instead of reading DATA_MAP_LOCAL directly, since that accessor get-or-creates. Apply the same treatment to OpenTelemetryVertxMetricsFactory, which had the same latent NPE. These were the only two direct reads of DATA_MAP_LOCAL outside tests. Fixes #57131 --- .../OpenTelemetryVertxMetricsFactory.java | 2 +- .../DuplicatedContextWithoutLocalsTest.java | 84 +++++++++++++++++++ .../client/impl/ClientRequestContextImpl.java | 15 ++-- 3 files changed, 90 insertions(+), 11 deletions(-) create mode 100644 extensions/resteasy-reactive/rest-client/deployment/src/test/java/io/quarkus/rest/client/reactive/DuplicatedContextWithoutLocalsTest.java diff --git a/extensions/opentelemetry/runtime/src/main/java/io/quarkus/opentelemetry/runtime/tracing/instrumentation/vertx/OpenTelemetryVertxMetricsFactory.java b/extensions/opentelemetry/runtime/src/main/java/io/quarkus/opentelemetry/runtime/tracing/instrumentation/vertx/OpenTelemetryVertxMetricsFactory.java index db1221e7ce743..18086f70f1f8b 100644 --- a/extensions/opentelemetry/runtime/src/main/java/io/quarkus/opentelemetry/runtime/tracing/instrumentation/vertx/OpenTelemetryVertxMetricsFactory.java +++ b/extensions/opentelemetry/runtime/src/main/java/io/quarkus/opentelemetry/runtime/tracing/instrumentation/vertx/OpenTelemetryVertxMetricsFactory.java @@ -42,7 +42,7 @@ public MetricRequest requestBegin(SocketAddress remoteAddress, HttpRequest reque public void requestRouted(final MetricRequest requestMetric, final String route) { if (route != null) { requestMetric.getContext() - .ifPresent(context -> context.getLocal(VertxContext.DATA_MAP_LOCAL).put("VertxRoute", route)); + .ifPresent(context -> VertxContext.localContextData(context).put("VertxRoute", route)); } } } diff --git a/extensions/resteasy-reactive/rest-client/deployment/src/test/java/io/quarkus/rest/client/reactive/DuplicatedContextWithoutLocalsTest.java b/extensions/resteasy-reactive/rest-client/deployment/src/test/java/io/quarkus/rest/client/reactive/DuplicatedContextWithoutLocalsTest.java new file mode 100644 index 0000000000000..d487a2493dc78 --- /dev/null +++ b/extensions/resteasy-reactive/rest-client/deployment/src/test/java/io/quarkus/rest/client/reactive/DuplicatedContextWithoutLocalsTest.java @@ -0,0 +1,84 @@ +package io.quarkus.rest.client.reactive; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; +import org.eclipse.microprofile.rest.client.inject.RestClient; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusExtensionTest; +import io.smallrye.common.vertx.VertxContext; +import io.vertx.core.Vertx; +import io.vertx.core.internal.ContextInternal; +import io.vertx.core.spi.context.storage.AccessMode; + +/** + * A duplicated context carries no data map until someone writes a context local to it, because + * context locals start unset. Invoking the client from such a context must not fail. + */ +public class DuplicatedContextWithoutLocalsTest { + + @RegisterExtension + static final QuarkusExtensionTest config = new QuarkusExtensionTest() + .withApplicationRoot((jar) -> jar.addClasses(Resource.class, Client.class)) + .overrideRuntimeConfigKey("quarkus.rest-client.client.url", + "http://localhost:${quarkus.http.test-port:8081}"); + + @Inject + Vertx vertx; + + @Inject + @RestClient + Client client; + + @Test + void shouldInvokeClientFromDuplicatedContextWithoutLocals() throws Exception { + ContextInternal duplicated = ((ContextInternal) vertx.getOrCreateContext()).duplicate(); + assertThat(duplicated.getLocal(VertxContext.DATA_MAP_LOCAL, AccessMode.CONCURRENT)).isNull(); + + CompletableFuture result = new CompletableFuture<>(); + // Dispatch the duplicated context onto a regular thread, the way a JCA resource adapter + // delivers a message to a message endpoint. + Thread thread = new Thread(() -> { + duplicated.beginDispatch(); + try { + result.complete(client.get()); + } catch (Throwable t) { + result.completeExceptionally(t); + } finally { + duplicated.endDispatch(null); + } + }); + thread.start(); + thread.join(); + + assertThat(result.get(10, TimeUnit.SECONDS)).isEqualTo("test"); + } + + @Path("test") + public static class Resource { + + @Path("toClient") + @GET + public String toClient() { + return "test"; + } + } + + @Path("test") + @RegisterRestClient(configKey = "client") + public interface Client { + + @GET + @Path("toClient") + String get(); + } +} diff --git a/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/ClientRequestContextImpl.java b/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/ClientRequestContextImpl.java index 34dfa6d160816..b726683883f50 100644 --- a/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/ClientRequestContextImpl.java +++ b/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/ClientRequestContextImpl.java @@ -20,7 +20,6 @@ import java.util.NoSuchElementException; import java.util.Set; import java.util.TreeSet; -import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Predicate; @@ -73,17 +72,13 @@ public ClientRequestContextImpl(RestClientRequestContext restClientRequestContex // to avoid creating a chain of parent references that prevents GC of previous calls' contexts. Context current = client.vertx.getOrCreateContext(); this.context = VertxContext.createNewDuplicatedContext(current); - ContextInternal ctx = (ContextInternal) this.context; if (VertxContext.isDuplicatedContext(current)) { - // Copy old-style locals from the caller context so they remain visible - ContextInternal curCtx = (ContextInternal) current; - ConcurrentHashMap map = ctx.getLocal(VertxContext.DATA_MAP_LOCAL); - if (map == null) { - map = new ConcurrentHashMap<>(); - ctx.putLocal(VertxContext.DATA_MAP_LOCAL, map); - } - map.putAll(curCtx.getLocal(VertxContext.DATA_MAP_LOCAL)); + // Copy old-style locals from the caller context so they remain visible. + // Go through the VertxContext accessor: context locals start unset, so a duplicated + // context that nobody wrote a local to carries no data map at all. + VertxContext.localContextData(this.context).putAll(VertxContext.localContextData(current)); } + ContextInternal ctx = (ContextInternal) this.context; ctx.putLocal(VertxContext.PARENT_CONTEXT_LOCAL, current); restClientRequestContext.properties.put(VERTX_CONTEXT_PROPERTY, context); } From 293b4e0beb5d44f595ceec12841ca24740b6f7ce Mon Sep 17 00:00:00 2001 From: Guillaume Smet Date: Tue, 6 Oct 2026 11:45:08 +0200 Subject: [PATCH 063/254] Migrate hibernate-orm deployment tests from RESTEasy Classic to Quarkus REST There was a class loader leak related to RESTEasy Classic and I thought the easier way to fix it was to switch to Quarkus REST (and then I discovered another leak but that's another story...). --- extensions/hibernate-orm/deployment/pom.xml | 4 ++-- .../java/io/quarkus/narayana/quarkus/FruitResource.java | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/extensions/hibernate-orm/deployment/pom.xml b/extensions/hibernate-orm/deployment/pom.xml index 6147160201767..51f304f95647c 100644 --- a/extensions/hibernate-orm/deployment/pom.xml +++ b/extensions/hibernate-orm/deployment/pom.xml @@ -99,12 +99,12 @@ io.quarkus - quarkus-resteasy-deployment + quarkus-rest-deployment test io.quarkus - quarkus-resteasy-jackson-deployment + quarkus-rest-jackson-deployment test diff --git a/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/narayana/quarkus/FruitResource.java b/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/narayana/quarkus/FruitResource.java index 3e5aa71b1cbdb..10156fe21ff6b 100644 --- a/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/narayana/quarkus/FruitResource.java +++ b/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/narayana/quarkus/FruitResource.java @@ -20,7 +20,7 @@ import jakarta.ws.rs.ext.Provider; import org.jboss.logging.Logger; -import org.jboss.resteasy.annotations.jaxrs.PathParam; +import org.jboss.resteasy.reactive.RestPath; import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.node.ObjectNode; @@ -47,7 +47,7 @@ public List get() { @GET @Path("{id}") - public Fruit getSingle(@PathParam Integer id) { + public Fruit getSingle(@RestPath Integer id) { Fruit entity = entityManager.find(Fruit.class, id); if (entity == null) { throw new WebApplicationException("Fruit with id of " + id + " does not exist.", 404); @@ -70,7 +70,7 @@ public Response create(Fruit fruit) { @PUT @Path("{id}") @Transactional - public Fruit update(@PathParam Integer id, Fruit fruit) { + public Fruit update(@RestPath Integer id, Fruit fruit) { if (fruit.getName() == null) { throw new WebApplicationException("Fruit Name was not set on request.", 422); } @@ -88,7 +88,7 @@ public Fruit update(@PathParam Integer id, Fruit fruit) { @DELETE @Path("{id}") @Transactional - public Response delete(@PathParam Integer id) { + public Response delete(@RestPath Integer id) { Fruit entity = entityManager.getReference(Fruit.class, id); if (entity == null) { throw new WebApplicationException("Fruit with id of " + id + " does not exist.", 404); From 4de0aa608bf7912d3bfb8b5decd31fd14b2f41f1 Mon Sep 17 00:00:00 2001 From: Teymur Babayev Date: Mon, 5 Oct 2026 18:43:54 +0200 Subject: [PATCH 064/254] Add contribution guidelines for reproducible builds in Quarkus --- REPRODUCIBILITY.md | 420 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 420 insertions(+) create mode 100644 REPRODUCIBILITY.md diff --git a/REPRODUCIBILITY.md b/REPRODUCIBILITY.md new file mode 100644 index 0000000000000..7a0b94de50d2b --- /dev/null +++ b/REPRODUCIBILITY.md @@ -0,0 +1,420 @@ +# Reproducible builds in Quarkus + +Quarkus should produce the same build output when given the same sources, dependencies, +configuration, and build environment. This applies to generated and transformed classes, +recorded bytecode, serialized build inputs, metadata, and packaged application files. +A build that simply succeeds repeatedly is not necessarily reproducible - what makes it +reproducible is that the build output should match bit by bit. + +This guide is for contributors writing build steps, build items, recorders, and packaging +code. The recurring rule is to fix all the sources of instability: collection order, +timestamps, unique identifiers, and unique file paths are the common offenders. + +**TL;DR:** call recorders in a stable order with stable parameters, iterate over +collections in a stable order, derive bytecode-recorded identifiers from stable +content, and use the configured output timestamp instead of the current clock. + +## Use collections with a stable iteration order + +A build step that calls recorder methods must follow two rules: + +1. Recorder methods must be called in the exact same order from one build to + another. For example, when a recorder method is called in a loop, the loop + iterations must be stable. +2. Parameters passed to a recorder method must be fully stable from one build to + another. + +Collections are the most common way to break both rules. + +A collection can introduce unstable recorded bytecode in two main ways: + +- A build step annotated with `@Record` iterates over the collection, so its iteration + order determines the order in which recorder methods are called. +- The collection is passed to a recorder method, directly or as part of another object, + so its iteration order becomes part of the recorded bytecode. + +The collection may be created locally or obtained from a build item, so always consider +how it was populated. + +There might be some cases, where a collection is not iterated over, is used only for +operations such as `Set#contains(...)`, is not passed to a recorder and does not end up in +any build item. In such cases, its order is unlikely to matter. However, making sure all +these conditions are met is difficult, so it is better to use a collection with a stable +order from the start. + +The fact that a collection by itself has a stable iteration order does not +necessarily mean that its order is stable: a list passed to a recorder could have been +initialized in a way that made its internal order unstable. General rules of thumb: + +* It is fine to use `HashMap` and `HashSet` as long as their keys or elements produce + stable hash codes. It's an implementation detail, but vanilla hash collections have + a deterministic iteration order as long as keys have stable hash codes **and** elements + are inserted into them in a stable order. `Class` and objects inheriting + `Object.hashCode()` are not suitable to use as keys in such collections. + +* If you **need** to use a key with an unstable hash code, prefer using `LinkedHashMap` + or `LinkedHashSet`. + +* Use `TreeMap` to sort entries by key or `TreeSet` to sort elements. + +* If you have no control over where the collection comes from and you think it **may** + have an unstable order, you need to ensure its order is fixed. However, bear in mind + that sometimes it's easier and cleaner to fix the order in the upstream build item + producer once than it is to fix its order in multiple consumers. + +* Immutability does not imply stable iteration order. In particular, `Set.copyOf()`, + `Map.copyOf()`, `Collectors.toUnmodifiableSet()`, and `Collectors.toUnmodifiableMap()` + can and will change the observed order across different JVM runs. When an ordered + collection must also be immutable, wrap it with `Collections.unmodifiableSet()` or + `Collections.unmodifiableMap()` instead of copying it into a JDK immutable collection. + +## Build items and build-step order + +When a build item extends the abstract `MultiBuildItem` class, it can be produced +by multiple build steps. The consumers of such a multi-build item will receive a list +of all produced items. + +The build chain assigns stable producer ordinals to `MultiBuildItem` producers. +This prevents the order of items from different producers from depending on +which parallel build step finishes first. So, the build chain itself is stable, +and if every producer of a `MultiBuildItem` is stable, the order of the consumed +list should be stable as well. However, that is not always the case because +producers may produce such build items by iterating over a collection in an unstable +order. + +In such cases, one possible solution (besides fixing the problem at its source, +which is frequently preferable) is to make the `MultiBuildItem` implement `Comparable`. +This way, consumers will receive a sorted list of items. However, bear in mind that +`compareTo()` must fully distinguish their ordering. Unresolved ties can still allow +unstable ordering. + +## Try to eliminate other well-known sources of instability + +Derive generated class names, proxy keys, identifiers, and serial numbers from +stable content. Avoid random UUIDs, identity hash codes, timestamps from the +current clock, and mutable global counters. + +Use the configured package output timestamp for values embedded in build +artifacts, such as archive entries, generated build information, and SBOM +metadata. Do not call the current clock while generating such values when a +reproducible timestamp is available. + +For a Maven build, the `project.build.outputTimestamp` property can provide Quarkus +with a fixed timestamp. + +For a Gradle archive task, set `preserveFileTimestamps` to `false` and +`reproducibleFileOrder` to `true`. Consult +[this reference](https://docs.gradle.org/current/dsl/org.gradle.api.tasks.bundling/Jar.html) +for more information. + +Once the fixed timestamp has been configured with the build tool, you can consume +`PackageConfig` in your build step and use its `outputTimestamp()` method to get +the configured timestamp. + +## Examples + +### Unstable collection order + +The following build item and build step look harmless: + +```java +public final class HandlerBuildItem extends MultiBuildItem implements Comparable { + + private final String name; + private final int priority; + private final Set types; + + // ... + + @Override + public int compareTo(HandlerBuildItem other) { + return Integer.compare(priority, other.priority); + } +} +``` + +```java +@BuildStep +@Record(STATIC_INIT) +void registerHandlers(List handlers, RecorderContext context, HandlerRecorder recorder) { + for (HandlerBuildItem handler : handlers) { + List> classes = handler.getTypes().stream().map(context::classProxy).toList(); + Set> types = new HashSet<>(classes); + recorder.addHandler(handler.getName(), types); + } +} +``` + +However, there are three problems: + +1. `compareTo()` only considers the priority. Two handlers with the same priority can + swap places between builds, so the recorder methods are called in an unstable order. +2. `types` is a `HashSet` of `Class` objects, which have no stable hash code, so the + parameter passed to the recorder has an unstable iteration order. +3. `handler.getTypes()` returns a `Set` whose order you don't control, so even an + ordered set would be filled in an unstable order. + +A stable version breaks ties by name, and builds the recorder parameter in a sorted +order: + +```java +@Override +public int compareTo(HandlerBuildItem other) { + int result = Integer.compare(priority, other.priority); + return result != 0 ? result : name.compareTo(other.name); +} +``` + +```java +for (HandlerBuildItem handler : handlers) { + List> sortedClasses = handler.getTypes().stream().sorted().map(context::classProxy).toList(); + Set> types = new LinkedHashSet<>(sortedClasses); + recorder.addHandler(handler.getName(), types); +} +``` + +Alternatively, pass the class names to the recorder instead of `Class` objects. +`String` has a stable hash code, so a plain `HashSet` works, and the recorder loads +the classes at runtime: + +```java +for (HandlerBuildItem handler : handlers) { + List sortedTypes = handler.getTypes().stream().sorted().toList(); + Set types = new HashSet<>(sortedTypes); + recorder.addHandler(handler.getName(), types); +} +``` + +### A defensive copy with `Set.copyOf()` + +The following build item makes a defensive copy of the configured media types: + +```java +public final class CompressionBuildItem extends SimpleBuildItem { + + private final Set mediaTypes; + + public CompressionBuildItem(List mediaTypes) { + this.mediaTypes = Set.copyOf(mediaTypes); + } + + // ... +} +``` + +A consumer then passes the set to a recorder: + +```java +recorder.configureCompression(compression.getMediaTypes()); +``` + +The configured list has a stable order and `String` has a stable hash code, yet the +recorded bytecode differs between builds. `Set.copyOf()` returns a JDK immutable set +whose iteration order is randomized once per JVM. Because the order only changes +between JVMs, the check that runs all augmentations in a single JVM does not catch +this; the cross-JVM mode and the integration-test check do. + +To keep the copy immutable, wrap a regular set instead: + +```java +this.mediaTypes = Collections.unmodifiableSet(new HashSet<>(mediaTypes)); +``` + +### A global counter + +The following generator names a static field for each regular expression it +encounters: + +```java +private static final AtomicInteger COUNTER = new AtomicInteger(); + +void generatePatternField(ClassCreator creator, String regex) { + String fieldName = "PATTERN_" + COUNTER.getAndIncrement(); + // ... +} +``` + +The generated field names depend on how many fields were generated before, in any +class, by any thread, and in any earlier build in the same JVM. Scope the counter to +the generated class instead, so the names only depend on that class's content: + +```java +void generatePatternField(ClassCreator creator, AtomicInteger counter, String regex) { + String fieldName = "PATTERN_" + counter.getAndIncrement(); + // ... +} +``` + +The caller must create a new counter for each generated class and reuse it only +while generating that class. + +### A build timestamp + +The following build step records the build time: + +```java +@BuildStep +@Record(RUNTIME_INIT) +void buildInfo(InfoRecorder recorder) { + recorder.setBuildTime(Instant.now().toString()); +} +``` + +Use the configured output timestamp instead: + +```java +@BuildStep +@Record(RUNTIME_INIT) +void buildInfo(PackageConfig packageConfig, InfoRecorder recorder) { + recorder.setBuildTime(packageConfig.outputTimestamp().toString()); +} +``` + +When no fixed timestamp is configured, `outputTimestamp()` defaults to the current +time, so the output only becomes reproducible once the build tool provides one. + +## Run reproducibility checks locally + +### JVM tests + +In order to run reproducibility checks locally, build the affected modules and +their dependencies first. Then run the deployment tests, replacing the module name +below: + +```bash +./mvnw install -f extensions/ -DskipTests +./mvnw test -f extensions//deployment -Dno-build-cache \ + -Dquarkus-internal.test.reproducibility-check=3 +``` + +The property above causes the test infrastructure to run augmentation three +times and compare the generated and transformed in-memory class bytes. A passing +check aborts each test before its test methods run. It is a bytecode check, not a +replacement for the test's behavioral assertions. The same property also enables +generated-bytecode checks in `ArcTestContainer`; tests configured with `shouldFail` +are skipped by that check. + +To catch values that differ between JVM processes, run the deployment tests +with an additional property: + +```bash +-Dquarkus-internal.test.reproducibility-check.cross-jvm=true +``` + +This mode is slower because each augmentation runs in a separate JVM. + +### Investigate a failure + +On a mismatch, the reproducibility check writes class files and their decompiled +`*.java` counterparts under `target/debug/` in the module being checked. Start +with `mismatch.txt`, which identifies the changed, missing, and extra classes. +A typical failure produces the following structure: + +```text +target/debug///reproducibility-mismatch/ +├── mismatch.txt +├── run-1/ # reference class files +├── run-/ # differing class files +├── run-1-decompiled/ # readable reference +└── run--decompiled/ # readable difference +``` + +For a changed class, which is often generated from a method annotated with `@Record` +in a deployment module, diff the matching Java files in the two `*-decompiled` +directories: + +```bash +diff -u run-1-decompiled/.java run--decompiled/.java +``` + +Cross-JVM runs write their class dumps under `target/reproducibility/` in that module. + +### Integration tests + +The integration-test reproducibility check repeatedly packages integration-test +applications and compares their `target/quarkus-app` directories with +`diffoscope`. Before running the check, build and install the affected Quarkus +modules and install `diffoscope`. Installing `procyon-decompiler` is also +recommended because it lets `diffoscope` present class-file differences as +readable Java code. + +The following example runs the check five times for the module of your choice: + +```bash +NUM_RUNS=5 \ +OUTPUT_DIR= \ +EXTRA_MAVEN_ARGS="--settings .github/mvn-settings.xml \ +-Dformat.skip \ +-Denforcer.skip \ +-DskipDocs \ +-Dforbiddenapis.skip \ +-DskipExtensionValidation \ +-DskipCodestartValidation \ +-pl " \ +.github/check-it-reproducibility.sh +``` + +The `-pl` argument selects the integration-test module. Replace +`` with the module you want to check. Set `NUM_RUNS=2` +for a quicker initial check. + +The script writes its results to `OUTPUT_DIR`. The directory contains a +`report.md` summary, lists of checked and non-reproducible modules, the reference +outputs, and detailed HTML comparisons under `diffs/`. + +**Note:** If you are running on macOS and the `diffoscope` output is not legible, +try running the script above with the +`DIFFOSCOPE="diffoscope --exclude-command=zipdetails"` environment variable set. + +## CI setup + +Reproducibility checks currently run nightly on the `main` branch. They are +not yet part of every pull request because their runtime needs to be reduced. +The goal is to include them in PR checks once they are fast enough. + +For both the JVM and integration-test checks, CI uses five runs. + +If you wish to see the CI status of the reproducibility checks after you merged a PR, +you can check the [Quarkus status website](https://status.quarkus.io/) the next day. +From the status page, you can follow the relevant workflow run to confirm that it +tested a commit containing your change and inspect its failure artifacts or report. +Scheduled runs may start or finish later than expected, so check the run's timestamp +and commit rather than relying only on the calendar day. + +If the checks failed, it would be great if you could investigate the failure and +fix it. + +If you cannot fix it, please report it on the +[Quarkus issue tracker](https://github.com/quarkusio/quarkus/issues). + +- [Deployment tests and Jakarta REST TCK][repro-jvm] + repeat augmentation and compare generated and transformed class bytes. +- [Integration tests][repro-it] + rebuild packaged applications and compare `target/quarkus-app` outputs with + `diffoscope`. + +[repro-jvm]: https://github.com/quarkusio/quarkus/actions/workflows/reproducibility-checks.yml +[repro-it]: https://github.com/quarkusio/quarkus/actions/workflows/reproducibility-checks-it.yml + +## Treat test-only exceptions as a last resort + +`ReproducibilityCheckBuildItem` marks an augmentation that compares generated +bytecode. It allows a build step to become aware that the reproducibility check +is running and change its behavior accordingly. + +This may be useful to make sure that the checks stay green even when the build +step generates volatile bytecode. That is why it is important to use this marker +only when the volatile bytecode is not generated for a production application. +For example, it is used where test/dev-only WebJar output needs a random temporary +path and substitutes it for a stable one if a marker build item is present. + +First try to make the generated value stable. Use the marker only when the +volatile bytecode is genuinely limited to the test or dev environment and +cannot occur in the production artifact. Keep the exception narrow and +document why it is valid. Prefer fixing a specific source of variation over +excluding generated classes from comparison. + +## Further information + +If you need additional assistance, feel free to tag @reaver585 or @gsmet in the issue. +They are the maintainers of the reproducibility checks. From 199bd90584a68a0228ed3477b738d715b50654cb Mon Sep 17 00:00:00 2001 From: brunobat Date: Tue, 6 Oct 2026 13:15:27 +0100 Subject: [PATCH 065/254] OTel tracing set to 100% on tests --- .../main/asciidoc/opentelemetry-tracing.adoc | 4 ++- .../deployment/BasicTracingTest.java | 1 - .../ObservationContextPropagationTest.java | 1 - .../deployment/ObservationMDCTest.java | 1 - .../deployment/ObservedInterceptorTest.java | 1 - ...pagatingTracingObservationHandlerTest.java | 1 - .../src/test/resources/application.properties | 1 - .../deployment/tracing/TracerProcessor.java | 18 ++++++++----- .../OpenTelemetryServiceNameAppNameTest.java | 1 - ...ryServiceNameCombinedResourceWinsTest.java | 1 - ...tryServiceNameCombinedServiceWinsTest.java | 1 - ...lemetryServiceNameNoResourceAttrsTest.java | 1 - ...nTelemetryServiceNameResourceAttrTest.java | 1 - .../GraphQLOpenTelemetryTest.java | 1 - ...txEventBusInstrumentationDisabledTest.java | 1 - .../VertxHttpInstrumentationDisabledTest.java | 1 - .../VertxOpenTelemetryTest.java | 1 - .../deployment/logs/OtelLoggingTest.java | 1 - .../deployment/metrics/JvmMetricsTest.java | 1 - .../traces/MutinyTracingHelperTest.java | 1 - .../OpenTelemetrySpanSecurityEventsTest.java | 1 - .../traces/SimpleSpanProcessorTest.java | 1 - .../traces/TestModeSamplerDefaultTest.java | 26 +++++++++++++++++++ .../resources/application-default.properties | 2 -- .../application-just-tracing.properties | 2 -- .../resource-config/application.properties | 2 -- .../tracing/OpenTelemetrySignalsTest.java | 4 +-- .../test/tracing/PipelineTracingTest.java | 1 - .../tracing/TracesDisabledSignalsTest.java | 1 - ...penTelemetryAnnotationsWebSocketsTest.java | 1 - .../OpenTelemetryWebSocketsTest.java | 1 - .../TracesDisabledWebSocketsTest.java | 1 - .../micrometer/prometheus/OtelOnProfile.java | 3 +-- .../src/main/resources/application.properties | 2 -- .../src/main/resources/application.properties | 2 -- .../src/main/resources/application.properties | 4 +-- .../opentelemetry/DeploymentProcessor.java | 3 +-- 37 files changed, 44 insertions(+), 53 deletions(-) create mode 100644 extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/traces/TestModeSamplerDefaultTest.java diff --git a/docs/src/main/asciidoc/opentelemetry-tracing.adoc b/docs/src/main/asciidoc/opentelemetry-tracing.adoc index b83480b2fb24d..c511dac2715fc 100644 --- a/docs/src/main/asciidoc/opentelemetry-tracing.adoc +++ b/docs/src/main/asciidoc/opentelemetry-tracing.adoc @@ -407,7 +407,9 @@ quarkus.otel.traces.sampler.arg=0.5 [NOTE] ==== As of Quarkus 4, the default sampling ratio changed from 100% (1.0d) to 10% (0.1d) to reduce overhead in production environments. -In development mode, sampling defaults to 100% (1.0d) to ensure all traces are captured for debugging. +In development and test modes, sampling defaults to 100% (1.0d) to ensure all traces are captured for debugging and for deterministic test assertions. +This applies only when a ratio-based sampler (`parentbased_traceidratio` or `traceidratio`) is used; an explicit `quarkus.otel.traces.sampler.arg` still takes precedence. +Note that `@QuarkusIntegrationTest` (native or JAR) applications are built in production mode, so they use the production default unless sampling is configured explicitly. ==== [TIP] diff --git a/extensions/observation/deployment/src/test/java/io/quarkus/observation/opentelemetry/deployment/BasicTracingTest.java b/extensions/observation/deployment/src/test/java/io/quarkus/observation/opentelemetry/deployment/BasicTracingTest.java index f1fc1adbdff58..aed54026808d2 100644 --- a/extensions/observation/deployment/src/test/java/io/quarkus/observation/opentelemetry/deployment/BasicTracingTest.java +++ b/extensions/observation/deployment/src/test/java/io/quarkus/observation/opentelemetry/deployment/BasicTracingTest.java @@ -34,7 +34,6 @@ public class BasicTracingTest { "services/io.opentelemetry.sdk.autoconfigure.spi.traces.ConfigurableSpanExporterProvider") .addAsResource(new StringAsset( "quarkus.otel.traces.exporter=test-span-exporter\n" + - "quarkus.otel.traces.sampler.arg=1.0d\n" + "quarkus.otel.bsp.schedule.delay=50ms\n" + "quarkus.observation.print-out=true\n" + "quarkus.otel.metrics.exporter=none\n" + diff --git a/extensions/observation/deployment/src/test/java/io/quarkus/observation/opentelemetry/deployment/ObservationContextPropagationTest.java b/extensions/observation/deployment/src/test/java/io/quarkus/observation/opentelemetry/deployment/ObservationContextPropagationTest.java index 7276b80815093..348ccbb0ab0df 100644 --- a/extensions/observation/deployment/src/test/java/io/quarkus/observation/opentelemetry/deployment/ObservationContextPropagationTest.java +++ b/extensions/observation/deployment/src/test/java/io/quarkus/observation/opentelemetry/deployment/ObservationContextPropagationTest.java @@ -56,7 +56,6 @@ public class ObservationContextPropagationTest { "services/io.opentelemetry.sdk.autoconfigure.spi.traces.ConfigurableSpanExporterProvider") .addAsResource(new StringAsset( "quarkus.otel.traces.exporter=test-span-exporter\n" + - "quarkus.otel.traces.sampler.arg=1.0d\n" + "quarkus.otel.bsp.schedule.delay=50ms\n" + "quarkus.otel.metrics.exporter=none\n"), "application.properties")); diff --git a/extensions/observation/deployment/src/test/java/io/quarkus/observation/opentelemetry/deployment/ObservationMDCTest.java b/extensions/observation/deployment/src/test/java/io/quarkus/observation/opentelemetry/deployment/ObservationMDCTest.java index 6d3a6f6d098ee..ea0f82c3d7236 100644 --- a/extensions/observation/deployment/src/test/java/io/quarkus/observation/opentelemetry/deployment/ObservationMDCTest.java +++ b/extensions/observation/deployment/src/test/java/io/quarkus/observation/opentelemetry/deployment/ObservationMDCTest.java @@ -75,7 +75,6 @@ public class ObservationMDCTest { "services/io.opentelemetry.sdk.autoconfigure.spi.traces.ConfigurableSpanExporterProvider") .addAsResource(new StringAsset( "quarkus.otel.traces.exporter=test-span-exporter\n" + - "quarkus.otel.traces.sampler.arg=1.0d\n" + "quarkus.otel.bsp.schedule.delay=50ms\n" + "quarkus.otel.metrics.exporter=none\n"), "application.properties")); diff --git a/extensions/observation/deployment/src/test/java/io/quarkus/observation/opentelemetry/deployment/ObservedInterceptorTest.java b/extensions/observation/deployment/src/test/java/io/quarkus/observation/opentelemetry/deployment/ObservedInterceptorTest.java index 134910e27f51c..37215a4bfb463 100644 --- a/extensions/observation/deployment/src/test/java/io/quarkus/observation/opentelemetry/deployment/ObservedInterceptorTest.java +++ b/extensions/observation/deployment/src/test/java/io/quarkus/observation/opentelemetry/deployment/ObservedInterceptorTest.java @@ -48,7 +48,6 @@ public class ObservedInterceptorTest { "services/io.opentelemetry.sdk.autoconfigure.spi.traces.ConfigurableSpanExporterProvider") .addAsResource(new StringAsset( "quarkus.otel.traces.exporter=test-span-exporter\n" + - "quarkus.otel.traces.sampler.arg=1.0d\n" + "quarkus.otel.bsp.schedule.delay=50ms\n" + "quarkus.observation.print-out=true\n" + "quarkus.otel.metrics.exporter=none\n" + diff --git a/extensions/observation/deployment/src/test/java/io/quarkus/observation/opentelemetry/deployment/PropagatingTracingObservationHandlerTest.java b/extensions/observation/deployment/src/test/java/io/quarkus/observation/opentelemetry/deployment/PropagatingTracingObservationHandlerTest.java index b1bb075c2fb9f..e76a7f434625b 100644 --- a/extensions/observation/deployment/src/test/java/io/quarkus/observation/opentelemetry/deployment/PropagatingTracingObservationHandlerTest.java +++ b/extensions/observation/deployment/src/test/java/io/quarkus/observation/opentelemetry/deployment/PropagatingTracingObservationHandlerTest.java @@ -55,7 +55,6 @@ public class PropagatingTracingObservationHandlerTest { "services/io.opentelemetry.sdk.autoconfigure.spi.traces.ConfigurableSpanExporterProvider") .addAsResource(new StringAsset( "quarkus.otel.traces.exporter=test-span-exporter\n" + - "quarkus.otel.traces.sampler.arg=1.0d\n" + "quarkus.otel.bsp.schedule.delay=50ms\n" + "quarkus.otel.metrics.exporter=none\n"), "application.properties")); diff --git a/extensions/observation/deployment/src/test/resources/application.properties b/extensions/observation/deployment/src/test/resources/application.properties index 5b56c953d73aa..68d5f9e75f8a2 100644 --- a/extensions/observation/deployment/src/test/resources/application.properties +++ b/extensions/observation/deployment/src/test/resources/application.properties @@ -1,4 +1,3 @@ quarkus.otel.traces.exporter=test-span-exporter -quarkus.otel.traces.sampler.arg=1.0d quarkus.otel.bsp.schedule.delay=50ms quarkus.otel.metrics.exporter=none diff --git a/extensions/opentelemetry/deployment/src/main/java/io/quarkus/opentelemetry/deployment/tracing/TracerProcessor.java b/extensions/opentelemetry/deployment/src/main/java/io/quarkus/opentelemetry/deployment/tracing/TracerProcessor.java index 44a70acf96031..27a8551ac7b6a 100644 --- a/extensions/opentelemetry/deployment/src/main/java/io/quarkus/opentelemetry/deployment/tracing/TracerProcessor.java +++ b/extensions/opentelemetry/deployment/src/main/java/io/quarkus/opentelemetry/deployment/tracing/TracerProcessor.java @@ -135,22 +135,26 @@ UnremovableBeanBuildItem ensureProducersAreRetained( } /** - * Checks the {@link LaunchModeBuildItem} to see if we are in {@linkplain LaunchMode#DEVELOPMENT development mode}. + * Checks the {@link LaunchModeBuildItem} to see if we are in {@linkplain LaunchMode#DEVELOPMENT development mode} or + * {@linkplain LaunchMode#TEST test mode}. *

* Checks the {@link TracesBuildConfig#sampler()} for a value of {@code parentbased_traceidratio} or * {@code traceidration}. If set to one of those values, the {@code quarkus.otel.traces.sampler.arg} is defaulted - * to 100% (1.0d) in dev mode. + * to 100% (1.0d) in dev and test mode. *

* * @param launchMode the current launch mode - * @param buildConfig the build configuration used to determine if the sampler arg needs to be overridden in dev mode + * @param buildConfig the build configuration used to determine if the sampler arg needs to be overridden * - * @return a new build item setting the sampler argument to 100% if we are in dev mode, otherwise returns {@code null} + * @return a new build item setting the sampler argument to 100% if we are in dev or test mode, otherwise returns + * {@code null} */ @BuildStep - RunTimeConfigurationDefaultBuildItem setDevModeSamplerDefault(LaunchModeBuildItem launchMode, OTelBuildConfig buildConfig) { - // In dev mode, use 100% sampling for better debugging experience - if (launchMode.getLaunchMode() == LaunchMode.DEVELOPMENT) { + RunTimeConfigurationDefaultBuildItem setDevAndTestModeSamplerDefault(LaunchModeBuildItem launchMode, + OTelBuildConfig buildConfig) { + // In dev and test mode, use 100% sampling for better debugging and deterministic test assertions + final LaunchMode mode = launchMode.getLaunchMode(); + if (mode == LaunchMode.DEVELOPMENT || mode == LaunchMode.TEST) { final String sampler = buildConfig.traces().sampler(); if (SamplerType.PARENT_BASED_TRACE_ID_RATIO.getValue().equals(sampler) || SamplerType.TRACE_ID_RATIO.getValue().equals(sampler)) { diff --git a/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/OpenTelemetryServiceNameAppNameTest.java b/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/OpenTelemetryServiceNameAppNameTest.java index 19cce910733df..24e882aaf3345 100644 --- a/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/OpenTelemetryServiceNameAppNameTest.java +++ b/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/OpenTelemetryServiceNameAppNameTest.java @@ -18,7 +18,6 @@ public class OpenTelemetryServiceNameAppNameTest extends OpenTelemetryServiceNam .addClass(TestSpanExporterProvider.class) .addAsResource(new StringAsset("" + "quarkus.otel.bsp.schedule.delay=50\n" + - "quarkus.otel.traces.sampler.arg=1.0d\n" + "quarkus.application.name=" + SERVICE_NAME + "\n" + "quarkus.datasource.devservices.enabled=false\n"), "application.properties")); } diff --git a/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/OpenTelemetryServiceNameCombinedResourceWinsTest.java b/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/OpenTelemetryServiceNameCombinedResourceWinsTest.java index 2dad03ca67e73..9b9dc0f27ed1d 100644 --- a/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/OpenTelemetryServiceNameCombinedResourceWinsTest.java +++ b/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/OpenTelemetryServiceNameCombinedResourceWinsTest.java @@ -19,6 +19,5 @@ public class OpenTelemetryServiceNameCombinedResourceWinsTest extends OpenTeleme .overrideRuntimeConfigKey("quarkus.otel.bsp.schedule.delay", "50")// speed up test .overrideRuntimeConfigKey("quarkus.otel.service.name", SERVICE_NAME) .overrideRuntimeConfigKey("quarkus.otel.resource.attributes", "service.name=" + "attributes-must-fail") - .overrideRuntimeConfigKey("quarkus.otel.traces.sampler.arg", "1.0d") .overrideConfigKey("quarkus.datasource.devservices.enabled", "false"); } diff --git a/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/OpenTelemetryServiceNameCombinedServiceWinsTest.java b/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/OpenTelemetryServiceNameCombinedServiceWinsTest.java index f683eadb623e1..52e02534796c3 100644 --- a/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/OpenTelemetryServiceNameCombinedServiceWinsTest.java +++ b/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/OpenTelemetryServiceNameCombinedServiceWinsTest.java @@ -18,6 +18,5 @@ public class OpenTelemetryServiceNameCombinedServiceWinsTest extends OpenTelemet .overrideConfigKey("quarkus.application.name", "application-name-must-fail") .overrideRuntimeConfigKey("quarkus.otel.bsp.schedule.delay", "50")// speed up test .overrideRuntimeConfigKey("quarkus.otel.resource.attributes", "service.name=" + SERVICE_NAME) - .overrideRuntimeConfigKey("quarkus.otel.traces.sampler.arg", "1.0d") .overrideConfigKey("quarkus.datasource.devservices.enabled", "false"); } diff --git a/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/OpenTelemetryServiceNameNoResourceAttrsTest.java b/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/OpenTelemetryServiceNameNoResourceAttrsTest.java index 3b348c6d727fe..b0ccfb960da7d 100644 --- a/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/OpenTelemetryServiceNameNoResourceAttrsTest.java +++ b/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/OpenTelemetryServiceNameNoResourceAttrsTest.java @@ -17,6 +17,5 @@ public class OpenTelemetryServiceNameNoResourceAttrsTest extends OpenTelemetrySe .addClass(TestSpanExporterProvider.class)) .overrideConfigKey("quarkus.otel.service.name", SERVICE_NAME) .overrideRuntimeConfigKey("quarkus.otel.bsp.schedule.delay", "50")// speed up test - .overrideRuntimeConfigKey("quarkus.otel.traces.sampler.arg", "1.0d") .overrideConfigKey("quarkus.datasource.devservices.enabled", "false"); } diff --git a/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/OpenTelemetryServiceNameResourceAttrTest.java b/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/OpenTelemetryServiceNameResourceAttrTest.java index 68e9138ac71f4..f0b9d3285dd62 100644 --- a/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/OpenTelemetryServiceNameResourceAttrTest.java +++ b/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/OpenTelemetryServiceNameResourceAttrTest.java @@ -17,6 +17,5 @@ public class OpenTelemetryServiceNameResourceAttrTest extends OpenTelemetryServi .addClass(TestSpanExporterProvider.class)) .overrideRuntimeConfigKey("quarkus.otel.bsp.schedule.delay", "50")// speed up test .overrideRuntimeConfigKey("quarkus.otel.resource.attributes", "service.name=" + SERVICE_NAME) - .overrideRuntimeConfigKey("quarkus.otel.traces.sampler.arg", "1.0d") .overrideConfigKey("quarkus.datasource.devservices.enabled", "false"); } diff --git a/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/instrumentation/GraphQLOpenTelemetryTest.java b/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/instrumentation/GraphQLOpenTelemetryTest.java index 1bde07ee6ed83..602c1ce6e53c7 100644 --- a/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/instrumentation/GraphQLOpenTelemetryTest.java +++ b/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/instrumentation/GraphQLOpenTelemetryTest.java @@ -66,7 +66,6 @@ public class GraphQLOpenTelemetryTest { smallrye.graphql.printDataFetcherException=true smallrye.graphql.events.enabled=true quarkus.otel.traces.exporter=test-span-exporter - quarkus.otel.traces.sampler.arg=1.0d quarkus.otel.metrics.enabled=false quarkus.otel.logs.enabled=false quarkus.log.category."io.opentelemetry.usage".min-level=ALL diff --git a/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/instrumentation/VertxEventBusInstrumentationDisabledTest.java b/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/instrumentation/VertxEventBusInstrumentationDisabledTest.java index 59b5d42c44761..93856056532ea 100644 --- a/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/instrumentation/VertxEventBusInstrumentationDisabledTest.java +++ b/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/instrumentation/VertxEventBusInstrumentationDisabledTest.java @@ -38,7 +38,6 @@ public class VertxEventBusInstrumentationDisabledTest { .addAsResource(new StringAsset(TestSpanExporterProvider.class.getCanonicalName()), "META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.traces.ConfigurableSpanExporterProvider")) .overrideConfigKey("quarkus.otel.traces.exporter", "test-span-exporter") - .overrideConfigKey("quarkus.otel.traces.sampler.arg", "1.0d") .overrideConfigKey("quarkus.otel.metrics.enabled", "false") .overrideConfigKey("quarkus.otel.logs.enabled", "false") .overrideConfigKey("quarkus.otel.bsp.schedule.delay", "200") diff --git a/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/instrumentation/VertxHttpInstrumentationDisabledTest.java b/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/instrumentation/VertxHttpInstrumentationDisabledTest.java index 5d8e26a73aac9..13de625174c25 100644 --- a/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/instrumentation/VertxHttpInstrumentationDisabledTest.java +++ b/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/instrumentation/VertxHttpInstrumentationDisabledTest.java @@ -38,7 +38,6 @@ public class VertxHttpInstrumentationDisabledTest { .addAsResource(new StringAsset(TestSpanExporterProvider.class.getCanonicalName()), "META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.traces.ConfigurableSpanExporterProvider")) .overrideConfigKey("quarkus.otel.traces.exporter", "test-span-exporter") - .overrideConfigKey("quarkus.otel.traces.sampler.arg", "1.0d") .overrideConfigKey("quarkus.otel.metrics.enabled", "false") .overrideConfigKey("quarkus.otel.logs.enabled", "false") .overrideConfigKey("quarkus.otel.bsp.schedule.delay", "200") diff --git a/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/instrumentation/VertxOpenTelemetryTest.java b/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/instrumentation/VertxOpenTelemetryTest.java index 5a54935a17479..2ae15409d6a98 100644 --- a/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/instrumentation/VertxOpenTelemetryTest.java +++ b/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/instrumentation/VertxOpenTelemetryTest.java @@ -62,7 +62,6 @@ public class VertxOpenTelemetryTest { .addAsResource(new StringAsset(TestSpanExporterProvider.class.getCanonicalName()), "META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.traces.ConfigurableSpanExporterProvider")) .overrideConfigKey("quarkus.otel.traces.exporter", "test-span-exporter") - .overrideConfigKey("quarkus.otel.traces.sampler.arg", "1.0d") .overrideConfigKey("quarkus.otel.metrics.enabled", "false") .overrideConfigKey("quarkus.otel.logs.enabled", "false") .overrideConfigKey("quarkus.otel.bsp.schedule.delay", "200") diff --git a/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/logs/OtelLoggingTest.java b/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/logs/OtelLoggingTest.java index f0f257b7267df..1f66dd2fccb73 100644 --- a/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/logs/OtelLoggingTest.java +++ b/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/logs/OtelLoggingTest.java @@ -55,7 +55,6 @@ public class OtelLoggingTest { "META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.traces.ConfigurableSpanExporterProvider") .add(new StringAsset( "quarkus.otel.traces.enabled=true\n" + - "quarkus.otel.traces.sampler.arg=1.0d\n" + "quarkus.log.category.\"io.quarkus.opentelemetry\".level=INFO\n" + "quarkus.datasource.devservices.enabled=false\n"), "application.properties")); diff --git a/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/metrics/JvmMetricsTest.java b/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/metrics/JvmMetricsTest.java index 95030f0f83acd..d872900241e07 100644 --- a/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/metrics/JvmMetricsTest.java +++ b/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/metrics/JvmMetricsTest.java @@ -41,7 +41,6 @@ public class JvmMetricsTest extends BaseJvmMetricsTest { "META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.metrics.ConfigurableMetricExporterProvider") .add(new StringAsset( "quarkus.otel.traces.exporter=none\n" + - "quarkus.otel.traces.sampler.arg=1.0d\n" + "quarkus.otel.logs.exporter=none\n" + "quarkus.otel.metrics.exporter=in-memory\n" + "quarkus.otel.metric.export.interval=300ms\n" + diff --git a/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/traces/MutinyTracingHelperTest.java b/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/traces/MutinyTracingHelperTest.java index e7fec9d84995e..784ca3cfe086b 100644 --- a/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/traces/MutinyTracingHelperTest.java +++ b/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/traces/MutinyTracingHelperTest.java @@ -49,7 +49,6 @@ class MutinyTracingHelperTest { "META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.traces.ConfigurableSpanExporterProvider") .addAsResource(new StringAsset( "quarkus.otel.bsp.schedule.delay=50ms\n" - + "quarkus.otel.traces.sampler.arg=1.0d\n" + "quarkus.datasource.devservices.enabled=false\n"), "application.properties")); diff --git a/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/traces/OpenTelemetrySpanSecurityEventsTest.java b/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/traces/OpenTelemetrySpanSecurityEventsTest.java index 8515529505df8..1c2778dfd4068 100644 --- a/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/traces/OpenTelemetrySpanSecurityEventsTest.java +++ b/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/traces/OpenTelemetrySpanSecurityEventsTest.java @@ -42,7 +42,6 @@ public class OpenTelemetrySpanSecurityEventsTest { quarkus.otel.metrics.enabled=false quarkus.otel.logs.enabled=false quarkus.otel.security-events.event-types=AUTHENTICATION_SUCCESS,AUTHORIZATION_SUCCESS,OTHER - quarkus.otel.traces.sampler.arg=1.0d quarkus.datasource.devservices.enabled=false """), "application.properties")); diff --git a/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/traces/SimpleSpanProcessorTest.java b/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/traces/SimpleSpanProcessorTest.java index 172c1e0206382..7d594190276fb 100644 --- a/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/traces/SimpleSpanProcessorTest.java +++ b/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/traces/SimpleSpanProcessorTest.java @@ -45,7 +45,6 @@ public class SimpleSpanProcessorTest { "META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.traces.ConfigurableSpanExporterProvider")) .overrideConfigKey("quarkus.otel.simple", "true") .overrideConfigKey("quarkus.otel.traces.exporter", "test-span-exporter") - .overrideConfigKey("quarkus.otel.traces.sampler.arg", "1.0d") .overrideConfigKey("quarkus.otel.metrics.enabled", "false") .overrideConfigKey("quarkus.otel.logs.enabled", "false") .overrideConfigKey("quarkus.datasource.devservices.enabled", "false"); diff --git a/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/traces/TestModeSamplerDefaultTest.java b/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/traces/TestModeSamplerDefaultTest.java new file mode 100644 index 0000000000000..cd6076d01aaf5 --- /dev/null +++ b/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/traces/TestModeSamplerDefaultTest.java @@ -0,0 +1,26 @@ +package io.quarkus.opentelemetry.deployment.traces; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.eclipse.microprofile.config.ConfigProvider; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; + +public class TestModeSamplerDefaultTest { + + // No application.properties: the app uses the default sampler + // (parentbased_traceidratio) and does NOT set sampler.arg. + @RegisterExtension + static final QuarkusUnitTest TEST = new QuarkusUnitTest() + .withApplicationRoot((JavaArchive jar) -> jar.addClasses()); + + @Test + void testModeDefaultsSamplerArgTo100Percent() { + String samplerArg = ConfigProvider.getConfig() + .getValue("quarkus.otel.traces.sampler.arg", String.class); + assertThat(samplerArg).isEqualTo("1.0d"); + } +} diff --git a/extensions/opentelemetry/deployment/src/test/resources/application-default.properties b/extensions/opentelemetry/deployment/src/test/resources/application-default.properties index d62bdd8dbe59e..84b8a4465eb78 100644 --- a/extensions/opentelemetry/deployment/src/test/resources/application-default.properties +++ b/extensions/opentelemetry/deployment/src/test/resources/application-default.properties @@ -1,7 +1,5 @@ # Traces quarkus.otel.traces.exporter=test-span-exporter -# Sampler should be at 100% for testing purposes -quarkus.otel.traces.sampler.arg=1.0d quarkus.otel.bsp.schedule.delay=50ms quarkus.otel.bsp.export.timeout=1s diff --git a/extensions/opentelemetry/deployment/src/test/resources/resource-config/application-just-tracing.properties b/extensions/opentelemetry/deployment/src/test/resources/resource-config/application-just-tracing.properties index 1889f9673d2b4..5951e9d2189a6 100644 --- a/extensions/opentelemetry/deployment/src/test/resources/resource-config/application-just-tracing.properties +++ b/extensions/opentelemetry/deployment/src/test/resources/resource-config/application-just-tracing.properties @@ -2,8 +2,6 @@ quarkus.application.name=resource-test quarkus.otel.resource.attributes=service.name=authservice,service.instance.id=${quarkus.uuid} quarkus.otel.traces.exporter=test-span-exporter -# Sampler should be at 100% for testing purposes -quarkus.otel.traces.sampler.arg=1.0d quarkus.otel.bsp.schedule.delay=50ms quarkus.otel.metrics.enabled=false quarkus.otel.logs.enabled=false diff --git a/extensions/opentelemetry/deployment/src/test/resources/resource-config/application.properties b/extensions/opentelemetry/deployment/src/test/resources/resource-config/application.properties index a1303926065c5..6a451f6a12766 100644 --- a/extensions/opentelemetry/deployment/src/test/resources/resource-config/application.properties +++ b/extensions/opentelemetry/deployment/src/test/resources/resource-config/application.properties @@ -2,8 +2,6 @@ quarkus.application.name=resource-test quarkus.otel.resource.attributes=service.name=authservice,service.instance.id=${quarkus.uuid} quarkus.otel.traces.exporter=test-span-exporter -# Sampler should be at 100% for testing purposes -quarkus.otel.traces.sampler.arg=1.0d quarkus.otel.bsp.schedule.delay=50ms quarkus.otel.metrics.exporter=in-memory quarkus.otel.metric.export.interval=300ms diff --git a/extensions/signals/deployment/src/test/java/io/quarkus/signals/deployment/test/tracing/OpenTelemetrySignalsTest.java b/extensions/signals/deployment/src/test/java/io/quarkus/signals/deployment/test/tracing/OpenTelemetrySignalsTest.java index 8ac6fbd4a7129..52d2bd1c3faa4 100644 --- a/extensions/signals/deployment/src/test/java/io/quarkus/signals/deployment/test/tracing/OpenTelemetrySignalsTest.java +++ b/extensions/signals/deployment/src/test/java/io/quarkus/signals/deployment/test/tracing/OpenTelemetrySignalsTest.java @@ -52,10 +52,8 @@ public class OpenTelemetrySignalsTest { static final QuarkusExtensionTest test = new QuarkusExtensionTest() .withApplicationRoot(root -> root .addClasses(PingReceivers.class, Ping.class, Urgent.class, InMemorySpanExporterProducer.class) - // always_on: record and export every span so the assertions can find them; the short batch - // processor delays make exported spans show up quickly in the in-memory exporter + // the short batch processor delays make exported spans show up quickly in the in-memory exporter .addAsResource(new StringAsset(""" - quarkus.otel.traces.sampler=always_on quarkus.otel.bsp.export.timeout=1s quarkus.otel.bsp.schedule.delay=50 """), "application.properties")) diff --git a/extensions/signals/deployment/src/test/java/io/quarkus/signals/deployment/test/tracing/PipelineTracingTest.java b/extensions/signals/deployment/src/test/java/io/quarkus/signals/deployment/test/tracing/PipelineTracingTest.java index 721c25d6c5730..e2de1c3b8a93c 100644 --- a/extensions/signals/deployment/src/test/java/io/quarkus/signals/deployment/test/tracing/PipelineTracingTest.java +++ b/extensions/signals/deployment/src/test/java/io/quarkus/signals/deployment/test/tracing/PipelineTracingTest.java @@ -44,7 +44,6 @@ public class PipelineTracingTest { ValidationStage.class, EnrichmentStage.class, ShipmentStage.class, InMemorySpanExporterProducer.class) .addAsResource(new StringAsset(""" - quarkus.otel.traces.sampler=always_on quarkus.otel.bsp.export.timeout=1s quarkus.otel.bsp.schedule.delay=50 """), "application.properties")) diff --git a/extensions/signals/deployment/src/test/java/io/quarkus/signals/deployment/test/tracing/TracesDisabledSignalsTest.java b/extensions/signals/deployment/src/test/java/io/quarkus/signals/deployment/test/tracing/TracesDisabledSignalsTest.java index e024aae65913a..1d503f1a5f93e 100644 --- a/extensions/signals/deployment/src/test/java/io/quarkus/signals/deployment/test/tracing/TracesDisabledSignalsTest.java +++ b/extensions/signals/deployment/src/test/java/io/quarkus/signals/deployment/test/tracing/TracesDisabledSignalsTest.java @@ -37,7 +37,6 @@ public class TracesDisabledSignalsTest { .addClasses(PingReceiver.class, Ping.class, InMemorySpanExporterProducer.class) .addAsResource(new StringAsset(""" quarkus.signals.telemetry.traces.enabled=false - quarkus.otel.traces.sampler=always_on quarkus.otel.bsp.export.timeout=1s quarkus.otel.bsp.schedule.delay=50 """), "application.properties")) diff --git a/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/telemetry/OpenTelemetryAnnotationsWebSocketsTest.java b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/telemetry/OpenTelemetryAnnotationsWebSocketsTest.java index f7bbb25943b84..0dbb2ee635688 100644 --- a/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/telemetry/OpenTelemetryAnnotationsWebSocketsTest.java +++ b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/telemetry/OpenTelemetryAnnotationsWebSocketsTest.java @@ -64,7 +64,6 @@ public class OpenTelemetryAnnotationsWebSocketsTest { .addClasses(OtelBounceEndpoint.class, WSClient.class, InMemorySpanExporterProducer.class, OtelBounceClient.class, Endpoint.class) .addAsResource(new StringAsset(""" - quarkus.otel.traces.sampler=always_on quarkus.otel.bsp.export.timeout=1s quarkus.otel.bsp.schedule.delay=50 """), "application.properties")) diff --git a/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/telemetry/OpenTelemetryWebSocketsTest.java b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/telemetry/OpenTelemetryWebSocketsTest.java index af33d861cb1b3..a4c59a421c7aa 100644 --- a/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/telemetry/OpenTelemetryWebSocketsTest.java +++ b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/telemetry/OpenTelemetryWebSocketsTest.java @@ -42,7 +42,6 @@ public class OpenTelemetryWebSocketsTest { .withApplicationRoot(root -> root .addClasses(BounceEndpoint.class, WSClient.class, InMemorySpanExporterProducer.class, BounceClient.class) .addAsResource(new StringAsset(""" - quarkus.otel.traces.sampler=always_on quarkus.otel.bsp.export.timeout=1s quarkus.otel.bsp.schedule.delay=50 """), "application.properties")) diff --git a/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/telemetry/TracesDisabledWebSocketsTest.java b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/telemetry/TracesDisabledWebSocketsTest.java index 9637d1e64f4ab..e992ba44fd83b 100644 --- a/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/telemetry/TracesDisabledWebSocketsTest.java +++ b/extensions/websockets-next/deployment/src/test/java/io/quarkus/websockets/next/test/telemetry/TracesDisabledWebSocketsTest.java @@ -39,7 +39,6 @@ public class TracesDisabledWebSocketsTest { .withApplicationRoot(root -> root .addClasses(BounceEndpoint.class, WSClient.class, InMemorySpanExporterProducer.class, BounceClient.class) .addAsResource(new StringAsset(""" - quarkus.otel.traces.sampler=always_on quarkus.otel.bsp.export.timeout=1s quarkus.otel.bsp.schedule.delay=50 quarkus.websockets-next.server.traces.enabled=false diff --git a/integration-tests/micrometer-prometheus-opentelemetry/src/test/java/io/quarkus/it/micrometer/prometheus/OtelOnProfile.java b/integration-tests/micrometer-prometheus-opentelemetry/src/test/java/io/quarkus/it/micrometer/prometheus/OtelOnProfile.java index 7decfad1d5704..7d6e9084af52b 100644 --- a/integration-tests/micrometer-prometheus-opentelemetry/src/test/java/io/quarkus/it/micrometer/prometheus/OtelOnProfile.java +++ b/integration-tests/micrometer-prometheus-opentelemetry/src/test/java/io/quarkus/it/micrometer/prometheus/OtelOnProfile.java @@ -10,8 +10,7 @@ public class OtelOnProfile implements QuarkusTestProfile { @Override public Map getConfigOverrides() { Map config = new HashMap<>(Map.of( - "quarkus.otel.enabled", "true", - "quarkus.otel.traces.sampler", "always_on")); + "quarkus.otel.enabled", "true")); return config; } } diff --git a/integration-tests/observability-lgtm-fixedports/src/main/resources/application.properties b/integration-tests/observability-lgtm-fixedports/src/main/resources/application.properties index 0dafb97bfae7b..650d0d077605e 100644 --- a/integration-tests/observability-lgtm-fixedports/src/main/resources/application.properties +++ b/integration-tests/observability-lgtm-fixedports/src/main/resources/application.properties @@ -3,8 +3,6 @@ quarkus.log.category."io.quarkus.devservices".level=DEBUG #opentelemetry quarkus.otel.metric.export.interval=1s -# Sampler should be at 100% for testing purposes -quarkus.otel.traces.sampler.arg=1.0d #quarkus.observability.lgtm.image-name=grafana/otel-lgtm #quarkus.observability.lgtm.logging=ALL diff --git a/integration-tests/observability-lgtm/src/main/resources/application.properties b/integration-tests/observability-lgtm/src/main/resources/application.properties index 729d1c013dc2b..93b969c0498f1 100644 --- a/integration-tests/observability-lgtm/src/main/resources/application.properties +++ b/integration-tests/observability-lgtm/src/main/resources/application.properties @@ -3,8 +3,6 @@ quarkus.log.category."io.quarkus.devservices".level=DEBUG #opentelemetry quarkus.otel.metric.export.interval=1s -# Sampler should be at 100% for testing purposes -quarkus.otel.traces.sampler.arg=1.0d #quarkus.observability.lgtm.image-name=grafana/otel-lgtm #quarkus.observability.lgtm.logging=ALL diff --git a/integration-tests/opentelemetry-vertx-exporter/src/main/resources/application.properties b/integration-tests/opentelemetry-vertx-exporter/src/main/resources/application.properties index 90840a44d1398..714111c582b85 100644 --- a/integration-tests/opentelemetry-vertx-exporter/src/main/resources/application.properties +++ b/integration-tests/opentelemetry-vertx-exporter/src/main/resources/application.properties @@ -1,4 +1,2 @@ quarkus.application.name=integration test -quarkus.otel.metric.export.interval=1000ms -# Sampler should be at 100% for testing purposes -quarkus.otel.traces.sampler.arg=1.0d \ No newline at end of file +quarkus.otel.metric.export.interval=1000ms \ No newline at end of file diff --git a/tcks/microprofile-opentelemetry/src/test/java/io/quarkus/tck/opentelemetry/DeploymentProcessor.java b/tcks/microprofile-opentelemetry/src/test/java/io/quarkus/tck/opentelemetry/DeploymentProcessor.java index 0dd4ef1cbfd2f..bb0f568e5f088 100644 --- a/tcks/microprofile-opentelemetry/src/test/java/io/quarkus/tck/opentelemetry/DeploymentProcessor.java +++ b/tcks/microprofile-opentelemetry/src/test/java/io/quarkus/tck/opentelemetry/DeploymentProcessor.java @@ -32,8 +32,7 @@ public void process(Archive archive, TestClass testClass) { } war.addAsResource(new StringAsset( - "quarkus.otel.sdk.disabled=" + (otelSdkDisabled ? "true" : "false") + "\n" - + "quarkus.otel.traces.sampler.arg=1.0d\n"), + "quarkus.otel.sdk.disabled=" + (otelSdkDisabled ? "true" : "false") + "\n"), "application.properties"); war.addClass(ExecutorProvider.class); From a67b1c1c6a301d859e6a1d94dd623bff7ae76902 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:13:05 +0000 Subject: [PATCH 066/254] Bump org.eclipse.microprofile.openapi:microprofile-openapi-api Bumps [org.eclipse.microprofile.openapi:microprofile-openapi-api](https://github.com/eclipse/microprofile-open-api) from 4.2-RC4 to 4.2-RC5. - [Release notes](https://github.com/eclipse/microprofile-open-api/releases) - [Commits](https://github.com/eclipse/microprofile-open-api/commits) --- updated-dependencies: - dependency-name: org.eclipse.microprofile.openapi:microprofile-openapi-api dependency-version: 4.2-RC5 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- bom/application/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bom/application/pom.xml b/bom/application/pom.xml index 06931e9148bda..e05cfca0f153c 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -45,7 +45,7 @@ 4.0 2.1 2.0.2 - 4.2-RC4 + 4.2-RC5 2.20.0 4.0.0 4.3.0 From 0268736294f0d51078c4c8f10f24ded7188c1712 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:13:49 +0000 Subject: [PATCH 067/254] Bump org.eclipse.microprofile.health:microprofile-health-tck Bumps [org.eclipse.microprofile.health:microprofile-health-tck](https://github.com/eclipse/microprofile-health) from 4.0.1 to 4.0.2. - [Release notes](https://github.com/eclipse/microprofile-health/releases) - [Commits](https://github.com/eclipse/microprofile-health/compare/4.0.1...4.0.2) --- updated-dependencies: - dependency-name: org.eclipse.microprofile.health:microprofile-health-tck dependency-version: 4.0.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- tcks/microprofile-health/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tcks/microprofile-health/pom.xml b/tcks/microprofile-health/pom.xml index 0f853320e62a8..ba0f6a691549c 100644 --- a/tcks/microprofile-health/pom.xml +++ b/tcks/microprofile-health/pom.xml @@ -13,7 +13,7 @@ Quarkus - TCK - MicroProfile Health - 4.0.1 + 4.0.2 From 1ad5dc0416ad07c0482776e37d44f1171b8e147f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:21:07 +0000 Subject: [PATCH 068/254] Bump org.apache.maven.plugins:maven-install-plugin from 3.1.4 to 3.2.0 Bumps [org.apache.maven.plugins:maven-install-plugin](https://github.com/apache/maven-install-plugin) from 3.1.4 to 3.2.0. - [Release notes](https://github.com/apache/maven-install-plugin/releases) - [Commits](https://github.com/apache/maven-install-plugin/compare/maven-install-plugin-3.1.4...maven-install-plugin-3.2.0) --- updated-dependencies: - dependency-name: org.apache.maven.plugins:maven-install-plugin dependency-version: 3.2.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- independent-projects/parent/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/independent-projects/parent/pom.xml b/independent-projects/parent/pom.xml index 3aa4cc176f1ce..04a21ea173bfb 100644 --- a/independent-projects/parent/pom.xml +++ b/independent-projects/parent/pom.xml @@ -28,7 +28,7 @@ 2.27.0 3.2.8 1.13.0 - 3.1.4 + 3.2.0 3.12.0 3.5.1 3.15.2 From ebedf236e3c198a0e43f950e6147f08369d59d3d Mon Sep 17 00:00:00 2001 From: Guillaume Smet Date: Tue, 6 Oct 2026 16:21:15 +0200 Subject: [PATCH 069/254] Use Maven wrapper in preview workflow ubuntu-slim doesn't have Maven installed and it's better to use the Maven wrapper anyway. --- .github/workflows/preview.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index b6b00f2008e36..00cc0ff79b2e0 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -86,7 +86,7 @@ jobs: restore-keys: ${{ runner.os }}-npm- - name: Build with Maven - run: QUARKUS_ROQ_GENERATOR_BATCH=true QUARKUS_PROFILE=only-latest-guides mvn -B package quarkus:run -DskipTests + run: QUARKUS_ROQ_GENERATOR_BATCH=true QUARKUS_PROFILE=only-latest-guides ./mvnw -B package quarkus:run -DskipTests - name: Reduce the size of the website to be compatible with surge run: | From 1fdc898cc4fe92a72644873ec57f5385ca33786a Mon Sep 17 00:00:00 2001 From: Guillaume Smet Date: Tue, 6 Oct 2026 20:42:02 +0200 Subject: [PATCH 070/254] Use mvnw instead of system mvn in Gradle tests Hopefully, it will fix CI. --- integration-tests/gradle/pom.xml | 25 +++++++++++++++++++ .../producer-maven/build.gradle | 17 +++++++++++-- .../test-bom-with-exclusion/build.gradle | 17 +++++++++++-- 3 files changed, 55 insertions(+), 4 deletions(-) diff --git a/integration-tests/gradle/pom.xml b/integration-tests/gradle/pom.xml index c5d27a1d36c0a..be5316198b605 100644 --- a/integration-tests/gradle/pom.xml +++ b/integration-tests/gradle/pom.xml @@ -646,6 +646,31 @@ \ + + + + copy-maven-wrapper + process-resources + + copy-resources + + + UTF-8 + ${project.build.directory}/ + + + ${maven.multiModuleProjectDirectory}/ + + .mvn/wrapper/** + mvnw + mvnw.cmd + + false + + + + +
diff --git a/integration-tests/gradle/src/main/resources/declared-deps-minimal/producer-maven/build.gradle b/integration-tests/gradle/src/main/resources/declared-deps-minimal/producer-maven/build.gradle index 460a5e9120f38..2b45fb7a095af 100644 --- a/integration-tests/gradle/src/main/resources/declared-deps-minimal/producer-maven/build.gradle +++ b/integration-tests/gradle/src/main/resources/declared-deps-minimal/producer-maven/build.gradle @@ -2,7 +2,20 @@ def isWindows() { System.getProperty('os.name').toLowerCase().contains('win') } +def findMvnw() { + def dir = projectDir + while (dir != null) { + def wrapper = new File(dir, isWindows() ? 'mvnw.cmd' : 'mvnw') + if (wrapper.exists()) { + return wrapper.absolutePath + } + dir = dir.parentFile + } + throw new GradleException('Could not find mvnw in any parent directory of ' + projectDir) +} + tasks.register('publishToMavenLocal', Exec) { - commandLine = isWindows() ? ['cmd', '/c', 'mvn', '-q', 'clean', 'install'] - : ['mvn', '-q', 'clean', 'install'] + def mvnw = findMvnw() + commandLine = isWindows() ? ['cmd', '/c', mvnw, '-q', 'clean', 'install'] + : [mvnw, '-q', 'clean', 'install'] } diff --git a/integration-tests/gradle/src/main/resources/enforcing-platform-for-conditional-deps/other-deps/test-bom-with-exclusion/build.gradle b/integration-tests/gradle/src/main/resources/enforcing-platform-for-conditional-deps/other-deps/test-bom-with-exclusion/build.gradle index 460a5e9120f38..2b45fb7a095af 100644 --- a/integration-tests/gradle/src/main/resources/enforcing-platform-for-conditional-deps/other-deps/test-bom-with-exclusion/build.gradle +++ b/integration-tests/gradle/src/main/resources/enforcing-platform-for-conditional-deps/other-deps/test-bom-with-exclusion/build.gradle @@ -2,7 +2,20 @@ def isWindows() { System.getProperty('os.name').toLowerCase().contains('win') } +def findMvnw() { + def dir = projectDir + while (dir != null) { + def wrapper = new File(dir, isWindows() ? 'mvnw.cmd' : 'mvnw') + if (wrapper.exists()) { + return wrapper.absolutePath + } + dir = dir.parentFile + } + throw new GradleException('Could not find mvnw in any parent directory of ' + projectDir) +} + tasks.register('publishToMavenLocal', Exec) { - commandLine = isWindows() ? ['cmd', '/c', 'mvn', '-q', 'clean', 'install'] - : ['mvn', '-q', 'clean', 'install'] + def mvnw = findMvnw() + commandLine = isWindows() ? ['cmd', '/c', mvnw, '-q', 'clean', 'install'] + : [mvnw, '-q', 'clean', 'install'] } From 2b3be8f808e46061fc729f5468825416aaf99b22 Mon Sep 17 00:00:00 2001 From: Guillaume Smet Date: Tue, 6 Oct 2026 23:01:58 +0200 Subject: [PATCH 071/254] Update system-stubs-jupiter to 2.1.8 --- independent-projects/tools/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/independent-projects/tools/pom.xml b/independent-projects/tools/pom.xml index 268e45c3b4c5b..0e2a2892954be 100644 --- a/independent-projects/tools/pom.xml +++ b/independent-projects/tools/pom.xml @@ -59,7 +59,7 @@ 999-SNAPSHOT 41 3.6.0 - 2.0.2 + 2.1.8 4.3.0 0.0.7 3.0.6 From cd9533a3c00c94e3e0834f4b9d41f415a41da0cf Mon Sep 17 00:00:00 2001 From: jnbdz <237008+jnbdz@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:15:40 -0400 Subject: [PATCH 072/254] Create the OIDC Redis token state manager without a recorder The synthetic TokenStateManager bean now uses a BeanCreator with the Redis client name as a bean parameter, so OidcRedisTokenStateManagerRecorder and its static-init @Record step are removed. --- .../OidcRedisTokenStateManagerProcessor.java | 14 +++------ .../OidcRedisTokenStateManagerCreator.java | 25 +++++++++++++++ .../OidcRedisTokenStateManagerRecorder.java | 31 ------------------- 3 files changed, 30 insertions(+), 40 deletions(-) create mode 100644 extensions/oidc-redis-token-state-manager/runtime/src/main/java/io/quarkus/oidc/redis/token/state/manager/runtime/OidcRedisTokenStateManagerCreator.java delete mode 100644 extensions/oidc-redis-token-state-manager/runtime/src/main/java/io/quarkus/oidc/redis/token/state/manager/runtime/OidcRedisTokenStateManagerRecorder.java diff --git a/extensions/oidc-redis-token-state-manager/deployment/src/main/java/io/quarkus/oidc/redis/token/state/manager/deployment/OidcRedisTokenStateManagerProcessor.java b/extensions/oidc-redis-token-state-manager/deployment/src/main/java/io/quarkus/oidc/redis/token/state/manager/deployment/OidcRedisTokenStateManagerProcessor.java index 10e39b1bd9a88..f84645c367e66 100644 --- a/extensions/oidc-redis-token-state-manager/deployment/src/main/java/io/quarkus/oidc/redis/token/state/manager/deployment/OidcRedisTokenStateManagerProcessor.java +++ b/extensions/oidc-redis-token-state-manager/deployment/src/main/java/io/quarkus/oidc/redis/token/state/manager/deployment/OidcRedisTokenStateManagerProcessor.java @@ -10,12 +10,10 @@ import io.quarkus.arc.deployment.SyntheticBeanBuildItem; import io.quarkus.deployment.annotations.BuildStep; import io.quarkus.deployment.annotations.BuildSteps; -import io.quarkus.deployment.annotations.ExecutionTime; -import io.quarkus.deployment.annotations.Record; import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; import io.quarkus.oidc.TokenStateManager; import io.quarkus.oidc.redis.token.state.manager.runtime.AuthorizationCodeTokensRecord; -import io.quarkus.oidc.redis.token.state.manager.runtime.OidcRedisTokenStateManagerRecorder; +import io.quarkus.oidc.redis.token.state.manager.runtime.OidcRedisTokenStateManagerCreator; import io.quarkus.redis.client.RedisClientName; import io.quarkus.redis.datasource.ReactiveRedisDataSource; import io.quarkus.redis.deployment.client.RequestedRedisClientBuildItem; @@ -35,23 +33,21 @@ ReflectiveClassBuildItem registerTokenStateRecordForReflection() { .serialization().fields().methods().constructors().build(); } - @Record(ExecutionTime.STATIC_INIT) @BuildStep - SyntheticBeanBuildItem createTokenStateManager(OidcRedisTokenStateManagerRecorder recorder, - OidcRedisTokenStateManagerBuildConfig buildConfig) { + SyntheticBeanBuildItem createTokenStateManager(OidcRedisTokenStateManagerBuildConfig buildConfig) { var redisClientName = buildConfig.redisClientName(); var beanConfigurator = SyntheticBeanBuildItem.configure(TokenStateManager.class) .priority(1) .alternative(true) .unremovable() - .scope(ApplicationScoped.class); + .scope(ApplicationScoped.class) + .creator(OidcRedisTokenStateManagerCreator.class); if (RedisConfig.isDefaultClient(redisClientName)) { beanConfigurator - .createWith(recorder.createTokenStateManager(null)) .addInjectionPoint(Type.create(ReactiveRedisDataSource.class)); } else { beanConfigurator - .createWith(recorder.createTokenStateManager(redisClientName)) + .param(OidcRedisTokenStateManagerCreator.REDIS_CLIENT_NAME_PARAM, redisClientName) .addInjectionPoint(Type.create(ReactiveRedisDataSource.class), AnnotationInstance.builder(RedisClientName.class).value(redisClientName).build()); } diff --git a/extensions/oidc-redis-token-state-manager/runtime/src/main/java/io/quarkus/oidc/redis/token/state/manager/runtime/OidcRedisTokenStateManagerCreator.java b/extensions/oidc-redis-token-state-manager/runtime/src/main/java/io/quarkus/oidc/redis/token/state/manager/runtime/OidcRedisTokenStateManagerCreator.java new file mode 100644 index 0000000000000..0332c5c818abe --- /dev/null +++ b/extensions/oidc-redis-token-state-manager/runtime/src/main/java/io/quarkus/oidc/redis/token/state/manager/runtime/OidcRedisTokenStateManagerCreator.java @@ -0,0 +1,25 @@ +package io.quarkus.oidc.redis.token.state.manager.runtime; + +import io.quarkus.arc.BeanCreator; +import io.quarkus.arc.SyntheticCreationalContext; +import io.quarkus.oidc.TokenStateManager; +import io.quarkus.redis.client.RedisClientName; +import io.quarkus.redis.datasource.ReactiveRedisDataSource; + +public class OidcRedisTokenStateManagerCreator implements BeanCreator { + + public static final String REDIS_CLIENT_NAME_PARAM = "redisClientName"; + + @Override + public TokenStateManager create(SyntheticCreationalContext context) { + String clientName = (String) context.getParams().get(REDIS_CLIENT_NAME_PARAM); + final ReactiveRedisDataSource dataSource; + if (clientName == null) { + dataSource = context.getInjectedReference(ReactiveRedisDataSource.class); + } else { + dataSource = context.getInjectedReference(ReactiveRedisDataSource.class, + RedisClientName.Literal.of(clientName)); + } + return new OidcRedisTokenStateManager(dataSource); + } +} diff --git a/extensions/oidc-redis-token-state-manager/runtime/src/main/java/io/quarkus/oidc/redis/token/state/manager/runtime/OidcRedisTokenStateManagerRecorder.java b/extensions/oidc-redis-token-state-manager/runtime/src/main/java/io/quarkus/oidc/redis/token/state/manager/runtime/OidcRedisTokenStateManagerRecorder.java deleted file mode 100644 index 4f959cced65a6..0000000000000 --- a/extensions/oidc-redis-token-state-manager/runtime/src/main/java/io/quarkus/oidc/redis/token/state/manager/runtime/OidcRedisTokenStateManagerRecorder.java +++ /dev/null @@ -1,31 +0,0 @@ -package io.quarkus.oidc.redis.token.state.manager.runtime; - -import java.util.function.Function; - -import io.quarkus.arc.SyntheticCreationalContext; -import io.quarkus.oidc.TokenStateManager; -import io.quarkus.redis.client.RedisClientName; -import io.quarkus.redis.datasource.ReactiveRedisDataSource; -import io.quarkus.runtime.annotations.Recorder; - -@Recorder -public class OidcRedisTokenStateManagerRecorder { - - public Function, TokenStateManager> createTokenStateManager( - String clientName) { - return new Function<>() { - @Override - public TokenStateManager apply(SyntheticCreationalContext ctx) { - final ReactiveRedisDataSource dataSource; - if (clientName == null) { - dataSource = ctx.getInjectedReference(ReactiveRedisDataSource.class); - } else { - dataSource = ctx.getInjectedReference(ReactiveRedisDataSource.class, - RedisClientName.Literal.of(clientName)); - } - return new OidcRedisTokenStateManager(dataSource); - } - }; - } - -} From 2fcf32df99c428909e865a3127292a9f8343feb0 Mon Sep 17 00:00:00 2001 From: marko-bekhta Date: Wed, 7 Oct 2026 10:29:20 +0200 Subject: [PATCH 073/254] Fix argument order in SortedNullSafeMap two-entry factory method --- .../quarkus/core/impl/SortedNullSafeMap.java | 2 +- .../core/impl/SortedNullSafeMapTest.java | 121 ++++++++++++++++++ 2 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 core/runtime/src/test/java/io/quarkus/core/impl/SortedNullSafeMapTest.java diff --git a/core/runtime/src/main/java/io/quarkus/core/impl/SortedNullSafeMap.java b/core/runtime/src/main/java/io/quarkus/core/impl/SortedNullSafeMap.java index 1c13fe1be9192..5a8984c801f0e 100644 --- a/core/runtime/src/main/java/io/quarkus/core/impl/SortedNullSafeMap.java +++ b/core/runtime/src/main/java/io/quarkus/core/impl/SortedNullSafeMap.java @@ -56,7 +56,7 @@ public static Map of(String k1, V v1) { public static Map of(String k1, V v1, String k2, V v2) { return v1 == null ? of(k2, v2) : v2 == null ? Map.of(k1, v1) : - ofEntries(k1, k2, v1, v2); + ofEntries(k1, v1, k2, v2); } //@formatter:on diff --git a/core/runtime/src/test/java/io/quarkus/core/impl/SortedNullSafeMapTest.java b/core/runtime/src/test/java/io/quarkus/core/impl/SortedNullSafeMapTest.java new file mode 100644 index 0000000000000..2bdd57e8768b1 --- /dev/null +++ b/core/runtime/src/test/java/io/quarkus/core/impl/SortedNullSafeMapTest.java @@ -0,0 +1,121 @@ +package io.quarkus.core.impl; + +import static java.util.Map.entry; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +class SortedNullSafeMapTest { + + @Test + void ofNoArgsReturnsEmptyMap() { + Map map = SortedNullSafeMap.of(); + + assertThat(map).isEmpty(); + assertUnmodifiable(map); + } + + @Test + void ofSingleEntryKeepsNonNullValue() { + Map map = SortedNullSafeMap.of("a", 1); + + assertThat(map).containsExactly(entry("a", 1)); + assertUnmodifiable(map); + } + + @Test + void ofSingleEntryWithNullValueReturnsEmptyMap() { + Map map = SortedNullSafeMap.of("a", null); + + assertThat(map).isEmpty(); + } + + @Test + void ofTwoEntriesKeepsBothNonNullValues() { + Map map = SortedNullSafeMap.of("a", 1, "b", 2); + + assertThat(map).containsExactly(entry("a", 1), entry("b", 2)); + assertUnmodifiable(map); + } + + @Test + void ofTwoEntriesSkipsNullValues() { + assertThat(SortedNullSafeMap.of("a", null, "b", 2)).containsExactly(entry("b", 2)); + assertThat(SortedNullSafeMap.of("a", 1, "b", null)).containsExactly(entry("a", 1)); + assertThat(SortedNullSafeMap. of("a", null, "b", null)).isEmpty(); + } + + @Test + void ofThreeEntriesKeepsNonNullValues() { + Map map = SortedNullSafeMap.of("a", 1, "b", 2, "c", 3); + + assertThat(map).containsExactly(entry("a", 1), entry("b", 2), entry("c", 3)); + assertUnmodifiable(map); + } + + @Test + void ofThreeEntriesSkipsNullValues() { + assertThat(SortedNullSafeMap.of("a", null, "b", 2, "c", 3)) + .containsExactly(entry("b", 2), entry("c", 3)); + assertThat(SortedNullSafeMap.of("a", 1, "b", null, "c", 3)) + .containsExactly(entry("a", 1), entry("c", 3)); + assertThat(SortedNullSafeMap.of("a", 1, "b", 2, "c", null)) + .containsExactly(entry("a", 1), entry("b", 2)); + assertThat(SortedNullSafeMap. of("a", null, "b", null, "c", null)).isEmpty(); + } + + @Test + void ofFourEntriesKeepsNonNullValues() { + Map map = SortedNullSafeMap.of("a", 1, "b", 2, "c", 3, "d", 4); + + assertThat(map).containsExactly(entry("a", 1), entry("b", 2), entry("c", 3), entry("d", 4)); + assertUnmodifiable(map); + } + + @Test + void ofFourEntriesSkipsNullValues() { + assertThat(SortedNullSafeMap.of("a", null, "b", 2, "c", 3, "d", 4)) + .containsExactly(entry("b", 2), entry("c", 3), entry("d", 4)); + assertThat(SortedNullSafeMap.of("a", 1, "b", null, "c", 3, "d", null)) + .containsExactly(entry("a", 1), entry("c", 3)); + assertThat(SortedNullSafeMap. of("a", null, "b", null, "c", null, "d", null)).isEmpty(); + } + + @Test + void ofEntriesWithEmptyArrayReturnsEmptyMap() { + Map map = SortedNullSafeMap.ofEntries(); + + assertThat(map).isEmpty(); + assertUnmodifiable(map); + } + + @Test + void ofEntriesWithOddNumberOfArgumentsThrows() { + assertThatThrownBy(() -> SortedNullSafeMap.ofEntries("a", 1, "b")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Odd number"); + } + + @Test + void ofEntriesSkipsNullValuesAndSortsKeys() { + Map map = SortedNullSafeMap.ofEntries("c", 3, "a", null, "b", 2); + + assertThat(map).containsExactly(entry("b", 2), entry("c", 3)); + assertUnmodifiable(map); + } + + @Test + void ofEntriesWithAllNullValuesReturnsEmptyMap() { + Map map = SortedNullSafeMap.ofEntries("a", null, "b", null); + + assertThat(map).isEmpty(); + } + + private static void assertUnmodifiable(Map map) { + assertThatThrownBy(() -> map.put("new-key", null)) + .isInstanceOf(UnsupportedOperationException.class); + } +} From d3801f94d1567b215b6a48e48e14bb12b429b86f Mon Sep 17 00:00:00 2001 From: brunobat Date: Thu, 1 Oct 2026 16:21:55 +0100 Subject: [PATCH 074/254] Bump Otel sdk to 1.65.0. --- bom/application/pom.xml | 9 +++- .../instrumentation/JvmMetricsService.java | 42 ++++++++++------- .../micrometer-opentelemetry/pom.xml | 4 ++ .../pom.xml | 4 ++ .../observation-opentelemetry/pom.xml | 4 ++ .../observation-prometheus/pom.xml | 4 ++ .../observation-reactive/pom.xml | 4 ++ integration-tests/opentelemetry-grpc/pom.xml | 4 ++ .../pom.xml | 4 ++ .../pom.xml | 4 ++ .../opentelemetry-quartz/pom.xml | 4 ++ .../opentelemetry-quickstart/pom.xml | 4 ++ .../opentelemetry-reactive-messaging/pom.xml | 4 ++ .../opentelemetry-reactive/pom.xml | 4 ++ .../pom.xml | 4 ++ .../opentelemetry-scheduler/pom.xml | 4 ++ integration-tests/opentelemetry-spi/pom.xml | 4 ++ integration-tests/opentelemetry-vertx/pom.xml | 4 ++ .../opentelemetry/vertx/ExporterRouter.java | 8 +++- integration-tests/opentelemetry/pom.xml | 4 ++ test-framework/opentelemetry/pom.xml | 46 +++++++++++++++++++ .../SpanAttributesJsonMapperCustomizer.java | 41 +++++++++++++++++ test-framework/pom.xml | 1 + 23 files changed, 194 insertions(+), 21 deletions(-) create mode 100644 test-framework/opentelemetry/pom.xml create mode 100644 test-framework/opentelemetry/src/main/java/io/quarkus/test/opentelemetry/SpanAttributesJsonMapperCustomizer.java diff --git a/bom/application/pom.xml b/bom/application/pom.xml index e05cfca0f153c..01af9089eff5d 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -29,8 +29,8 @@ 3.0.1.Final 3.2.0.Final 7.0.5.Final - 2.29.0-alpha - 1.40.0-alpha + 2.31.1-alpha + 1.43.0-alpha 6.0.0.Alpha4 1.17.1 2.2.2 @@ -3787,6 +3787,11 @@ quarkus-test-common ${project.version} + + io.quarkus + quarkus-test-opentelemetry + ${project.version} + io.quarkus quarkus-test-security diff --git a/extensions/opentelemetry/runtime/src/main/java/io/quarkus/opentelemetry/runtime/metrics/instrumentation/JvmMetricsService.java b/extensions/opentelemetry/runtime/src/main/java/io/quarkus/opentelemetry/runtime/metrics/instrumentation/JvmMetricsService.java index 8b294b21d1d96..8afa2a890e413 100644 --- a/extensions/opentelemetry/runtime/src/main/java/io/quarkus/opentelemetry/runtime/metrics/instrumentation/JvmMetricsService.java +++ b/extensions/opentelemetry/runtime/src/main/java/io/quarkus/opentelemetry/runtime/metrics/instrumentation/JvmMetricsService.java @@ -1,9 +1,13 @@ package io.quarkus.opentelemetry.runtime.metrics.instrumentation; +import java.util.ArrayList; +import java.util.List; + import jakarta.annotation.PreDestroy; import jakarta.enterprise.context.ApplicationScoped; import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.instrumentation.api.config.IncludeExclude; import io.opentelemetry.instrumentation.runtimetelemetry.RuntimeTelemetry; import io.opentelemetry.instrumentation.runtimetelemetry.RuntimeTelemetryBuilder; import io.opentelemetry.instrumentation.runtimetelemetry.internal.Internal; @@ -26,27 +30,31 @@ public JvmMetricsService(final OpenTelemetry openTelemetry, final OTelRuntimeCon RuntimeTelemetryBuilder builder = RuntimeTelemetry.builder(openTelemetry); - Internal.setEnableJfrFeature(builder, "CONTEXT_SWITCH_METRICS"); - Internal.setEnableJfrFeature(builder, "CPU_COUNT_METRICS"); - Internal.setEnableJfrFeature(builder, "LOCK_METRICS"); - Internal.setEnableJfrFeature(builder, "NETWORK_IO_METRICS"); - Internal.setDisableJfrFeature(builder, "MEMORY_POOL_METRICS"); - Internal.setUseLegacyJfrCpuCountMetric(builder, true); + // JMX metrics are emitted by default. The selectors below opt-in to the JFR based metrics + // that complement them (they are OpenTelemetry metric names, wildcards allowed). The JFR + // memory pool metrics are intentionally left out so that memory is sourced from JMX. + List includedJfrMetrics = new ArrayList<>(List.of( + "jvm.cpu.context_switch", + "jvm.cpu.limit", + "jvm.cpu.longlock", + "jvm.network.*")); if (ImageMode.current().isNativeImage()) { - Internal.setEnableJfrFeature(builder, "THREAD_METRICS"); - Internal.setEnableJfrFeature(builder, "CLASS_LOAD_METRICS"); - Internal.setEnableJfrFeature(builder, "GC_DURATION_METRICS"); - Internal.setEnableJfrFeature(builder, "CPU_UTILIZATION_METRICS"); - Internal.setEnableJfrFeature(builder, "MEMORY_ALLOCATION_METRICS"); - } else { - Internal.setDisableJfrFeature(builder, "THREAD_METRICS"); - Internal.setDisableJfrFeature(builder, "CLASS_LOAD_METRICS"); - Internal.setDisableJfrFeature(builder, "GC_DURATION_METRICS"); - Internal.setDisableJfrFeature(builder, "CPU_UTILIZATION_METRICS"); - Internal.setDisableJfrFeature(builder, "MEMORY_ALLOCATION_METRICS"); + // In native mode some of the JMX sourced metrics are not available, so their JFR + // equivalents are enabled instead. Note the CPU utilization metric is the whole-system + // JFR one (jvm.system.cpu.utilization) rather than the JMX jvm.cpu.recent_utilization. + includedJfrMetrics.add("jvm.thread.count"); + includedJfrMetrics.add("jvm.class.*"); + includedJfrMetrics.add("jvm.gc.duration"); + includedJfrMetrics.add("jvm.system.cpu.utilization"); + includedJfrMetrics.add("jvm.memory.allocation"); } + Internal.setJfrMetrics(builder, IncludeExclude.builder() + .setIncluded(includedJfrMetrics) + .build()); + Internal.setUseLegacyJfrCpuCountMetric(builder, true); + runtimeTelemetry = builder.build(); } diff --git a/integration-tests/micrometer-opentelemetry/pom.xml b/integration-tests/micrometer-opentelemetry/pom.xml index 1c3e701733608..858df4b421c1b 100644 --- a/integration-tests/micrometer-opentelemetry/pom.xml +++ b/integration-tests/micrometer-opentelemetry/pom.xml @@ -52,6 +52,10 @@ io.opentelemetry opentelemetry-sdk-testing + + io.quarkus + quarkus-test-opentelemetry + org.assertj assertj-core diff --git a/integration-tests/observation-micrometer-opentelemetry/pom.xml b/integration-tests/observation-micrometer-opentelemetry/pom.xml index ee3035025379b..f66a98670702c 100644 --- a/integration-tests/observation-micrometer-opentelemetry/pom.xml +++ b/integration-tests/observation-micrometer-opentelemetry/pom.xml @@ -30,6 +30,10 @@ io.opentelemetry opentelemetry-sdk-testing + + io.quarkus + quarkus-test-opentelemetry + diff --git a/integration-tests/observation-opentelemetry/pom.xml b/integration-tests/observation-opentelemetry/pom.xml index 3bd2c18f593e8..a681fed8e2f3f 100644 --- a/integration-tests/observation-opentelemetry/pom.xml +++ b/integration-tests/observation-opentelemetry/pom.xml @@ -32,6 +32,10 @@ io.opentelemetry opentelemetry-sdk-testing + + io.quarkus + quarkus-test-opentelemetry + diff --git a/integration-tests/observation-prometheus/pom.xml b/integration-tests/observation-prometheus/pom.xml index 84d83b51d0b37..3a0284620bde8 100644 --- a/integration-tests/observation-prometheus/pom.xml +++ b/integration-tests/observation-prometheus/pom.xml @@ -34,6 +34,10 @@ io.opentelemetry opentelemetry-sdk-testing + + io.quarkus + quarkus-test-opentelemetry + diff --git a/integration-tests/observation-reactive/pom.xml b/integration-tests/observation-reactive/pom.xml index 268ae7bbf6c5a..07728a1d0d705 100644 --- a/integration-tests/observation-reactive/pom.xml +++ b/integration-tests/observation-reactive/pom.xml @@ -43,6 +43,10 @@ io.opentelemetry opentelemetry-sdk-testing + + io.quarkus + quarkus-test-opentelemetry + diff --git a/integration-tests/opentelemetry-grpc/pom.xml b/integration-tests/opentelemetry-grpc/pom.xml index e462475895ff6..e6072607ccf04 100644 --- a/integration-tests/opentelemetry-grpc/pom.xml +++ b/integration-tests/opentelemetry-grpc/pom.xml @@ -44,6 +44,10 @@ io.opentelemetry opentelemetry-sdk-testing + + io.quarkus + quarkus-test-opentelemetry + diff --git a/integration-tests/opentelemetry-jdbc-instrumentation/pom.xml b/integration-tests/opentelemetry-jdbc-instrumentation/pom.xml index afce8466580ed..918a43724b65b 100644 --- a/integration-tests/opentelemetry-jdbc-instrumentation/pom.xml +++ b/integration-tests/opentelemetry-jdbc-instrumentation/pom.xml @@ -30,6 +30,10 @@ io.opentelemetry opentelemetry-sdk-testing + + io.quarkus + quarkus-test-opentelemetry + diff --git a/integration-tests/opentelemetry-mongodb-client-instrumentation/pom.xml b/integration-tests/opentelemetry-mongodb-client-instrumentation/pom.xml index 3d7185f8e6b07..8a26eb2e793c5 100644 --- a/integration-tests/opentelemetry-mongodb-client-instrumentation/pom.xml +++ b/integration-tests/opentelemetry-mongodb-client-instrumentation/pom.xml @@ -35,6 +35,10 @@ io.opentelemetry opentelemetry-sdk-testing + + io.quarkus + quarkus-test-opentelemetry + diff --git a/integration-tests/opentelemetry-quartz/pom.xml b/integration-tests/opentelemetry-quartz/pom.xml index 95e5c4ebd86a8..8a59f49798171 100644 --- a/integration-tests/opentelemetry-quartz/pom.xml +++ b/integration-tests/opentelemetry-quartz/pom.xml @@ -39,6 +39,10 @@ io.opentelemetry opentelemetry-sdk-testing + + io.quarkus + quarkus-test-opentelemetry + diff --git a/integration-tests/opentelemetry-quickstart/pom.xml b/integration-tests/opentelemetry-quickstart/pom.xml index f2686e7607c63..8cb071720a9f3 100644 --- a/integration-tests/opentelemetry-quickstart/pom.xml +++ b/integration-tests/opentelemetry-quickstart/pom.xml @@ -37,6 +37,10 @@ io.opentelemetry opentelemetry-sdk-testing + + io.quarkus + quarkus-test-opentelemetry + diff --git a/integration-tests/opentelemetry-reactive-messaging/pom.xml b/integration-tests/opentelemetry-reactive-messaging/pom.xml index c47b0afd75090..7b34bd6bed9c4 100644 --- a/integration-tests/opentelemetry-reactive-messaging/pom.xml +++ b/integration-tests/opentelemetry-reactive-messaging/pom.xml @@ -40,6 +40,10 @@ io.opentelemetry opentelemetry-sdk-testing + + io.quarkus + quarkus-test-opentelemetry + diff --git a/integration-tests/opentelemetry-reactive/pom.xml b/integration-tests/opentelemetry-reactive/pom.xml index 0f7ce5de9e861..7918410a913cf 100644 --- a/integration-tests/opentelemetry-reactive/pom.xml +++ b/integration-tests/opentelemetry-reactive/pom.xml @@ -40,6 +40,10 @@ io.opentelemetry opentelemetry-sdk-testing + + io.quarkus + quarkus-test-opentelemetry + diff --git a/integration-tests/opentelemetry-redis-instrumentation/pom.xml b/integration-tests/opentelemetry-redis-instrumentation/pom.xml index e23c3070c6502..cc638273ac138 100644 --- a/integration-tests/opentelemetry-redis-instrumentation/pom.xml +++ b/integration-tests/opentelemetry-redis-instrumentation/pom.xml @@ -30,6 +30,10 @@ io.opentelemetry opentelemetry-sdk-testing + + io.quarkus + quarkus-test-opentelemetry + io.quarkus diff --git a/integration-tests/opentelemetry-scheduler/pom.xml b/integration-tests/opentelemetry-scheduler/pom.xml index c03813d3b3801..da98ceb51afd4 100644 --- a/integration-tests/opentelemetry-scheduler/pom.xml +++ b/integration-tests/opentelemetry-scheduler/pom.xml @@ -39,6 +39,10 @@ io.opentelemetry opentelemetry-sdk-testing + + io.quarkus + quarkus-test-opentelemetry + diff --git a/integration-tests/opentelemetry-spi/pom.xml b/integration-tests/opentelemetry-spi/pom.xml index 6a7cf6c0ad34d..e8913bfa51b3c 100644 --- a/integration-tests/opentelemetry-spi/pom.xml +++ b/integration-tests/opentelemetry-spi/pom.xml @@ -40,6 +40,10 @@ io.opentelemetry opentelemetry-sdk-testing + + io.quarkus + quarkus-test-opentelemetry + io.opentelemetry.contrib diff --git a/integration-tests/opentelemetry-vertx/pom.xml b/integration-tests/opentelemetry-vertx/pom.xml index eaf8f83dafac5..81c87acfc55dc 100644 --- a/integration-tests/opentelemetry-vertx/pom.xml +++ b/integration-tests/opentelemetry-vertx/pom.xml @@ -36,6 +36,10 @@ io.opentelemetry opentelemetry-sdk-testing + + io.quarkus + quarkus-test-opentelemetry + diff --git a/integration-tests/opentelemetry-vertx/src/main/java/io/quarkus/it/opentelemetry/vertx/ExporterRouter.java b/integration-tests/opentelemetry-vertx/src/main/java/io/quarkus/it/opentelemetry/vertx/ExporterRouter.java index b362633125459..1ffda97c33f9a 100644 --- a/integration-tests/opentelemetry-vertx/src/main/java/io/quarkus/it/opentelemetry/vertx/ExporterRouter.java +++ b/integration-tests/opentelemetry-vertx/src/main/java/io/quarkus/it/opentelemetry/vertx/ExporterRouter.java @@ -12,8 +12,8 @@ import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter; import io.opentelemetry.sdk.trace.data.SpanData; import io.quarkus.runtime.StartupEvent; -import io.vertx.core.json.Json; import io.vertx.ext.web.Router; +import tools.jackson.databind.ObjectMapper; @ApplicationScoped public class ExporterRouter { @@ -21,6 +21,10 @@ public class ExporterRouter { Router router; @Inject InMemorySpanExporter exporter; + // The managed mapper is customized by SpanAttributesJsonMapperCustomizer so that a SpanData's + // attributes are serialized as a flat {key: value} map (see that class for the why). + @Inject + ObjectMapper mapper; public void register(@Observes StartupEvent ev) { router.get("/reset").handler(rc -> { @@ -37,7 +41,7 @@ public void register(@Observes StartupEvent ev) { rc.response() .putHeader("content-type", "application/json; charset=utf-8") - .end(Json.encodePrettily(export)); + .end(mapper.writeValueAsString(export)); }); } diff --git a/integration-tests/opentelemetry/pom.xml b/integration-tests/opentelemetry/pom.xml index 9be3f1de4ae94..26dedd10eab5f 100644 --- a/integration-tests/opentelemetry/pom.xml +++ b/integration-tests/opentelemetry/pom.xml @@ -52,6 +52,10 @@ io.opentelemetry opentelemetry-sdk-testing + + io.quarkus + quarkus-test-opentelemetry + diff --git a/test-framework/opentelemetry/pom.xml b/test-framework/opentelemetry/pom.xml new file mode 100644 index 0000000000000..ba3f67c1bffd5 --- /dev/null +++ b/test-framework/opentelemetry/pom.xml @@ -0,0 +1,46 @@ + + + + io.quarkus + quarkus-test-framework + 999-SNAPSHOT + ../pom.xml + + + 4.0.0 + quarkus-test-opentelemetry + Quarkus - Test Framework - OpenTelemetry + Module that contains utilities to test Quarkus OpenTelemetry integrations + + + + io.quarkus + quarkus-jackson + + + io.opentelemetry + opentelemetry-api + + + + + + + + io.smallrye + jandex-maven-plugin + + + make-index + + jandex + + + + + + + + diff --git a/test-framework/opentelemetry/src/main/java/io/quarkus/test/opentelemetry/SpanAttributesJsonMapperCustomizer.java b/test-framework/opentelemetry/src/main/java/io/quarkus/test/opentelemetry/SpanAttributesJsonMapperCustomizer.java new file mode 100644 index 0000000000000..ee0ff5b0e0390 --- /dev/null +++ b/test-framework/opentelemetry/src/main/java/io/quarkus/test/opentelemetry/SpanAttributesJsonMapperCustomizer.java @@ -0,0 +1,41 @@ +package io.quarkus.test.opentelemetry; + +import java.util.Map; + +import jakarta.inject.Singleton; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.quarkus.jackson.JsonMapperBuilderCustomizer; +import tools.jackson.core.JsonGenerator; +import tools.jackson.databind.SerializationContext; +import tools.jackson.databind.ValueSerializer; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.module.SimpleModule; + +/** + * OTel SDK 1.65 changed AttributesMap so it no longer extends HashMap. As a result the default + * (reflection based) JSON serialization of a SpanData's attributes no longer emits the attribute + * entries, only bean getters like "empty" and "totalAddedValues". This customizer renders any + * OpenTelemetry {@link Attributes} instance as a flat {key: value} map so the exported spans keep + * exposing their attributes to the tests. + */ +@Singleton +public class SpanAttributesJsonMapperCustomizer implements JsonMapperBuilderCustomizer { + + @Override + public void customize(JsonMapper.Builder builder) { + SimpleModule module = new SimpleModule(); + module.addSerializer(Attributes.class, new ValueSerializer() { + @Override + public void serialize(Attributes attributes, JsonGenerator gen, SerializationContext ctxt) { + gen.writeStartObject(); + for (Map.Entry, Object> entry : attributes.asMap().entrySet()) { + gen.writePOJOProperty(entry.getKey().getKey(), entry.getValue()); + } + gen.writeEndObject(); + } + }); + builder.addModule(module); + } +} diff --git a/test-framework/pom.xml b/test-framework/pom.xml index b27e6b690f7c1..295addf6f0948 100644 --- a/test-framework/pom.xml +++ b/test-framework/pom.xml @@ -40,6 +40,7 @@ keycloak-server jacoco mongodb + opentelemetry kafka-companion google-cloud-functions observability From e046ddcaaac39f16aab1bf1a59c4fc350fac1f63 Mon Sep 17 00:00:00 2001 From: Guillaume Smet Date: Wed, 7 Oct 2026 10:49:49 +0200 Subject: [PATCH 075/254] Update Develocity extension to 1.4.0 --- .mvn/extensions.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.mvn/extensions.xml b/.mvn/extensions.xml index e75c83643befe..c7eed26696fb1 100644 --- a/.mvn/extensions.xml +++ b/.mvn/extensions.xml @@ -17,7 +17,7 @@ io.quarkus.develocity quarkus-project-develocity-extension - 1.3.1 + 1.4.0 eu.maveniverse.maven.scalpel From 640126ecce656008918cf7ebc3e89affae6b276c Mon Sep 17 00:00:00 2001 From: Jan Martiska Date: Wed, 7 Oct 2026 11:22:58 +0200 Subject: [PATCH 076/254] Update Elytron to 2.9.4 --- bom/application/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bom/application/pom.xml b/bom/application/pom.xml index 01af9089eff5d..1faa700c0a015 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -111,7 +111,7 @@ 2.0.2.Final 2.0.1 1.0.1.Final - 2.9.2.SP1 + 2.9.4.Final 2.3.0 3.10.1 5.1.8 From 77d67cd25ba2846f823a4f6c886e448679b95496 Mon Sep 17 00:00:00 2001 From: "David M. Lloyd" Date: Tue, 6 Oct 2026 16:30:26 -0500 Subject: [PATCH 077/254] Bug fix: Module name calculation can fail if the name does not have enough parts --- .../java/io/quarkus/maven/dependency/ResolvedDependency.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/maven/dependency/ResolvedDependency.java b/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/maven/dependency/ResolvedDependency.java index ed055753e461d..d24ba67345133 100644 --- a/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/maven/dependency/ResolvedDependency.java +++ b/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/maven/dependency/ResolvedDependency.java @@ -177,7 +177,8 @@ static String computeModuleName(ResolvedDependency dep) { if (groupParts.get(idx).equals(artifactParts.get(0))) { // slower check int overlap = groupSize - idx; - if (groupParts.subList(idx, groupSize).equals(artifactParts.subList(0, overlap))) { + if (overlap <= artifactParts.size() + && groupParts.subList(idx, groupSize).equals(artifactParts.subList(0, overlap))) { // cut out the overlapping parts from the artifact list artifactParts = artifactParts.subList(overlap, artifactParts.size()); break; From 09393ce53db00898d0c53fd79f99f8f820d23db5 Mon Sep 17 00:00:00 2001 From: "David M. Lloyd" Date: Tue, 10 Feb 2026 15:18:48 -0600 Subject: [PATCH 078/254] GraalVM dyanmic bootstraps registration This will allow Gizmo 2 users to generate optimized lambdas and other indy/condy-based optimizations. --- core/deployment/pom.xml | 4 + .../GeneratedClassGizmo2Adaptor.java | 72 ++++++-- .../ConstantBootstrapBuildItem.java | 169 ++++++++++++++++++ .../logging/LoggingResourceProcessor.java | 12 +- .../recording/AnnotationProxyProvider.java | 2 - .../steps/NativeImageFeatureStep.java | 136 +++++++++++++- .../deployment/steps/PreInitBuildStep.java | 8 +- .../InterceptedStaticMethodsProcessor.java | 8 +- .../deployment/CachedResultsProcessor.java | 2 - .../SbomNativeImageFeatureStep.java | 8 +- .../deployment/devui/GrpcDevUIProcessor.java | 4 +- .../bytecode/BytecodeProcessor.java | 8 +- ...onalPersistenceUnitExternalEntityTest.java | 4 +- .../HibernateValidatorProcessor.java | 6 +- .../jackson/deployment/JacksonProcessor.java | 2 - .../jsonb/deployment/JsonbProcessor.java | 4 +- .../jta/deployment/NarayanaJtaProcessor.java | 4 +- ...OidcGraphQLClientIntegrationProcessor.java | 4 +- .../deployment/OidcClientBuildStep.java | 4 +- .../OidcClientFilterDeploymentHelper.java | 4 +- .../AccessTokenRequestFilterGenerator.java | 4 +- .../quarkus/jlink/deployment/JLinkSteps.java | 4 +- .../deployment/MessageBundleProcessor.java | 12 +- .../qute/deployment/QuteProcessor.java | 4 +- .../deployment/ReactiveRoutesProcessor.java | 10 +- .../deployment/RestClientConfigUtils.java | 4 +- .../RestClientReactiveProcessor.java | 15 +- .../deployment/QuarkusInvokerFactory.java | 15 +- .../deployment/ResteasyReactiveProcessor.java | 9 +- .../deployment/SchedulerProcessor.java | 6 +- .../QuarkusSecurityJpaReactiveProcessor.java | 5 +- .../QuarkusSecurityJpaProcessor.java | 4 +- .../deployment/PermissionSecurityChecks.java | 7 +- .../deployment/SecurityProcessor.java | 4 +- .../signals/deployment/SignalsProcessor.java | 2 - .../SmallRyeFaultToleranceProcessor.java | 4 +- .../deployment/JacksonSerdeGenerator.java | 7 +- .../SmallRyeReactiveMessagingProcessor.java | 4 +- .../ClassConfigurationPropertiesUtil.java | 4 +- .../ConfigurationPropertiesBuildStep.java | 8 +- .../InterfaceConfigurationPropertiesUtil.java | 4 +- .../deployment/YamlListObjectHandler.java | 7 +- .../deployment/SpringDataJPAProcessor.java | 7 +- .../generate/CustomQueryMethodsAdder.java | 4 +- .../generate/DerivedMethodsAdder.java | 4 +- .../generate/SpringDataRepositoryCreator.java | 5 +- .../rest/deployment/ResourceImplementor.java | 3 +- .../BeanMethodInvocationGenerator.java | 3 +- .../StringPropertyAccessorGenerator.java | 3 +- .../AbstractExceptionMapperGenerator.java | 4 +- .../vertx/deployment/VertxProcessor.java | 10 +- .../next/deployment/WebSocketProcessor.java | 6 +- .../arc/processor/AbstractGenerator.java | 2 + .../qute/generator/AbstractGenerator.java | 2 - .../io/quarkus/test/BytecodeToolsTest.java | 7 +- 55 files changed, 483 insertions(+), 185 deletions(-) create mode 100644 core/deployment/src/main/java/io/quarkus/deployment/builditem/nativeimage/ConstantBootstrapBuildItem.java diff --git a/core/deployment/pom.xml b/core/deployment/pom.xml index cdd34446cb429..72590cc26fa01 100644 --- a/core/deployment/pom.xml +++ b/core/deployment/pom.xml @@ -182,6 +182,10 @@ org.junit.jupiter junit-jupiter + + io.quarkus + quarkus-modular-spi + org.bouncycastle bcpkix-jdk18on diff --git a/core/deployment/src/main/java/io/quarkus/deployment/GeneratedClassGizmo2Adaptor.java b/core/deployment/src/main/java/io/quarkus/deployment/GeneratedClassGizmo2Adaptor.java index ed4434080d0c6..c334c6413abdb 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/GeneratedClassGizmo2Adaptor.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/GeneratedClassGizmo2Adaptor.java @@ -1,5 +1,7 @@ package io.quarkus.deployment; +import java.lang.constant.ClassDesc; +import java.lang.constant.MethodTypeDesc; import java.nio.charset.StandardCharsets; import java.util.function.Function; import java.util.function.Predicate; @@ -9,6 +11,7 @@ import io.quarkus.deployment.builditem.GeneratedClassBuildItem; import io.quarkus.deployment.builditem.GeneratedResourceBuildItem; import io.quarkus.deployment.builditem.GeneratedServiceProviderBuildItem; +import io.quarkus.deployment.builditem.nativeimage.ConstantBootstrapBuildItem; import io.quarkus.gizmo2.ClassOutput; import io.smallrye.common.annotation.Experimental; @@ -18,64 +21,84 @@ public class GeneratedClassGizmo2Adaptor implements ClassOutput { private final BuildProducer generatedClasses; private final BuildProducer generatedResources; private final BuildProducer generatedServiceProviders; + private final BuildProducer constantBootstraps; private final Predicate applicationClassPredicate; public GeneratedClassGizmo2Adaptor(BuildProducer generatedClasses, - final BuildProducer generatedResources, + BuildProducer generatedResources, BuildProducer generatedServiceProviders, + BuildProducer constantBootstraps, boolean applicationClass) { - this.generatedClasses = generatedClasses; - this.generatedResources = generatedResources; - this.generatedServiceProviders = generatedServiceProviders; - this.applicationClassPredicate = ignored -> applicationClass; + this(generatedClasses, generatedResources, generatedServiceProviders, constantBootstraps, + (String ignored) -> applicationClass); } - @Deprecated + @Deprecated(since = "4.0") public GeneratedClassGizmo2Adaptor(BuildProducer generatedClasses, - final BuildProducer generatedResources, + BuildProducer generatedResources, boolean applicationClass) { this(generatedClasses, generatedResources, null, applicationClass); } + @Deprecated(since = "4.0") + public GeneratedClassGizmo2Adaptor(BuildProducer generatedClasses, + BuildProducer generatedResources, + BuildProducer generatedServiceProviders, + boolean applicationClass) { + this(generatedClasses, generatedResources, generatedServiceProviders, null, applicationClass); + } + public GeneratedClassGizmo2Adaptor(BuildProducer generatedClasses, BuildProducer generatedResources, BuildProducer generatedServiceProviders, + BuildProducer constantBootstraps, Predicate applicationClassPredicate) { this.generatedClasses = generatedClasses; this.generatedResources = generatedResources; this.generatedServiceProviders = generatedServiceProviders; + this.constantBootstraps = constantBootstraps; this.applicationClassPredicate = applicationClassPredicate; } - @Deprecated + @Deprecated(since = "4.0") public GeneratedClassGizmo2Adaptor(BuildProducer generatedClasses, final BuildProducer generatedResources, Predicate applicationClassPredicate) { this(generatedClasses, generatedResources, null, applicationClassPredicate); } + @Deprecated(since = "4.0") public GeneratedClassGizmo2Adaptor(BuildProducer generatedClasses, BuildProducer generatedResources, BuildProducer generatedServiceProviders, + Predicate applicationClassPredicate) { + this(generatedClasses, generatedResources, generatedServiceProviders, null, applicationClassPredicate); + } + + public GeneratedClassGizmo2Adaptor(BuildProducer generatedClasses, + BuildProducer generatedResources, + BuildProducer generatedServiceProviders, + BuildProducer constantBootstraps, Function generatedToBaseNameFunction) { - this.generatedClasses = generatedClasses; - this.generatedResources = generatedResources; - this.generatedServiceProviders = generatedServiceProviders; - this.applicationClassPredicate = new Predicate() { - @Override - public boolean test(String s) { - return isApplicationClass(generatedToBaseNameFunction.apply(s)); - } - }; + this(generatedClasses, generatedResources, generatedServiceProviders, constantBootstraps, + (String s) -> isApplicationClass(generatedToBaseNameFunction.apply(s))); } - @Deprecated + @Deprecated(since = "4.0") public GeneratedClassGizmo2Adaptor(BuildProducer generatedClasses, - final BuildProducer generatedResources, + BuildProducer generatedResources, Function generatedToBaseNameFunction) { this(generatedClasses, generatedResources, null, generatedToBaseNameFunction); } + @Deprecated(since = "4.0") + public GeneratedClassGizmo2Adaptor(BuildProducer generatedClasses, + BuildProducer generatedResources, + BuildProducer generatedServiceProviders, + Function generatedToBaseNameFunction) { + this(generatedClasses, generatedResources, generatedServiceProviders, null, generatedToBaseNameFunction); + } + @Override public void write(String resourceName, byte[] bytes) { if (resourceName.endsWith(".class")) { @@ -101,6 +124,17 @@ public void write(String resourceName, byte[] bytes) { } } + //@Override + public void registerBootstrapMethod(ClassDesc owner, String name, MethodTypeDesc type) { + if (constantBootstraps != null) { + constantBootstraps.produce(new ConstantBootstrapBuildItem(null, owner, name, type)); + } else { + throw new IllegalStateException("To use dynamic constants or indy from generated code at this site, " + + "you must provide a build producer for ConstantBootstrapBuildItem to the constructor " + + "of GeneratedClassGizmo2Adaptor"); + } + } + public static boolean isApplicationClass(String className) { return QuarkusClassLoader.isApplicationClass(className); } diff --git a/core/deployment/src/main/java/io/quarkus/deployment/builditem/nativeimage/ConstantBootstrapBuildItem.java b/core/deployment/src/main/java/io/quarkus/deployment/builditem/nativeimage/ConstantBootstrapBuildItem.java new file mode 100644 index 0000000000000..a50c880f283b1 --- /dev/null +++ b/core/deployment/src/main/java/io/quarkus/deployment/builditem/nativeimage/ConstantBootstrapBuildItem.java @@ -0,0 +1,169 @@ +package io.quarkus.deployment.builditem.nativeimage; + +import static java.lang.constant.ConstantDescs.CD_Class; +import static java.lang.constant.ConstantDescs.CD_MethodHandles_Lookup; +import static java.lang.constant.ConstantDescs.CD_MethodType; +import static java.lang.constant.ConstantDescs.CD_String; + +import java.lang.constant.ClassDesc; +import java.lang.constant.DirectMethodHandleDesc; +import java.lang.constant.MethodTypeDesc; +import java.util.Objects; +import java.util.Set; + +import io.quarkus.builder.item.MultiBuildItem; +import io.smallrye.common.constraint.Assert; + +/** + * A constant bootstrap method which needs to be registered with GraalVM. + * Constant bootstrap methods are {@code static} methods which are used to initialize + * a dynamic constant or an {@code invokedynamic} call site. + * The method must define at least these parameters, in this order: + *
    + *
  • {@code MethodHandles.Lookup}
  • + *
  • {@code String} (the constant name)
  • + *
  • For dynamic constants, {@code Class} (the constant type); for {@code invokedynamic} call sites, {@code MethodType} (the + * call site method type); for bootstraps that can be used either way, the type should be + * {@code java.lang.invoke.TypeDescriptor}
  • + *
+ * Furthermore, the return type of dynamic constant bootstraps must match the {@code Class} that is passed in, + * whereas the return type of {@code invokedynamic} bootstraps must be {@code CallSite} or a subclass thereof. + *

+ * The constructor of this item will validate as many of these constraints as possible. + */ +public final class ConstantBootstrapBuildItem extends MultiBuildItem { + private static final Set validArg2Types = Set.of( + CD_Class.descriptorString(), + CD_MethodType.descriptorString(), + ClassDesc.of("java.lang.invoke.TypeDescriptor").descriptorString()); + + private final String moduleName; + private final ClassDesc classDesc; + private String className; + private final String methodName; + private final MethodTypeDesc methodTypeDesc; + private final int hashCode; + + /** + * Construct a new instance. + * + * @param moduleName the module name of the type containing the method (must not be {@code null}) + * @param classDesc the descriptor of the type containing the method (must not be {@code null}) + * @param methodName the name of the bootstrap method (must not be {@code null}) + * @param methodTypeDesc the descriptor of the type of the bootstrap method (must not be {@code null}) + */ + public ConstantBootstrapBuildItem(final String moduleName, final ClassDesc classDesc, final String methodName, + final MethodTypeDesc methodTypeDesc) { + // todo: null check + this.moduleName = moduleName; + this.classDesc = Assert.checkNotNullParam("classDesc", classDesc); + if (!classDesc.isClassOrInterface()) { + throw new IllegalArgumentException("classDesc must refer to a class or interface"); + } + this.methodName = Assert.checkNotEmptyParam("methodName", Assert.checkNotNullParam("methodName", methodName)); + this.methodTypeDesc = Assert.checkNotNullParam("methodTypeDesc", methodTypeDesc); + int pc = methodTypeDesc.parameterCount(); + if (pc < 3) { + throw new IllegalArgumentException("Bootstrap methods have at least 3 parameters"); + } + if (!methodTypeDesc.parameterType(0).descriptorString().equals(CD_MethodHandles_Lookup.descriptorString())) { + throw new IllegalArgumentException( + "Bootstrap method must have class MethodHandles.Lookup as the first parameter type"); + } + if (!methodTypeDesc.parameterType(1).descriptorString().equals(CD_String.descriptorString())) { + throw new IllegalArgumentException("Bootstrap method must have class String as the second parameter type"); + } + if (!validArg2Types.contains(methodTypeDesc.parameterType(2).descriptorString())) { + throw new IllegalArgumentException( + "Bootstrap method must have a valid constant type descriptor type class as the third parameter type"); + } + hashCode = Objects.hash(moduleName, classDesc.descriptorString(), methodName, methodTypeDesc.descriptorString()); + } + + /** + * Construct a new instance. + * + * @param moduleName the module name of the type containing the method (must not be {@code null}) + * @param desc the descriptor of the bootstrap method (must not be {@code null}) + */ + public ConstantBootstrapBuildItem(final String moduleName, final DirectMethodHandleDesc desc) { + this(moduleName, Assert.checkNotNullParam("desc", desc).owner(), desc.methodName(), desc.invocationType()); + } + + /** + * {@return the module name (not {@code null})} + */ + public String moduleName() { + return moduleName; + } + + /** + * {@return the owner's class descriptor (not {@code null})} + */ + public ClassDesc classDesc() { + return classDesc; + } + + /** + * {@return the owner's class name (not {@code null})} + */ + public String className() { + String className = this.className; + if (className == null) { + String ds = classDesc.descriptorString(); + className = this.className = ds.substring(1, ds.length() - 1).replace('/', '.'); + } + return className; + } + + /** + * {@return the bootstrap method name (not {@code null})} + */ + public String methodName() { + return methodName; + } + + /** + * {@return the bootstrap method type descriptor (not {@code null})} + */ + public MethodTypeDesc methodTypeDesc() { + return methodTypeDesc; + } + + /** + * {@return {@code true} if this item is equal to the given object, or {@code false} if it is not} + * + * @param obj the other object + */ + public boolean equals(final Object obj) { + return obj instanceof ConstantBootstrapBuildItem other && equals(other); + } + + /** + * {@return {@code true} if this item is equal to the given item, or {@code false} if it is not} + * + * @param other the other item + */ + public boolean equals(final ConstantBootstrapBuildItem other) { + return this == other || other != null && hashCode == other.hashCode + && moduleName.equals(other.moduleName) + && classDesc.descriptorString().equals(other.classDesc.descriptorString()) + && methodName.equals(other.methodName) + && methodTypeDesc.descriptorString().equals(other.methodTypeDesc.descriptorString()); + } + + /** + * {@return the object hash code} + */ + public int hashCode() { + return hashCode; + } + + /** + * {@return a human-readable representation of this object (not {@code null})} + */ + public String toString() { + return "ConstantBootstrapBuildItem[" + moduleName + "/" + className() + ":" + methodName() + + methodTypeDesc.descriptorString() + "]"; + } +} diff --git a/core/deployment/src/main/java/io/quarkus/deployment/logging/LoggingResourceProcessor.java b/core/deployment/src/main/java/io/quarkus/deployment/logging/LoggingResourceProcessor.java index e242a0cf34009..1a725c25f4b2c 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/logging/LoggingResourceProcessor.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/logging/LoggingResourceProcessor.java @@ -91,6 +91,7 @@ import io.quarkus.deployment.builditem.ServiceStartBuildItem; import io.quarkus.deployment.builditem.StreamingLogHandlerBuildItem; import io.quarkus.deployment.builditem.SystemPropertyBuildItem; +import io.quarkus.deployment.builditem.nativeimage.ConstantBootstrapBuildItem; import io.quarkus.deployment.builditem.nativeimage.NativeImageSystemPropertyBuildItem; import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; import io.quarkus.deployment.builditem.nativeimage.RuntimeInitializedClassBuildItem; @@ -113,7 +114,6 @@ import io.quarkus.gizmo2.Const; import io.quarkus.gizmo2.Expr; import io.quarkus.gizmo2.Gizmo; -import io.quarkus.gizmo2.LambdaStrategy; import io.quarkus.gizmo2.ParamVar; import io.quarkus.gizmo2.This; import io.quarkus.gizmo2.Var; @@ -661,8 +661,10 @@ void setUpMinLevelLogging(LogBuildTimeConfig log, LogCategoryMinLevelDefaultsBuildItem categoryMinLevelDefaults, final BuildProducer gcProducer, final BuildProducer grProducer, - BuildProducer generatedServiceProviders) { - ClassOutput output = new GeneratedClassGizmo2Adaptor(gcProducer, grProducer, generatedServiceProviders, false); + final BuildProducer generatedServiceProviders, + final BuildProducer constantBootstraps) { + ClassOutput output = new GeneratedClassGizmo2Adaptor(gcProducer, grProducer, generatedServiceProviders, + constantBootstraps, false); generateDefaultLoggerNode(output); if (allRootMinLevelOrHigher(log.minLevel().intValue(), log.categories(), categoryMinLevelDefaults.content)) { Level minLevel = log.minLevel(); @@ -714,7 +716,6 @@ private static void generateMinLevelCompute(Map Map categoryMinLevelDefaults, Level rootMinLevel, ClassOutput output) { Gizmo g = Gizmo.create(output) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS) .withDebugInfo(false) .withParameters(false); g.class_(MIN_LEVEL_COMPUTE_CLASS_NAME, cc -> { @@ -745,7 +746,6 @@ private static void generateMinLevelCompute(Map private static void generateDefaultLoggerNode(ClassOutput output) { Gizmo g = Gizmo.create(output) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS) .withDebugInfo(false) .withParameters(false); g.class_(LOGGER_NODE_CLASS_NAME, cc -> { @@ -767,7 +767,6 @@ interface MinLevelEnabledFunction { private static void generateLogManagerLogger(ClassOutput output, MinLevelEnabledFunction isMinLevelEnabledFunction) { Gizmo gizmo = Gizmo.create(output) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS) .withDebugInfo(false) .withParameters(false); gizmo.class_(LOGMANAGER_LOGGER_CLASS_NAME, cc -> { @@ -809,7 +808,6 @@ private static Expr getLogManagerLevelIntValue(String levelName, BlockCreator b0 private static void generateDefaultLoggingLogger(Level minLevel, ClassOutput output) { Gizmo gizmo = Gizmo.create(output) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS) .withDebugInfo(false) .withParameters(false); gizmo.class_(LOGGING_LOGGER_CLASS_NAME, cc -> { diff --git a/core/deployment/src/main/java/io/quarkus/deployment/recording/AnnotationProxyProvider.java b/core/deployment/src/main/java/io/quarkus/deployment/recording/AnnotationProxyProvider.java index 3ab3d8015d532..c6bc88803c230 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/recording/AnnotationProxyProvider.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/recording/AnnotationProxyProvider.java @@ -27,7 +27,6 @@ import io.quarkus.deployment.util.IoUtil; import io.quarkus.gizmo2.GenericType; import io.quarkus.gizmo2.Gizmo; -import io.quarkus.gizmo2.LambdaStrategy; import io.quarkus.gizmo2.ParamVar; import io.quarkus.gizmo2.TypeArgument; import io.quarkus.gizmo2.desc.ConstructorDesc; @@ -180,7 +179,6 @@ public Object invoke(Object proxy, Method method, Object[] args) throws Throwabl public A build(io.quarkus.gizmo2.ClassOutput classOutput) { generatedLiterals.computeIfAbsent(annotationLiteral, generatedName -> { Gizmo gizmo = Gizmo.create(classOutput) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS) .withDebugInfo(false) .withParameters(false); gizmo.class_(generatedName, cc -> { diff --git a/core/deployment/src/main/java/io/quarkus/deployment/steps/NativeImageFeatureStep.java b/core/deployment/src/main/java/io/quarkus/deployment/steps/NativeImageFeatureStep.java index b0ff2efc8cc2b..d75534483d6c6 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/steps/NativeImageFeatureStep.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/steps/NativeImageFeatureStep.java @@ -3,7 +3,14 @@ import static io.quarkus.gizmo2.desc.Descs.MD_Class; import static io.quarkus.gizmo2.desc.Descs.MD_Collection; import static io.quarkus.gizmo2.desc.Descs.MD_Thread; +import static java.lang.constant.ConstantDescs.CD_Class; +import static java.lang.constant.ConstantDescs.CD_MethodType; +import static java.lang.constant.ConstantDescs.CD_String; +import static java.lang.constant.ConstantDescs.CD_int; +import java.lang.constant.ClassDesc; +import java.lang.constant.ConstantDescs; +import java.lang.constant.MethodTypeDesc; import java.lang.reflect.Field; import java.util.ArrayList; import java.util.List; @@ -13,28 +20,37 @@ import org.graalvm.nativeimage.hosted.Feature; import org.graalvm.nativeimage.hosted.RuntimeClassInitialization; import org.graalvm.nativeimage.hosted.RuntimeSystemProperties; +import org.jboss.logging.Logger; import io.quarkus.deployment.GeneratedClassGizmo2Adaptor; import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; import io.quarkus.deployment.builditem.GeneratedNativeImageClassBuildItem; +import io.quarkus.deployment.builditem.ModuleExportBuildItem; +import io.quarkus.deployment.builditem.nativeimage.ConstantBootstrapBuildItem; +import io.quarkus.deployment.builditem.nativeimage.JPMSExportBuildItem; import io.quarkus.deployment.builditem.nativeimage.RuntimeInitializedClassBuildItem; import io.quarkus.deployment.builditem.nativeimage.RuntimeInitializedPackageBuildItem; import io.quarkus.deployment.builditem.nativeimage.RuntimeReinitializedClassBuildItem; import io.quarkus.deployment.builditem.nativeimage.UnsafeAccessedFieldBuildItem; import io.quarkus.deployment.pkg.NativeConfig; +import io.quarkus.deployment.pkg.builditem.CurateOutcomeBuildItem; import io.quarkus.gizmo2.Const; import io.quarkus.gizmo2.Expr; import io.quarkus.gizmo2.Gizmo; -import io.quarkus.gizmo2.LambdaStrategy; import io.quarkus.gizmo2.LocalVar; import io.quarkus.gizmo2.ParamVar; +import io.quarkus.gizmo2.desc.ClassMethodDesc; import io.quarkus.gizmo2.desc.MethodDesc; +import io.quarkus.modular.spi.items.AddDependencyBuildItem; import io.quarkus.runtime.LocalesBuildTimeConfig; import io.quarkus.runtime.graal.GraalVM; +import io.smallrye.modules.desc.Dependency; public class NativeImageFeatureStep { + private static final Logger log = Logger.getLogger(NativeImageFeatureStep.class); + public static final String GRAAL_FEATURE = "io.quarkus.runner.Feature"; private static final MethodDesc BUILD_TIME_INITIALIZATION = MethodDesc.of(RuntimeClassInitialization.class, @@ -57,12 +73,73 @@ public class NativeImageFeatureStep { private static final MethodDesc GET_CONTEXT_CLASS_LOADER = MethodDesc.of(Thread.class, "getContextClassLoader", ClassLoader.class); + private static final ClassDesc CD_FeatureImpl_DuringSetupAccessImpl = ClassDesc + .of("com.oracle.svm.hosted.FeatureImpl$DuringSetupAccessImpl"); + private static final ClassDesc CD_Executable = ClassDesc.of("java.lang.reflect.Executable"); + private static final ClassDesc CD_Method = ClassDesc.of("java.lang.reflect.Method"); + private static final ClassDesc CD_TypeDescriptor = ClassDesc.of("java.lang.invoke.TypeDescriptor"); + + private static final MethodDesc MD_Class_getDeclaredMethod = ClassMethodDesc.of(CD_Class, "getDeclaredMethod", CD_Method, + CD_String, CD_Class.arrayType()); + private static final ClassMethodDesc MD_FeatureImpl_DuringSetupAccessImpl_registerBuildTimeCondyIncludeList = ClassMethodDesc + .of(CD_FeatureImpl_DuringSetupAccessImpl, "registerBuildTimeCondyIncludeList", ConstantDescs.CD_void, + CD_Executable); + private static final ClassMethodDesc MD_FeatureImpl_DuringSetupAccessImpl_registerBuildTimeIndyIncludeList = ClassMethodDesc + .of(CD_FeatureImpl_DuringSetupAccessImpl, "registerBuildTimeIndyIncludeList", ConstantDescs.CD_void, CD_Executable); + + /** + * Exports the {@code com.oracle.svm.hosted} package from the GraalVM native image builder module + * so that the generated feature class can access {@code FeatureImpl.DuringSetupAccessImpl} for + * registering constant bootstrap methods. + * + * @return the JPMS export item + */ + @BuildStep + JPMSExportBuildItem constantBootstrapJpmsExport() { + return new JPMSExportBuildItem("org.graalvm.nativeimage.builder", "com.oracle.svm.hosted"); + } + + /** + * Exports the {@code com.oracle.svm.hosted} package from the GraalVM native image builder module + * to the application module so that the generated feature class can access + * {@code FeatureImpl.DuringSetupAccessImpl} for registering constant bootstrap methods. + * + * @param curateOutcomeBuildItem the application model + * @return the module export item + */ + @BuildStep + ModuleExportBuildItem constantBootstrapModuleExport( + CurateOutcomeBuildItem curateOutcomeBuildItem) { + String appModuleName = curateOutcomeBuildItem.getApplicationModel().getAppArtifact().getModuleName(); + return new ModuleExportBuildItem("org.graalvm.nativeimage.builder", + appModuleName, + "com.oracle.svm.hosted"); + } + + /** + * Adds a module dependency from the application module to the GraalVM native image builder module + * so that the generated feature class can access {@code FeatureImpl.DuringSetupAccessImpl} for + * registering constant bootstrap methods. + * + * @param curateOutcomeBuildItem the application model + * @return the module dependency item + */ + @BuildStep + AddDependencyBuildItem constantBootstrapModuleDependency( + CurateOutcomeBuildItem curateOutcomeBuildItem) { + String appModuleName = curateOutcomeBuildItem.getApplicationModel().getAppArtifact().getModuleName(); + return new AddDependencyBuildItem(appModuleName, "org.graalvm.nativeimage.builder", + Dependency.Modifier.Set.of(Dependency.Modifier.READ, Dependency.Modifier.LINKED, + Dependency.Modifier.OPTIONAL)); + } + @BuildStep void generateFeature(BuildProducer nativeImageClass, List runtimeInitializedClassBuildItems, List runtimeInitializedPackageBuildItems, List runtimeReinitializedClassBuildItems, List unsafeAccessedFields, + List constantBootstrapBuildItems, NativeConfig nativeConfig, LocalesBuildTimeConfig localesBuildTimeConfig) { @@ -71,9 +148,11 @@ void generateFeature(BuildProducer nativeIma .produce(new GeneratedNativeImageClassBuildItem(item.binaryName(), item.getClassData())), item -> { }, - null, - false)) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS); + item -> { + }, + item -> { + }, + false)); g.class_(GRAAL_FEATURE, cc -> { cc.implements_(Feature.class); @@ -86,9 +165,56 @@ void generateFeature(BuildProducer nativeIma }); cc.method("duringSetup", mc -> { - mc.parameter("access", Feature.DuringSetupAccess.class); + ParamVar access = mc.parameter("access", Feature.DuringSetupAccess.class); mc.body(b0 -> { b0.invokeStatic(BUILD_TIME_INITIALIZATION, b0.newArray(String.class, Const.of(""))); + + if (!constantBootstrapBuildItems.isEmpty()) { + LocalVar graalVMVersion = b0.localVar("graalVMVersion", + b0.invokeStatic(GRAALVM_VERSION_GET_CURRENT)); + /* GraalVM >= 25.0.4; see https://github.com/oracle/graal/issues/12453 */ + b0.if_(b0.ge( + b0.invokeVirtual(GRAALVM_VERSION_COMPARE_TO, + graalVMVersion, + b0.newArray(CD_int, Const.of(25), Const.of(0), Const.of(4))), + 0), b1 -> { + // in order to register constant bootstraps, we must call a method on DuringSetupAccessImpl for each one + LocalVar accessImpl = b1.localVar("accessImpl", + b1.cast(access, CD_FeatureImpl_DuringSetupAccessImpl)); + for (ConstantBootstrapBuildItem cb : constantBootstrapBuildItems) { + MethodTypeDesc mtd = cb.methodTypeDesc(); + ClassDesc typeParamType = mtd.parameterType(2); + boolean indy = false; + boolean condy = false; + if (typeParamType.descriptorString().equals(CD_Class.descriptorString())) { + condy = true; + } else if (typeParamType.descriptorString().equals(CD_MethodType.descriptorString())) { + indy = true; + } else if (typeParamType.descriptorString() + .equals(CD_TypeDescriptor.descriptorString())) { + // it can be used as either type + indy = condy = true; + } else { + log.warnf("Encountered an invalid constant bootstrap build item descriptor: %s", + cb.methodTypeDesc().descriptorString()); + } + LocalVar declaredMethod = b1.localVar("declaredMethod", b1.invokeVirtual( + MD_Class_getDeclaredMethod, Const.of(cb.classDesc()), Const.of(cb.methodName()), + b1.newArray(Class.class, + mtd.parameterList().stream().map(Const::of).toList()))); + if (condy) { + b1.invokeVirtual( + MD_FeatureImpl_DuringSetupAccessImpl_registerBuildTimeCondyIncludeList, + accessImpl, declaredMethod); + } + if (indy) { + b1.invokeVirtual( + MD_FeatureImpl_DuringSetupAccessImpl_registerBuildTimeIndyIncludeList, + accessImpl, declaredMethod); + } + } + }); + } b0.return_(); }); }); diff --git a/core/deployment/src/main/java/io/quarkus/deployment/steps/PreInitBuildStep.java b/core/deployment/src/main/java/io/quarkus/deployment/steps/PreInitBuildStep.java index f068c44d16e8a..6faaf601169d0 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/steps/PreInitBuildStep.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/steps/PreInitBuildStep.java @@ -14,13 +14,13 @@ import io.quarkus.deployment.builditem.GeneratedServiceProviderBuildItem; import io.quarkus.deployment.builditem.PreInitBuildItem; import io.quarkus.deployment.builditem.PreInitRunnableBuildItem; +import io.quarkus.deployment.builditem.nativeimage.ConstantBootstrapBuildItem; import io.quarkus.deployment.pkg.AotJarEnabled; import io.quarkus.gizmo.MethodDescriptor; import io.quarkus.gizmo2.ClassOutput; import io.quarkus.gizmo2.Const; import io.quarkus.gizmo2.Expr; import io.quarkus.gizmo2.Gizmo; -import io.quarkus.gizmo2.LambdaStrategy; import io.quarkus.gizmo2.desc.ClassMethodDesc; import io.quarkus.gizmo2.desc.ConstructorDesc; import io.quarkus.gizmo2.desc.MethodDesc; @@ -43,11 +43,11 @@ public class PreInitBuildStep { PreInitBuildItem executePreInitTasks(List preInitRunnables, BuildProducer generatedClasses, BuildProducer generatedResources, - BuildProducer generatedServiceProviders) { + BuildProducer generatedServiceProviders, + BuildProducer constantBootstraps) { ClassOutput output = new GeneratedClassGizmo2Adaptor(generatedClasses, generatedResources, generatedServiceProviders, - false); + constantBootstraps, false); Gizmo gizmo = Gizmo.create(output) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS) .withDebugInfo(false) .withParameters(false); diff --git a/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/staticmethods/InterceptedStaticMethodsProcessor.java b/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/staticmethods/InterceptedStaticMethodsProcessor.java index 6b1d66e1bb6db..e4929e0e9049c 100644 --- a/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/staticmethods/InterceptedStaticMethodsProcessor.java +++ b/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/staticmethods/InterceptedStaticMethodsProcessor.java @@ -64,6 +64,7 @@ import io.quarkus.deployment.builditem.GeneratedClassBuildItem; import io.quarkus.deployment.builditem.GeneratedResourceBuildItem; import io.quarkus.deployment.builditem.GeneratedServiceProviderBuildItem; +import io.quarkus.deployment.builditem.nativeimage.ConstantBootstrapBuildItem; import io.quarkus.deployment.builditem.nativeimage.ReflectiveMethodBuildItem; import io.quarkus.gizmo.AnnotatedElement; import io.quarkus.gizmo.ClassTransformer; @@ -74,7 +75,6 @@ import io.quarkus.gizmo2.Const; import io.quarkus.gizmo2.Expr; import io.quarkus.gizmo2.Gizmo; -import io.quarkus.gizmo2.LambdaStrategy; import io.quarkus.gizmo2.LocalVar; import io.quarkus.gizmo2.ParamVar; import io.quarkus.gizmo2.Reflection2Gizmo; @@ -187,6 +187,7 @@ void processInterceptedStaticMethods(BeanArchiveIndexBuildItem beanArchiveIndex, BuildProducer generatedClasses, BuildProducer generatedResources, BuildProducer generatedServiceProviders, + BuildProducer constantBootstraps, BuildProducer transformers, BuildProducer reflectiveMethods) { @@ -198,7 +199,7 @@ void processInterceptedStaticMethods(BeanArchiveIndexBuildItem beanArchiveIndex, Map baseToGeneratedInitializer = new HashMap<>(); ClassOutput classOutput = new GeneratedClassGizmo2Adaptor(generatedClasses, generatedResources, - generatedServiceProviders, new Predicate() { + generatedServiceProviders, constantBootstraps, new Predicate() { @Override public boolean test(String name) { if (INITIALIZER_CLASS_NAME.equals(name)) { @@ -219,8 +220,7 @@ public boolean test(String name) { return applicationClassPredicate.test(base); } }); - Gizmo gizmo = Gizmo.create(classOutput) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS); + Gizmo gizmo = Gizmo.create(classOutput); // declaring class -> intercepted static methods Map> interceptedStaticMethodsMap = new HashMap<>(); diff --git a/extensions/cache/deployment/src/main/java/io/quarkus/cache/deployment/CachedResultsProcessor.java b/extensions/cache/deployment/src/main/java/io/quarkus/cache/deployment/CachedResultsProcessor.java index 8fa0687d89b5f..9a1ba3586af2a 100644 --- a/extensions/cache/deployment/src/main/java/io/quarkus/cache/deployment/CachedResultsProcessor.java +++ b/extensions/cache/deployment/src/main/java/io/quarkus/cache/deployment/CachedResultsProcessor.java @@ -47,7 +47,6 @@ import io.quarkus.gizmo2.ClassOutput; import io.quarkus.gizmo2.Expr; import io.quarkus.gizmo2.Gizmo; -import io.quarkus.gizmo2.LambdaStrategy; import io.quarkus.gizmo2.ParamVar; import io.quarkus.gizmo2.desc.FieldDesc; import io.quarkus.gizmo2.desc.InterfaceMethodDesc; @@ -85,7 +84,6 @@ void generateWrapperBeans(CombinedIndexBuildItem index, BuildProducer diffs) { ClassOutput classOutput = new GeneratedBeanGizmo2Adaptor(generatedBeans); Gizmo gizmo = Gizmo.create(classOutput) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS) .withDebugInfo(false) .withParameters(false); // Generate a wrapper bean for each @CachedResults config diff --git a/extensions/cyclonedx/deployment/src/main/java/io/quarkus/cyclonedx/deployment/SbomNativeImageFeatureStep.java b/extensions/cyclonedx/deployment/src/main/java/io/quarkus/cyclonedx/deployment/SbomNativeImageFeatureStep.java index d80e85738eaeb..d0b505fae8021 100644 --- a/extensions/cyclonedx/deployment/src/main/java/io/quarkus/cyclonedx/deployment/SbomNativeImageFeatureStep.java +++ b/extensions/cyclonedx/deployment/src/main/java/io/quarkus/cyclonedx/deployment/SbomNativeImageFeatureStep.java @@ -20,7 +20,6 @@ import io.quarkus.gizmo2.Const; import io.quarkus.gizmo2.Expr; import io.quarkus.gizmo2.Gizmo; -import io.quarkus.gizmo2.LambdaStrategy; import io.quarkus.gizmo2.LocalVar; import io.quarkus.gizmo2.desc.ClassMethodDesc; import io.quarkus.gizmo2.desc.ConstructorDesc; @@ -128,8 +127,11 @@ void generateSbomEmbedFeature( .produce(new GeneratedNativeImageClassBuildItem(item.binaryName(), item.getClassData())), item -> { }, - false)) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS); + item -> { + }, + item -> { + }, + false)); g.class_(SBOM_EMBED_FEATURE, cc -> { cc.implements_(Feature.class); diff --git a/extensions/grpc/deployment/src/main/java/io/quarkus/grpc/deployment/devui/GrpcDevUIProcessor.java b/extensions/grpc/deployment/src/main/java/io/quarkus/grpc/deployment/devui/GrpcDevUIProcessor.java index fc8cfe75f1d28..ea104cec7c580 100644 --- a/extensions/grpc/deployment/src/main/java/io/quarkus/grpc/deployment/devui/GrpcDevUIProcessor.java +++ b/extensions/grpc/deployment/src/main/java/io/quarkus/grpc/deployment/devui/GrpcDevUIProcessor.java @@ -45,7 +45,6 @@ import io.quarkus.devui.spi.page.Page; import io.quarkus.gizmo2.Const; import io.quarkus.gizmo2.Gizmo; -import io.quarkus.gizmo2.LambdaStrategy; import io.quarkus.gizmo2.desc.ConstructorDesc; import io.quarkus.gizmo2.desc.MethodDesc; import io.quarkus.grpc.deployment.DelegatingGrpcBeanBuildItem; @@ -73,8 +72,7 @@ void prepareDelegatingBeanStorage( BuildProducer unremovableBeans, BuildProducer generatedBeans) { String className = "io.quarkus.grpc.internal.DelegatingGrpcBeansStorageImpl"; - Gizmo gizmo = Gizmo.create(new GeneratedBeanGizmo2Adaptor(generatedBeans)) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS); + Gizmo gizmo = Gizmo.create(new GeneratedBeanGizmo2Adaptor(generatedBeans)); gizmo.class_(className, cc -> { cc.extends_(DelegatingGrpcBeansStorage.class); cc.addAnnotation(Singleton.class); diff --git a/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/bytecode/BytecodeProcessor.java b/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/bytecode/BytecodeProcessor.java index 30921c8ed55ed..6b01cdf339a89 100644 --- a/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/bytecode/BytecodeProcessor.java +++ b/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/bytecode/BytecodeProcessor.java @@ -34,13 +34,13 @@ import io.quarkus.deployment.builditem.GeneratedServiceProviderBuildItem; import io.quarkus.deployment.builditem.LiveReloadBuildItem; import io.quarkus.deployment.builditem.TransformedClassesBuildItem; +import io.quarkus.deployment.builditem.nativeimage.ConstantBootstrapBuildItem; import io.quarkus.deployment.builditem.nativeimage.NativeImageProxyDefinitionBuildItem; import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; import io.quarkus.deployment.pkg.AotJarEnabled; import io.quarkus.deployment.pkg.steps.NativeOrNativeSourcesBuild; import io.quarkus.deployment.util.IoUtil; import io.quarkus.gizmo2.Gizmo; -import io.quarkus.gizmo2.LambdaStrategy; import io.quarkus.hibernate.orm.deployment.ClassNames; import io.quarkus.hibernate.orm.deployment.HibernateOrmEnabled; import io.quarkus.hibernate.orm.deployment.JpaModelBuildItem; @@ -184,7 +184,8 @@ void generateMissingPackageInfos(CombinedIndexBuildItem combinedIndex, List predicates, BuildProducer generatedClasses, BuildProducer generatedResources, - BuildProducer generatedServiceProviders) { + BuildProducer generatedServiceProviders, + BuildProducer constantBootstraps) { IndexView index = combinedIndex.getIndex(); @@ -214,8 +215,7 @@ public boolean test(String className) { Gizmo gizmo = Gizmo .create(new GeneratedClassGizmo2Adaptor(generatedClasses, generatedResources, generatedServiceProviders, - appClassPredicate)) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS) + constantBootstraps, appClassPredicate)) .withDebugInfo(false) .withParameters(false); diff --git a/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/spi/AdditionalPersistenceUnitExternalEntityTest.java b/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/spi/AdditionalPersistenceUnitExternalEntityTest.java index c73aa360fa293..aa45b5135b4cd 100644 --- a/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/spi/AdditionalPersistenceUnitExternalEntityTest.java +++ b/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/spi/AdditionalPersistenceUnitExternalEntityTest.java @@ -23,7 +23,6 @@ import io.quarkus.builder.BuildContext; import io.quarkus.builder.BuildStep; import io.quarkus.gizmo2.Gizmo; -import io.quarkus.gizmo2.LambdaStrategy; import io.quarkus.gizmo2.ParamVar; import io.quarkus.gizmo2.desc.ConstructorDesc; import io.quarkus.gizmo2.desc.FieldDesc; @@ -102,8 +101,7 @@ private static byte[] generateEntityBytecode() { if (path.endsWith(".class")) { result[0] = bytes; } - }) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS); + }); g.class_(EXTERNAL_ENTITY_CLASS_NAME, cc -> { cc.public_(); cc.addAnnotation(Entity.class); diff --git a/extensions/hibernate-validator/deployment/src/main/java/io/quarkus/hibernate/validator/deployment/HibernateValidatorProcessor.java b/extensions/hibernate-validator/deployment/src/main/java/io/quarkus/hibernate/validator/deployment/HibernateValidatorProcessor.java index 90f598c50f0e7..5f777f2c8242b 100644 --- a/extensions/hibernate-validator/deployment/src/main/java/io/quarkus/hibernate/validator/deployment/HibernateValidatorProcessor.java +++ b/extensions/hibernate-validator/deployment/src/main/java/io/quarkus/hibernate/validator/deployment/HibernateValidatorProcessor.java @@ -97,6 +97,7 @@ import io.quarkus.deployment.builditem.RunTimeConfigBuilderBuildItem; import io.quarkus.deployment.builditem.ShutdownContextBuildItem; import io.quarkus.deployment.builditem.StaticInitConfigBuilderBuildItem; +import io.quarkus.deployment.builditem.nativeimage.ConstantBootstrapBuildItem; import io.quarkus.deployment.builditem.nativeimage.NativeImageResourceBundleBuildItem; import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; import io.quarkus.deployment.builditem.nativeimage.ReflectiveFieldBuildItem; @@ -108,7 +109,6 @@ import io.quarkus.deployment.util.AsmUtil; import io.quarkus.gizmo2.Const; import io.quarkus.gizmo2.Gizmo; -import io.quarkus.gizmo2.LambdaStrategy; import io.quarkus.gizmo2.LocalVar; import io.quarkus.gizmo2.ParamVar; import io.quarkus.gizmo2.StaticFieldVar; @@ -259,6 +259,7 @@ void configValidator( BuildProducer generatedClass, BuildProducer generatedResource, BuildProducer generatedServiceProviders, + BuildProducer constantBootstraps, BuildProducer reflectiveClass, BuildProducer staticInitConfigBuilder, BuildProducer runTimeConfigBuilder) { @@ -344,8 +345,7 @@ void configValidator( String builderClassName = HibernateBeanValidationConfigValidator.class.getName() + "Builder"; Gizmo gizmo = Gizmo.create(new GeneratedClassGizmo2Adaptor(generatedClass, generatedResource, generatedServiceProviders, - true)) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS) + constantBootstraps, true)) .withDebugInfo(false) .withParameters(false); gizmo.class_(builderClassName, cc -> { diff --git a/extensions/jackson/deployment/src/main/java/io/quarkus/jackson/deployment/JacksonProcessor.java b/extensions/jackson/deployment/src/main/java/io/quarkus/jackson/deployment/JacksonProcessor.java index 8d6480a3d413b..98f89dfa77b14 100644 --- a/extensions/jackson/deployment/src/main/java/io/quarkus/jackson/deployment/JacksonProcessor.java +++ b/extensions/jackson/deployment/src/main/java/io/quarkus/jackson/deployment/JacksonProcessor.java @@ -52,7 +52,6 @@ import io.quarkus.gizmo2.Const; import io.quarkus.gizmo2.Expr; import io.quarkus.gizmo2.Gizmo; -import io.quarkus.gizmo2.LambdaStrategy; import io.quarkus.gizmo2.LocalVar; import io.quarkus.gizmo2.ParamVar; import io.quarkus.gizmo2.Reflection2Gizmo; @@ -359,7 +358,6 @@ void generateCustomizer(BuildProducer generatedBeans, ClassOutput classOutput = new GeneratedBeanGizmo2Adaptor(generatedBeans); Gizmo g = Gizmo.create(classOutput) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS) .withDebugInfo(false) .withParameters(false); g.class_("io.quarkus.jackson.customizer.RegisterSerializersAndDeserializersCustomizer", cc -> { diff --git a/extensions/jsonb/deployment/src/main/java/io/quarkus/jsonb/deployment/JsonbProcessor.java b/extensions/jsonb/deployment/src/main/java/io/quarkus/jsonb/deployment/JsonbProcessor.java index 2ec9af6a6c12e..59152f808fbb5 100644 --- a/extensions/jsonb/deployment/src/main/java/io/quarkus/jsonb/deployment/JsonbProcessor.java +++ b/extensions/jsonb/deployment/src/main/java/io/quarkus/jsonb/deployment/JsonbProcessor.java @@ -42,7 +42,6 @@ import io.quarkus.deployment.builditem.nativeimage.ServiceProviderBuildItem; import io.quarkus.gizmo2.ClassOutput; import io.quarkus.gizmo2.Gizmo; -import io.quarkus.gizmo2.LambdaStrategy; import io.quarkus.gizmo2.ParamVar; import io.quarkus.gizmo2.desc.ConstructorDesc; import io.quarkus.gizmo2.desc.MethodDesc; @@ -159,8 +158,7 @@ void generateCustomizer(BuildProducer generatedBeans, ClassOutput classOutput = new GeneratedBeanGizmo2Adaptor(generatedBeans); - Gizmo gizmo = Gizmo.create(classOutput) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS); + Gizmo gizmo = Gizmo.create(classOutput); gizmo.class_("io.quarkus.jsonb.customizer.RegisterSerializersAndDeserializersCustomizer", cc -> { cc.implements_(JsonbConfigCustomizer.class); cc.defaultConstructor(); diff --git a/extensions/narayana-jta/deployment/src/main/java/io/quarkus/narayana/jta/deployment/NarayanaJtaProcessor.java b/extensions/narayana-jta/deployment/src/main/java/io/quarkus/narayana/jta/deployment/NarayanaJtaProcessor.java index 9f532c22df6f0..ef5fe4579feca 100644 --- a/extensions/narayana-jta/deployment/src/main/java/io/quarkus/narayana/jta/deployment/NarayanaJtaProcessor.java +++ b/extensions/narayana-jta/deployment/src/main/java/io/quarkus/narayana/jta/deployment/NarayanaJtaProcessor.java @@ -66,7 +66,6 @@ import io.quarkus.deployment.builditem.nativeimage.RuntimeInitializedClassBuildItem; import io.quarkus.deployment.logging.LogCleanupFilterBuildItem; import io.quarkus.gizmo2.Gizmo; -import io.quarkus.gizmo2.LambdaStrategy; import io.quarkus.narayana.jta.runtime.NarayanaJtaProducers; import io.quarkus.narayana.jta.runtime.NarayanaJtaRecorder; import io.quarkus.narayana.jta.runtime.context.TransactionContext; @@ -196,8 +195,7 @@ void testTx(BuildProducer generatedBeanBuildItemBuildPro //generate the annotated interceptor with gizmo //all the logic is in the parent, but we don't have access to the //binding annotation here - Gizmo gizmo = Gizmo.create(new GeneratedBeanGizmo2Adaptor(generatedBeanBuildItemBuildProducer)) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS); + Gizmo gizmo = Gizmo.create(new GeneratedBeanGizmo2Adaptor(generatedBeanBuildItemBuildProducer)); gizmo.class_(TestTransactionInterceptor.class.getName() + "Generated", cc -> { cc.extends_(TestTransactionInterceptor.class); cc.addAnnotation(ClassDesc.of(TEST_TRANSACTION), RetentionPolicy.RUNTIME, ac -> { diff --git a/extensions/oidc-client-graphql/deployment/src/main/java/io/quarkus/oidc/client/graphql/OidcGraphQLClientIntegrationProcessor.java b/extensions/oidc-client-graphql/deployment/src/main/java/io/quarkus/oidc/client/graphql/OidcGraphQLClientIntegrationProcessor.java index d59860cc59293..b48ce0b3eca1c 100644 --- a/extensions/oidc-client-graphql/deployment/src/main/java/io/quarkus/oidc/client/graphql/OidcGraphQLClientIntegrationProcessor.java +++ b/extensions/oidc-client-graphql/deployment/src/main/java/io/quarkus/oidc/client/graphql/OidcGraphQLClientIntegrationProcessor.java @@ -32,7 +32,6 @@ import io.quarkus.gizmo2.ClassOutput; import io.quarkus.gizmo2.Const; import io.quarkus.gizmo2.Gizmo; -import io.quarkus.gizmo2.LambdaStrategy; import io.quarkus.gizmo2.desc.ConstructorDesc; import io.quarkus.oidc.client.graphql.runtime.AbstractGraphQLTokenProvider; import io.quarkus.oidc.client.graphql.runtime.OidcClientGraphQLConfig; @@ -107,8 +106,7 @@ void generateGraphQLTokenProducers(GraphQLTokenProducerInfo graphQLTokenProducer BuildProducer generatedBeanProducer) { graphQLTokenProducerInfo.oidcClientToTokenProducerBeanName.forEach((oidcClientName, generatedClassName) -> { ClassOutput classOutput = new GeneratedBeanGizmo2Adaptor(generatedBeanProducer); - Gizmo gizmo = Gizmo.create(classOutput) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS).withDebugInfo(false).withParameters(false); + Gizmo gizmo = Gizmo.create(classOutput).withDebugInfo(false).withParameters(false); gizmo.class_(generatedClassName, cc -> { cc.public_(); cc.addAnnotation(Singleton.class); diff --git a/extensions/oidc-client/deployment/src/main/java/io/quarkus/oidc/client/deployment/OidcClientBuildStep.java b/extensions/oidc-client/deployment/src/main/java/io/quarkus/oidc/client/deployment/OidcClientBuildStep.java index c93b86f098786..3ca2b8449db42 100644 --- a/extensions/oidc-client/deployment/src/main/java/io/quarkus/oidc/client/deployment/OidcClientBuildStep.java +++ b/extensions/oidc-client/deployment/src/main/java/io/quarkus/oidc/client/deployment/OidcClientBuildStep.java @@ -38,7 +38,6 @@ import io.quarkus.deployment.builditem.nativeimage.RuntimeInitializedClassBuildItem; import io.quarkus.gizmo2.Const; import io.quarkus.gizmo2.Gizmo; -import io.quarkus.gizmo2.LambdaStrategy; import io.quarkus.gizmo2.desc.ConstructorDesc; import io.quarkus.gizmo2.desc.MethodDesc; import io.quarkus.oidc.client.NamedOidcClient; @@ -133,8 +132,7 @@ public void createNonDefaultTokensProducers( BuildProducer generatedBean, OidcClientNamesBuildItem oidcClientNames) { - Gizmo gizmo = Gizmo.create(new GeneratedBeanGizmo2Adaptor(generatedBean)) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS); + Gizmo gizmo = Gizmo.create(new GeneratedBeanGizmo2Adaptor(generatedBean)); String targetPackage = DotNames .packagePrefix(DotName.createSimple(TokensProducer.class)); diff --git a/extensions/oidc-client/deployment/src/main/java/io/quarkus/oidc/client/deployment/OidcClientFilterDeploymentHelper.java b/extensions/oidc-client/deployment/src/main/java/io/quarkus/oidc/client/deployment/OidcClientFilterDeploymentHelper.java index 690e44f28b4b9..297869a9f52cf 100644 --- a/extensions/oidc-client/deployment/src/main/java/io/quarkus/oidc/client/deployment/OidcClientFilterDeploymentHelper.java +++ b/extensions/oidc-client/deployment/src/main/java/io/quarkus/oidc/client/deployment/OidcClientFilterDeploymentHelper.java @@ -28,7 +28,6 @@ import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.gizmo2.Const; import io.quarkus.gizmo2.Gizmo; -import io.quarkus.gizmo2.LambdaStrategy; import io.quarkus.gizmo2.desc.ConstructorDesc; import io.quarkus.gizmo2.desc.MethodDesc; import io.quarkus.oidc.client.filter.OidcClientFilter; @@ -54,8 +53,7 @@ private record TokensProducerKey(String clientName, MethodInfo methodInfo) { public OidcClientFilterDeploymentHelper(Class baseClass, BuildProducer generatedBean, boolean refreshOnUnauthorized) { this.baseClass = baseClass; - this.gizmo = Gizmo.create(new GeneratedBeanGizmo2Adaptor(generatedBean)) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS); + this.gizmo = Gizmo.create(new GeneratedBeanGizmo2Adaptor(generatedBean)); this.targetPackage = DotNames .packagePrefix(DotName.createSimple(baseClass)); this.refreshOnUnauthorized = refreshOnUnauthorized; diff --git a/extensions/oidc-token-propagation-common/deployment/src/main/java/io/quarkus/oidc/token/propagation/common/deployment/AccessTokenRequestFilterGenerator.java b/extensions/oidc-token-propagation-common/deployment/src/main/java/io/quarkus/oidc/token/propagation/common/deployment/AccessTokenRequestFilterGenerator.java index d2a0534b38fad..0b6d7ca3fb98d 100644 --- a/extensions/oidc-token-propagation-common/deployment/src/main/java/io/quarkus/oidc/token/propagation/common/deployment/AccessTokenRequestFilterGenerator.java +++ b/extensions/oidc-token-propagation-common/deployment/src/main/java/io/quarkus/oidc/token/propagation/common/deployment/AccessTokenRequestFilterGenerator.java @@ -18,7 +18,6 @@ import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.gizmo2.Const; import io.quarkus.gizmo2.Gizmo; -import io.quarkus.gizmo2.LambdaStrategy; import io.quarkus.gizmo2.desc.ConstructorDesc; import io.quarkus.runtime.util.HashUtil; import io.quarkus.security.spi.runtime.MethodDescription; @@ -48,8 +47,7 @@ public String generateClass(AccessTokenInstanceBuildItem instance) { return cache.computeIfAbsent( new RequestFilterKey(instance.getClientName(), instance.exchangeTokenActivated(), methodDescription), i -> { - Gizmo gizmo = Gizmo.create(new GeneratedBeanGizmo2Adaptor(generatedBeanProducer)) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS); + Gizmo gizmo = Gizmo.create(new GeneratedBeanGizmo2Adaptor(generatedBeanProducer)); String className = createUniqueClassName(i, instance); gizmo.class_(className, cc -> { cc.extends_(requestFilterClass); diff --git a/extensions/packaging/jlink/deployment/src/main/java/io/quarkus/jlink/deployment/JLinkSteps.java b/extensions/packaging/jlink/deployment/src/main/java/io/quarkus/jlink/deployment/JLinkSteps.java index 3f5da1799b83d..a1693fe610ea9 100644 --- a/extensions/packaging/jlink/deployment/src/main/java/io/quarkus/jlink/deployment/JLinkSteps.java +++ b/extensions/packaging/jlink/deployment/src/main/java/io/quarkus/jlink/deployment/JLinkSteps.java @@ -26,7 +26,6 @@ import io.quarkus.deployment.pkg.builditem.CurateOutcomeBuildItem; import io.quarkus.gizmo2.Const; import io.quarkus.gizmo2.Gizmo; -import io.quarkus.gizmo2.LambdaStrategy; import io.quarkus.gizmo2.ParamVar; import io.quarkus.gizmo2.desc.MethodDesc; import io.quarkus.jlink.launcher.JLinkAppLauncher; @@ -99,8 +98,7 @@ public JLinkStagedOutputItem stageOutput( // the list of resources produced by generating the main class List dynModuleResources = new ArrayList<>(); // generate the simple main class which runs the launcher with the app module info - Gizmo gizmo = Gizmo.create((path, bytes) -> dynModuleResources.add(new MemoryResource(path, bytes))) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS); + Gizmo gizmo = Gizmo.create((path, bytes) -> dynModuleResources.add(new MemoryResource(path, bytes))); gizmo.class_(APP_MAIN, cc -> { cc.public_(); cc.staticMethod("main", mc -> { diff --git a/extensions/qute/deployment/src/main/java/io/quarkus/qute/deployment/MessageBundleProcessor.java b/extensions/qute/deployment/src/main/java/io/quarkus/qute/deployment/MessageBundleProcessor.java index f339a59889226..a5679088ea613 100644 --- a/extensions/qute/deployment/src/main/java/io/quarkus/qute/deployment/MessageBundleProcessor.java +++ b/extensions/qute/deployment/src/main/java/io/quarkus/qute/deployment/MessageBundleProcessor.java @@ -70,12 +70,12 @@ import io.quarkus.deployment.builditem.GeneratedResourceBuildItem; import io.quarkus.deployment.builditem.GeneratedServiceProviderBuildItem; import io.quarkus.deployment.builditem.HotDeploymentWatchedFileBuildItem; +import io.quarkus.deployment.builditem.nativeimage.ConstantBootstrapBuildItem; import io.quarkus.deployment.pkg.builditem.BuildSystemTargetBuildItem; import io.quarkus.gizmo2.ClassOutput; import io.quarkus.gizmo2.Const; import io.quarkus.gizmo2.Expr; import io.quarkus.gizmo2.Gizmo; -import io.quarkus.gizmo2.LambdaStrategy; import io.quarkus.gizmo2.LocalVar; import io.quarkus.gizmo2.ParamVar; import io.quarkus.gizmo2.Var; @@ -135,6 +135,7 @@ List processBundles(BeanArchiveIndexBuildItem beanArchiv BuildProducer generatedClasses, BuildProducer generatedResources, BuildProducer generatedServiceProviders, + BuildProducer constantBootstraps, BeanRegistrationPhaseBuildItem beanRegistration, BuildProducer configurators, BuildProducer messageTemplateMethods, @@ -274,7 +275,7 @@ List processBundles(BeanArchiveIndexBuildItem beanArchiv // Generate implementations // name -> impl class Map generatedImplementations = generateImplementations(bundles, generatedClasses, generatedResources, - generatedServiceProviders, messageTemplateMethods, index); + generatedServiceProviders, constantBootstraps, messageTemplateMethods, index); // Register synthetic beans for (MessageBundleBuildItem bundle : bundles) { @@ -719,13 +720,14 @@ private Map generateImplementations(List generatedClasses, BuildProducer generatedResources, BuildProducer generatedServiceProviders, + BuildProducer constantBootstraps, BuildProducer messageTemplateMethods, IndexView index) throws IOException { Map generatedTypes = new HashMap<>(); ClassOutput defaultClassOutput = new GeneratedClassGizmo2Adaptor(generatedClasses, generatedResources, - generatedServiceProviders, new AppClassPredicate()); + generatedServiceProviders, constantBootstraps, new AppClassPredicate()); for (MessageBundleBuildItem bundle : bundles) { ClassInfo bundleInterface = bundle.getDefaultBundleInterface(); @@ -769,7 +771,7 @@ private Map generateImplementations(List() { + generatedServiceProviders, constantBootstraps, new AppClassPredicate(new Function() { @Override public String apply(String className) { String localeSuffix = "_" + locale; @@ -799,7 +801,6 @@ private String generateForwardingImplementation(MessageBundleBuildItem bundle, C LOG.debugf("Generate forwarding bundle implementation for %s", bundleInterface); Gizmo gizmo = Gizmo.create(classOutput) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS) .withDebugInfo(false) .withParameters(false); gizmo.class_(generatedClassName, cc -> { @@ -1012,7 +1013,6 @@ private String generateImplementation(MessageBundleBuildItem bundle, ClassInfo d String resolveMethodPrefix = baseName + SUFFIX; Gizmo gizmo = Gizmo.create(classOutput) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS) .withDebugInfo(false) .withParameters(false); gizmo.class_(generatedClassName, cc -> { diff --git a/extensions/qute/deployment/src/main/java/io/quarkus/qute/deployment/QuteProcessor.java b/extensions/qute/deployment/src/main/java/io/quarkus/qute/deployment/QuteProcessor.java index c15b60061bee9..7a53fd0ce40d0 100644 --- a/extensions/qute/deployment/src/main/java/io/quarkus/qute/deployment/QuteProcessor.java +++ b/extensions/qute/deployment/src/main/java/io/quarkus/qute/deployment/QuteProcessor.java @@ -100,6 +100,7 @@ import io.quarkus.deployment.builditem.LaunchModeBuildItem; import io.quarkus.deployment.builditem.LiveReloadBuildItem; import io.quarkus.deployment.builditem.ServiceStartBuildItem; +import io.quarkus.deployment.builditem.nativeimage.ConstantBootstrapBuildItem; import io.quarkus.deployment.builditem.nativeimage.NativeImageResourceBuildItem; import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; import io.quarkus.deployment.pkg.NativeConfig; @@ -1977,6 +1978,7 @@ void generateValueResolvers(QuteConfig config, BuildProducer generatedClasses, BuildProducer generatedResources, BuildProducer generatedServiceProviders, + BuildProducer constantBootstraps, BeanArchiveIndexBuildItem beanArchiveIndex, ApplicationArchivesBuildItem applicationArchivesBuildItem, List templateExtensionMethods, @@ -1999,7 +2001,7 @@ void generateValueResolvers(QuteConfig config, IndexView index = beanArchiveIndex.getIndex(); ClassOutput classOutput = new GeneratedClassGizmo2Adaptor(generatedClasses, generatedResources, - generatedServiceProviders, new Function() { + generatedServiceProviders, constantBootstraps, new Function() { @Override public String apply(String name) { int idx = name.lastIndexOf(ExtensionMethodGenerator.NAMESPACE_SUFFIX); diff --git a/extensions/reactive-routes/deployment/src/main/java/io/quarkus/vertx/web/deployment/ReactiveRoutesProcessor.java b/extensions/reactive-routes/deployment/src/main/java/io/quarkus/vertx/web/deployment/ReactiveRoutesProcessor.java index d63a71136064b..4257d870e568c 100644 --- a/extensions/reactive-routes/deployment/src/main/java/io/quarkus/vertx/web/deployment/ReactiveRoutesProcessor.java +++ b/extensions/reactive-routes/deployment/src/main/java/io/quarkus/vertx/web/deployment/ReactiveRoutesProcessor.java @@ -77,13 +77,13 @@ import io.quarkus.deployment.builditem.GeneratedServiceProviderBuildItem; import io.quarkus.deployment.builditem.LaunchModeBuildItem; import io.quarkus.deployment.builditem.ShutdownContextBuildItem; +import io.quarkus.deployment.builditem.nativeimage.ConstantBootstrapBuildItem; import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; import io.quarkus.deployment.builditem.nativeimage.ReflectiveHierarchyBuildItem; import io.quarkus.gizmo2.Const; import io.quarkus.gizmo2.Expr; import io.quarkus.gizmo2.FieldVar; import io.quarkus.gizmo2.Gizmo; -import io.quarkus.gizmo2.LambdaStrategy; import io.quarkus.gizmo2.LocalVar; import io.quarkus.gizmo2.ParamVar; import io.quarkus.gizmo2.This; @@ -100,6 +100,7 @@ import io.quarkus.security.Authenticated; import io.quarkus.security.PermissionsAllowed; import io.quarkus.vertx.deployment.ReinitializeVertxJsonBuildItem; +import io.quarkus.vertx.http.deployment.BodyHandlerBuildItem; import io.quarkus.vertx.http.deployment.FilterBuildItem; import io.quarkus.vertx.http.deployment.RequireBodyHandlerBuildItem; import io.quarkus.vertx.http.deployment.RouteBuildItem; @@ -286,9 +287,10 @@ void addAdditionalRoutes( BuildProducer generatedClass, BuildProducer generatedResource, BuildProducer generatedServiceProviders, + BuildProducer constantBootstraps, BuildProducer reflectiveClasses, BuildProducer reflectiveHierarchy, - io.quarkus.vertx.http.deployment.BodyHandlerBuildItem bodyHandler, + BodyHandlerBuildItem bodyHandler, BuildProducer routeProducer, BuildProducer filterProducer, List bodyHandlerRequired, @@ -314,9 +316,9 @@ public boolean test(String name) { return GeneratedClassGizmo2Adaptor.isApplicationClass(className); } }; - Gizmo gizmo = Gizmo.create(new GeneratedClassGizmo2Adaptor(generatedClass, generatedResource, generatedServiceProviders, + Gizmo gizmo = Gizmo.create(new GeneratedClassGizmo2Adaptor(generatedClass, generatedResource, + generatedServiceProviders, constantBootstraps, appClassPredicate)) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS) .withDebugInfo(false) .withParameters(false); IndexView index = beanArchive.getIndex(); diff --git a/extensions/resteasy-classic/rest-client-config/deployment/src/main/java/io/quarkus/restclient/config/deployment/RestClientConfigUtils.java b/extensions/resteasy-classic/rest-client-config/deployment/src/main/java/io/quarkus/restclient/config/deployment/RestClientConfigUtils.java index 6ad068e01f5aa..5f79d672121b1 100644 --- a/extensions/resteasy-classic/rest-client-config/deployment/src/main/java/io/quarkus/restclient/config/deployment/RestClientConfigUtils.java +++ b/extensions/resteasy-classic/rest-client-config/deployment/src/main/java/io/quarkus/restclient/config/deployment/RestClientConfigUtils.java @@ -11,7 +11,6 @@ import io.quarkus.deployment.builditem.StaticInitConfigBuilderBuildItem; import io.quarkus.gizmo2.Const; import io.quarkus.gizmo2.Gizmo; -import io.quarkus.gizmo2.LambdaStrategy; import io.quarkus.gizmo2.desc.ConstructorDesc; import io.quarkus.gizmo2.desc.MethodDesc; import io.quarkus.restclient.config.AbstractRestClientConfigBuilder; @@ -34,8 +33,7 @@ public static void generateRestClientConfigBuilder( String className = "io.quarkus.runtime.generated.RestClientConfigBuilder"; Gizmo gizmo = Gizmo - .create(new GeneratedClassGizmo2Adaptor(generatedClass, generatedResource, generatedServiceProvider, true)) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS); + .create(new GeneratedClassGizmo2Adaptor(generatedClass, generatedResource, generatedServiceProvider, true)); gizmo.class_(className, cc -> { cc.final_(); cc.extends_(AbstractRestClientConfigBuilder.class); diff --git a/extensions/resteasy-reactive/rest-client/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/RestClientReactiveProcessor.java b/extensions/resteasy-reactive/rest-client/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/RestClientReactiveProcessor.java index 5f4203b8c8dc5..24cdf07f1dd9f 100644 --- a/extensions/resteasy-reactive/rest-client/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/RestClientReactiveProcessor.java +++ b/extensions/resteasy-reactive/rest-client/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/RestClientReactiveProcessor.java @@ -92,13 +92,13 @@ import io.quarkus.deployment.builditem.RunTimeConfigBuilderBuildItem; import io.quarkus.deployment.builditem.ShutdownContextBuildItem; import io.quarkus.deployment.builditem.StaticInitConfigBuilderBuildItem; +import io.quarkus.deployment.builditem.nativeimage.ConstantBootstrapBuildItem; import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; import io.quarkus.deployment.builditem.nativeimage.ServiceProviderBuildItem; import io.quarkus.deployment.execannotations.ExecutionModelAnnotationsAllowedBuildItem; import io.quarkus.gizmo2.Const; import io.quarkus.gizmo2.Expr; import io.quarkus.gizmo2.Gizmo; -import io.quarkus.gizmo2.LambdaStrategy; import io.quarkus.gizmo2.LocalVar; import io.quarkus.gizmo2.ParamVar; import io.quarkus.gizmo2.creator.BlockCreator; @@ -313,6 +313,8 @@ void registerProvidersFromAnnotations(CombinedIndexBuildItem indexBuildItem, List clientResponseFilters, BuildProducer generatedBeansProducer, BuildProducer generatedClassesProducer, + BuildProducer generatedResourcesProducer, + BuildProducer constantBootstraps, BuildProducer unremovableBeansProducer, BuildProducer reflectiveClassesProducer, BuildProducer executionModelAnnotationsAllowedProducer, @@ -368,8 +370,9 @@ void registerProvidersFromAnnotations(CombinedIndexBuildItem indexBuildItem, } MultivaluedMap generatedProviders = new QuarkusMultivaluedHashMap<>(); - Gizmo classGizmo = Gizmo.create(new GeneratedClassGizmo2Adaptor(generatedClassesProducer, null, null, true)) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS); + Gizmo classGizmo = Gizmo + .create(new GeneratedClassGizmo2Adaptor(generatedClassesProducer, generatedResourcesProducer, + null, constantBootstraps, true)); populateClientExceptionMapperFromAnnotations(index, classGizmo, reflectiveClassesProducer, executionModelAnnotationsAllowedProducer) .forEach(generatedProviders::add); @@ -416,8 +419,7 @@ void registerProvidersFromAnnotations(CombinedIndexBuildItem indexBuildItem, } // Generate the class using Gizmo2 - Gizmo gizmo = Gizmo.create(new GeneratedBeanGizmo2Adaptor(generatedBeansProducer)) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS); + Gizmo gizmo = Gizmo.create(new GeneratedBeanGizmo2Adaptor(generatedBeansProducer)); gizmo.class_(annotationRegisteredProvidersImpl, cc -> { cc.extends_(AnnotationRegisteredProviders.class); cc.addAnnotation(Singleton.class); @@ -682,8 +684,7 @@ void addRestClientBeans( ClassDesc interfaceClassDesc = classDescOf(jaxrsInterface); ClassDesc wrapperBaseClassDesc = ClassDesc.of(RestClientReactiveCDIWrapperBase.class.getName()); - Gizmo gizmo = Gizmo.create(new GeneratedBeanGizmo2Adaptor(generatedBeans)) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS); + Gizmo gizmo = Gizmo.create(new GeneratedBeanGizmo2Adaptor(generatedBeans)); gizmo.class_(wrapperClassName, cc -> { cc.extends_(RestClientReactiveCDIWrapperBase.class); cc.implements_(interfaceClassDesc); diff --git a/extensions/resteasy-reactive/rest/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/QuarkusInvokerFactory.java b/extensions/resteasy-reactive/rest/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/QuarkusInvokerFactory.java index aac9bb207031a..9ec145dc6a032 100644 --- a/extensions/resteasy-reactive/rest/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/QuarkusInvokerFactory.java +++ b/extensions/resteasy-reactive/rest/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/QuarkusInvokerFactory.java @@ -22,10 +22,11 @@ import io.quarkus.deployment.GeneratedClassGizmo2Adaptor; import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.builditem.GeneratedClassBuildItem; +import io.quarkus.deployment.builditem.GeneratedResourceBuildItem; +import io.quarkus.deployment.builditem.nativeimage.ConstantBootstrapBuildItem; import io.quarkus.gizmo2.ClassOutput; import io.quarkus.gizmo2.Expr; import io.quarkus.gizmo2.Gizmo; -import io.quarkus.gizmo2.LambdaStrategy; import io.quarkus.gizmo2.ParamVar; import io.quarkus.resteasy.reactive.server.runtime.ResteasyReactiveRecorder; @@ -33,15 +34,21 @@ public class QuarkusInvokerFactory implements EndpointInvokerFactory { private final Predicate applicationClassPredicate; final BuildProducer generatedClassBuildItemBuildProducer; + final BuildProducer generatedResourcesBuildItemBuildProducer; + final BuildProducer constantBootstrapBuildItemBuildProducer; final ResteasyReactiveRecorder recorder; private final Map> generatedInvokers = new HashMap<>(); public QuarkusInvokerFactory(Predicate applicationClassPredicate, BuildProducer generatedClassBuildItemBuildProducer, + BuildProducer generatedResourcesBuildItemBuildProducer, + BuildProducer constantBootstrapBuildItemBuildProducer, ResteasyReactiveRecorder recorder) { this.applicationClassPredicate = applicationClassPredicate; this.generatedClassBuildItemBuildProducer = generatedClassBuildItemBuildProducer; + this.generatedResourcesBuildItemBuildProducer = generatedResourcesBuildItemBuildProducer; + this.constantBootstrapBuildItemBuildProducer = constantBootstrapBuildItemBuildProducer; this.recorder = recorder; } @@ -59,10 +66,12 @@ public Supplier create(ResourceMethod method, ClassInfo current if (generatedInvokers.containsKey(baseName)) { return generatedInvokers.get(baseName); } - ClassOutput classOutput = new GeneratedClassGizmo2Adaptor(generatedClassBuildItemBuildProducer, null, null, + ClassOutput classOutput = new GeneratedClassGizmo2Adaptor(generatedClassBuildItemBuildProducer, + generatedResourcesBuildItemBuildProducer, + null, + constantBootstrapBuildItemBuildProducer, applicationClassPredicate.test(currentClassInfo.name().toString())); Gizmo g = Gizmo.create(classOutput) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS) .withDebugInfo(false) .withParameters(false); g.class_(baseName, cc -> { diff --git a/extensions/resteasy-reactive/rest/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/ResteasyReactiveProcessor.java b/extensions/resteasy-reactive/rest/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/ResteasyReactiveProcessor.java index 67013d92b85b7..f13a262f0cf88 100644 --- a/extensions/resteasy-reactive/rest/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/ResteasyReactiveProcessor.java +++ b/extensions/resteasy-reactive/rest/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/ResteasyReactiveProcessor.java @@ -177,6 +177,7 @@ import io.quarkus.deployment.builditem.LogCategoryBuildItem; import io.quarkus.deployment.builditem.RecordableConstructorBuildItem; import io.quarkus.deployment.builditem.ShutdownContextBuildItem; +import io.quarkus.deployment.builditem.nativeimage.ConstantBootstrapBuildItem; import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; import io.quarkus.deployment.builditem.nativeimage.ReflectiveHierarchyBuildItem; import io.quarkus.deployment.pkg.builditem.CompiledJavaVersionBuildItem; @@ -187,7 +188,6 @@ import io.quarkus.gizmo.MethodCreator; import io.quarkus.gizmo.MethodDescriptor; import io.quarkus.gizmo2.Const; -import io.quarkus.gizmo2.LambdaStrategy; import io.quarkus.gizmo2.ParamVar; import io.quarkus.gizmo2.desc.ClassMethodDesc; import io.quarkus.gizmo2.desc.MethodDesc; @@ -541,7 +541,9 @@ public void setupEndpoints(ApplicationIndexBuildItem applicationIndexBuildItem, ResteasyReactiveConfig config, Optional resourceScanningResultBuildItem, BuildProducer generatedClassBuildItemBuildProducer, + BuildProducer generatedResourcesBuildProducer, BuildProducer bytecodeTransformerBuildItemBuildProducer, + BuildProducer constantBootstraps, BuildProducer reflectiveClassBuildItemBuildProducer, ResteasyReactiveRecorder recorder, List serverDefaultProducesHandlers, @@ -631,7 +633,7 @@ public void setupEndpoints(ApplicationIndexBuildItem applicationIndexBuildItem, .setFactoryCreator(new QuarkusFactoryCreator(recorder, beanContainerBuildItem.getValue())) .setEndpointInvokerFactory( new QuarkusInvokerFactory(applicationClassPredicate, generatedClassBuildItemBuildProducer, - recorder)) + generatedResourcesBuildProducer, constantBootstraps, recorder)) .setGeneratedClassBuildItemBuildProducer(generatedClassBuildItemBuildProducer) .setExistingConverters(existingConverters) .setScannedResourcePaths(scannedResourcePaths) @@ -1603,8 +1605,7 @@ public void generateHandlerDispatcher(Optional MethodDesc handleMethod = MethodDesc.of(ServerRestHandler.class, "handle", void.class, ResteasyReactiveRequestContext.class); io.quarkus.gizmo2.Gizmo gizmo = io.quarkus.gizmo2.Gizmo - .create(new GeneratedClassGizmo2Adaptor(generatedClass, generatedResource, true)) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS); + .create(new GeneratedClassGizmo2Adaptor(generatedClass, generatedResource, true)); gizmo.class_(HANDLER_DISPATCHER_CLASS, cc -> { cc.final_(); cc.extends_(ServerRestHandlerDispatcher.class); diff --git a/extensions/scheduler/deployment/src/main/java/io/quarkus/scheduler/deployment/SchedulerProcessor.java b/extensions/scheduler/deployment/src/main/java/io/quarkus/scheduler/deployment/SchedulerProcessor.java index c14f26dc0c066..eeb701dc18a5e 100644 --- a/extensions/scheduler/deployment/src/main/java/io/quarkus/scheduler/deployment/SchedulerProcessor.java +++ b/extensions/scheduler/deployment/src/main/java/io/quarkus/scheduler/deployment/SchedulerProcessor.java @@ -71,13 +71,13 @@ import io.quarkus.deployment.builditem.GeneratedClassBuildItem; import io.quarkus.deployment.builditem.GeneratedResourceBuildItem; import io.quarkus.deployment.builditem.GeneratedServiceProviderBuildItem; +import io.quarkus.deployment.builditem.nativeimage.ConstantBootstrapBuildItem; import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; import io.quarkus.deployment.metrics.MetricsCapabilityBuildItem; import io.quarkus.gizmo2.ClassOutput; import io.quarkus.gizmo2.Const; import io.quarkus.gizmo2.Expr; import io.quarkus.gizmo2.Gizmo; -import io.quarkus.gizmo2.LambdaStrategy; import io.quarkus.gizmo2.LocalVar; import io.quarkus.gizmo2.ParamVar; import io.quarkus.gizmo2.creator.BlockCreator; @@ -377,6 +377,7 @@ public void build( BuildProducer generatedClasses, BuildProducer generatedResources, BuildProducer generatedServiceProviders, + BuildProducer constantBootstraps, BuildProducer reflectiveClass, AnnotationProxyBuildItem annotationProxy, List schedulerForcedStartItems, @@ -405,9 +406,8 @@ public String apply(String name) { .build()); ClassOutput classOutput = new GeneratedClassGizmo2Adaptor(generatedClasses, generatedResources, - generatedServiceProviders, generatedToBaseNameFun); + generatedServiceProviders, constantBootstraps, generatedToBaseNameFun); Gizmo gizmo = Gizmo.create(classOutput) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS) .withDebugInfo(false) .withParameters(false); diff --git a/extensions/security-jpa-reactive/deployment/src/main/java/io/quarkus/security/jpa/reactive/deployment/QuarkusSecurityJpaReactiveProcessor.java b/extensions/security-jpa-reactive/deployment/src/main/java/io/quarkus/security/jpa/reactive/deployment/QuarkusSecurityJpaReactiveProcessor.java index 7c6f697df1719..002185f3ddd62 100644 --- a/extensions/security-jpa-reactive/deployment/src/main/java/io/quarkus/security/jpa/reactive/deployment/QuarkusSecurityJpaReactiveProcessor.java +++ b/extensions/security-jpa-reactive/deployment/src/main/java/io/quarkus/security/jpa/reactive/deployment/QuarkusSecurityJpaReactiveProcessor.java @@ -33,7 +33,6 @@ import io.quarkus.gizmo2.Const; import io.quarkus.gizmo2.Expr; import io.quarkus.gizmo2.Gizmo; -import io.quarkus.gizmo2.LambdaStrategy; import io.quarkus.gizmo2.LocalVar; import io.quarkus.gizmo2.Var; import io.quarkus.gizmo2.creator.BlockCreator; @@ -95,7 +94,7 @@ private static void generateIdentityProvider(Index index, JpaSecurityDefinition GeneratedBeanGizmo2Adaptor gizmoAdaptor = new GeneratedBeanGizmo2Adaptor(beanProducer); String name = jpaSecurityDefinition.annotatedClass.name() + "__JpaReactiveIdentityProviderImpl"; - Gizmo.create(gizmoAdaptor).withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS).class_(name, cc -> { + Gizmo.create(gizmoAdaptor).class_(name, cc -> { cc.extends_(JpaReactiveIdentityProvider.class); cc.addAnnotation(Singleton.class); cc.defaultConstructor(); @@ -142,7 +141,7 @@ private static void generateTrustedIdentityProvider(Index index, JpaSecurityDefi GeneratedBeanGizmo2Adaptor gizmoAdaptor = new GeneratedBeanGizmo2Adaptor(beanProducer); String name = jpaSecurityDefinition.annotatedClass.name() + "__JpaReactiveTrustedIdentityProviderImpl"; - Gizmo.create(gizmoAdaptor).withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS).class_(name, cc -> { + Gizmo.create(gizmoAdaptor).class_(name, cc -> { cc.extends_(JpaReactiveTrustedIdentityProvider.class); cc.addAnnotation(Singleton.class); cc.defaultConstructor(); diff --git a/extensions/security-jpa/deployment/src/main/java/io/quarkus/security/jpa/deployment/QuarkusSecurityJpaProcessor.java b/extensions/security-jpa/deployment/src/main/java/io/quarkus/security/jpa/deployment/QuarkusSecurityJpaProcessor.java index 00a8f594f39e0..fe3d05efd01d7 100644 --- a/extensions/security-jpa/deployment/src/main/java/io/quarkus/security/jpa/deployment/QuarkusSecurityJpaProcessor.java +++ b/extensions/security-jpa/deployment/src/main/java/io/quarkus/security/jpa/deployment/QuarkusSecurityJpaProcessor.java @@ -39,7 +39,6 @@ import io.quarkus.gizmo2.Const; import io.quarkus.gizmo2.Expr; import io.quarkus.gizmo2.Gizmo; -import io.quarkus.gizmo2.LambdaStrategy; import io.quarkus.gizmo2.LocalVar; import io.quarkus.gizmo2.creator.BlockCreator; import io.quarkus.gizmo2.creator.ClassCreator; @@ -138,7 +137,6 @@ private void generateIdentityProvider(Index index, JpaSecurityDefinition jpaSecu String name = jpaSecurityDefinition.annotatedClass.name() + "__JpaIdentityProviderImpl"; Gizmo.create(gizmoAdaptor) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS) .class_(name, cc -> { cc.extends_(JpaIdentityProvider.class); cc.addAnnotation(Singleton.class); @@ -186,7 +184,7 @@ private void generateTrustedIdentityProvider(Index index, JpaSecurityDefinition GeneratedBeanGizmo2Adaptor gizmoAdaptor = new GeneratedBeanGizmo2Adaptor(beanProducer); String name = jpaSecurityDefinition.annotatedClass.name() + "__JpaTrustedIdentityProviderImpl"; - Gizmo.create(gizmoAdaptor).withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS).class_(name, cc -> { + Gizmo.create(gizmoAdaptor).class_(name, cc -> { cc.extends_(JpaTrustedIdentityProvider.class); cc.addAnnotation(Singleton.class); cc.defaultConstructor(); diff --git a/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/PermissionSecurityChecks.java b/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/PermissionSecurityChecks.java index d578ba905b1a8..ff25d0325932b 100644 --- a/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/PermissionSecurityChecks.java +++ b/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/PermissionSecurityChecks.java @@ -48,7 +48,6 @@ import io.quarkus.gizmo2.Expr; import io.quarkus.gizmo2.GenericType; import io.quarkus.gizmo2.Gizmo; -import io.quarkus.gizmo2.LambdaStrategy; import io.quarkus.gizmo2.ParamVar; import io.quarkus.gizmo2.TypeArgument; import io.quarkus.gizmo2.creator.BlockCreator; @@ -787,8 +786,7 @@ void generatePermissionCheckers(BuildProducer generated BuildProducer generatedResourceProducer, BuildProducer generatedServiceProviderProducer) { var gizmo = Gizmo.create(new GeneratedClassGizmo2Adaptor(generatedClassProducer, generatedResourceProducer, - generatedServiceProviderProducer, true)) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS); + generatedServiceProviderProducer, true)); permissionNameToChecker.values().forEach(checkerMetadata -> { var declaringCdiBean = checkerMetadata.checkerMethod().declaringClass(); @@ -1644,8 +1642,7 @@ private void close() { reflectiveClassesProducer.produce(ReflectiveClassBuildItem.builder(GENERATED_CLASS_NAME).methods().build()); var gizmo = Gizmo.create(new GeneratedClassGizmo2Adaptor(generatedClassesProducer, generatedResourcesProducer, - generatedServiceProvidersProducer, true)) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS); + generatedServiceProvidersProducer, true)); gizmo.class_(GENERATED_CLASS_NAME, cc -> { cc.final_(); for (ConverterTask task : converterTasks) { diff --git a/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/SecurityProcessor.java b/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/SecurityProcessor.java index 322107802391e..a01aec534982a 100644 --- a/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/SecurityProcessor.java +++ b/extensions/security/deployment/src/main/java/io/quarkus/security/deployment/SecurityProcessor.java @@ -109,7 +109,6 @@ import io.quarkus.gizmo2.Const; import io.quarkus.gizmo2.Expr; import io.quarkus.gizmo2.Gizmo; -import io.quarkus.gizmo2.LambdaStrategy; import io.quarkus.gizmo2.LocalVar; import io.quarkus.gizmo2.desc.ConstructorDesc; import io.quarkus.gizmo2.desc.MethodDesc; @@ -525,8 +524,7 @@ void addBouncyCastleProvidersToNativeImage( String className = resourceName.substring(0, resourceName.length() - 6).replace('/', '.'); nativeImageClass.produce(new GeneratedNativeImageClassBuildItem(className, bytes)); } - }) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS); + }); gizmo.class_("io.quarkus.security.BouncyCastleFeature", cc -> { cc.implements_(org.graalvm.nativeimage.hosted.Feature.class); diff --git a/extensions/signals/deployment/src/main/java/io/quarkus/signals/deployment/SignalsProcessor.java b/extensions/signals/deployment/src/main/java/io/quarkus/signals/deployment/SignalsProcessor.java index 317c7123277db..6d1ceb15a7319 100644 --- a/extensions/signals/deployment/src/main/java/io/quarkus/signals/deployment/SignalsProcessor.java +++ b/extensions/signals/deployment/src/main/java/io/quarkus/signals/deployment/SignalsProcessor.java @@ -67,7 +67,6 @@ import io.quarkus.gizmo2.Expr; import io.quarkus.gizmo2.GenericType; import io.quarkus.gizmo2.Gizmo; -import io.quarkus.gizmo2.LambdaStrategy; import io.quarkus.gizmo2.LocalVar; import io.quarkus.gizmo2.TypeArgument; import io.quarkus.gizmo2.desc.ConstructorDesc; @@ -231,7 +230,6 @@ public String apply(String generatedClassName) { } }); Gizmo gizmo = Gizmo.create(classOutput) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS) .withDebugInfo(false) .withParameters(false); diff --git a/extensions/smallrye-fault-tolerance/deployment/src/main/java/io/quarkus/smallrye/faulttolerance/deployment/SmallRyeFaultToleranceProcessor.java b/extensions/smallrye-fault-tolerance/deployment/src/main/java/io/quarkus/smallrye/faulttolerance/deployment/SmallRyeFaultToleranceProcessor.java index 39d014073b677..170f6ad74521a 100644 --- a/extensions/smallrye-fault-tolerance/deployment/src/main/java/io/quarkus/smallrye/faulttolerance/deployment/SmallRyeFaultToleranceProcessor.java +++ b/extensions/smallrye-fault-tolerance/deployment/src/main/java/io/quarkus/smallrye/faulttolerance/deployment/SmallRyeFaultToleranceProcessor.java @@ -46,6 +46,7 @@ import io.quarkus.deployment.builditem.OpenTelemetrySdkBuildItem; import io.quarkus.deployment.builditem.RunTimeConfigurationDefaultBuildItem; import io.quarkus.deployment.builditem.SystemPropertyBuildItem; +import io.quarkus.deployment.builditem.nativeimage.ConstantBootstrapBuildItem; import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; import io.quarkus.deployment.builditem.nativeimage.ReflectiveMethodBuildItem; import io.quarkus.deployment.builditem.nativeimage.RuntimeInitializedClassBuildItem; @@ -237,6 +238,7 @@ void processFaultToleranceAnnotations(SmallRyeFaultToleranceRecorder recorder, BuildProducer generatedClasses, BuildProducer generatedResources, BuildProducer generatedServiceProviders, + BuildProducer constantBootstraps, BuildProducer reflectiveClass, BuildProducer reflectiveMethod, BuildProducer errors, @@ -260,7 +262,7 @@ void processFaultToleranceAnnotations(SmallRyeFaultToleranceRecorder recorder, // only generating annotation literal classes for MicroProfile/SmallRye Fault Tolerance annotations, // none of them are application classes ClassOutput classOutput = new GeneratedClassGizmo2Adaptor(generatedClasses, generatedResources, - generatedServiceProviders, false); + generatedServiceProviders, constantBootstraps, false); FaultToleranceScanner scanner = new FaultToleranceScanner(index, annotationStore, annotationProxy, classOutput, recorderContext, reflectiveMethod); diff --git a/extensions/smallrye-reactive-messaging-kafka/deployment/src/main/java/io/quarkus/smallrye/reactivemessaging/kafka/deployment/JacksonSerdeGenerator.java b/extensions/smallrye-reactive-messaging-kafka/deployment/src/main/java/io/quarkus/smallrye/reactivemessaging/kafka/deployment/JacksonSerdeGenerator.java index 527b893dd3efa..f8583636393bf 100644 --- a/extensions/smallrye-reactive-messaging-kafka/deployment/src/main/java/io/quarkus/smallrye/reactivemessaging/kafka/deployment/JacksonSerdeGenerator.java +++ b/extensions/smallrye-reactive-messaging-kafka/deployment/src/main/java/io/quarkus/smallrye/reactivemessaging/kafka/deployment/JacksonSerdeGenerator.java @@ -9,7 +9,6 @@ import io.quarkus.deployment.builditem.GeneratedServiceProviderBuildItem; import io.quarkus.gizmo2.Const; import io.quarkus.gizmo2.Gizmo; -import io.quarkus.gizmo2.LambdaStrategy; import io.quarkus.gizmo2.desc.ConstructorDesc; import io.quarkus.kafka.client.serialization.ObjectMapperDeserializer; import io.quarkus.kafka.client.serialization.ObjectMapperSerializer; @@ -25,8 +24,7 @@ public static String generateSerializer(BuildProducer g BuildProducer generatedResources, BuildProducer generatedServiceProviders, Type type) { var classOutput = new GeneratedClassGizmo2Adaptor(generatedClass, generatedResources, generatedServiceProviders, true); - var gizmo = Gizmo.create(classOutput) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS); + var gizmo = Gizmo.create(classOutput); String baseName = type.name().withoutPackagePrefix(); String out = baseName + "_Serializer_" + HashUtil.sha1(type.name().toString()); String className = type.name().packagePrefix() + "." + out; @@ -41,8 +39,7 @@ public static String generateDeserializer(BuildProducer BuildProducer generatedResources, BuildProducer generatedServiceProviders, Type type) { var classOutput = new GeneratedClassGizmo2Adaptor(generatedClass, generatedResources, generatedServiceProviders, true); - var gizmo = Gizmo.create(classOutput) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS); + var gizmo = Gizmo.create(classOutput); String baseName = type.name().withoutPackagePrefix(); String out = baseName + "_Deserializer_" + HashUtil.sha1(type.name().toString()); String className = type.name().packagePrefix() + "." + out; diff --git a/extensions/smallrye-reactive-messaging/deployment/src/main/java/io/quarkus/smallrye/reactivemessaging/deployment/SmallRyeReactiveMessagingProcessor.java b/extensions/smallrye-reactive-messaging/deployment/src/main/java/io/quarkus/smallrye/reactivemessaging/deployment/SmallRyeReactiveMessagingProcessor.java index e0b1791d200a8..b691362ebf2ca 100644 --- a/extensions/smallrye-reactive-messaging/deployment/src/main/java/io/quarkus/smallrye/reactivemessaging/deployment/SmallRyeReactiveMessagingProcessor.java +++ b/extensions/smallrye-reactive-messaging/deployment/src/main/java/io/quarkus/smallrye/reactivemessaging/deployment/SmallRyeReactiveMessagingProcessor.java @@ -65,7 +65,6 @@ import io.quarkus.gizmo2.Const; import io.quarkus.gizmo2.Expr; import io.quarkus.gizmo2.Gizmo; -import io.quarkus.gizmo2.LambdaStrategy; import io.quarkus.gizmo2.LocalVar; import io.quarkus.gizmo2.ParamVar; import io.quarkus.gizmo2.desc.ConstructorDesc; @@ -283,8 +282,7 @@ public void build(SmallRyeReactiveMessagingRecorder recorder, ReactiveMessagingConfiguration conf) { Gizmo gizmo = Gizmo - .create(new GeneratedClassGizmo2Adaptor(generatedClass, generatedResources, generatedServiceProviders, true)) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS); + .create(new GeneratedClassGizmo2Adaptor(generatedClass, generatedResources, generatedServiceProviders, true)); Set connectorManagedIncomingChannels = connectorManagedChannels.stream() .filter(c -> c.getDirection() == ChannelDirection.INCOMING) diff --git a/extensions/spring-boot-properties/deployment/src/main/java/io/quarkus/spring/boot/properties/deployment/ClassConfigurationPropertiesUtil.java b/extensions/spring-boot-properties/deployment/src/main/java/io/quarkus/spring/boot/properties/deployment/ClassConfigurationPropertiesUtil.java index b193ebaf37650..bd3e443e29de0 100644 --- a/extensions/spring-boot-properties/deployment/src/main/java/io/quarkus/spring/boot/properties/deployment/ClassConfigurationPropertiesUtil.java +++ b/extensions/spring-boot-properties/deployment/src/main/java/io/quarkus/spring/boot/properties/deployment/ClassConfigurationPropertiesUtil.java @@ -39,7 +39,6 @@ import io.quarkus.gizmo2.Const; import io.quarkus.gizmo2.Expr; import io.quarkus.gizmo2.Gizmo; -import io.quarkus.gizmo2.LambdaStrategy; import io.quarkus.gizmo2.LocalVar; import io.quarkus.gizmo2.creator.BlockCreator; import io.quarkus.gizmo2.creator.ClassCreator; @@ -110,8 +109,7 @@ final class ClassConfigurationPropertiesUtil { */ static void generateStartupObserverThatInjectsConfigClass(ClassOutput classOutput, Set configClasses) { - Gizmo gizmo = Gizmo.create(classOutput) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS); + Gizmo gizmo = Gizmo.create(classOutput); gizmo.class_(ConfigurationPropertiesUtil.PACKAGE_TO_PLACE_GENERATED_CLASSES + ".ConfigPropertiesObserver", cc -> { cc.addAnnotation(Dependent.class); diff --git a/extensions/spring-boot-properties/deployment/src/main/java/io/quarkus/spring/boot/properties/deployment/ConfigurationPropertiesBuildStep.java b/extensions/spring-boot-properties/deployment/src/main/java/io/quarkus/spring/boot/properties/deployment/ConfigurationPropertiesBuildStep.java index cdec9d3cb8050..a13f9e6606b5e 100644 --- a/extensions/spring-boot-properties/deployment/src/main/java/io/quarkus/spring/boot/properties/deployment/ConfigurationPropertiesBuildStep.java +++ b/extensions/spring-boot-properties/deployment/src/main/java/io/quarkus/spring/boot/properties/deployment/ConfigurationPropertiesBuildStep.java @@ -25,11 +25,11 @@ import io.quarkus.deployment.builditem.GeneratedResourceBuildItem; import io.quarkus.deployment.builditem.GeneratedServiceProviderBuildItem; import io.quarkus.deployment.builditem.RunTimeConfigurationDefaultBuildItem; +import io.quarkus.deployment.builditem.nativeimage.ConstantBootstrapBuildItem; import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; import io.quarkus.deployment.builditem.nativeimage.ReflectiveMethodBuildItem; import io.quarkus.gizmo2.ClassOutput; import io.quarkus.gizmo2.Gizmo; -import io.quarkus.gizmo2.LambdaStrategy; import io.quarkus.spring.boot.properties.deployment.InterfaceConfigurationPropertiesUtil.GeneratedClass; public class ConfigurationPropertiesBuildStep { @@ -40,6 +40,7 @@ void setup(CombinedIndexBuildItem combinedIndex, Capabilities capabilities, BuildProducer generatedClasses, BuildProducer generatedResources, + BuildProducer constantBootstraps, BuildProducer generatedServiceProviders, BuildProducer generatedBeans, BuildProducer defaultConfigValues, @@ -52,7 +53,7 @@ void setup(CombinedIndexBuildItem combinedIndex, ClassOutput beansClassOutput = new GeneratedBeanGizmo2Adaptor(generatedBeans); ClassOutput nonBeansClassOutput = new GeneratedClassGizmo2Adaptor(generatedClasses, generatedResources, - generatedServiceProviders, true); + generatedServiceProviders, constantBootstraps, true); /* * We generate CDI producer bean containing one method for each of the @ConfigProperties @@ -63,8 +64,7 @@ void setup(CombinedIndexBuildItem combinedIndex, IndexView index = combinedIndex.getIndex(); YamlListObjectHandler yamlListObjectHandler = new YamlListObjectHandler(nonBeansClassOutput, index, reflectiveClasses); - Gizmo gizmo = Gizmo.create(beansClassOutput) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS); + Gizmo gizmo = Gizmo.create(beansClassOutput); gizmo.class_(ConfigurationPropertiesUtil.PACKAGE_TO_PLACE_GENERATED_CLASSES + ".ConfigPropertiesProducer", cc -> { cc.addAnnotation(Singleton.class); cc.defaultConstructor(); diff --git a/extensions/spring-boot-properties/deployment/src/main/java/io/quarkus/spring/boot/properties/deployment/InterfaceConfigurationPropertiesUtil.java b/extensions/spring-boot-properties/deployment/src/main/java/io/quarkus/spring/boot/properties/deployment/InterfaceConfigurationPropertiesUtil.java index 994aefa38699e..59624593496a0 100644 --- a/extensions/spring-boot-properties/deployment/src/main/java/io/quarkus/spring/boot/properties/deployment/InterfaceConfigurationPropertiesUtil.java +++ b/extensions/spring-boot-properties/deployment/src/main/java/io/quarkus/spring/boot/properties/deployment/InterfaceConfigurationPropertiesUtil.java @@ -36,7 +36,6 @@ import io.quarkus.gizmo2.Const; import io.quarkus.gizmo2.Expr; import io.quarkus.gizmo2.Gizmo; -import io.quarkus.gizmo2.LambdaStrategy; import io.quarkus.gizmo2.LocalVar; import io.quarkus.gizmo2.creator.ClassCreator; import io.quarkus.gizmo2.desc.ConstructorDesc; @@ -128,8 +127,7 @@ private String generateImplementationForInterfaceConfigPropertiesRec(ClassInfo o ClassDesc generatedClassDesc = ClassDesc.of(generatedClassName); ClassDesc configClassDesc = ClassDesc.of(Config.class.getName()); - Gizmo gizmo = Gizmo.create(classOutput) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS); + Gizmo gizmo = Gizmo.create(classOutput); gizmo.class_(generatedClassName, cc -> { cc.implements_(ClassDesc.of(currentInterface.name().toString())); diff --git a/extensions/spring-boot-properties/deployment/src/main/java/io/quarkus/spring/boot/properties/deployment/YamlListObjectHandler.java b/extensions/spring-boot-properties/deployment/src/main/java/io/quarkus/spring/boot/properties/deployment/YamlListObjectHandler.java index 2af48670e9481..ce4d47bdb3570 100644 --- a/extensions/spring-boot-properties/deployment/src/main/java/io/quarkus/spring/boot/properties/deployment/YamlListObjectHandler.java +++ b/extensions/spring-boot-properties/deployment/src/main/java/io/quarkus/spring/boot/properties/deployment/YamlListObjectHandler.java @@ -25,7 +25,6 @@ import io.quarkus.gizmo2.Expr; import io.quarkus.gizmo2.GenericType; import io.quarkus.gizmo2.Gizmo; -import io.quarkus.gizmo2.LambdaStrategy; import io.quarkus.gizmo2.LocalVar; import io.quarkus.gizmo2.TypeArgument; import io.quarkus.gizmo2.creator.BlockCreator; @@ -108,8 +107,7 @@ public Expr handle(Member member, BlockCreator bc, Expr configObject, private MethodDesc generateWrapperClass(String wrapperClassName, String configName, ClassInfo classInfo, String getterName) { - Gizmo gizmo = Gizmo.create(classOutput) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS); + Gizmo gizmo = Gizmo.create(classOutput); ClassDesc wrapperClassDesc = ClassDesc.of(wrapperClassName); ClassDesc elementClassDesc = ClassDesc.of(classInfo.name().toString()); GenericType listOfElement = GenericType.ofClass(List.class, TypeArgument.of(elementClassDesc)); @@ -145,8 +143,7 @@ private MethodDesc generateWrapperClass(String wrapperClassName, String configNa } private void generateConverterClass(String wrapperConverterClassName, String wrapperClassName, ClassInfo classInfo) { - Gizmo gizmo = Gizmo.create(classOutput) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS); + Gizmo gizmo = Gizmo.create(classOutput); ClassDesc wrapperClassDesc = ClassDesc.of(wrapperClassName); gizmo.class_(wrapperConverterClassName, cc -> { cc.extends_(GenericType.ofClass(ClassDesc.of(ABSTRACT_YAML_CONVERTER_CNAME), diff --git a/extensions/spring-data-jpa/deployment/src/main/java/io/quarkus/spring/data/deployment/SpringDataJPAProcessor.java b/extensions/spring-data-jpa/deployment/src/main/java/io/quarkus/spring/data/deployment/SpringDataJPAProcessor.java index 141c2d5b3cd72..300d7d6cea5ca 100644 --- a/extensions/spring-data-jpa/deployment/src/main/java/io/quarkus/spring/data/deployment/SpringDataJPAProcessor.java +++ b/extensions/spring-data-jpa/deployment/src/main/java/io/quarkus/spring/data/deployment/SpringDataJPAProcessor.java @@ -45,6 +45,7 @@ import io.quarkus.deployment.builditem.GeneratedClassBuildItem; import io.quarkus.deployment.builditem.GeneratedResourceBuildItem; import io.quarkus.deployment.builditem.GeneratedServiceProviderBuildItem; +import io.quarkus.deployment.builditem.nativeimage.ConstantBootstrapBuildItem; import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; import io.quarkus.gizmo2.ClassOutput; import io.quarkus.hibernate.orm.deployment.spi.IgnorableNonIndexedClasses; @@ -128,6 +129,7 @@ void build(CombinedIndexBuildItem index, BuildProducer generatedServiceProviders, BuildProducer additionalBeans, BuildProducer reflectiveClasses, + BuildProducer constantBootstraps, BuildProducer entityToPersistenceUnit) { detectAndLogSpecificSpringPropertiesIfExist(); @@ -144,7 +146,7 @@ void build(CombinedIndexBuildItem index, Set entities = implementCrudRepositories(generatedBeans, generatedClasses, generatedResources, generatedServiceProviders, additionalBeans, reflectiveClasses, - interfacesExtendingRepository, indexView); + constantBootstraps, interfacesExtendingRepository, indexView); determineEntityPersistenceUnits(jpaModelPersistenceUnitMapping, entities, "Spring Data JPA") .forEach((e, pu) -> entityToPersistenceUnit.produce(new EntityToPersistenceUnitBuildItem(e, pu))); @@ -253,11 +255,12 @@ private Set implementCrudRepositories(BuildProducer generatedServiceProviders, BuildProducer additionalBeans, BuildProducer reflectiveClasses, + BuildProducer constantBootstraps, Set crudRepositoriesToImplement, IndexView index) { ClassOutput beansClassOutput = new GeneratedBeanGizmo2Adaptor(generatedBeans); ClassOutput otherClassOutput = new GeneratedClassGizmo2Adaptor(generatedClasses, generatedResources, - generatedServiceProviders, true); + generatedServiceProviders, constantBootstraps, true); SpringDataRepositoryCreator repositoryCreator = new SpringDataRepositoryCreator(beansClassOutput, otherClassOutput, index, (n) -> { diff --git a/extensions/spring-data-jpa/deployment/src/main/java/io/quarkus/spring/data/deployment/generate/CustomQueryMethodsAdder.java b/extensions/spring-data-jpa/deployment/src/main/java/io/quarkus/spring/data/deployment/generate/CustomQueryMethodsAdder.java index ed238e2ee0906..4ec2ef4e35638 100644 --- a/extensions/spring-data-jpa/deployment/src/main/java/io/quarkus/spring/data/deployment/generate/CustomQueryMethodsAdder.java +++ b/extensions/spring-data-jpa/deployment/src/main/java/io/quarkus/spring/data/deployment/generate/CustomQueryMethodsAdder.java @@ -34,7 +34,6 @@ import io.quarkus.gizmo2.Const; import io.quarkus.gizmo2.Expr; import io.quarkus.gizmo2.Gizmo; -import io.quarkus.gizmo2.LambdaStrategy; import io.quarkus.gizmo2.LocalVar; import io.quarkus.gizmo2.ParamVar; import io.quarkus.gizmo2.creator.BlockCreator; @@ -508,8 +507,7 @@ private void generateCustomResultTypes(DotName interfaceName, DotName implName, ClassInfo interfaceInfo = index.getClassByName(interfaceName); - Gizmo gizmo = Gizmo.create(nonBeansClassOutput) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS); + Gizmo gizmo = Gizmo.create(nonBeansClassOutput); gizmo.class_(implName.toString(), implClassCreator -> { implClassCreator.implements_(ClassDesc.of(interfaceName.toString())); diff --git a/extensions/spring-data-jpa/deployment/src/main/java/io/quarkus/spring/data/deployment/generate/DerivedMethodsAdder.java b/extensions/spring-data-jpa/deployment/src/main/java/io/quarkus/spring/data/deployment/generate/DerivedMethodsAdder.java index 6f2388ca41710..c13c6d0d191e4 100644 --- a/extensions/spring-data-jpa/deployment/src/main/java/io/quarkus/spring/data/deployment/generate/DerivedMethodsAdder.java +++ b/extensions/spring-data-jpa/deployment/src/main/java/io/quarkus/spring/data/deployment/generate/DerivedMethodsAdder.java @@ -33,7 +33,6 @@ import io.quarkus.gizmo2.Const; import io.quarkus.gizmo2.Expr; import io.quarkus.gizmo2.Gizmo; -import io.quarkus.gizmo2.LambdaStrategy; import io.quarkus.gizmo2.LocalVar; import io.quarkus.gizmo2.ParamVar; import io.quarkus.gizmo2.creator.ClassCreator; @@ -424,8 +423,7 @@ private void generateCustomResultTypes(DotName interfaceName, DotName implName, ClassInfo interfaceInfo = index.getClassByName(interfaceName); - Gizmo gizmo = Gizmo.create(nonBeansClassOutput) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS); + Gizmo gizmo = Gizmo.create(nonBeansClassOutput); gizmo.class_(implName.toString(), implClassCreator -> { implClassCreator.implements_(ClassDesc.of(interfaceName.toString())); diff --git a/extensions/spring-data-jpa/deployment/src/main/java/io/quarkus/spring/data/deployment/generate/SpringDataRepositoryCreator.java b/extensions/spring-data-jpa/deployment/src/main/java/io/quarkus/spring/data/deployment/generate/SpringDataRepositoryCreator.java index 89b957278e48d..b0b32f9cd2296 100644 --- a/extensions/spring-data-jpa/deployment/src/main/java/io/quarkus/spring/data/deployment/generate/SpringDataRepositoryCreator.java +++ b/extensions/spring-data-jpa/deployment/src/main/java/io/quarkus/spring/data/deployment/generate/SpringDataRepositoryCreator.java @@ -89,10 +89,11 @@ public Result implementCrudRepository(ClassInfo repositoryToImplement, IndexView // Track existing methods across all adders Set existingMethods = new HashSet<>(); + // todo: the gizmo-to-bean adapter doesn't know about constant bootstraps; use anon classes for lambdas for now Gizmo gizmo = Gizmo.create(classOutput) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS) .withDebugInfo(false) - .withParameters(false); + .withParameters(false) + .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS); gizmo.class_(generatedClassName, classCreator -> { classCreator.implements_(ClassDesc.of(repositoryToImplementStr)); classCreator.addAnnotation(ApplicationScoped.class); diff --git a/extensions/spring-data-rest/deployment/src/main/java/io/quarkus/spring/data/rest/deployment/ResourceImplementor.java b/extensions/spring-data-rest/deployment/src/main/java/io/quarkus/spring/data/rest/deployment/ResourceImplementor.java index a66533655292d..78c232dc5d002 100644 --- a/extensions/spring-data-rest/deployment/src/main/java/io/quarkus/spring/data/rest/deployment/ResourceImplementor.java +++ b/extensions/spring-data-rest/deployment/src/main/java/io/quarkus/spring/data/rest/deployment/ResourceImplementor.java @@ -6,7 +6,6 @@ import io.quarkus.gizmo2.ClassOutput; import io.quarkus.gizmo2.Gizmo; -import io.quarkus.gizmo2.LambdaStrategy; import io.quarkus.rest.data.panache.RestDataResource; import io.quarkus.runtime.util.HashUtil; @@ -28,7 +27,7 @@ public String implement(ClassOutput classOutput, String resourceType, String ent String className = resourceType + "ResourceImpl_" + HashUtil.sha1(resourceType); LOGGER.tracef("Starting generation of '%s'", className); - Gizmo.create(classOutput).withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS).class_(className, cc -> { + Gizmo.create(classOutput).class_(className, cc -> { cc.implements_(RestDataResource.class); cc.addAnnotation(ApplicationScoped.class); cc.defaultConstructor(); diff --git a/extensions/spring-security/deployment/src/main/java/io/quarkus/spring/security/deployment/BeanMethodInvocationGenerator.java b/extensions/spring-security/deployment/src/main/java/io/quarkus/spring/security/deployment/BeanMethodInvocationGenerator.java index 5edd489e08caf..0a897223b3fec 100644 --- a/extensions/spring-security/deployment/src/main/java/io/quarkus/spring/security/deployment/BeanMethodInvocationGenerator.java +++ b/extensions/spring-security/deployment/src/main/java/io/quarkus/spring/security/deployment/BeanMethodInvocationGenerator.java @@ -31,7 +31,6 @@ import io.quarkus.gizmo2.Const; import io.quarkus.gizmo2.Expr; import io.quarkus.gizmo2.Gizmo; -import io.quarkus.gizmo2.LambdaStrategy; import io.quarkus.gizmo2.LocalVar; import io.quarkus.gizmo2.ParamVar; import io.quarkus.gizmo2.desc.ConstructorDesc; @@ -113,7 +112,7 @@ final String generateSecurityCheck(String expression, MethodInfo securedMethodIn final String[] finalBeanMethodArgumentExpressions = beanMethodArgumentExpressions; final boolean[] checkRequiresMethodArguments = { false }; - Gizmo.create(classOutput).withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS).class_(generatedClassName, cc -> { + Gizmo.create(classOutput).class_(generatedClassName, cc -> { cc.extends_(AbstractBeanMethodSecurityCheck.class); cc.defaultConstructor(); diff --git a/extensions/spring-security/deployment/src/main/java/io/quarkus/spring/security/deployment/StringPropertyAccessorGenerator.java b/extensions/spring-security/deployment/src/main/java/io/quarkus/spring/security/deployment/StringPropertyAccessorGenerator.java index daebb49ab04ab..a1be30dfe1354 100644 --- a/extensions/spring-security/deployment/src/main/java/io/quarkus/spring/security/deployment/StringPropertyAccessorGenerator.java +++ b/extensions/spring-security/deployment/src/main/java/io/quarkus/spring/security/deployment/StringPropertyAccessorGenerator.java @@ -13,7 +13,6 @@ import io.quarkus.gizmo2.Const; import io.quarkus.gizmo2.Expr; import io.quarkus.gizmo2.Gizmo; -import io.quarkus.gizmo2.LambdaStrategy; import io.quarkus.gizmo2.LocalVar; import io.quarkus.gizmo2.ParamVar; import io.quarkus.gizmo2.desc.ClassMethodDesc; @@ -57,7 +56,7 @@ static String getAccessorClassName(DotName className) { */ static String generate(DotName className, Set properties, ClassOutput classOutput) { String generatedClassName = getAccessorClassName(className); - Gizmo.create(classOutput).withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS).class_(generatedClassName, cc -> { + Gizmo.create(classOutput).class_(generatedClassName, cc -> { cc.implements_(StringPropertyAccessor.class); cc.addAnnotation(Singleton.class); cc.defaultConstructor(); diff --git a/extensions/spring-web/core/deployment/src/main/java/io/quarkus/spring/web/deployment/AbstractExceptionMapperGenerator.java b/extensions/spring-web/core/deployment/src/main/java/io/quarkus/spring/web/deployment/AbstractExceptionMapperGenerator.java index 37a5c6e3ee480..b5ac6b0d92f04 100644 --- a/extensions/spring-web/core/deployment/src/main/java/io/quarkus/spring/web/deployment/AbstractExceptionMapperGenerator.java +++ b/extensions/spring-web/core/deployment/src/main/java/io/quarkus/spring/web/deployment/AbstractExceptionMapperGenerator.java @@ -16,7 +16,6 @@ import io.quarkus.gizmo2.Expr; import io.quarkus.gizmo2.GenericType; import io.quarkus.gizmo2.Gizmo; -import io.quarkus.gizmo2.LambdaStrategy; import io.quarkus.gizmo2.TypeArgument; import io.quarkus.gizmo2.creator.BlockCreator; import io.quarkus.gizmo2.creator.ClassCreator; @@ -50,8 +49,7 @@ String generate() { ClassDesc exceptionClassDesc = ClassDesc.of(exceptionClassName); ClassDesc generatedClassDesc = ClassDesc.of(generatedClassName); - Gizmo gizmo = Gizmo.create(classOutput) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS); + Gizmo gizmo = Gizmo.create(classOutput); gizmo.class_(generatedClassName, cc -> { cc.implements_(GenericType.ofClass(ExceptionMapper.class, TypeArgument.of(exceptionClassDesc))); diff --git a/extensions/vertx/deployment/src/main/java/io/quarkus/vertx/deployment/VertxProcessor.java b/extensions/vertx/deployment/src/main/java/io/quarkus/vertx/deployment/VertxProcessor.java index deb3dec9e9873..3fc5d38b4cce1 100644 --- a/extensions/vertx/deployment/src/main/java/io/quarkus/vertx/deployment/VertxProcessor.java +++ b/extensions/vertx/deployment/src/main/java/io/quarkus/vertx/deployment/VertxProcessor.java @@ -57,6 +57,7 @@ import io.quarkus.deployment.builditem.LaunchModeBuildItem; import io.quarkus.deployment.builditem.ServiceStartBuildItem; import io.quarkus.deployment.builditem.ShutdownContextBuildItem; +import io.quarkus.deployment.builditem.nativeimage.ConstantBootstrapBuildItem; import io.quarkus.deployment.builditem.nativeimage.NativeImageConfigBuildItem; import io.quarkus.deployment.builditem.nativeimage.NativeImageResourceBuildItem; import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; @@ -97,14 +98,17 @@ VertxBuildItem build(CoreVertxBuildItem vertx, VertxEventBusConsumerRecorder rec BuildProducer generatedClass, BuildProducer generatedResource, BuildProducer generatedServiceProviders, - AnnotationProxyBuildItem annotationProxy, LaunchModeBuildItem launchMode, ShutdownContextBuildItem shutdown, + BuildProducer constantBootstraps, + AnnotationProxyBuildItem annotationProxy, + LaunchModeBuildItem launchMode, + ShutdownContextBuildItem shutdown, BuildProducer serviceStart, BuildProducer reflectiveClassBuildItemBuildProducer, List codecs, LocalCodecSelectorTypesBuildItem localCodecSelectorTypes, RecorderContext recorderContext) { List messageConsumerConfigurations = new ArrayList<>(); - ClassOutput classOutput = new GeneratedClassGizmo2Adaptor(generatedClass, generatedResource, generatedServiceProviders, - true); + ClassOutput classOutput = new GeneratedClassGizmo2Adaptor(generatedClass, generatedResource, + generatedServiceProviders, constantBootstraps, true); for (EventConsumerBusinessMethodItem businessMethod : messageConsumerBusinessMethods) { ConsumeEvent annotation = annotationProxy.builder(businessMethod.getConsumeEvent(), ConsumeEvent.class) .withDefaultValue("value", businessMethod.getBean().getBeanClass().toString()) diff --git a/extensions/websockets-next/deployment/src/main/java/io/quarkus/websockets/next/deployment/WebSocketProcessor.java b/extensions/websockets-next/deployment/src/main/java/io/quarkus/websockets/next/deployment/WebSocketProcessor.java index 97b4000d4702b..f87ee8d4d928f 100644 --- a/extensions/websockets-next/deployment/src/main/java/io/quarkus/websockets/next/deployment/WebSocketProcessor.java +++ b/extensions/websockets-next/deployment/src/main/java/io/quarkus/websockets/next/deployment/WebSocketProcessor.java @@ -95,6 +95,7 @@ import io.quarkus.deployment.builditem.GeneratedResourceBuildItem; import io.quarkus.deployment.builditem.GeneratedServiceProviderBuildItem; import io.quarkus.deployment.builditem.SystemPropertyBuildItem; +import io.quarkus.deployment.builditem.nativeimage.ConstantBootstrapBuildItem; import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; import io.quarkus.deployment.builditem.nativeimage.ReflectiveHierarchyBuildItem; import io.quarkus.deployment.execannotations.ExecutionModelAnnotationsAllowedBuildItem; @@ -103,7 +104,6 @@ import io.quarkus.gizmo2.Const; import io.quarkus.gizmo2.Expr; import io.quarkus.gizmo2.Gizmo; -import io.quarkus.gizmo2.LambdaStrategy; import io.quarkus.gizmo2.LocalVar; import io.quarkus.gizmo2.ParamVar; import io.quarkus.gizmo2.Var; @@ -491,12 +491,13 @@ void generateEndpoints(BeanArchiveIndexBuildItem index, List generatedClasses, BuildProducer generatedResources, BuildProducer generatedServiceProviders, + BuildProducer constantBootstraps, BuildProducer generatedEndpoints, BuildProducer reflectiveClasses, Optional metricsCapability) { final boolean metricsSupportEnabled = isMetricsSupportEnabled(metricsCapability); ClassOutput classOutput = new GeneratedClassGizmo2Adaptor(generatedClasses, generatedResources, - generatedServiceProviders, new Function() { + generatedServiceProviders, constantBootstraps, new Function() { @Override public String apply(String name) { int idx = name.indexOf(CLIENT_ENDPOINT_SUFFIX); @@ -513,7 +514,6 @@ public String apply(String name) { } }); Gizmo gizmo = Gizmo.create(classOutput) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS) .withDebugInfo(false) .withParameters(false); for (WebSocketEndpointBuildItem endpoint : endpoints) { diff --git a/independent-projects/arc/processor/src/main/java/io/quarkus/arc/processor/AbstractGenerator.java b/independent-projects/arc/processor/src/main/java/io/quarkus/arc/processor/AbstractGenerator.java index 4410cb184acd8..0fa6eb38833f3 100644 --- a/independent-projects/arc/processor/src/main/java/io/quarkus/arc/processor/AbstractGenerator.java +++ b/independent-projects/arc/processor/src/main/java/io/quarkus/arc/processor/AbstractGenerator.java @@ -33,6 +33,8 @@ static Gizmo gizmo(ClassOutput classOutput) { if (tccl == null) { throw new IllegalStateException("No TCCL available"); } + // todo: in order to use the optimized lambda strategy, we have to update all sources of ClassOutput + // so that they output the constant bootstrap build item, so that native image knows to initialize it properly. return Gizmo.create(classOutput) .withDebugInfo(false) .withParameters(false) diff --git a/independent-projects/qute/generator/src/main/java/io/quarkus/qute/generator/AbstractGenerator.java b/independent-projects/qute/generator/src/main/java/io/quarkus/qute/generator/AbstractGenerator.java index 389cdaf3f9a8d..041d04dc928a1 100644 --- a/independent-projects/qute/generator/src/main/java/io/quarkus/qute/generator/AbstractGenerator.java +++ b/independent-projects/qute/generator/src/main/java/io/quarkus/qute/generator/AbstractGenerator.java @@ -12,7 +12,6 @@ import io.quarkus.gizmo2.ClassOutput; import io.quarkus.gizmo2.Expr; import io.quarkus.gizmo2.Gizmo; -import io.quarkus.gizmo2.LambdaStrategy; import io.quarkus.gizmo2.LocalVar; import io.quarkus.gizmo2.creator.BlockCreator; import io.quarkus.gizmo2.creator.ClassCreator; @@ -27,7 +26,6 @@ protected AbstractGenerator(IndexView index, ClassOutput classOutput) { this.generatedTypes = new HashSet<>(); this.index = index; this.gizmo = Gizmo.create(classOutput) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS) .withDebugInfo(false) .withParameters(false); } diff --git a/test-framework/junit-internal/src/test/java/io/quarkus/test/BytecodeToolsTest.java b/test-framework/junit-internal/src/test/java/io/quarkus/test/BytecodeToolsTest.java index 3cce7700e0ccc..bad6194a642ce 100644 --- a/test-framework/junit-internal/src/test/java/io/quarkus/test/BytecodeToolsTest.java +++ b/test-framework/junit-internal/src/test/java/io/quarkus/test/BytecodeToolsTest.java @@ -11,7 +11,6 @@ import org.junit.jupiter.api.io.TempDir; import io.quarkus.gizmo2.Gizmo; -import io.quarkus.gizmo2.LambdaStrategy; class BytecodeToolsTest { @@ -87,8 +86,7 @@ void dumpCreatesExpectedFileStructure(@TempDir Path tempDir) throws Exception { private Map generateSimpleClass(String binaryName) { Map result = new HashMap<>(); - Gizmo g = Gizmo.create((path, bytes) -> result.put(path, bytes)) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS); + Gizmo g = Gizmo.create(result::put); g.class_(binaryName, cc -> { cc.public_(); }); @@ -97,8 +95,7 @@ private Map generateSimpleClass(String binaryName) { private Map generateClassWithField(String binaryName, String fieldName) { Map result = new HashMap<>(); - Gizmo g = Gizmo.create((path, bytes) -> result.put(path, bytes)) - .withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS); + Gizmo g = Gizmo.create(result::put); g.class_(binaryName, cc -> { cc.public_(); cc.field(fieldName, fc -> { From e4645b537583765bda955156f1b8009876ea09f6 Mon Sep 17 00:00:00 2001 From: "David M. Lloyd" Date: Mon, 5 Oct 2026 09:32:34 -0500 Subject: [PATCH 079/254] Bug fix: Make sure that aliases and RV wrappers depend on their service This was causing recorder compatibility to break when parallel start/stop was enabled. --- .../quarkus/deployment/steps/ServiceGraphBuilder.java | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/core/deployment/src/main/java/io/quarkus/deployment/steps/ServiceGraphBuilder.java b/core/deployment/src/main/java/io/quarkus/deployment/steps/ServiceGraphBuilder.java index 1aa9fcab21268..4f522a67394ad 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/steps/ServiceGraphBuilder.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/steps/ServiceGraphBuilder.java @@ -154,9 +154,14 @@ private static GraphPlan build(List entries, Map> for (TransliteratedAction action : actions) { MutableNode serviceNode = new MutableNode(action.serviceKey(), stepId, kindForAction(action), null, action); - // no implicit intra-step ordering: services are independent from - // recorders in the same step. Use afterBuildItem(), after(), or - // require() to declare explicit ordering dependencies. + // Pure SERVICE nodes are independent from recorders in the same step + // (they get values from the graph, not the values map). + // ALIAS and RV_WRAPPER nodes read from the StartupContext values map + // that the recorder writes to, so they must depend on the recorder. + if (recorderNode != null && (serviceNode.kind == NodeKind.ALIAS + || serviceNode.kind == NodeKind.RV_WRAPPER)) { + serviceNode.stepDeps.add(recorderNode); + } nodes.add(serviceNode); stepToNodes.computeIfAbsent(stepId, k -> new ArrayList<>()).add(serviceNode); serviceKeyToNode.put(action.serviceKey(), serviceNode); From b4c5a40e7fde1caea34f0ec69cf8273cdd7cffa4 Mon Sep 17 00:00:00 2001 From: "David M. Lloyd" Date: Mon, 5 Oct 2026 11:39:14 -0500 Subject: [PATCH 080/254] Bug fix: Mark "absent" nodes separately from sentinels This was causing the stopped-counts to be inaccurate once parallel stop was enabled. --- .../deployment/steps/MainClassBuildStep.java | 23 +++++++++++++++++++ .../deployment/steps/ServiceGraphBuilder.java | 4 +++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/core/deployment/src/main/java/io/quarkus/deployment/steps/MainClassBuildStep.java b/core/deployment/src/main/java/io/quarkus/deployment/steps/MainClassBuildStep.java index 5ce06bbb17c3a..a84ad8cdf5998 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/steps/MainClassBuildStep.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/steps/MainClassBuildStep.java @@ -1031,6 +1031,11 @@ private LocalVar emitActionHandle( boolean isTop = (nodeIndex == 0); generateSentinelMethod(file, methodName, isTop); } + case ABSENT -> { + methodName = phaseName + "$absent$" + nodeIndex; + targetClass = Application.APP_CLASS_NAME; + generateAbsentSentinelMethod(file, methodName); + } case LEGACY_RECORDER -> { methodName = phaseName + "$legacy$" + nodeIndex; targetClass = Application.APP_CLASS_NAME; @@ -1077,6 +1082,24 @@ private static LocalVar emitFindStatic(BlockCreator code, String className, Stri return code.localVar("mh$" + className.replace('.', '_').replace('/', '_') + "$" + methodName, mh); } + /** + * Generate a method for an absent-placeholder sentinel (for optional dependencies). + * These sentinels simply signal void completion; they do not signal start-done or register stop handlers. + * + * @param classCreator the Application class creator + * @param methodName the method name to generate + */ + private static void generateAbsentSentinelMethod(ClassCreator classCreator, String methodName) { + classCreator.staticMethod(methodName, MethodTypeDesc.of(ConstantDescs.CD_void, CD_ServiceNode), mc -> { + mc.private_(); + ParamVar node = mc.parameter("node"); + mc.body(b0 -> { + b0.invokeVirtual(MD_ServiceNode_startComplete, node); + b0.return_(); + }); + }); + } + /** * Generate a sentinel static method in the Application class. *

diff --git a/core/deployment/src/main/java/io/quarkus/deployment/steps/ServiceGraphBuilder.java b/core/deployment/src/main/java/io/quarkus/deployment/steps/ServiceGraphBuilder.java index 4f522a67394ad..f29d99143d954 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/steps/ServiceGraphBuilder.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/steps/ServiceGraphBuilder.java @@ -237,7 +237,7 @@ private static GraphPlan build(List entries, Map> // LambdaTransliterator's graphDepIndex MutableNode absent = new MutableNode( "<>", node.stepId, - NodeKind.SENTINEL, null, null); + NodeKind.ABSENT, null, null); nodeIter.add(absent); node.actionDeps.add(absent); } else if (dep.injected()) { @@ -561,6 +561,8 @@ List allDeps() { enum NodeKind { /** Top or bottom sentinel. */ SENTINEL, + /** Absent-placeholder sentinel for an optional dependency with no matching service. */ + ABSENT, /** Legacy bytecode recorder chunk (one or more recorders from the same step). */ LEGACY_RECORDER, /** New-style service with a transliterated lambda body. */ From 21b1bc58e8f87f5883cd7981c646eaf3c73929ce Mon Sep 17 00:00:00 2001 From: "David M. Lloyd" Date: Mon, 5 Oct 2026 11:53:40 -0500 Subject: [PATCH 081/254] Bug fix: Add missing dependency between websocket services This fixes a warning-on-stop that appears when parallel stop is enabled. --- .../quarkus/websockets/deployment/ServerWebSocketProcessor.java | 1 + 1 file changed, 1 insertion(+) diff --git a/extensions/websockets/server/deployment/src/main/java/io/quarkus/websockets/deployment/ServerWebSocketProcessor.java b/extensions/websockets/server/deployment/src/main/java/io/quarkus/websockets/deployment/ServerWebSocketProcessor.java index a19460c75a205..8207e44186cff 100644 --- a/extensions/websockets/server/deployment/src/main/java/io/quarkus/websockets/deployment/ServerWebSocketProcessor.java +++ b/extensions/websockets/server/deployment/src/main/java/io/quarkus/websockets/deployment/ServerWebSocketProcessor.java @@ -121,6 +121,7 @@ public FilterBuildItem deploy(final CombinedIndexBuildItem indexBuildItem, .forService(RouteHandler.class, "io.quarkus.websocket.filter") .require(WebSocketDeploymentInfo.class) .require(ServerWebSocketContainer.class) + .after("io.quarkus.websocket.worker-setup") .onStart((ctx, info, container) -> WebsocketServerRecorder.createHandler(info, container)::handle); int priority = 1 + SecurityHandlerPriorities.AUTHORIZATION; From d5203c44eb3ccc5fdd8c38523c88a33d87b9e7b1 Mon Sep 17 00:00:00 2001 From: "David M. Lloyd" Date: Mon, 5 Oct 2026 14:14:16 -0500 Subject: [PATCH 082/254] Bug fix: Ensure that stop handlers run on the executor This fixes a bug in parallel stop where stop handlers were blocking each other. --- .../src/main/java/io/quarkus/core/impl/ServiceNode.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/core/runtime/src/main/java/io/quarkus/core/impl/ServiceNode.java b/core/runtime/src/main/java/io/quarkus/core/impl/ServiceNode.java index cbe3968cbaa54..40ec889ed7b47 100644 --- a/core/runtime/src/main/java/io/quarkus/core/impl/ServiceNode.java +++ b/core/runtime/src/main/java/io/quarkus/core/impl/ServiceNode.java @@ -681,7 +681,7 @@ void dependentTerminated() { old = wit; } if (shouldStop) { - runStopHandler(); + graph.executor().execute(this::runStopHandler); } } @@ -713,7 +713,7 @@ void initiateStop() { } old = wit; } - runStopHandler(); + graph.executor().execute(this::runStopHandler); } /** @@ -743,7 +743,7 @@ private void tryInitiateStopIfReady() { } old = wit; } - runStopHandler(); + graph.executor().execute(this::runStopHandler); } /** From 9760b4bd094f56e877925bea16ea11893dba1377 Mon Sep 17 00:00:00 2001 From: "David M. Lloyd" Date: Tue, 6 Oct 2026 07:29:06 -0500 Subject: [PATCH 083/254] Bug fix: Ensure that dep lists are always sorted. Fixes #57174 --- .../deployment/steps/ServiceGraphBuilder.java | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/core/deployment/src/main/java/io/quarkus/deployment/steps/ServiceGraphBuilder.java b/core/deployment/src/main/java/io/quarkus/deployment/steps/ServiceGraphBuilder.java index f29d99143d954..25f1ea4cbfb95 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/steps/ServiceGraphBuilder.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/steps/ServiceGraphBuilder.java @@ -10,6 +10,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.stream.Stream; import io.quarkus.core.deployment.service.impl.TransliteratedAction; import io.quarkus.deployment.builditem.MainBytecodeRecorderBuildItem; @@ -539,15 +540,16 @@ List allDeps() { return actionDeps; } if (actionDeps.isEmpty()) { - return new ArrayList<>(stepDeps); + return stepDeps.stream() + .sorted(Comparator.comparing(n -> n.name)) + .toList(); } - List result = new ArrayList<>(actionDeps); - for (MutableNode sd : stepDeps) { - if (!actionDeps.contains(sd)) { - result.add(sd); - } - } - return result; + return Stream.concat( + actionDeps.stream(), + stepDeps.stream() + .sorted(Comparator.comparing(n -> n.name)) + .filter(n -> !actionDeps.contains(n))) + .toList(); } } From c221b2637d9d6665988e15c7a826429fc7c5ecc1 Mon Sep 17 00:00:00 2001 From: "David M. Lloyd" Date: Tue, 6 Oct 2026 16:01:20 -0500 Subject: [PATCH 084/254] Bug fix: Remove dead code from service implementation --- .../core/impl/AsyncStartContextImpl.java | 138 ------------------ .../core/impl/AsyncVoidStartContextImpl.java | 135 ----------------- 2 files changed, 273 deletions(-) delete mode 100644 core/runtime/src/main/java/io/quarkus/core/impl/AsyncStartContextImpl.java delete mode 100644 core/runtime/src/main/java/io/quarkus/core/impl/AsyncVoidStartContextImpl.java diff --git a/core/runtime/src/main/java/io/quarkus/core/impl/AsyncStartContextImpl.java b/core/runtime/src/main/java/io/quarkus/core/impl/AsyncStartContextImpl.java deleted file mode 100644 index 8120761ca9b90..0000000000000 --- a/core/runtime/src/main/java/io/quarkus/core/impl/AsyncStartContextImpl.java +++ /dev/null @@ -1,138 +0,0 @@ -package io.quarkus.core.impl; - -import java.lang.invoke.ConstantBootstraps; -import java.lang.invoke.MethodHandles; -import java.lang.invoke.VarHandle; -import java.util.concurrent.CountDownLatch; -import java.util.function.Consumer; - -import io.quarkus.core.AsyncStartContext; -import io.quarkus.core.AsyncStopContext; -import io.quarkus.runtime.ShutdownContext; - -/** - * {@link AsyncStartContext} implementation for sequential typed service execution. - *

- * Stop handlers registered via {@link #onStop(Runnable)} or {@link #onStopAsync(Consumer)} - * are delegated to the {@link ShutdownContext} provided at construction time. - *

- * For async services, this implementation blocks the calling thread: {@link #startComplete(Object)} - * stores the value, {@link #startFailed(Throwable)} wraps and rethrows, and - * {@link #startCanceled()} stores {@code null}. - *

- * Passing {@code null} to {@link #startComplete(Object)} is an error; use a void service if - * the service produces no value. - *

- * Completion methods follow the precedence rules defined by {@link AsyncStartContext}. - * - * @param the service type - */ -public final class AsyncStartContextImpl implements AsyncStartContext { - - private static final int S_PENDING = 0; - private static final int S_COMPLETED = 1; - private static final int S_CANCELED = 2; - private static final int S_FAILED = 3; - - private static final VarHandle stateHandle = ConstantBootstraps.fieldVarHandle( - MethodHandles.lookup(), "state", VarHandle.class, AsyncStartContextImpl.class, int.class); - - private final ShutdownContext shutdownContext; - @SuppressWarnings("unused") // managed by VarHandle - private volatile int state; - private T value; - private Throwable failure; - - /** - * Construct a new instance. - * - * @param shutdownContext the shutdown context to delegate stop handlers to (must not be {@code null}) - */ - public AsyncStartContextImpl(ShutdownContext shutdownContext) { - this.shutdownContext = shutdownContext; - } - - @Override - public void onStopAsync(Consumer stopper) { - // the latch blocks the shutdown thread until stopComplete() is called - shutdownContext.addShutdownTask(() -> { - CountDownLatch latch = new CountDownLatch(1); - stopper.accept(latch::countDown); - boolean intr = false; - try { - while (latch.getCount() > 0) { - try { - latch.await(); - } catch (InterruptedException e) { - intr = true; - } - } - } finally { - if (intr) { - Thread.currentThread().interrupt(); - } - } - }); - } - - @Override - public void startComplete(T value) { - if (!stateHandle.compareAndSet(this, S_PENDING, S_COMPLETED)) { - throw new IllegalStateException("A completion method has already been called"); - } - if (value == null) { - throw new IllegalArgumentException("Service value must not be null; use a void service instead"); - } - this.value = value; - } - - @Override - public void startCanceled() { - // cancellation is idempotent and harmless after completion; only failure is final - int prev; - do { - prev = (int) stateHandle.getVolatile(this); - if (prev == S_FAILED) { - throw new IllegalStateException("Cannot cancel: the service has already failed"); - } - if (prev == S_CANCELED) { - return; - } - } while ((int) stateHandle.compareAndExchange(this, prev, S_CANCELED) != prev); - } - - @Override - public void startFailed(Throwable e) { - if (!stateHandle.compareAndSet(this, S_PENDING, S_FAILED)) { - throw new IllegalStateException("A completion method has already been called"); - } - this.failure = e; - } - - /** - * Get the service value after the action has completed. - * This method should only be called after the action lambda has returned. - * - * @return the service value, or {@code null} if the service was canceled - * @throws RuntimeException if the service start failed - */ - public T getValueOrThrow() { - if (failure != null) { - if (failure instanceof RuntimeException re) { - throw re; - } - throw new RuntimeException("Service start failed", failure); - } - return value; - } - - /** - * Determine whether the async action has signalled completion. - * - * @return {@code true} if {@link #startComplete}, {@link #startCanceled}, - * or {@link #startFailed} has been called - */ - public boolean isCompleted() { - return (int) stateHandle.getVolatile(this) != S_PENDING; - } -} diff --git a/core/runtime/src/main/java/io/quarkus/core/impl/AsyncVoidStartContextImpl.java b/core/runtime/src/main/java/io/quarkus/core/impl/AsyncVoidStartContextImpl.java deleted file mode 100644 index 177289eeb818b..0000000000000 --- a/core/runtime/src/main/java/io/quarkus/core/impl/AsyncVoidStartContextImpl.java +++ /dev/null @@ -1,135 +0,0 @@ -package io.quarkus.core.impl; - -import java.lang.invoke.ConstantBootstraps; -import java.lang.invoke.MethodHandles; -import java.lang.invoke.VarHandle; -import java.util.concurrent.CountDownLatch; -import java.util.function.Consumer; - -import io.quarkus.core.AsyncStopContext; -import io.quarkus.core.AsyncVoidStartContext; -import io.quarkus.runtime.ShutdownContext; - -/** - * {@link AsyncVoidStartContext} implementation for sequential void service execution. - *

- * Stop handlers registered via {@link #onStop(Runnable)} or {@link #onStopAsync(Consumer)} - * are delegated to the {@link ShutdownContext} provided at construction time. - *

- * For async void services, this implementation blocks the calling thread: - * {@link #startComplete()} signals success, {@link #startFailed(Throwable)} wraps and rethrows, - * and {@link #startCanceled()} signals cancellation. - *

- * Completion methods follow the precedence rules defined by {@link AsyncVoidStartContext}. - */ -public final class AsyncVoidStartContextImpl implements AsyncVoidStartContext { - - private static final int S_PENDING = 0; - private static final int S_COMPLETED = 1; - private static final int S_CANCELED = 2; - private static final int S_FAILED = 3; - - private static final VarHandle stateHandle = ConstantBootstraps.fieldVarHandle( - MethodHandles.lookup(), "state", VarHandle.class, AsyncVoidStartContextImpl.class, int.class); - - private final ShutdownContext shutdownContext; - @SuppressWarnings("unused") // managed by VarHandle - private volatile int state; - private Throwable failure; - - /** - * Construct a new instance. - * - * @param shutdownContext the shutdown context to delegate stop handlers to (must not be {@code null}) - */ - public AsyncVoidStartContextImpl(ShutdownContext shutdownContext) { - this.shutdownContext = shutdownContext; - } - - @Override - public void onStopAsync(Consumer stopper) { - // the latch blocks the shutdown thread until stopComplete() is called - shutdownContext.addShutdownTask(() -> { - CountDownLatch latch = new CountDownLatch(1); - stopper.accept(latch::countDown); - boolean intr = false; - try { - while (latch.getCount() > 0) { - try { - latch.await(); - } catch (InterruptedException e) { - intr = true; - } - } - } finally { - if (intr) { - Thread.currentThread().interrupt(); - } - } - }); - } - - @Override - public void startComplete() { - // idempotent; throws only if canceled or failed - int prev; - do { - prev = (int) stateHandle.getVolatile(this); - if (prev == S_COMPLETED) { - return; - } - if (prev != S_PENDING) { - throw new IllegalStateException("Cannot complete: the service has already " - + (prev == S_CANCELED ? "been canceled" : "failed")); - } - } while ((int) stateHandle.compareAndExchange(this, prev, S_COMPLETED) != prev); - } - - @Override - public void startCanceled() { - // idempotent; harmless no-op after completion; only failure is final - int prev; - do { - prev = (int) stateHandle.getVolatile(this); - if (prev == S_FAILED) { - throw new IllegalStateException("Cannot cancel: the service has already failed"); - } - if (prev == S_CANCELED || prev == S_COMPLETED) { - return; - } - } while ((int) stateHandle.compareAndExchange(this, prev, S_CANCELED) != prev); - } - - @Override - public void startFailed(Throwable e) { - if (!stateHandle.compareAndSet(this, S_PENDING, S_FAILED)) { - throw new IllegalStateException("A completion method has already been called"); - } - this.failure = e; - } - - /** - * Check for failure after the action has completed. - * This method should only be called after the action lambda has returned. - * - * @throws RuntimeException if the service start failed - */ - public void checkFailure() { - if (failure != null) { - if (failure instanceof RuntimeException re) { - throw re; - } - throw new RuntimeException("Service start failed", failure); - } - } - - /** - * Determine whether the async action has signalled completion. - * - * @return {@code true} if {@link #startComplete()}, {@link #startCanceled()}, - * or {@link #startFailed(Throwable)} has been called - */ - public boolean isCompleted() { - return (int) stateHandle.getVolatile(this) != S_PENDING; - } -} From 60c9d464440a6c3245797d01c6eb7202df717c46 Mon Sep 17 00:00:00 2001 From: "David M. Lloyd" Date: Wed, 7 Oct 2026 08:59:12 -0500 Subject: [PATCH 085/254] Usability fix: Update skill to use better var name for registrar --- .../converting-recorders-to-services/SKILL.md | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/.agents/skills/converting-recorders-to-services/SKILL.md b/.agents/skills/converting-recorders-to-services/SKILL.md index 8af00806c47bd..db22949e5be8c 100644 --- a/.agents/skills/converting-recorders-to-services/SKILL.md +++ b/.agents/skills/converting-recorders-to-services/SKILL.md @@ -92,8 +92,8 @@ ServiceStartBuildItem setup(MyRecorder recorder, SomeBuildItem item) { **After:** ```java @BuildStep -ServiceStartBuildItem setup(ServiceRegistrar reg, SomeBuildItem item) { - reg +ServiceStartBuildItem setup(ServiceRegistrar serviceRegistrar, SomeBuildItem item) { + serviceRegistrar .forService("io.quarkus.my-feature.setup") .onStart(ctx -> MyRecorder.initialize()); return new ServiceStartBuildItem("my-feature"); @@ -102,7 +102,7 @@ ServiceStartBuildItem setup(ServiceRegistrar reg, SomeBuildItem item) { Key changes: - Remove `@Record(ExecutionTime.*)` annotation. -- Replace recorder parameter with `ServiceRegistrar reg`. +- Replace recorder parameter with `ServiceRegistrar serviceRegistrar`. - Use `.atPhase(Phase.STATIC_INIT)` for static-init services (default is runtime-init / `Phase.APPLICATION`). - For void services, use hierarchical dot-separated names: @@ -127,7 +127,7 @@ synthetic beans must be initialized), declare the dependency via `afterBuildItem()`: ```java -reg +serviceRegistrar .forService("io.quarkus.arc.lifecycle") .afterBuildItem(SyntheticBeansRuntimeInitBuildItem.class) .onStart(ctx -> ArcRecorder.fireLifecycleEvent(new StartupEvent())); @@ -149,13 +149,13 @@ via `retainServiceValues()`). ```java // static-init service -reg.forService(ArcContainer.class) +serviceRegistrar.forService(ArcContainer.class) .atPhase(Phase.STATIC_INIT) .afterBuildItem(ResourcesGeneratedPhaseBuildItem.class) .onStart(ctx -> Arc.initialize()); // runtime service that depends on it -reg.forService("io.quarkus.my-ext.setup") +serviceRegistrar.forService("io.quarkus.my-ext.setup") .require(ArcContainer.class) // cross-phase: resolved via proxy .onStart((ctx, container) -> { ... }); ``` @@ -174,7 +174,7 @@ and stops after X. Use it for cleanup services that must outlive their dependents: ```java -reg +serviceRegistrar .forService("io.quarkus.vertx.netty-thread-local-cleanup") .atPhase(Phase.STATIC_INIT) .before(IOThreadDetector.class) @@ -190,8 +190,8 @@ Build items consumed by unconverted extensions need bridge proxies: | Build Item Stores | Bridge Method | |-------------------|---------------| -| `RuntimeValue` | `reg.staticInitServiceAsRuntimeValue(T.class)` or `reg.serviceAsRuntimeValue(T.class)` | -| Bare `T` (interface) | `reg.staticInitServiceAsRecorderValue(T.class)` or `reg.serviceAsRecorderValue(T.class)` | +| `RuntimeValue` | `serviceRegistrar.staticInitServiceAsRuntimeValue(T.class)` or `serviceRegistrar.serviceAsRuntimeValue(T.class)` | +| Bare `T` (interface) | `serviceRegistrar.staticInitServiceAsRecorderValue(T.class)` or `serviceRegistrar.serviceAsRecorderValue(T.class)` | | Bare `T` (concrete) | Use `RuntimeValue` variant — concrete classes may not be proxyable | Recorder proxies resolve via `startupContext.getServiceValue()` (for @@ -209,7 +209,7 @@ SyntheticBeanBuildItem.configure(MyBean.class).supplier(supplier).done(); **After:** ```java -reg.forService(MyBean.class, "my-bean-name") +serviceRegistrar.forService(MyBean.class, "my-bean-name") .onStart(ctx -> new MyBean(args)); SyntheticBeanBuildItem.configure(MyBean.class) @@ -239,7 +239,7 @@ flips the service's return type from `T` to `Optional`; returning effects) instead of failing. ```java -reg.forService(Handler.class, "file") +serviceRegistrar.forService(Handler.class, "file") .atPhase(Phase.LOGGING) .require(LogRuntimeConfig.class) .optional() From 8b886ad22acd8848cf7161142dfa5572b22e87ee Mon Sep 17 00:00:00 2001 From: Holly Cummins Date: Wed, 7 Oct 2026 16:03:05 +0200 Subject: [PATCH 086/254] Drop stale patch code --- .github/workflows/doc-build.yml | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/.github/workflows/doc-build.yml b/.github/workflows/doc-build.yml index eea57e2d95058..a2a62a6cfd01c 100644 --- a/.github/workflows/doc-build.yml +++ b/.github/workflows/doc-build.yml @@ -189,16 +189,6 @@ jobs: path: ~/.cache/ms-playwright key: ${{ runner.os }}-playwright-${{ hashFiles('pom.xml') }} - # TODO remove this once the plugin is in Roq - - name: Build TOC plugin - run: ./roq-plugin-toc/build.sh - - # TODO: Remove once https://github.com/quarkiverse/quarkus-roq/issues/1178 is fixed and Roq is upgraded - - name: Patch Roq AsciiDoc tag handling - run: | - mvn -B dependency:resolve -q - bash patches/apply-roq-include-fix.sh - - name: Build with Maven run: QUARKUS_ROQ_GENERATOR_BATCH=true QUARKUS_PROFILE=only-latest-guides mvn -B package quarkus:run -DskipTests From 7af570b6261feaeab9850114d24856faa8064f7f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:12:55 +0000 Subject: [PATCH 087/254] Bump org.apache.commons:commons-lang3 from 3.20.0 to 3.21.0 Bumps org.apache.commons:commons-lang3 from 3.20.0 to 3.21.0. --- updated-dependencies: - dependency-name: org.apache.commons:commons-lang3 dependency-version: 3.21.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- bom/application/pom.xml | 2 +- independent-projects/bootstrap/pom.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bom/application/pom.xml b/bom/application/pom.xml index 01af9089eff5d..0d63b9a223687 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -94,7 +94,7 @@ 3.1.4 2.22.2 2.0.0.Final - 3.20.0 + 3.21.0 1.22.1 1.7.1 2.8 From 0100bcb8be880d8c3c7472c20bca082881a82817 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:14:02 +0000 Subject: [PATCH 088/254] Bump org.eclipse.microprofile.config:microprofile-config-tck Bumps [org.eclipse.microprofile.config:microprofile-config-tck](https://github.com/eclipse/microprofile-config) from 3.1.1 to 3.1.2. - [Release notes](https://github.com/eclipse/microprofile-config/releases) - [Commits](https://github.com/eclipse/microprofile-config/compare/3.1.1...3.1.2) --- updated-dependencies: - dependency-name: org.eclipse.microprofile.config:microprofile-config-tck dependency-version: 3.1.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- tcks/microprofile-config/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tcks/microprofile-config/pom.xml b/tcks/microprofile-config/pom.xml index 481dda6e74fb9..b0afe5d13d292 100644 --- a/tcks/microprofile-config/pom.xml +++ b/tcks/microprofile-config/pom.xml @@ -13,7 +13,7 @@ Quarkus - TCK - MicroProfile Config - 3.1.1 + 3.1.2 From a09c53fcef4f0f518b5fe9a5a45f21c5f34abad1 Mon Sep 17 00:00:00 2001 From: Guillaume Smet Date: Wed, 7 Oct 2026 17:48:22 +0200 Subject: [PATCH 089/254] Bring back preview.yml to ubuntu-latest ubuntu-slim is not powerful enough to run Roq. --- .github/workflows/preview.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 00cc0ff79b2e0..04b4a4e4abe9e 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -12,7 +12,7 @@ defaults: jobs: preview: - runs-on: ubuntu-slim + runs-on: ubuntu-latest permissions: actions: read issues: write From 6c76305f8067556e61d78fbde3c009899d0b2c7b Mon Sep 17 00:00:00 2001 From: Jerome Prinet Date: Wed, 7 Oct 2026 17:57:23 +0200 Subject: [PATCH 090/254] Set up a JDK before publishing Develocity build scans ubuntu-slim ships without Java, so maven-publish-build-scan failed with 'Unable to locate executable file: java' and silently skipped publishing. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/develocity-publish-build-scans.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/develocity-publish-build-scans.yml b/.github/workflows/develocity-publish-build-scans.yml index 802545ab48dd0..03fcef7ff382a 100644 --- a/.github/workflows/develocity-publish-build-scans.yml +++ b/.github/workflows/develocity-publish-build-scans.yml @@ -25,6 +25,11 @@ jobs: with: add-pr-comment: false add-job-summary: true + - name: Set up JDK + uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + with: + distribution: temurin + java-version: 25 - name: Publish Maven Build Scans id: publish uses: gradle/develocity-actions/maven-publish-build-scan@4b276bd81fb55e2fcbae724c48699de57e2156eb # v2.3 From 8529e85784d8b697b58c735d9a2643ec928e87d8 Mon Sep 17 00:00:00 2001 From: jnbdz <237008+jnbdz@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:19:20 -0400 Subject: [PATCH 091/254] Register SmallRye Health startup work as services The health check response provider registration and the runtime configuration of the reporter now run as services instead of recorder calls. The UI handler stays on the recorder because its route handler is consumed by vertx-http, and the Dev UI step no longer declares an unused recorder. --- .../SmallRyeHealthDevUiProcessor.java | 7 +---- .../deployment/SmallRyeHealthProcessor.java | 27 +++++++++++++------ .../runtime/SmallRyeHealthRecorder.java | 19 ++++++------- 3 files changed, 28 insertions(+), 25 deletions(-) diff --git a/extensions/smallrye-health/deployment/src/main/java/io/quarkus/smallrye/health/deployment/SmallRyeHealthDevUiProcessor.java b/extensions/smallrye-health/deployment/src/main/java/io/quarkus/smallrye/health/deployment/SmallRyeHealthDevUiProcessor.java index 50713cf51233d..81fe127ea1ccb 100644 --- a/extensions/smallrye-health/deployment/src/main/java/io/quarkus/smallrye/health/deployment/SmallRyeHealthDevUiProcessor.java +++ b/extensions/smallrye-health/deployment/src/main/java/io/quarkus/smallrye/health/deployment/SmallRyeHealthDevUiProcessor.java @@ -2,13 +2,10 @@ import io.quarkus.deployment.IsDevelopment; import io.quarkus.deployment.annotations.BuildStep; -import io.quarkus.deployment.annotations.ExecutionTime; -import io.quarkus.deployment.annotations.Record; import io.quarkus.deployment.builditem.LaunchModeBuildItem; import io.quarkus.devjsonrpc.spi.JsonRPCProvidersBuildItem; import io.quarkus.devui.spi.page.CardPageBuildItem; import io.quarkus.devui.spi.page.Page; -import io.quarkus.smallrye.health.runtime.SmallRyeHealthRecorder; import io.quarkus.smallrye.health.runtime.dev.ui.HealthJsonRPCService; import io.quarkus.vertx.http.deployment.NonApplicationRootPathBuildItem; import io.quarkus.vertx.http.runtime.management.ManagementInterfaceBuildTimeConfig; @@ -19,12 +16,10 @@ public class SmallRyeHealthDevUiProcessor { @BuildStep(onlyIf = IsDevelopment.class) - @Record(ExecutionTime.STATIC_INIT) CardPageBuildItem create(NonApplicationRootPathBuildItem nonApplicationRootPathBuildItem, SmallRyeHealthBuildTimeConfig config, ManagementInterfaceBuildTimeConfig managementBuildTimeConfig, - LaunchModeBuildItem launchModeBuildItem, - SmallRyeHealthRecorder unused) { + LaunchModeBuildItem launchModeBuildItem) { CardPageBuildItem pageBuildItem = new CardPageBuildItem(); pageBuildItem.setLogo("smallrye_dark.svg", "smallrye_light.svg"); pageBuildItem.addLibraryVersion("io.smallrye", "smallrye-health", "SmallRye Health", diff --git a/extensions/smallrye-health/deployment/src/main/java/io/quarkus/smallrye/health/deployment/SmallRyeHealthProcessor.java b/extensions/smallrye-health/deployment/src/main/java/io/quarkus/smallrye/health/deployment/SmallRyeHealthProcessor.java index 44604157f0356..1411ce08cb5e7 100644 --- a/extensions/smallrye-health/deployment/src/main/java/io/quarkus/smallrye/health/deployment/SmallRyeHealthProcessor.java +++ b/extensions/smallrye-health/deployment/src/main/java/io/quarkus/smallrye/health/deployment/SmallRyeHealthProcessor.java @@ -30,12 +30,14 @@ import io.quarkus.arc.deployment.ExcludedTypeBuildItem; import io.quarkus.arc.deployment.SyntheticBeansRuntimeInitBuildItem; import io.quarkus.arc.processor.BuiltinScope; +import io.quarkus.arc.runtime.BeanContainer; +import io.quarkus.core.Phase; +import io.quarkus.core.deployment.service.ServiceRegistrar; import io.quarkus.deployment.Capabilities; import io.quarkus.deployment.Capability; import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; import io.quarkus.deployment.annotations.BuildSteps; -import io.quarkus.deployment.annotations.Consume; import io.quarkus.deployment.annotations.ExecutionTime; import io.quarkus.deployment.annotations.Record; import io.quarkus.deployment.builditem.HotDeploymentWatchedFileBuildItem; @@ -55,9 +57,11 @@ import io.quarkus.smallrye.health.runtime.QuarkusAsyncHealthCheckFactory; import io.quarkus.smallrye.health.runtime.ShutdownReadinessCheck; import io.quarkus.smallrye.health.runtime.ShutdownReadinessListener; +import io.quarkus.smallrye.health.runtime.SmallRyeHealthBuildFixedConfig; import io.quarkus.smallrye.health.runtime.SmallRyeHealthGroupHandler; import io.quarkus.smallrye.health.runtime.SmallRyeHealthHandler; import io.quarkus.smallrye.health.runtime.SmallRyeHealthRecorder; +import io.quarkus.smallrye.health.runtime.SmallRyeHealthRuntimeConfig; import io.quarkus.smallrye.health.runtime.SmallRyeIndividualHealthGroupHandler; import io.quarkus.smallrye.health.runtime.SmallRyeLivenessHandler; import io.quarkus.smallrye.health.runtime.SmallRyeReadinessHandler; @@ -152,9 +156,8 @@ void healthCheck(BuildProducer buildItemBuildProducer, } @BuildStep - @Record(ExecutionTime.STATIC_INIT) @SuppressWarnings("unchecked") - void build(SmallRyeHealthRecorder recorder, + void build(ServiceRegistrar serviceRegistrar, BuildProducer excludedTypes, BuildProducer additionalBean, BuildProducer beanDefiningAnnotation) @@ -191,7 +194,10 @@ void build(SmallRyeHealthRecorder recorder, final String provider = providers.iterator().next(); final Class responseProvider = (Class) Class .forName(provider, true, Thread.currentThread().getContextClassLoader()); - recorder.registerHealthCheckResponseProvider(responseProvider); + serviceRegistrar + .forService("io.quarkus.smallrye-health.response-provider") + .atPhase(Phase.STATIC_INIT) + .onStart(ctx -> SmallRyeHealthRecorder.registerHealthCheckResponseProvider(responseProvider)); } @BuildStep @@ -447,10 +453,15 @@ void registerHealthUiHandler( } @BuildStep - @Record(ExecutionTime.RUNTIME_INIT) - @Consume(SyntheticBeansRuntimeInitBuildItem.class) - void processSmallRyeHealthRuntimeConfig(SmallRyeHealthRecorder recorder) { - recorder.processSmallRyeHealthRuntimeConfiguration(); + void processSmallRyeHealthRuntimeConfig(ServiceRegistrar serviceRegistrar) { + serviceRegistrar + .forService("io.quarkus.smallrye-health.runtime-config") + .require(BeanContainer.class) + .require(SmallRyeHealthRuntimeConfig.class) + .require(SmallRyeHealthBuildFixedConfig.class) + .afterBuildItem(SyntheticBeansRuntimeInitBuildItem.class) + .onStart((ctx, container, runtimeConfig, buildFixedConfig) -> SmallRyeHealthRecorder + .processSmallRyeHealthRuntimeConfiguration(container, runtimeConfig, buildFixedConfig)); } // Replace health URL in static files diff --git a/extensions/smallrye-health/runtime/src/main/java/io/quarkus/smallrye/health/runtime/SmallRyeHealthRecorder.java b/extensions/smallrye-health/runtime/src/main/java/io/quarkus/smallrye/health/runtime/SmallRyeHealthRecorder.java index 4b2dcedf6278a..47024b1b97e34 100644 --- a/extensions/smallrye-health/runtime/src/main/java/io/quarkus/smallrye/health/runtime/SmallRyeHealthRecorder.java +++ b/extensions/smallrye-health/runtime/src/main/java/io/quarkus/smallrye/health/runtime/SmallRyeHealthRecorder.java @@ -7,7 +7,7 @@ import org.eclipse.microprofile.health.HealthCheckResponse; import org.eclipse.microprofile.health.spi.HealthCheckResponseProvider; -import io.quarkus.arc.Arc; +import io.quarkus.arc.runtime.BeanContainer; import io.quarkus.runtime.RuntimeValue; import io.quarkus.runtime.ShutdownContext; import io.quarkus.runtime.annotations.Recorder; @@ -20,17 +20,13 @@ @Recorder public class SmallRyeHealthRecorder { - private final SmallRyeHealthBuildFixedConfig buildFixedConfig; private final RuntimeValue runtimeConfig; - public SmallRyeHealthRecorder( - final SmallRyeHealthBuildFixedConfig buildFixedConfig, - final RuntimeValue runtimeConfig) { - this.buildFixedConfig = buildFixedConfig; + public SmallRyeHealthRecorder(final RuntimeValue runtimeConfig) { this.runtimeConfig = runtimeConfig; } - public void registerHealthCheckResponseProvider(Class providerClass) { + public static void registerHealthCheckResponseProvider(Class providerClass) { try { HealthCheckResponse.setResponseProvider(providerClass.getConstructor().newInstance()); } catch (Exception e) { @@ -52,11 +48,12 @@ public Handler uiHandler(String healthUiFinalDestination, String } } - public void processSmallRyeHealthRuntimeConfiguration() { - SmallRyeHealthReporter reporter = Arc.container().select(SmallRyeHealthReporter.class).get(); - reporter.setAdditionalProperties(runtimeConfig.getValue().additionalProperties()); + public static void processSmallRyeHealthRuntimeConfiguration(BeanContainer container, + SmallRyeHealthRuntimeConfig runtimeConfig, SmallRyeHealthBuildFixedConfig buildFixedConfig) { + SmallRyeHealthReporter reporter = container.beanInstance(SmallRyeHealthReporter.class); + reporter.setAdditionalProperties(runtimeConfig.additionalProperties()); - reporter.setHealthChecksConfigs(runtimeConfig.getValue().check().entrySet().stream() + reporter.setHealthChecksConfigs(runtimeConfig.check().entrySet().stream() .collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().enabled()))); SmallRyeHealthHandlerBase.problemDetails = buildFixedConfig.includeProblemDetails(); From e419802d975640e646d2733a59b31d14031adba3 Mon Sep 17 00:00:00 2001 From: jnbdz <237008+jnbdz@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:46:06 -0400 Subject: [PATCH 092/254] Include the Jackson 3 DOM handlers in native images only when a DOM type is reachable Jackson 2 loaded DOMSerializer and DOMDeserializer through a string-based Class.forName, which kept them and the JDK XML parser and transformer implementations out of native images unless registered. Jackson 3 instantiates them directly and holds Node.class and Document.class constants in OptionalHandlerFactory, so every native image containing Jackson now links TransformerFactory and its Xalan implementation, even when the application never touches a DOM type. Substitute OptionalHandlerFactory to detect the DOM types by name and load the handlers reflectively, and register them for reflection from JacksonSerializerRegistrationFeature only when org.w3c.dom.Node or Document is reachable, the same mechanism already used for the java.sql and javax.xml.datatype handlers. An application that serializes a DOM node necessarily has a Node implementation reachable, so no configuration is needed: the jpa-postgresql native image no longer contains any org.w3c.dom, javax.xml.parsers or javax.xml.transform class, while integration-tests/jackson serializes and deserializes a Document in a native executable. Fixes #55650 --- .../JacksonSerializerRegistrationFeature.java | 19 +- .../graal/Target_OptionalHandlerFactory.java | 179 ++++++++++++++++++ .../io/quarkus/it/jackson/DomResource.java | 50 +++++ .../io/quarkus/it/jackson/DomResourceIT.java | 10 + .../quarkus/it/jackson/DomResourceTest.java | 34 ++++ .../JPAFunctionalityInGraalITCase.java | 2 - 6 files changed, 290 insertions(+), 4 deletions(-) create mode 100644 extensions/jackson/runtime/src/main/java/io/quarkus/jackson/runtime/graal/Target_OptionalHandlerFactory.java create mode 100644 integration-tests/jackson/src/main/java/io/quarkus/it/jackson/DomResource.java create mode 100644 integration-tests/jackson/src/test/java/io/quarkus/it/jackson/DomResourceIT.java create mode 100644 integration-tests/jackson/src/test/java/io/quarkus/it/jackson/DomResourceTest.java diff --git a/extensions/jackson/runtime/src/main/java/io/quarkus/jackson/runtime/graal/JacksonSerializerRegistrationFeature.java b/extensions/jackson/runtime/src/main/java/io/quarkus/jackson/runtime/graal/JacksonSerializerRegistrationFeature.java index 8f03077bf8dc2..71a283233b810 100644 --- a/extensions/jackson/runtime/src/main/java/io/quarkus/jackson/runtime/graal/JacksonSerializerRegistrationFeature.java +++ b/extensions/jackson/runtime/src/main/java/io/quarkus/jackson/runtime/graal/JacksonSerializerRegistrationFeature.java @@ -10,8 +10,12 @@ import org.graalvm.nativeimage.hosted.Feature; import org.graalvm.nativeimage.hosted.RuntimeReflection; +import org.w3c.dom.Document; +import org.w3c.dom.Node; import tools.jackson.databind.ext.CoreXMLDeserializers; +import tools.jackson.databind.ext.DOMDeserializer; +import tools.jackson.databind.ext.DOMSerializer; import tools.jackson.databind.ext.QNameSerializer; import tools.jackson.databind.ext.XMLGregorianCalendarSerializer; import tools.jackson.databind.ext.sql.JavaSqlDateDeserializer; @@ -22,9 +26,10 @@ /** * Registers Jackson serializers/deserializers for reflection only when their corresponding * types are reachable during native image analysis. This avoids unconditionally pulling in - * {@code java.sql.*} and {@code javax.xml.datatype.*} class hierarchies into the native image. + * {@code java.sql.*}, {@code javax.xml.datatype.*} and {@code org.w3c.dom.*} class hierarchies into the native image. * - * See GitHub issue #53818. + * See GitHub issue #53818 and + * GitHub issue #55650. */ public class JacksonSerializerRegistrationFeature implements Feature { @@ -51,6 +56,16 @@ public void beforeAnalysis(BeforeAnalysisAccess access) { QNameSerializer.class, XMLGregorianCalendarSerializer.class, CoreXMLDeserializers.class); + + // DOM handlers, loaded reflectively by the OptionalHandlerFactory substitution: they initialize the JDK XML + // parsers and transformers, which should only be in the image when a DOM type is reachable. + // Node and Document deliberately share one registration: Document is a Node, DOMSerializer handles any Node, + // and both deserializers pull in the same XML stack, so reaching either type costs the image the same and + // separate triggers would only add a way to end up with one handler and not the other. + registerWhenReachable(access, new Class[] { Node.class, Document.class }, false, + DOMSerializer.class, + DOMDeserializer.NodeDeserializer.class, + DOMDeserializer.DocumentDeserializer.class); } private void registerWhenReachable(BeforeAnalysisAccess access, Class[] triggerClasses, diff --git a/extensions/jackson/runtime/src/main/java/io/quarkus/jackson/runtime/graal/Target_OptionalHandlerFactory.java b/extensions/jackson/runtime/src/main/java/io/quarkus/jackson/runtime/graal/Target_OptionalHandlerFactory.java new file mode 100644 index 0000000000000..a7daf9520e9e4 --- /dev/null +++ b/extensions/jackson/runtime/src/main/java/io/quarkus/jackson/runtime/graal/Target_OptionalHandlerFactory.java @@ -0,0 +1,179 @@ +package io.quarkus.jackson.runtime.graal; + +import javax.xml.datatype.Duration; +import javax.xml.datatype.XMLGregorianCalendar; +import javax.xml.namespace.QName; + +import com.oracle.svm.core.annotate.Alias; +import com.oracle.svm.core.annotate.Delete; +import com.oracle.svm.core.annotate.Substitute; +import com.oracle.svm.core.annotate.TargetClass; + +import tools.jackson.databind.DeserializationConfig; +import tools.jackson.databind.JavaType; +import tools.jackson.databind.SerializationConfig; +import tools.jackson.databind.ValueDeserializer; +import tools.jackson.databind.ValueSerializer; +import tools.jackson.databind.ext.CoreXMLDeserializers; +import tools.jackson.databind.ext.OptionalHandlerFactory; +import tools.jackson.databind.ext.QNameSerializer; +import tools.jackson.databind.ext.XMLGregorianCalendarSerializer; +import tools.jackson.databind.ext.sql.JavaSqlTypeHandlerFactory; +import tools.jackson.databind.ser.std.ToStringSerializer; + +/** + * Substitutes {@link OptionalHandlerFactory} so that the DOM handlers ({@code DOMSerializer}, + * {@code DOMDeserializer}) are only part of a native image when the application can actually use them. + *

+ * Jackson 2 loaded these handlers through a string-based {@code Class.forName(...)}, which kept + * them — and the JDK XML parser and transformer implementations their initialization drags in — + * out of native images unless explicitly registered. Jackson 3 instantiates them directly and holds + * {@code Node.class} and {@code Document.class} constants, making {@code javax.xml.transform.TransformerFactory} + * and its Xalan implementation statically reachable in every native image that contains Jackson. + *

+ * This substitution detects the DOM types by name and loads the handlers reflectively, as Jackson 2 + * did, and {@link JacksonSerializerRegistrationFeature} registers the handlers for reflection only + * when a DOM type is reachable in the image. In an image where no DOM type is reachable, DOM + * (de)serialization fails with a message explaining how to register the handlers explicitly. + *

+ * See GitHub issue #55650. + */ +@TargetClass(OptionalHandlerFactory.class) +final class Target_OptionalHandlerFactory { + + @Delete + private static Class CLASS_DOM_NODE; + + @Delete + private static Class CLASS_DOM_DOCUMENT; + + @Substitute + public ValueSerializer findSerializer(SerializationConfig config, JavaType type) { + final Class rawType = type.getRawClass(); + if (JacksonDomSupport.isNode(rawType)) { + return JacksonDomSupport.newHandler(JacksonDomSupport.DOM_SERIALIZER, rawType); + } + + String className = rawType.getName(); + if (className.startsWith(JacksonDomSupport.PACKAGE_PREFIX_JAVAX_XML) + || JacksonDomSupport.hasSuperClassStartingWith(rawType, JacksonDomSupport.PACKAGE_PREFIX_JAVAX_XML)) { + if (Duration.class.isAssignableFrom(rawType)) { + return ToStringSerializer.instance; + } + if (QName.class.isAssignableFrom(rawType)) { + return QNameSerializer.instance; + } + if (XMLGregorianCalendar.class.isAssignableFrom(rawType)) { + return Target_XMLGregorianCalendarSerializer.instance; + } + } + return JavaSqlTypeHandlerFactory.instance.findSerializer(config, type); + } + + @Substitute + public ValueDeserializer findDeserializer(DeserializationConfig config, JavaType type) { + final Class rawType = type.getRawClass(); + if (JacksonDomSupport.isDocument(rawType)) { + return JacksonDomSupport.newHandler(JacksonDomSupport.DOM_DOCUMENT_DESERIALIZER, rawType); + } + if (JacksonDomSupport.isNode(rawType)) { + return JacksonDomSupport.newHandler(JacksonDomSupport.DOM_NODE_DESERIALIZER, rawType); + } + String className = rawType.getName(); + if (className.startsWith(JacksonDomSupport.PACKAGE_PREFIX_JAVAX_XML) + || JacksonDomSupport.hasSuperClassStartingWith(rawType, JacksonDomSupport.PACKAGE_PREFIX_JAVAX_XML)) { + return CoreXMLDeserializers.findBeanDeserializer(config, type); + } + return JavaSqlTypeHandlerFactory.instance.findDeserializer(config, type); + } + + @Substitute + public boolean hasDeserializerFor(Class valueType) { + if (JacksonDomSupport.isNode(valueType)) { + return true; + } + String className = valueType.getName(); + if (className.startsWith(JacksonDomSupport.PACKAGE_PREFIX_JAVAX_XML) + || JacksonDomSupport.hasSuperClassStartingWith(valueType, JacksonDomSupport.PACKAGE_PREFIX_JAVAX_XML)) { + return CoreXMLDeserializers.hasDeserializerFor(valueType); + } + return JavaSqlTypeHandlerFactory.instance.hasDeserializerFor(valueType); + } +} + +/** + * Gives the substitution access to the package-private {@code instance} field. + */ +@TargetClass(XMLGregorianCalendarSerializer.class) +final class Target_XMLGregorianCalendarSerializer { + + @Alias + static XMLGregorianCalendarSerializer instance; +} + +final class JacksonDomSupport { + + static final String PACKAGE_PREFIX_JAVAX_XML = "javax.xml."; + + static final String DOM_SERIALIZER = "tools.jackson.databind.ext.DOMSerializer"; + static final String DOM_NODE_DESERIALIZER = "tools.jackson.databind.ext.DOMDeserializer$NodeDeserializer"; + static final String DOM_DOCUMENT_DESERIALIZER = "tools.jackson.databind.ext.DOMDeserializer$DocumentDeserializer"; + + private static final String NODE = "org.w3c.dom.Node"; + private static final String DOCUMENT = "org.w3c.dom.Document"; + + private JacksonDomSupport() { + } + + static boolean isNode(Class rawType) { + return implementsByName(rawType, NODE); + } + + static boolean isDocument(Class rawType) { + return implementsByName(rawType, DOCUMENT); + } + + /** + * Loads a DOM handler reflectively, so that the handler and the JDK XML implementation it initializes + * are only linked into the image when {@link JacksonSerializerRegistrationFeature} registered it. + */ + @SuppressWarnings("unchecked") + static T newHandler(String className, Class rawType) { + try { + return (T) Class.forName(className).getDeclaredConstructor().newInstance(); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Cannot serialize or deserialize `" + rawType.getName() + + "`: Jackson support for DOM types was not included in this native executable because no DOM type" + + " was found reachable when it was built. Register `" + className + + "` for reflection (for example with @RegisterForReflection(targets = ...)) to include it.", e); + } + } + + // by name, so that the org.w3c.dom types are not referenced by the substitution and only become reachable + // when the application uses them + private static boolean implementsByName(Class rawType, String interfaceName) { + for (Class type = rawType; type != null && type != Object.class; type = type.getSuperclass()) { + if (type.getName().equals(interfaceName)) { + return true; + } + for (Class iface : type.getInterfaces()) { + if (implementsByName(iface, interfaceName)) { + return true; + } + } + } + return false; + } + + static boolean hasSuperClassStartingWith(Class rawType, String prefix) { + for (Class supertype = rawType.getSuperclass(); supertype != null; supertype = supertype.getSuperclass()) { + if (supertype == Object.class) { + return false; + } + if (supertype.getName().startsWith(prefix)) { + return true; + } + } + return false; + } +} diff --git a/integration-tests/jackson/src/main/java/io/quarkus/it/jackson/DomResource.java b/integration-tests/jackson/src/main/java/io/quarkus/it/jackson/DomResource.java new file mode 100644 index 0000000000000..38f11f0f3e452 --- /dev/null +++ b/integration-tests/jackson/src/main/java/io/quarkus/it/jackson/DomResource.java @@ -0,0 +1,50 @@ +package io.quarkus.it.jackson; + +import java.io.IOException; +import java.io.StringReader; + +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; + +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +import org.w3c.dom.Document; +import org.xml.sax.InputSource; +import org.xml.sax.SAXException; + +import tools.jackson.databind.ObjectMapper; + +/** + * Exercises the Jackson DOM handlers, which are only included in native executables when a DOM type + * is reachable, as it is here. + */ +@Path("/dom") +public class DomResource { + + @Inject + ObjectMapper objectMapper; + + @POST + @Consumes(MediaType.APPLICATION_XML) + @Produces(MediaType.APPLICATION_JSON) + @Path("/serialize") + public String serialize(String xml) throws ParserConfigurationException, IOException, SAXException { + Document document = DocumentBuilderFactory.newInstance().newDocumentBuilder() + .parse(new InputSource(new StringReader(xml))); + return objectMapper.writeValueAsString(document); + } + + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.TEXT_PLAIN) + @Path("/deserialize") + public String deserialize(String json) { + Document document = objectMapper.readValue(json, Document.class); + return document.getDocumentElement().getTagName() + ":" + document.getDocumentElement().getTextContent(); + } +} diff --git a/integration-tests/jackson/src/test/java/io/quarkus/it/jackson/DomResourceIT.java b/integration-tests/jackson/src/test/java/io/quarkus/it/jackson/DomResourceIT.java new file mode 100644 index 0000000000000..ab46aad85411b --- /dev/null +++ b/integration-tests/jackson/src/test/java/io/quarkus/it/jackson/DomResourceIT.java @@ -0,0 +1,10 @@ +package io.quarkus.it.jackson; + +import io.quarkus.test.junit.QuarkusIntegrationTest; + +@QuarkusIntegrationTest +public class DomResourceIT extends DomResourceTest { + + // Execute the same tests but in native mode, where the DOM handlers are only present because + // quarkus.jackson.xml.enabled is set in application.properties. +} diff --git a/integration-tests/jackson/src/test/java/io/quarkus/it/jackson/DomResourceTest.java b/integration-tests/jackson/src/test/java/io/quarkus/it/jackson/DomResourceTest.java new file mode 100644 index 0000000000000..0ac859a831e5d --- /dev/null +++ b/integration-tests/jackson/src/test/java/io/quarkus/it/jackson/DomResourceTest.java @@ -0,0 +1,34 @@ +package io.quarkus.it.jackson; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.CoreMatchers.equalTo; + +import org.junit.jupiter.api.Test; + +import io.quarkus.test.junit.QuarkusTest; + +@QuarkusTest +public class DomResourceTest { + + @Test + public void testSerialize() { + given() + .contentType("application/xml") + .body("hello") + .when().post("/dom/serialize") + .then() + .statusCode(200) + .body(equalTo("\"hello\"")); + } + + @Test + public void testDeserialize() { + given() + .contentType("application/json") + .body("\"hello\"") + .when().post("/dom/deserialize") + .then() + .statusCode(200) + .body(equalTo("greeting:hello")); + } +} diff --git a/integration-tests/jpa-postgresql/src/test/java/io/quarkus/it/jpa/postgresql/JPAFunctionalityInGraalITCase.java b/integration-tests/jpa-postgresql/src/test/java/io/quarkus/it/jpa/postgresql/JPAFunctionalityInGraalITCase.java index 241ffef05b158..158945098ab53 100644 --- a/integration-tests/jpa-postgresql/src/test/java/io/quarkus/it/jpa/postgresql/JPAFunctionalityInGraalITCase.java +++ b/integration-tests/jpa-postgresql/src/test/java/io/quarkus/it/jpa/postgresql/JPAFunctionalityInGraalITCase.java @@ -1,7 +1,6 @@ package io.quarkus.it.jpa.postgresql; import org.hibernate.type.descriptor.jdbc.UUIDJdbcType; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import io.quarkus.test.junit.QuarkusIntegrationTest; @@ -14,7 +13,6 @@ public class JPAFunctionalityInGraalITCase extends JPAFunctionalityTest { @Test - @Disabled("With the move to Jackson 3, this needs to be reexamined") // Yoann advised me to disable this in https://quarkusio.zulipchat.com/#narrow/channel/187038-dev/topic/Jackson.203.2E0.2E0/near/611897490 public void verifyJdkXmlParsersHavebeenEcludedFromNative() { final ClassInclusionReport report = ClassInclusionReport.load(); //The following classes should be included in this applications; From 10da8ab5cd4fe29d93d32085ff8365ba266c6b19 Mon Sep 17 00:00:00 2001 From: "David M. Lloyd" Date: Wed, 7 Oct 2026 13:17:24 -0500 Subject: [PATCH 093/254] Bug fix: Any unknown class loader should be considered the run time class loader --- .../service/impl/QuarkusClassLoaderSerializer.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/QuarkusClassLoaderSerializer.java b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/QuarkusClassLoaderSerializer.java index e900f0c3844a6..7921707e02099 100644 --- a/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/QuarkusClassLoaderSerializer.java +++ b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/QuarkusClassLoaderSerializer.java @@ -17,7 +17,12 @@ private QuarkusClassLoaderSerializer() { public Serialized serialize(final Context ctxt, final Object object) throws IOException { if (object instanceof QuarkusClassLoader) { - // todo: improve this detection + return SerializedQuarkusClassLoader.of(SerializedQuarkusClassLoader.Kind.RUNTIME); + } + if (object instanceof ClassLoader && object != ClassLoader.getPlatformClassLoader() + && object != ClassLoader.getSystemClassLoader()) { + // any other classloader (e.g. Maven's ClassRealm) is a build-time artifact; + // at runtime, the classes it loaded will be on the runtime classpath return SerializedQuarkusClassLoader.of(SerializedQuarkusClassLoader.Kind.RUNTIME); } return ctxt.next(); From 21ca6618d52727c84a23d56717747aedb39d5d24 Mon Sep 17 00:00:00 2001 From: jnbdz <237008+jnbdz@users.noreply.github.com> Date: Fri, 4 Sep 2026 22:16:51 -0400 Subject: [PATCH 094/254] TLS registry: fail fast when the OpenSSL engine is requested in a native executable Since #55624 users can set quarkus.tls.ssl-engine=openssl. In JVM mode it works; in a native executable it fails at server listen inside Vert.x with "OpenSSL is not available", because the Netty/Vert.x GraalVM substitutions hard-code the OpenSSL engine as unavailable, and the message does not say that native mode is the cause. Reject such a TLS bucket when the registry loads it, with a ConfigurationException naming the property and native mode. The PQC enforcement policies are left to Vert.x, which already validates that the selected engine supports them. Relates to #456, #55624, #55629, #56263 --- .../tls/runtime/CertificateRegistryImpl.java | 27 ++++ ...ertificateRegistryImplNativeImageTest.java | 142 ++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 extensions/tls-registry/runtime/src/test/java/io/quarkus/tls/runtime/CertificateRegistryImplNativeImageTest.java diff --git a/extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/CertificateRegistryImpl.java b/extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/CertificateRegistryImpl.java index 24110aa2d42aa..833d454b4dd24 100644 --- a/extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/CertificateRegistryImpl.java +++ b/extensions/tls-registry/runtime/src/main/java/io/quarkus/tls/runtime/CertificateRegistryImpl.java @@ -13,6 +13,8 @@ import io.quarkus.arc.Arc; import io.quarkus.arc.InstanceHandle; +import io.quarkus.runtime.ImageMode; +import io.quarkus.runtime.configuration.ConfigurationException; import io.quarkus.tls.KeyStoreAndKeyCertOptions; import io.quarkus.tls.KeyStoreFactory; import io.quarkus.tls.KeyStoreProvider; @@ -21,6 +23,7 @@ import io.quarkus.tls.TrustStoreAndTrustOptions; import io.quarkus.tls.TrustStoreFactory; import io.quarkus.tls.TrustStoreProvider; +import io.quarkus.tls.runtime.config.SslEngineType; import io.quarkus.tls.runtime.config.TlsBucketConfig; import io.quarkus.tls.runtime.config.TlsConfig; import io.quarkus.tls.runtime.keystores.JKSKeyStores; @@ -100,6 +103,7 @@ public void closeReloader() { } private void verifyCertificateConfig(TlsBucketConfig config, Vertx vertx, String name) { + verifySslEngineSupportedInNativeImage(config, name); final TlsConfiguration tlsConfig = verifyCertificateConfigInternal(config, vertx, name); certificates.put(name, tlsConfig); @@ -112,6 +116,29 @@ private void verifyCertificateConfig(TlsBucketConfig config, Vertx vertx, String } } + /** + * The OpenSSL engine (netty-tcnative) is disabled by the Quarkus GraalVM substitutions, so a bucket that requests + * it is rejected when running as a native executable, with a message that names native mode as the cause. + * Without this, Vert.x fails at listen time ({@code SslContextManager.resolveEngineOptions}) with "OpenSSL is not + * available", which does not mention the mode. + */ + static void verifySslEngineSupportedInNativeImage(TlsBucketConfig config, String name) { + verifySslEngineSupportedInNativeImage(config, name, ImageMode.current().isNativeImage()); + } + + // Visible for testing + static void verifySslEngineSupportedInNativeImage(TlsBucketConfig config, String name, boolean nativeImage) { + if (!nativeImage || config.sslEngine().isEmpty() || config.sslEngine().get() != SslEngineType.OPENSSL) { + return; + } + String key = (TlsConfig.DEFAULT_NAME.equals(name) ? "quarkus.tls." : "quarkus.tls." + name + ".") + "ssl-engine"; + throw new ConfigurationException( + "TLS configuration '" + name + "' sets '" + key + "=openssl', but the OpenSSL engine " + + "(netty-tcnative) is not supported in Quarkus native executables. " + + "Remove the property or set it to 'jdkssl', or run the application in JVM mode.", + Set.of(key)); + } + private static TlsConfiguration verifyCertificateConfigInternal(TlsBucketConfig config, Vertx vertx, String name) { // Verify the key store KeyStoreAndKeyCertOptions ks = getKeyStore(config, vertx, name); diff --git a/extensions/tls-registry/runtime/src/test/java/io/quarkus/tls/runtime/CertificateRegistryImplNativeImageTest.java b/extensions/tls-registry/runtime/src/test/java/io/quarkus/tls/runtime/CertificateRegistryImplNativeImageTest.java new file mode 100644 index 0000000000000..4eeba70b487b9 --- /dev/null +++ b/extensions/tls-registry/runtime/src/test/java/io/quarkus/tls/runtime/CertificateRegistryImplNativeImageTest.java @@ -0,0 +1,142 @@ +package io.quarkus.tls.runtime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import io.quarkus.runtime.configuration.ConfigurationException; +import io.quarkus.tls.runtime.config.KeyStoreConfig; +import io.quarkus.tls.runtime.config.PqcEnforcementPolicy; +import io.quarkus.tls.runtime.config.SslEngineType; +import io.quarkus.tls.runtime.config.TlsBucketConfig; +import io.quarkus.tls.runtime.config.TlsConfig; +import io.quarkus.tls.runtime.config.TrustStoreConfig; + +/** + * The OpenSSL engine (netty-tcnative) is disabled by the Quarkus GraalVM substitutions, so a TLS bucket that requests + * it must be rejected early, with a message that names native mode as the cause, instead of letting Vert.x fail at + * listen time with "OpenSSL is not available". + */ +class CertificateRegistryImplNativeImageTest { + + private static final boolean NATIVE = true; + private static final boolean JVM = false; + + @Test + void jvmModeNeverRejects() { + assertThatCode(() -> CertificateRegistryImpl.verifySslEngineSupportedInNativeImage( + bucket(Optional.of(SslEngineType.OPENSSL), PqcEnforcementPolicy.STRICT), "foo", JVM)) + .doesNotThrowAnyException(); + } + + @Test + void nativeModeWithJdkEngineIsAccepted() { + assertThatCode(() -> CertificateRegistryImpl.verifySslEngineSupportedInNativeImage( + bucket(Optional.of(SslEngineType.JDKSSL), PqcEnforcementPolicy.STRICT), "foo", NATIVE)) + .doesNotThrowAnyException(); + } + + @Test + void nativeModeWithEngineUnsetIsAccepted() { + // the engine Vert.x selects for the policy, and whether it supports it, is validated by Vert.x itself + assertThatCode(() -> CertificateRegistryImpl.verifySslEngineSupportedInNativeImage( + bucket(Optional.empty(), PqcEnforcementPolicy.STRICT), "foo", NATIVE)) + .doesNotThrowAnyException(); + } + + @Test + void nativeModeWithOpenSslEngineIsRejectedNamingTheProperty() { + assertThatThrownBy(() -> CertificateRegistryImpl.verifySslEngineSupportedInNativeImage( + bucket(Optional.of(SslEngineType.OPENSSL), PqcEnforcementPolicy.RELAXED), "foo", NATIVE)) + .isInstanceOf(ConfigurationException.class) + .hasMessageContaining("quarkus.tls.foo.ssl-engine") + .hasMessageContaining("native"); + } + + @Test + void nativeModeWithOpenSslEngineOnDefaultBucketNamesTheUnnamedProperty() { + assertThatThrownBy(() -> CertificateRegistryImpl.verifySslEngineSupportedInNativeImage( + bucket(Optional.of(SslEngineType.OPENSSL), PqcEnforcementPolicy.RELAXED), TlsConfig.DEFAULT_NAME, + NATIVE)) + .isInstanceOf(ConfigurationException.class) + .hasMessageContaining("quarkus.tls.ssl-engine") + .satisfies(e -> assertThat(e.getMessage()).doesNotContain("quarkus.tls.")); + } + + private static TlsBucketConfig bucket(Optional engine, PqcEnforcementPolicy policy) { + return new TlsBucketConfig() { + @Override + public Optional keyStore() { + return Optional.empty(); + } + + @Override + public Optional trustStore() { + return Optional.empty(); + } + + @Override + public Optional> cipherSuites() { + return Optional.empty(); + } + + @Override + public Set protocols() { + return Set.of(); + } + + @Override + public Duration handshakeTimeout() { + return Duration.ofSeconds(10); + } + + @Override + public boolean alpn() { + return false; + } + + @Override + public PqcEnforcementPolicy pqcEnforcementPolicy() { + return policy; + } + + @Override + public Optional> keyExchangeGroups() { + return Optional.empty(); + } + + @Override + public Optional sslEngine() { + return engine; + } + + @Override + public Optional> certificateRevocationList() { + return Optional.empty(); + } + + @Override + public boolean trustAll() { + return false; + } + + @Override + public Optional hostnameVerificationAlgorithm() { + return Optional.empty(); + } + + @Override + public Optional reloadPeriod() { + return Optional.empty(); + } + }; + } +} From 06459f0c45bff6b7eae3c4806afa6392ee835491 Mon Sep 17 00:00:00 2001 From: jnbdz <237008+jnbdz@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:15:38 -0400 Subject: [PATCH 095/254] Document how to open Dev UI through another host name Dev UI only accepts localhost and 127.0.0.1 as the request host and as the CORS origin. The quarkus.dev-ui.hosts option, which allows more host names, was not mentioned in the guide, and neither was the fact that the HTTP host validation has to accept the name as well. The guide now describes both settings and why host names are compared as text and never resolved. A dev mode test covers the documented setup. Fixes #29462 --- docs/src/main/asciidoc/dev-ui.adoc | 21 +++++++++ .../devui/DevUIConfiguredHostTest.java | 47 +++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 extensions/devui/deployment/src/test/java/io/quarkus/devui/DevUIConfiguredHostTest.java diff --git a/docs/src/main/asciidoc/dev-ui.adoc b/docs/src/main/asciidoc/dev-ui.adoc index 59c6a0495e04b..b04a57a652e51 100644 --- a/docs/src/main/asciidoc/dev-ui.adoc +++ b/docs/src/main/asciidoc/dev-ui.adoc @@ -163,6 +163,27 @@ The settings page is a dialog that contains settings for Dev UI: - Dev MCP (Enable your Dev Mode to expose a MCP Server) - ... and more... (Other extensions might add to this) +[[dev-ui-hosts]] +=== Accessing Dev UI through another host name + +Dev UI only answers requests that are addressed to `localhost` or `127.0.0.1`, and only accepts these as the origin of a cross-origin request. +This protects it against DNS rebinding, where a page from another site makes the browser call a local port under a host name that the attacker controls. + +To open Dev UI through another name, for example the name of your machine, two settings are needed. +The HTTP server has to accept the host name, see xref:http-reference.adoc#host-header-validation[HTTP Host header validation], and Dev UI has to accept it as well: + +[source,properties] +---- +%dev.quarkus.http.host-validation.allowed-hosts=localhost,127.0.0.1,my-machine +%dev.quarkus.dev-ui.hosts=my-machine +---- + +Setting `quarkus.http.host-validation.allowed-hosts` replaces the built-in local names, so `localhost` and `127.0.0.1` have to be listed again to keep Dev UI reachable through them. + +`quarkus.dev-ui.hosts` takes a comma-separated list of host names. +Only list names that you control. +Host names are compared as text and are never resolved, so that a name which merely resolves to a local address is not accepted. + == Guide for Extension Developers Quarkus extensions can enhance the developer experience by contributing to the Dev UI. The rest of this guide outlines how to integrate your extension into the Dev UI, covering metadata configuration, adding pages to the card, menu, and footer, and best practices for dynamic content. diff --git a/extensions/devui/deployment/src/test/java/io/quarkus/devui/DevUIConfiguredHostTest.java b/extensions/devui/deployment/src/test/java/io/quarkus/devui/DevUIConfiguredHostTest.java new file mode 100644 index 0000000000000..22cc1ff686257 --- /dev/null +++ b/extensions/devui/deployment/src/test/java/io/quarkus/devui/DevUIConfiguredHostTest.java @@ -0,0 +1,47 @@ +package io.quarkus.devui; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.Socket; +import java.nio.charset.StandardCharsets; + +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.deployment.util.IoUtil; +import io.quarkus.test.QuarkusDevModeTest; + +public class DevUIConfiguredHostTest { + + @RegisterExtension + static final QuarkusDevModeTest config = new QuarkusDevModeTest() + .withApplicationRoot(jar -> jar.addAsResource(new StringAsset(""" + quarkus.http.host-validation.allowed-hosts=localhost,my-machine,other-machine + quarkus.dev-ui.hosts=my-machine + """), "application.properties")); + + @Test + public void configuredHostIsAccepted() throws Exception { + String response = preflight("my-machine"); + assertThat(response).startsWith("HTTP/1.1 200"); + assertThat(response).contains("access-control-allow-origin: http://my-machine:8080"); + } + + @Test + public void hostThatIsNotConfiguredForDevUIIsRejected() throws Exception { + assertThat(preflight("other-machine")).startsWith("HTTP/1.1 403"); + } + + private static String preflight(String host) throws Exception { + try (Socket socket = new Socket("localhost", 8080)) { + socket.getOutputStream().write(("OPTIONS /q/dev-ui/configuration-form-editor HTTP/1.1\r\n" + + "Host: " + host + ":8080\r\n" + + "Origin: http://" + host + ":8080\r\n" + + "Access-Control-Request-Method: GET,POST\r\n" + + "Connection: close\r\n\r\n").getBytes(StandardCharsets.US_ASCII)); + socket.getOutputStream().flush(); + return new String(IoUtil.readBytes(socket.getInputStream()), StandardCharsets.UTF_8); + } + } +} From 26b82f373d892c05d1f60277d269c03d30e2752d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 05:18:08 +0000 Subject: [PATCH 096/254] Bump at.yawk.lz4:lz4-java from 1.11.1 to 1.11.4 in /bom/application Bumps [at.yawk.lz4:lz4-java](https://github.com/yawkat/lz4-java) from 1.11.1 to 1.11.4. - [Release notes](https://github.com/yawkat/lz4-java/releases) - [Changelog](https://github.com/yawkat/lz4-java/blob/main/CHANGES.md) - [Commits](https://github.com/yawkat/lz4-java/compare/v1.11.1...v1.11.4) --- updated-dependencies: - dependency-name: at.yawk.lz4:lz4-java dependency-version: 1.11.4 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- bom/application/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bom/application/pom.xml b/bom/application/pom.xml index 0204ea8789f72..63b7eef64f7dc 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -145,7 +145,7 @@ 3.3.0 4.0.5 4.2.1 - 1.11.1 + 1.11.4 1.1.10.8 0.117.0 From bc74b18b55a1f17a76a46b214ae8897edf9f02ce Mon Sep 17 00:00:00 2001 From: Cristiano Nicolai <570894+cristianonicolai@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:36:36 +1000 Subject: [PATCH 097/254] Fix multi-field PanacheQL updates in MongoDB with Panache --- docs/src/main/asciidoc/mongodb-panache.adoc | 4 +- .../common/binder/MongoParserVisitor.java | 34 +++++++++++- .../common/binder/PanacheQlQueryBinder.java | 49 ++++++++++++++--- .../runtime/ReactiveMongoOperations.java | 26 ++++++++- .../common/runtime/MongoOperations.java | 26 ++++++++- .../common/runtime/MongoOperationsTest.java | 55 +++++++++++++++++++ .../it/mongodb/panache/test/TestResource.java | 8 +++ 7 files changed, 188 insertions(+), 14 deletions(-) diff --git a/docs/src/main/asciidoc/mongodb-panache.adoc b/docs/src/main/asciidoc/mongodb-panache.adoc index 1309eaa2e8264..6986945263880 100644 --- a/docs/src/main/asciidoc/mongodb-panache.adoc +++ b/docs/src/main/asciidoc/mongodb-panache.adoc @@ -604,8 +604,8 @@ For these operations, you can express the update document the same way you expre - `` (and single parameter) which will expand to the update document `{'$set' : {'singleColumnName': '?1'}}` - `firstname = ?1 and status = ?2` will be mapped to the update document `{'$set' : {'firstname': ?1, 'status': ?2}}` - `firstname = :firstname and status = :status` will be mapped to the update document `{'$set' : {'firstname': :firstname, 'status': :status}}` -- `{'firstname' : ?1 and 'status' : ?2}` will be mapped to the update document `{'$set' : {'firstname': ?1, 'status': ?2}}` -- `{'firstname' : :firstname and 'status' : :status}` will be mapped to the update document `{'$set' : {'firstname': :firstname, 'status': :status}}` +- `{'firstname' : ?1, 'status' : ?2}` will be mapped to the update document `{'$set' : {'firstname': ?1, 'status': ?2}}` +- `{'firstname' : :firstname, 'status' : :status}` will be mapped to the update document `{'$set' : {'firstname': :firstname, 'status': :status}}` - `{'$inc': {'cpt': ?1}}` will be used as-is === Query parameters diff --git a/extensions/panache/mongodb-panache-common/runtime/src/main/java/io/quarkus/mongodb/panache/common/binder/MongoParserVisitor.java b/extensions/panache/mongodb-panache-common/runtime/src/main/java/io/quarkus/mongodb/panache/common/binder/MongoParserVisitor.java index 86ebfa413534e..224ce0f6b31bd 100644 --- a/extensions/panache/mongodb-panache-common/runtime/src/main/java/io/quarkus/mongodb/panache/common/binder/MongoParserVisitor.java +++ b/extensions/panache/mongodb-panache-common/runtime/src/main/java/io/quarkus/mongodb/panache/common/binder/MongoParserVisitor.java @@ -4,10 +4,12 @@ import java.util.List; import java.util.Map; +import org.bson.Document; import org.bson.conversions.Bson; import com.mongodb.client.model.Filters; +import io.quarkus.panache.common.exception.PanacheQueryException; import io.quarkus.panacheql.internal.HqlParser; import io.quarkus.panacheql.internal.HqlParser.ComparisonPredicateContext; import io.quarkus.panacheql.internal.HqlParser.GroupedExpressionContext; @@ -21,14 +23,32 @@ class MongoParserVisitor extends HqlParserBaseVisitor { private Map replacementMap; private Map parameterMaps; + private final boolean update; public MongoParserVisitor(Map replacementMap, Map parameterMaps) { + this(replacementMap, parameterMaps, false); + } + + /** + * @param update whether the predicate is an update document instead of a filter: {@code field = value} assignments + * joined with {@code and} then list the fields to set. + */ + MongoParserVisitor(Map replacementMap, Map parameterMaps, boolean update) { this.replacementMap = replacementMap; this.parameterMaps = parameterMaps; + this.update = update; } @Override public Object visitAndPredicate(HqlParser.AndPredicateContext ctx) { + if (update) { + // 'field1 = ?1 and field2 = ?2' becomes {'field1': ?1, 'field2': ?2}, not an $and filter + Document fields = new Document(); + for (HqlParser.PredicateContext predicate : ctx.predicate()) { + fields.putAll(visitAssignments(predicate)); + } + return fields; + } List filters = new ArrayList<>(); for (HqlParser.PredicateContext predicate : ctx.predicate()) { filters.add((Bson) predicate.accept(this)); @@ -36,6 +56,18 @@ public Object visitAndPredicate(HqlParser.AndPredicateContext ctx) { return Filters.and(filters); } + /** + * Visits a predicate of an update, which can only contain {@code field = value} assignments, and returns the fields to + * set. + */ + Document visitAssignments(HqlParser.PredicateContext predicate) { + if (!(predicate.accept(this) instanceof Document fields)) { + throw new PanacheQueryException("Only 'field = value' assignments are supported in an update, found: " + + predicate.getText()); + } + return fields; + } + @Override public Object visitOrPredicate(HqlParser.OrPredicateContext ctx) { List filters = new ArrayList<>(); @@ -50,7 +82,7 @@ public Object visitComparisonPredicate(ComparisonPredicateContext ctx) { String field = (String) ctx.expression(0).accept(this); Object value = ctx.expression(1).accept(this); if (ctx.comparisonOperator().EQUAL() != null) { - return Filters.eq(field, value); + return update ? new Document(field, value) : Filters.eq(field, value); } if (ctx.comparisonOperator().NOT_EQUAL() != null) { return Filters.ne(field, value); diff --git a/extensions/panache/mongodb-panache-common/runtime/src/main/java/io/quarkus/mongodb/panache/common/binder/PanacheQlQueryBinder.java b/extensions/panache/mongodb-panache-common/runtime/src/main/java/io/quarkus/mongodb/panache/common/binder/PanacheQlQueryBinder.java index e23c58390d95d..70d8c9a8d246e 100644 --- a/extensions/panache/mongodb-panache-common/runtime/src/main/java/io/quarkus/mongodb/panache/common/binder/PanacheQlQueryBinder.java +++ b/extensions/panache/mongodb-panache-common/runtime/src/main/java/io/quarkus/mongodb/panache/common/binder/PanacheQlQueryBinder.java @@ -5,17 +5,42 @@ import org.antlr.v4.runtime.CharStreams; import org.antlr.v4.runtime.CommonTokenStream; +import org.antlr.v4.runtime.Token; import org.bson.Document; import org.bson.conversions.Bson; import io.quarkus.mongodb.panache.common.runtime.MongoPropertyUtil; +import io.quarkus.panache.common.exception.PanacheQueryException; import io.quarkus.panacheql.internal.HqlLexer; import io.quarkus.panacheql.internal.HqlParser; -import io.quarkus.panacheql.internal.HqlParserBaseVisitor; public class PanacheQlQueryBinder { public static Bson bindQuery(Class clazz, String query, Object[] params) { + return bind(clazz, query, params, false); + } + + public static Bson bindQuery(Class clazz, String query, Map params) { + return bind(clazz, query, params, false); + } + + /** + * Binds a PanacheQL update like firstname = ?1 and status = ?2 to the fields to set: + * {'firstname': ?1, 'status': ?2}. + */ + public static Bson bindUpdate(Class clazz, String update, Object[] params) { + return bind(clazz, update, params, true); + } + + /** + * Binds a PanacheQL update like firstname = :firstname and status = :status to the fields to set: + * {'firstname': :firstname, 'status': :status}. + */ + public static Bson bindUpdate(Class clazz, String update, Map params) { + return bind(clazz, update, params, true); + } + + private static Bson bind(Class clazz, String query, Object[] params, boolean update) { Map replacementMap = MongoPropertyUtil.getReplacementMap(clazz); //shorthand query @@ -31,10 +56,10 @@ public static Bson bindQuery(Class clazz, String query, Object[] params) { parameterMaps.put(bindParamsKey, params[i - 1]); } - return prepareQuery(query, replacementMap, parameterMaps); + return prepareQuery(query, replacementMap, parameterMaps, update); } - public static Bson bindQuery(Class clazz, String query, Map params) { + private static Bson bind(Class clazz, String query, Map params, boolean update) { Map replacementMap = MongoPropertyUtil.getReplacementMap(clazz); Map parameterMaps = new HashMap<>(); @@ -43,19 +68,29 @@ public static Bson bindQuery(Class clazz, String query, Map p parameterMaps.put(bindParamsKey, entry.getValue()); } - return prepareQuery(query, replacementMap, parameterMaps); + return prepareQuery(query, replacementMap, parameterMaps, update); } private static String replaceField(String field, Map replacementMap) { return replacementMap.getOrDefault(field, field); } - private static Bson prepareQuery(String query, Map replacementMap, Map parameterMaps) { + private static Bson prepareQuery(String query, Map replacementMap, Map parameterMaps, + boolean update) { HqlLexer lexer = new HqlLexer(CharStreams.fromString(query)); CommonTokenStream tokens = new CommonTokenStream(lexer); HqlParser parser = new HqlParser(tokens); HqlParser.PredicateContext predicate = parser.predicate(); - HqlParserBaseVisitor visitor = new MongoParserVisitor(replacementMap, parameterMaps); - return (Bson) predicate.accept(visitor); + MongoParserVisitor visitor = new MongoParserVisitor(replacementMap, parameterMaps, update); + if (!update) { + return (Bson) predicate.accept(visitor); + } + // the parser stops at the first token it can't use, e.g. the comma of 'field1 = ?1, field2 = ?2', + // which would otherwise silently drop the rest of the update + if (parser.getCurrentToken().getType() != Token.EOF) { + throw new PanacheQueryException("Unexpected '" + parser.getCurrentToken().getText() + "' in the update '" + query + + "', the fields to set must be separated by 'and'"); + } + return visitor.visitAssignments(predicate); } } diff --git a/extensions/panache/mongodb-panache-common/runtime/src/main/java/io/quarkus/mongodb/panache/common/reactive/runtime/ReactiveMongoOperations.java b/extensions/panache/mongodb-panache-common/runtime/src/main/java/io/quarkus/mongodb/panache/common/reactive/runtime/ReactiveMongoOperations.java index a94a5acab3077..849882b542976 100644 --- a/extensions/panache/mongodb-panache-common/runtime/src/main/java/io/quarkus/mongodb/panache/common/reactive/runtime/ReactiveMongoOperations.java +++ b/extensions/panache/mongodb-panache-common/runtime/src/main/java/io/quarkus/mongodb/panache/common/reactive/runtime/ReactiveMongoOperations.java @@ -431,7 +431,7 @@ public Bson bindFilter(Class clazz, String query, Map params) * As update document needs an update operator, we add $set if none is provided. */ Bson bindUpdate(Class clazz, String query, Object[] params) { - Bson bindUpdate = bindQuery(clazz, query, params); + Bson bindUpdate = bindUpdateQuery(clazz, query, params); if (!containsUpdateOperator(query)) { bindUpdate = new Document("$set", bindUpdate); } @@ -445,7 +445,7 @@ Bson bindUpdate(Class clazz, String query, Object[] params) { * As update document needs an update operator, we add $set if none is provided. */ Bson bindUpdate(Class clazz, String query, Map params) { - Bson bindUpdate = bindQuery(clazz, query, params); + Bson bindUpdate = bindUpdateQuery(clazz, query, params); if (!containsUpdateOperator(query)) { bindUpdate = new Document("$set", bindUpdate); } @@ -484,6 +484,28 @@ Bson bindQuery(Class clazz, String query, Map params) { } } + private Bson bindUpdateQuery(Class clazz, String update, Object[] params) { + //determine the type of the update + if (update.charAt(0) == '{') { + //this is a native update + return NativeQueryBinder.bindQuery(update, params); + } else { + //this is a PanacheQL update + return PanacheQlQueryBinder.bindUpdate(clazz, update, params); + } + } + + private Bson bindUpdateQuery(Class clazz, String update, Map params) { + //determine the type of the update + if (update.charAt(0) == '{') { + //this is a native update + return NativeQueryBinder.bindQuery(update, params); + } else { + //this is a PanacheQL update + return PanacheQlQueryBinder.bindUpdate(clazz, update, params); + } + } + public QueryType find(Class entityClass, String query, Map params) { return find(entityClass, query, null, params); } diff --git a/extensions/panache/mongodb-panache-common/runtime/src/main/java/io/quarkus/mongodb/panache/common/runtime/MongoOperations.java b/extensions/panache/mongodb-panache-common/runtime/src/main/java/io/quarkus/mongodb/panache/common/runtime/MongoOperations.java index d1386a3f2ccf0..50f80027872dc 100644 --- a/extensions/panache/mongodb-panache-common/runtime/src/main/java/io/quarkus/mongodb/panache/common/runtime/MongoOperations.java +++ b/extensions/panache/mongodb-panache-common/runtime/src/main/java/io/quarkus/mongodb/panache/common/runtime/MongoOperations.java @@ -461,7 +461,7 @@ public Bson bindFilter(Class clazz, String query, Map params) * As update document needs an update operator, we add $set if none is provided. */ Bson bindUpdate(Class clazz, String query, Object[] params) { - Bson bindUpdate = bindQuery(clazz, query, params); + Bson bindUpdate = bindUpdateQuery(clazz, query, params); if (!containsUpdateOperator(query)) { bindUpdate = new Document("$set", bindUpdate); } @@ -475,7 +475,7 @@ Bson bindUpdate(Class clazz, String query, Object[] params) { * As update document needs an update operator, we add $set if none is provided. */ Bson bindUpdate(Class clazz, String query, Map params) { - Bson bindUpdate = bindQuery(clazz, query, params); + Bson bindUpdate = bindUpdateQuery(clazz, query, params); if (!containsUpdateOperator(query)) { bindUpdate = new Document("$set", bindUpdate); } @@ -514,6 +514,28 @@ private Bson bindQuery(Class clazz, String query, Map params) } } + private Bson bindUpdateQuery(Class clazz, String update, Object[] params) { + //determine the type of the update + if (update.charAt(0) == '{') { + //this is a native update + return NativeQueryBinder.bindQuery(update, params); + } else { + //this is a PanacheQL update + return PanacheQlQueryBinder.bindUpdate(clazz, update, params); + } + } + + private Bson bindUpdateQuery(Class clazz, String update, Map params) { + //determine the type of the update + if (update.charAt(0) == '{') { + //this is a native update + return NativeQueryBinder.bindQuery(update, params); + } else { + //this is a PanacheQL update + return PanacheQlQueryBinder.bindUpdate(clazz, update, params); + } + } + public QueryType find(Class entityClass, String query, Map params) { return find(entityClass, query, null, params); } diff --git a/extensions/panache/mongodb-panache-common/runtime/src/test/java/io/quarkus/mongodb/panache/common/runtime/MongoOperationsTest.java b/extensions/panache/mongodb-panache-common/runtime/src/test/java/io/quarkus/mongodb/panache/common/runtime/MongoOperationsTest.java index 33e2499c76b84..d9785a6b18d5a 100644 --- a/extensions/panache/mongodb-panache-common/runtime/src/test/java/io/quarkus/mongodb/panache/common/runtime/MongoOperationsTest.java +++ b/extensions/panache/mongodb-panache-common/runtime/src/test/java/io/quarkus/mongodb/panache/common/runtime/MongoOperationsTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; import java.time.LocalDate; import java.time.LocalDateTime; @@ -40,6 +41,7 @@ import io.quarkus.mongodb.panache.common.PanacheUpdate; import io.quarkus.panache.common.Parameters; +import io.quarkus.panache.common.exception.PanacheQueryException; class MongoOperationsTest { private final MongoOperations operations = new MongoOperations() { @@ -651,6 +653,59 @@ public void testBindUpdate() { assertBsonEquals( new org.bson.Document("$set", Filters.eq("field", "a value")), update); + + // enhanced update of several fields by index + update = operations.bindUpdate(Object.class, "field = ?1 and field2 = ?2 and field3 = ?3", + new Object[] { "a value", "another value", 3 }); + assertBsonEquals( + new org.bson.Document("$set", new org.bson.Document("field", "a value") + .append("field2", "another value") + .append("field3", 3)), + update); + + // enhanced update of several fields by name + update = operations.bindUpdate(Object.class, "field = :field and field2 = :field2", + Parameters.with("field", "a value").and("field2", "another value").map()); + assertBsonEquals( + new org.bson.Document("$set", new org.bson.Document("field", "a value") + .append("field2", "another value")), + update); + + // enhanced update of several fields with a replaced property name + update = operations.bindUpdate(DemoObj.class, "property = ?1 and field = ?2", + new Object[] { "a value", "another value" }); + assertBsonEquals( + new org.bson.Document("$set", new org.bson.Document("value", "a value") + .append("field", "another value")), + update); + + // native update of several fields by index + update = operations.bindUpdate(Object.class, "{'field': ?1, 'field2': ?2}", + new Object[] { "a value", "another value" }); + assertBsonEquals( + new org.bson.Document("$set", new org.bson.Document("field", "a value") + .append("field2", "another value")), + update); + + // native update of several fields by name + update = operations.bindUpdate(Object.class, "{'firstname': :firstname, 'status': :status}", + Parameters.with("firstname", "a value").and("status", "another value").map()); + assertBsonEquals( + new org.bson.Document("$set", new org.bson.Document("firstname", "a value") + .append("status", "another value")), + update); + + // only assignments can be combined in an update + assertThrows(PanacheQueryException.class, + () -> operations.bindUpdate(Object.class, "field = ?1 and field2 > ?2", new Object[] { "a value", 2 })); + + // the fields to set of an enhanced update are separated by 'and', not by commas + assertThrows(PanacheQueryException.class, + () -> operations.bindUpdate(Object.class, "field = ?1, field2 = ?2", new Object[] { "a value", 2 })); + + // nothing else can follow the fields to set + assertThrows(PanacheQueryException.class, + () -> operations.bindUpdate(Object.class, "field = ?1 where field2 = ?2", new Object[] { "a value", 2 })); } @Test diff --git a/integration-tests/mongodb-panache/src/main/java/io/quarkus/it/mongodb/panache/test/TestResource.java b/integration-tests/mongodb-panache/src/main/java/io/quarkus/it/mongodb/panache/test/TestResource.java index 5a5aa17920b7d..54a1ba5fa3a99 100644 --- a/integration-tests/mongodb-panache/src/main/java/io/quarkus/it/mongodb/panache/test/TestResource.java +++ b/integration-tests/mongodb-panache/src/main/java/io/quarkus/it/mongodb/panache/test/TestResource.java @@ -171,6 +171,10 @@ public Response testImperativeEntity() { .where("{'category' : :category}", Parameters.with("category", "newCategory2")); Assertions.assertEquals(5, updated); Assertions.assertEquals(5, TestImperativeEntity.count("category = ?1", "newCategory3")); + updated = TestImperativeEntity.update("category = ?1 and description = ?2", "newCategory3", "newDescription") + .where("category = ?1", "newCategory3"); + Assertions.assertEquals(5, updated); + Assertions.assertEquals(5, TestImperativeEntity.count("description = ?1", "newDescription")); updated = TestImperativeEntity.update("newField", "newValue").all(); Assertions.assertEquals(10, updated); updated = TestImperativeEntity.update("{'$inc': {'cpt': 1}}").all(); @@ -596,6 +600,10 @@ public Response testReactiveEntity() { .where("{'category' : :category}", Parameters.with("category", "newCategory2")).await().indefinitely(); Assertions.assertEquals(5, updated); Assertions.assertEquals(5, TestReactiveEntity.count("category = ?1", "newCategory3").await().indefinitely()); + updated = TestReactiveEntity.update("category = ?1 and description = ?2", "newCategory3", "newDescription") + .where("category = ?1", "newCategory3").await().indefinitely(); + Assertions.assertEquals(5, updated); + Assertions.assertEquals(5, TestReactiveEntity.count("description = ?1", "newDescription").await().indefinitely()); updated = TestReactiveEntity.update("newField", "newValue").all().await().indefinitely(); Assertions.assertEquals(10, updated); updated = TestReactiveEntity.update("{'$inc': {'cpt': 1}}").all().await().indefinitely(); From 2be4f6b3832da44ee7d95cf15a7dc79664d38fca Mon Sep 17 00:00:00 2001 From: Martin Kouba Date: Thu, 8 Oct 2026 09:34:30 +0200 Subject: [PATCH 098/254] Upgrade snappy-java to 1.1.10.11 - fixes https://access.redhat.com/security/cve/cve-2026-90559 --- bom/application/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bom/application/pom.xml b/bom/application/pom.xml index 63b7eef64f7dc..66996c871dbd8 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -146,7 +146,7 @@ 4.0.5 4.2.1 1.11.4 - 1.1.10.8 + 1.1.10.11 0.117.0 2.13.16 From 3490e5e3fbfac0ccc3e03c4719257211ae09c5d2 Mon Sep 17 00:00:00 2001 From: Gaelle Fournier Date: Thu, 8 Oct 2026 12:30:17 +0200 Subject: [PATCH 099/254] Manage sftp-sshd --- bom/application/pom.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bom/application/pom.xml b/bom/application/pom.xml index 0204ea8789f72..1c408a0787999 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -5685,6 +5685,11 @@ sshd-core ${sshd-common.version} + + org.apache.sshd + sshd-sftp + ${sshd-common.version} + org.wildfly.security wildfly-elytron-sasl-gs2 From d66820bbc3f2b9217764b2cbf1a93112a0ac32aa Mon Sep 17 00:00:00 2001 From: Rostislav Svoboda Date: Thu, 8 Oct 2026 13:12:48 +0200 Subject: [PATCH 100/254] Fix incorrect references and typos in kafka guide --- docs/src/main/asciidoc/kafka.adoc | 44 +++++++++++++++---------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/docs/src/main/asciidoc/kafka.adoc b/docs/src/main/asciidoc/kafka.adoc index 5735161bf1ef1..f178429e7c303 100644 --- a/docs/src/main/asciidoc/kafka.adoc +++ b/docs/src/main/asciidoc/kafka.adoc @@ -244,7 +244,7 @@ See the xref:quarkus-reactive-architecture.adoc[Quarkus Reactive Architecture do But, you often need to combine Reactive Messaging with blocking processing such as database interactions. For this, you need to use the `@Blocking` annotation indicating that the processing is _blocking_ and should not be run on the caller thread. -For example, The following code illustrates how you can store incoming payloads to a database using Hibernate with Panache: +For example, the following code illustrates how you can store incoming payloads to a database using Hibernate with Panache: [source,java] ---- @@ -389,7 +389,7 @@ See https://smallrye.io/smallrye-reactive-messaging/latest/kafka/receiving-kafka If a message produced from a Kafka record is nacked, a failure strategy is applied. The Kafka connector supports the following strategies: - `fail`: fail the application, no more records will be processed (default strategy). The offset of the record that has not been processed correctly is not committed. -- `ignore`: the failure is logged, but the processing continue. The offset of the record that has not been processed correctly is committed. +- `ignore`: the failure is logged, but the processing continues. The offset of the record that has not been processed correctly is committed. - `dead-letter-queue`: the offset of the record that has not been processed correctly is committed, but the record is written to a Kafka dead letter topic. - `delayed-retry-topic`: the failed record is sent to delayed retry topics for later reprocessing, with configurable delays and maximum retries. @@ -486,7 +486,7 @@ In Kafka, a consumer group is a set of consumers which cooperate to consume data A topic is divided into a set of partitions. The partitions of a topic are assigned among the consumers in the group, effectively allowing to scale consumption throughput. Note that each partition is assigned to a single consumer from a group. -However, a consumer can be assigned multiple partitions if the number of partitions is greater than the number of consumer in the group. +However, a consumer can be assigned multiple partitions if the number of partitions is greater than the number of consumers in the group. Let's explore briefly different producer/consumer patterns and how to implement them using Quarkus: @@ -502,7 +502,7 @@ image::kafka-one-app-one-consumer.png[alt=Architecture, width=60%, align=center] + For a given application instance, the number of consumers inside the consumer group can be configured using `mp.messaging.incoming.$channel.concurrency` property. The partitions of the subscribed topic will be divided among the consumer threads. -Note that if the `concurrency` value exceed the number of partitions of the topic, some consumer threads won't be assigned any partitions. +Note that if the `concurrency` value exceeds the number of partitions of the topic, some consumer threads won't be assigned any partitions. + image::kafka-one-app-two-consumers.png[alt=Architecture, width=60%, align=center] + @@ -523,7 +523,7 @@ image::kafka-two-app-one-consumer-group.png[alt=Architecture, width=60% , align= . *Pub/Sub: Multiple consumer groups subscribed to a topic* + -Lastly different applications can subscribe independently to same topics using different *consumer group ids*. +Lastly different applications can subscribe independently to the same topics using different *consumer group ids*. For example, messages published to a topic called _orders_ can be consumed independently on two consumer applications, one with `mp.messaging.incoming.orders.group.id=invoicing` and second with `mp.messaging.incoming.orders.group.id=shipping`. Different consumer groups can thus scale independently according to the message consumption requirements. + @@ -534,7 +534,7 @@ image::kafka-two-app-two-consumer-groups.png[alt=Architecture, width=60%, align= A common business requirement is to consume and process Kafka records in order. The Kafka broker preserves order of records inside a partition and not inside a topic. Therefore, it is important to think about how records are partitioned inside a topic. -The default partitioner uses record key hash to compute the partition for a record, or when the key is not defined, chooses a partition randomly per batch or records. +The default partitioner uses record key hash to compute the partition for a record, or when the key is not defined, chooses a partition randomly per batch of records. During normal operation, a Kafka consumer preserves the order of records inside each partition assigned to it. SmallRye Reactive Messaging keeps this order for processing, unless `@Blocking(ordered = false)` is used (see <>). @@ -557,7 +557,7 @@ Note that due to consumer rebalances, Kafka consumers only guarantee at-least-on Inside a consumer group, as new group members arrive and old members leave, the partitions are re-assigned so that each member receives a proportional share of the partitions. This is known as rebalancing the group. To handle offset commit and assigned partitions yourself, you can provide a consumer rebalance listener. -To achieve this, implement the `io.smallrye.reactive.messaging.kafka.KafkaConsumerRebalanceListener` interface and expose it as a CDI bean with the `@Idenfier` qualifier. +To achieve this, implement the `io.smallrye.reactive.messaging.kafka.KafkaConsumerRebalanceListener` interface and expose it as a CDI bean with the `@Identifier` qualifier. A common use case is to store offset in a separate data store to implement exactly-once semantic, or starting the processing at a specific offset. The listener is invoked every time the consumer topic/partition assignment changes. @@ -683,7 +683,7 @@ mp.messaging.incoming.your-channel.auto.offset.reset=earliest mp.messaging.incoming.your-channel.group.id=${quarkus.uuid} ---- -IMPORTANT: If the `group.id` attribute is not set, it defaults the `quarkus.application.name` configuration property. +IMPORTANT: If the `group.id` attribute is not set, it defaults to the `quarkus.application.name` configuration property. ==== Manual topic-partition assignment @@ -691,7 +691,7 @@ The `assign-seek` channel attribute allows manually assigning topic-partitions t and optionally seek to a specified offset in the partition to start consuming records. If `assign-seek` is used, the consumer will not be dynamically subscribed to topics, but instead will statically assign the described partitions. -In manual topic-partition rebalancing doesn't happen and therefore rebalance listeners are never called. +In manual topic-partition assignment, rebalancing doesn't happen and therefore rebalance listeners are never called. The attribute takes a list of triplets separated by commas: `::`. @@ -1317,7 +1317,7 @@ The number of in-flight messages is configured using the `max-inflight-messages` The connector only sends that amount of messages concurrently. No other messages will be sent until at least one in-flight message gets acknowledged by the broker. -Then, the connector writes a new message to Kafka when one of the broker’s in-flight messages get acknowledged. +Then, the connector writes a new message to Kafka when one of the broker’s in-flight messages gets acknowledged. Be sure to configure Kafka’s `batch.size` and `linger.ms` accordingly. You can also remove the limit of in-flight messages by setting `max-inflight-messages` to `0`. @@ -1330,7 +1330,7 @@ This behavior is controlled by `retries` and `retry.backoff.ms` parameters. In addition to this, SmallRye Reactive Messaging will retry individual messages on recoverable errors, depending on the `retries` and `delivery.timeout.ms` parameters. Note that while having retries in a reliable system is a best practice, the `max.in.flight.requests.per.connection` parameter defaults to `5`, meaning that the order of the messages is not guaranteed. -If the message order is a must for your use case, setting `max.in.flight.requests.per.connection` to `1` will make sure a single batch of messages is sent at a time, in the expense of limiting the throughput of the producer. +If the message order is a must for your use case, setting `max.in.flight.requests.per.connection` to `1` will make sure a single batch of messages is sent at a time, at the expense of limiting the throughput of the producer. For applying retry mechanism on processing errors, see the section on <>. @@ -2136,7 +2136,7 @@ mp.messaging.outgoing.fruit-out.value.serializer=io.quarkus.kafka.client.seriali Now, your Kafka messages will contain a Jackson serialized representation of your `Fruit` data object. In this case, the `deserializer` configuration is not necessary as the <> is enabled by default. -If you want to deserialize a list of fruits, you need to create a deserializer with a Jackson `TypeReference` denoted the generic collection used. +If you want to deserialize a list of fruits, you need to create a deserializer with a Jackson `TypeReference` denoting the generic collection used. [source,java] ---- @@ -2213,7 +2213,7 @@ mp.messaging.outgoing.fruit-out.value.serializer=io.quarkus.kafka.client.seriali Now, your Kafka messages will contain a JSON-B serialized representation of your `Fruit` data object. -If you want to deserialize a list of fruits, you need to create a deserializer with a `Type` denoted the generic collection used. +If you want to deserialize a list of fruits, you need to create a deserializer with a `Type` denoting the generic collection used. [source,java] ---- @@ -2362,7 +2362,7 @@ For each channel, you can disable the checks using: ---- # Disable both liveness and readiness checks with `health-enabled=false`: -# Incoming channel (receiving records form Kafka) +# Incoming channel (receiving records from Kafka) mp.messaging.incoming.your-channel.health-enabled=false # Outgoing channel (writing records to Kafka) mp.messaging.outgoing.your-channel.health-enabled=false @@ -2382,7 +2382,7 @@ This approach is not intrusive as it's based on built-in Kafka client metrics. Using the `health-topic-verification-enabled=true` attribute, _startup_ probe uses an _admin client_ to check for the list of topics. Whereas the _readiness_ probe for an incoming channel checks that at least one partition is assigned for consumption, -and for an outgoing channel checks that the topic used by the producer exist in the broker. +and for an outgoing channel checks that the topic used by the producer exists in the broker. Note that to achieve this, an _admin connection_ is required. You can adjust the timeout for topic verification calls to the broker using the `health-topic-verification-timeout` configuration. @@ -2460,7 +2460,7 @@ To disable JMX MBean registration, set the following property in your `applicati quarkus.kafka.jmx.enabled=false ---- -When disabled, the `AppInfoParser` skipş the JMX MBean registration and the default value for `kafka.metric.reporters` is set to empty, preventing the `JmxReporter` from being loaded. +When disabled, the `AppInfoParser` skips the JMX MBean registration and the default value for `kafka.metric.reporters` is set to empty, preventing the `JmxReporter` from being loaded. Kafka client metrics are available through Micrometer when the xref:telemetry-micrometer.adoc[Micrometer extension] is present. == Authentication with OAuth @@ -2508,7 +2508,7 @@ quarkus.ssl.native=true Update the `oauth.client.id`, `oauth.client.secret` and `oauth.token.endpoint.uri` values. -OAuth authentication works for both JVM and native modes. Since SSL in not enabled by default in native mode, `quarkus.ssl.native=true` must be added to support JaasClientOauthLoginCallbackHandler, which uses SSL. (See the xref:native-and-ssl.adoc[Using SSL with Native Executables] guide for more details.) +OAuth authentication works for both JVM and native modes. Since SSL is not enabled by default in native mode, `quarkus.ssl.native=true` must be added to support JaasClientOauthLoginCallbackHandler, which uses SSL. (See the xref:native-and-ssl.adoc[Using SSL with Native Executables] guide for more details.) == TLS Configuration @@ -2660,7 +2660,7 @@ class BaristaTest { The application will process this message and send a message to `beverages` channel. <5> Use the `received` method on `beverages` channel to check the messages produced by the application. -If your Kafka consumer is batch based, you will need to send a batch of messages to the channel as by creating them manually. +If your Kafka consumer is batch based, you will need to send a batch of messages to the channel by creating them manually. For instance: @@ -3267,7 +3267,7 @@ If `Panache` is not used, `Mutiny.SessionFactory` can be injected and used simil <3> Requests a new transaction. The transaction completes when the passed action completes. <4> Persist the entity. It returns a `Uni`. <5> Switch back to a `Uni`. -<6> Close the session - this is close the connection with the database. The connection can then be recycled. +<6> Close the session - this closes the connection with the database. The connection can then be recycled. Unlike with _classic_ Hibernate, you can't use `@Transactional`. Instead, we use `session.withTransaction` and persist our entity. @@ -3305,7 +3305,7 @@ You can also use Avro. Let's imagine the following process: 1. You receive an HTTP request with a payload, -2. You create an Hibernate entity instance from this payload, +2. You create a Hibernate entity instance from this payload, 3. You persist that entity into a database, 4. You send the entity to a Kafka topic @@ -3417,7 +3417,7 @@ public Multi stream() { } ---- -Some environment cuts the SSE connection when there is not enough activity. +Some environments cut the SSE connection when there is not enough activity. The workaround consists of sending _ping_ messages (or empty objects) periodically. [source, java] @@ -3512,7 +3512,7 @@ public class FruitProducer { <4> Begin a Kafka transaction. <5> Send the payload to Kafka inside the Kafka transaction. <6> Persist the entity into the database in a Hibernate Reactive transaction. -<7> Once the persist operation completes, and there is no errors, the Kafka transaction is committed. +<7> Once the persist operation completes, and there are no errors, the Kafka transaction is committed. The result is omitted and returned as the HTTP response. In the previous example the database transaction (inner) will commit followed by the Kafka transaction (outer). From c6687c291a07b2412d7be49e0cfc0ad86cf2ce1a Mon Sep 17 00:00:00 2001 From: Guillaume Smet Date: Thu, 8 Oct 2026 13:42:52 +0200 Subject: [PATCH 101/254] Go back to ubuntu-latest for develocity-publish-build-scans.yml --- .github/workflows/develocity-publish-build-scans.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/develocity-publish-build-scans.yml b/.github/workflows/develocity-publish-build-scans.yml index 03fcef7ff382a..e6f1da21ea89e 100644 --- a/.github/workflows/develocity-publish-build-scans.yml +++ b/.github/workflows/develocity-publish-build-scans.yml @@ -12,7 +12,7 @@ defaults: jobs: publish-build-scans: if: github.repository == 'quarkusio/quarkus' && github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion != 'cancelled' - runs-on: ubuntu-slim + runs-on: ubuntu-latest permissions: actions: write pull-requests: write From c8bdc9fbb86fbb9a4c4b9ca6d1a5ab5be596fb7c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:46:17 +0000 Subject: [PATCH 102/254] Bump com.nimbusds:nimbus-jose-jwt from 10.9.1 to 10.10 Bumps [com.nimbusds:nimbus-jose-jwt](https://bitbucket.org/connect2id/nimbus-jose-jwt) from 10.9.1 to 10.10. - [Changelog](https://bitbucket.org/connect2id/nimbus-jose-jwt/src/master/CHANGELOG.txt) - [Commits](https://bitbucket.org/connect2id/nimbus-jose-jwt/branches/compare/10.10..10.9.1) --- updated-dependencies: - dependency-name: com.nimbusds:nimbus-jose-jwt dependency-version: '10.10' dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- bom/application/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bom/application/pom.xml b/bom/application/pom.xml index a830c26d10054..59f9fce706178 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -212,7 +212,7 @@ 4.2.1 0.16.1 - 10.9.1 + 10.10 0.9.7 0.0.17 1.5.0 From ad0f5c9fe0844eede49fd55495a6de3fd0acc12d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:46:30 +0000 Subject: [PATCH 103/254] Bump tools.jackson:jackson-bom from 3.1.4 to 3.1.7 Bumps [tools.jackson:jackson-bom](https://github.com/FasterXML/jackson-bom) from 3.1.4 to 3.1.7. - [Commits](https://github.com/FasterXML/jackson-bom/compare/jackson-bom-3.1.4...jackson-bom-3.1.7) --- updated-dependencies: - dependency-name: tools.jackson:jackson-bom dependency-version: 3.1.7 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- bom/application/pom.xml | 2 +- independent-projects/extension-maven-plugin/pom.xml | 2 +- independent-projects/resteasy-reactive/pom.xml | 2 +- independent-projects/tools/pom.xml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bom/application/pom.xml b/bom/application/pom.xml index a830c26d10054..6b24b0eb831e3 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -91,7 +91,7 @@ 25.0.4.1 1.10.1 2.3.1 - 3.1.4 + 3.1.7 2.22.2 2.0.0.Final 3.20.0 diff --git a/independent-projects/extension-maven-plugin/pom.xml b/independent-projects/extension-maven-plugin/pom.xml index c7c5123b9d12d..f13216f363aab 100644 --- a/independent-projects/extension-maven-plugin/pom.xml +++ b/independent-projects/extension-maven-plugin/pom.xml @@ -37,7 +37,7 @@ 11 11 3.9.16 - 3.1.4 + 3.1.7 1.6.1 6.1.3 5.24.0 diff --git a/independent-projects/resteasy-reactive/pom.xml b/independent-projects/resteasy-reactive/pom.xml index 769f7ae2e1a81..61736eae67236 100644 --- a/independent-projects/resteasy-reactive/pom.xml +++ b/independent-projects/resteasy-reactive/pom.xml @@ -61,7 +61,7 @@ 5.1.8 6.0.1 2.0.0.Final - 3.1.4 + 3.1.7 3.0.3 3.1.1 3.0.5 diff --git a/independent-projects/tools/pom.xml b/independent-projects/tools/pom.xml index 0e2a2892954be..31aadf82e24f7 100644 --- a/independent-projects/tools/pom.xml +++ b/independent-projects/tools/pom.xml @@ -50,7 +50,7 @@ 3.27.7 - 3.1.4 + 3.1.7 5.0.0 6.1.3 1.28.0 From 587b63376b1e96f9245ebf5abca3eb66dda5316c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:50:04 +0000 Subject: [PATCH 104/254] Bump kafka.version from 4.2.1 to 4.2.2 Bumps `kafka.version` from 4.2.1 to 4.2.2. Updates `org.apache.kafka:kafka-clients` from 4.2.1 to 4.2.2 Updates `org.apache.kafka:kafka-streams` from 4.2.1 to 4.2.2 Updates `org.apache.kafka:kafka-streams-test-utils` from 4.2.1 to 4.2.2 Updates `org.apache.kafka:kafka_2.13` from 4.2.1 to 4.2.2 --- updated-dependencies: - dependency-name: org.apache.kafka:kafka-clients dependency-version: 4.2.2 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: org.apache.kafka:kafka-streams dependency-version: 4.2.2 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: org.apache.kafka:kafka-streams-test-utils dependency-version: 4.2.2 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: org.apache.kafka:kafka_2.13 dependency-version: 4.2.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- bom/application/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bom/application/pom.xml b/bom/application/pom.xml index a830c26d10054..50413b66f2f3d 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -144,7 +144,7 @@ 3.6.3.Final 3.3.0 4.0.5 - 4.2.1 + 4.2.2 1.11.4 1.1.10.8 0.117.0 From a5149e825d6462b7e38ef432c671220b85bfefca Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:22:32 +0000 Subject: [PATCH 105/254] Bump com.h2database:h2 from 2.4.240 to 2.5.252 Bumps [com.h2database:h2](https://github.com/h2database/h2database) from 2.4.240 to 2.5.252. - [Release notes](https://github.com/h2database/h2database/releases) - [Commits](https://github.com/h2database/h2database/compare/version-2.4.240...version-2.5.252) --- updated-dependencies: - dependency-name: com.h2database:h2 dependency-version: 2.5.252 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- bom/application/pom.xml | 2 -- pom.xml | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/bom/application/pom.xml b/bom/application/pom.xml index a830c26d10054..2e07f9a612a5d 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -122,8 +122,6 @@ 4.1.5 9.2.1 2.5.2 - 2.4.240 42.7.13 3.5.10 26.7.0 diff --git a/pom.xml b/pom.xml index aadf843f3cd4f..187a793c8c2a8 100644 --- a/pom.xml +++ b/pom.xml @@ -68,7 +68,7 @@ - 2.4.240 + 2.5.252 0.8.15 8.0.0 6.0.1 From 4d2167a7ba28fc6a96898a0882c3aa0b6680adea Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:10:18 +0000 Subject: [PATCH 106/254] Bump com.gradle:develocity-maven-extension from 2.5.0 to 2.6.0 Bumps com.gradle:develocity-maven-extension from 2.5.0 to 2.6.0. --- updated-dependencies: - dependency-name: com.gradle:develocity-maven-extension dependency-version: 2.6.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .mvn/extensions.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.mvn/extensions.xml b/.mvn/extensions.xml index c7eed26696fb1..f2225d2699d08 100644 --- a/.mvn/extensions.xml +++ b/.mvn/extensions.xml @@ -2,7 +2,7 @@ com.gradle develocity-maven-extension - 2.5.0 + 2.6.0 com.gradle From 34ad4e1114f9cdc2c9d797408b95170c60584568 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:13:55 +0000 Subject: [PATCH 107/254] Bump org.bouncycastle:bctls-fips from 2.1.24 to 2.1.25 Bumps org.bouncycastle:bctls-fips from 2.1.24 to 2.1.25. --- updated-dependencies: - dependency-name: org.bouncycastle:bctls-fips dependency-version: 2.1.25 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- bom/application/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bom/application/pom.xml b/bom/application/pom.xml index a830c26d10054..f0e36964c0107 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -18,7 +18,7 @@ 2.0.5 1.86.1 2.1.3 - 2.1.24 + 2.1.25 13.2.0 6.0.0 3.0.2 From 0ffaeed941b6d7a11f92f2fb0771b515b2e1b425 Mon Sep 17 00:00:00 2001 From: "David M. Lloyd" Date: Thu, 8 Oct 2026 10:23:42 -0500 Subject: [PATCH 108/254] Reproducibility: use the class name for default step ID Fixes #57255 --- .../main/java/io/quarkus/builder/BuildStep.java | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/core/builder/src/main/java/io/quarkus/builder/BuildStep.java b/core/builder/src/main/java/io/quarkus/builder/BuildStep.java index 49ddb09dc857c..d49c5fa01e50e 100644 --- a/core/builder/src/main/java/io/quarkus/builder/BuildStep.java +++ b/core/builder/src/main/java/io/quarkus/builder/BuildStep.java @@ -16,12 +16,21 @@ public interface BuildStep { void execute(BuildContext context); /** - * The identifier should be unique for a build chain. + * Get the identifier for this build step. + * The identifier should be unique within a build chain, and must be deterministic + * (i.e. the same step must produce the same identifier across separate builds) + * because the identifier may be embedded in generated bytecode. + *

+ * The default implementation returns the implementation class name, which is + * sufficient when at most one instance of a given class is added to a chain. + * If multiple instances of the same class may be added to a single chain, the + * implementation should override this method to include a distinguishing suffix + * (for example, appending a constructor parameter or sequence number). * - * @return the identifier + * @return the identifier (must not be {@code null}) */ default String getId() { - return toString(); + return getClass().getName(); } /** From c086bf8d78b3525b18873f2a7583832b6f12009b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:53:00 +0000 Subject: [PATCH 109/254] Bump org.eclipse.jgit:org.eclipse.jgit Bumps [org.eclipse.jgit:org.eclipse.jgit](https://github.com/eclipse-jgit/jgit) from 7.7.1.202607240634-r to 7.8.0.202609011348-r. - [Commits](https://github.com/eclipse-jgit/jgit/compare/v7.7.1.202607240634-r...v7.8.0.202609011348-r) --- updated-dependencies: - dependency-name: org.eclipse.jgit:org.eclipse.jgit dependency-version: 7.8.0.202609011348-r dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- build-parent/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build-parent/pom.xml b/build-parent/pom.xml index bf10aaa37b4bd..04d33489287b5 100644 --- a/build-parent/pom.xml +++ b/build-parent/pom.xml @@ -40,7 +40,7 @@ 2.3.35 0.30.0 - 7.7.1.202607240634-r + 7.8.0.202609011348-r 1.7.0.Final From b0f7ff2bfb6fc13e382383f94dce2ceec3df0bbe Mon Sep 17 00:00:00 2001 From: Rolfe Dlugy-Hegwer Date: Thu, 8 Oct 2026 16:23:00 -0400 Subject: [PATCH 110/254] Correct the SPIFFE JWT source value in the OIDC client reference The SPIFFE JWT-SVID section says the JWT source is set to `spiffe`, but the accepted value is `spiffe-jwt`, which the example below it and the OIDC code flow guide already use. Content-only change. --- .../main/asciidoc/security-openid-connect-client-reference.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/main/asciidoc/security-openid-connect-client-reference.adoc b/docs/src/main/asciidoc/security-openid-connect-client-reference.adoc index 8ed7c68c82b04..7f34354af0810 100644 --- a/docs/src/main/asciidoc/security-openid-connect-client-reference.adoc +++ b/docs/src/main/asciidoc/security-openid-connect-client-reference.adoc @@ -1234,7 +1234,7 @@ public class OidcClientRequestCustomFilter extends AbstractOidcClientRequestFilt ==== SPIFFE JWT-SVID link:https://spiffe.io[SPIFFE] (Secure Production Identity Framework for Everyone) JWT-SVID tokens can be used as client assertions for client authentication. -When the JWT source is set to `spiffe`, the `client_assertion_type` parameter is set to `urn:ietf:params:oauth:client-assertion-type:jwt-spiffe` instead of the JWT bearer `urn:ietf:params:oauth:client-assertion-type:jwt-bearer` value. +When the JWT source is set to `spiffe-jwt`, the `client_assertion_type` parameter is set to `urn:ietf:params:oauth:client-assertion-type:jwt-spiffe` instead of the JWT bearer `urn:ietf:params:oauth:client-assertion-type:jwt-bearer` value. Enable it as follows: From 7ae52dff6bdf5f5a885e4dc0f07a43c116c0602d Mon Sep 17 00:00:00 2001 From: "David M. Lloyd" Date: Thu, 8 Oct 2026 16:17:43 -0500 Subject: [PATCH 111/254] Fix a bug and a race with service-to-recorder bridging - Fix a bug where `Optional` services are not stored in the interim service value map, preventing their usage as synthetic beans. - Fix a bug where the interim service value map is updated *after* the service is complete, potentially causing sporadic failures under certain conditions. --- .../service/impl/LambdaTransliterator.java | 91 +++++++++++-------- 1 file changed, 52 insertions(+), 39 deletions(-) diff --git a/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/LambdaTransliterator.java b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/LambdaTransliterator.java index d0428847d1ea7..ed3b18e57c009 100644 --- a/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/LambdaTransliterator.java +++ b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/LambdaTransliterator.java @@ -87,11 +87,25 @@ public final class LambdaTransliterator { /** Descriptor for {@link ServiceNode}. */ private static final ClassDesc CD_ServiceNode = ConstantUtils.classDesc(ServiceNode.class); + /** Descriptor for {@link ServiceGraph}. */ + private static final ClassDesc CD_ServiceGraph = ConstantUtils.classDesc(ServiceGraph.class); + /** Descriptor for {@link StartupContext}. */ + private static final ClassDesc CD_StartupContext = ConstantUtils.classDesc(StartupContext.class); /** Descriptor for {@link SortedNullSafeMap}. */ private static final ClassDesc CD_SortedNullSafeMap = ConstantUtils.classDesc(SortedNullSafeMap.class); /** Descriptor for {@link Map}. */ private static final ClassDesc CD_Map = ConstantUtils.classDesc(Map.class); + /** {@code ServiceGraph ServiceNode.graph()} */ + private static final MethodTypeDesc MTD_ServiceGraph = MethodTypeDesc.of(CD_ServiceGraph); + /** {@code StartupContext ServiceGraph.startupContext()} */ + private static final MethodTypeDesc MTD_StartupContext = MethodTypeDesc.of(CD_StartupContext); + /** {@code Object ServiceNode.value()} */ + private static final MethodTypeDesc MTD_Object = MethodTypeDesc.of(ConstantDescs.CD_Object); + /** {@code void StartupContext.putServiceValue(String, Object)} */ + private static final MethodTypeDesc MTD_void_String_Object = MethodTypeDesc.of( + ConstantDescs.CD_void, ConstantDescs.CD_String, ConstantDescs.CD_Object); + /** * Internal names of START context interfaces whose invokeinterface calls * are rewritten to invokevirtual on ServiceNode during transliteration. @@ -602,14 +616,31 @@ private static void generateActionDeployBody( } else if (optional) { // optional sync typed: the action returns Optional; an empty result marks the service as // valueless (skipped by consumeAll() consumers, empty for request() consumers) rather than failing + + // store the unwrapped value in the serviceValues map before signaling completion, + // since startCompleteOptional releases dependents that may read the map immediately + ClassDesc optionalDesc = ConstantUtils.classDesc(Optional.class); + code.aload(returnSlot); // Optional + code.invokevirtual(optionalDesc, "isPresent", + MethodTypeDesc.of(ConstantDescs.CD_boolean)); + code.ifThen(Opcode.IFNE, b -> { + b.aload(0); // ServiceNode + b.invokevirtual(CD_ServiceNode, "graph", MTD_ServiceGraph); + b.invokevirtual(CD_ServiceGraph, "startupContext", MTD_StartupContext); + b.loadConstant(serviceKey); + b.aload(returnSlot); // Optional + b.invokevirtual(optionalDesc, "get", MTD_Object); + b.invokevirtual(CD_StartupContext, "putServiceValue", MTD_void_String_Object); + }); + + // now signal completion code.aload(0); // ServiceNode code.aload(returnSlot); // Optional code.invokevirtual(serviceNodeDesc, "startCompleteOptional", - MethodTypeDesc.of(ConstantDescs.CD_void, ConstantUtils.classDesc(Optional.class))); + MethodTypeDesc.of(ConstantDescs.CD_void, optionalDesc)); } else { - // sync typed: null check, then signal completion with value + // sync typed: null check code.aload(returnSlot); - code.dup(); code.ifThen(Opcode.IFNULL, b -> { b.new_(ConstantUtils.classDesc(IllegalStateException.class)); b.dup(); @@ -621,28 +652,21 @@ private static void generateActionDeployBody( MethodTypeDesc.of(ConstantDescs.CD_void, stringDesc)); b.athrow(); }); - // startComplete(result) - code.aload(0); // ServiceNode - code.aload(returnSlot); - code.invokevirtual(serviceNodeDesc, "startComplete", - MethodTypeDesc.of(ConstantDescs.CD_void, objectDesc)); - // also store in the serviceValues map for recorder proxy resolution - // (legacy recorders resolve service proxies via startupContext.getServiceValue) - // TODO: remove once all recorder consumers are converted to services - ClassDesc serviceGraphDesc = ConstantUtils.classDesc(ServiceGraph.class); - ClassDesc startupContextDesc = ConstantUtils.classDesc(StartupContext.class); + // store in the serviceValues map before signaling completion, + // since startComplete releases dependents that may read the map immediately code.aload(0); // ServiceNode - code.invokevirtual(CD_ServiceNode, "graph", - MethodTypeDesc.of(serviceGraphDesc)); - code.invokevirtual(serviceGraphDesc, "startupContext", - MethodTypeDesc.of(startupContextDesc)); + code.invokevirtual(CD_ServiceNode, "graph", MTD_ServiceGraph); + code.invokevirtual(CD_ServiceGraph, "startupContext", MTD_StartupContext); code.loadConstant(serviceKey); + code.aload(returnSlot); + code.invokevirtual(CD_StartupContext, "putServiceValue", MTD_void_String_Object); + + // now signal completion code.aload(0); // ServiceNode - code.invokevirtual(CD_ServiceNode, "value", - MethodTypeDesc.of(objectDesc)); - code.invokevirtual(startupContextDesc, "putServiceValue", - MethodTypeDesc.of(ConstantDescs.CD_void, stringDesc, objectDesc)); + code.aload(returnSlot); + code.invokevirtual(serviceNodeDesc, "startComplete", + MethodTypeDesc.of(ConstantDescs.CD_void, objectDesc)); } code.return_(); @@ -662,24 +686,20 @@ private static void generateAliasDeployBody( TransliteratedAction.AliasService alias) { // slot 0 = ServiceNode (static method) ClassDesc serviceNodeDesc = ConstantUtils.classDesc(ServiceNode.class); - ClassDesc serviceGraphDesc = ConstantUtils.classDesc(ServiceGraph.class); - ClassDesc startupContextDesc = ConstantUtils.classDesc(StartupContext.class); ClassDesc objectDesc = ConstantUtils.classDesc(Object.class); ClassDesc stringDesc = ConstantUtils.classDesc(String.class); // get StartupContext: node.graph().startupContext() code.aload(0); // ServiceNode - code.invokevirtual(serviceNodeDesc, "graph", - MethodTypeDesc.of(serviceGraphDesc)); - code.invokevirtual(serviceGraphDesc, "startupContext", - MethodTypeDesc.of(startupContextDesc)); + code.invokevirtual(CD_ServiceNode, "graph", MTD_ServiceGraph); + code.invokevirtual(CD_ServiceGraph, "startupContext", MTD_StartupContext); int ctxSlot = 1; code.astore(ctxSlot); // load the value from the recorder proxy key code.aload(ctxSlot); code.loadConstant(alias.recorderProxyKey()); - code.invokevirtual(startupContextDesc, "getValue", + code.invokevirtual(CD_StartupContext, "getValue", MethodTypeDesc.of(objectDesc, stringDesc)); int valueSlot = 2; code.astore(valueSlot); @@ -688,8 +708,7 @@ private static void generateAliasDeployBody( code.aload(ctxSlot); code.loadConstant(alias.serviceKey()); code.aload(valueSlot); - code.invokevirtual(startupContextDesc, "putServiceValue", - MethodTypeDesc.of(ConstantDescs.CD_void, stringDesc, objectDesc)); + code.invokevirtual(CD_StartupContext, "putServiceValue", MTD_void_String_Object); // signal completion: typed if value is non-null, void otherwise code.aload(valueSlot); @@ -753,18 +772,12 @@ private static void generateRuntimeValueWrapperDeployBody( // store in the serviceValues map for recorder proxy resolution // (legacy recorders resolve service proxies via startupContext.getServiceValue // because __service$$value() returns true on the proxy) - ClassDesc serviceGraphDesc = ConstantUtils.classDesc(ServiceGraph.class); - ClassDesc startupContextDesc = ConstantUtils.classDesc(StartupContext.class); - ClassDesc stringDesc = ConstantUtils.classDesc(String.class); code.aload(0); // ServiceNode - code.invokevirtual(CD_ServiceNode, "graph", - MethodTypeDesc.of(serviceGraphDesc)); - code.invokevirtual(serviceGraphDesc, "startupContext", - MethodTypeDesc.of(startupContextDesc)); + code.invokevirtual(CD_ServiceNode, "graph", MTD_ServiceGraph); + code.invokevirtual(CD_ServiceGraph, "startupContext", MTD_StartupContext); code.loadConstant(rvw.rvKey()); code.aload(rvSlot); - code.invokevirtual(startupContextDesc, "putServiceValue", - MethodTypeDesc.of(ConstantDescs.CD_void, stringDesc, objectDesc)); + code.invokevirtual(CD_StartupContext, "putServiceValue", MTD_void_String_Object); // signal typed completion with the RuntimeValue wrapper code.aload(0); // ServiceNode From 42179cc0fd3dd25e62a6ede4d54978992364c409 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Phillip=20Kr=C3=BCger?= Date: Fri, 9 Oct 2026 12:27:40 +1100 Subject: [PATCH 112/254] Match configured Dev UI and Dev MCP hosts against the origin host The CORS filters of Dev UI and Dev MCP accepted an origin when it started with a configured host, so `my-machine` also accepted `http://my-machine.example.com`. Compare the host of the origin with the configured hosts instead, and apply the configured patterns to the host as well, the same way the host filters already do. Co-Authored-By: Claude Opus 5.5 --- .../devmcp/runtime/DevMcpCORSFilter.java | 33 ++++--- .../devui/DevUIConfiguredHostTest.java | 11 ++- .../devmcp/DevMcpConfiguredHostCorsTest.java | 97 +++++++++++++++++++ .../devui/runtime/DevUICORSFilter.java | 32 +++--- 4 files changed, 150 insertions(+), 23 deletions(-) create mode 100644 extensions/devui/deployment/src/test/java/io/quarkus/devui/devmcp/DevMcpConfiguredHostCorsTest.java diff --git a/extensions/devmcp/runtime/src/main/java/io/quarkus/devmcp/runtime/DevMcpCORSFilter.java b/extensions/devmcp/runtime/src/main/java/io/quarkus/devmcp/runtime/DevMcpCORSFilter.java index b10601a47eefa..8b8921cb0a841 100644 --- a/extensions/devmcp/runtime/src/main/java/io/quarkus/devmcp/runtime/DevMcpCORSFilter.java +++ b/extensions/devmcp/runtime/src/main/java/io/quarkus/devmcp/runtime/DevMcpCORSFilter.java @@ -1,5 +1,7 @@ package io.quarkus.devmcp.runtime; +import java.net.URI; +import java.net.URISyntaxException; import java.time.Duration; import java.util.ArrayList; import java.util.Collections; @@ -63,7 +65,7 @@ public void handle(RoutingContext event) { String origin = request.getHeader(HttpHeaders.ORIGIN); if (origin == null || isLocalHost(origin)) { corsFilter(null).handle(event); - } else if (isConfiguredHost(origin) || isConfiguredHostPattern(origin)) { + } else if (isConfiguredHost(originHost(origin))) { corsFilter(origin).handle(event); } else { if (!origin.startsWith(CHROME_EXTENSION)) { @@ -80,22 +82,31 @@ private boolean isLocalHost(String origin) { || origin.startsWith(HTTP_LOCAL_HOST_IP) || origin.startsWith(HTTPS_LOCAL_HOST_IP); } - private boolean isConfiguredHost(String origin) { - if (this.hosts != null) { - for (String configuredHost : this.hosts) { - if (origin.startsWith(HTTP + configuredHost) || - origin.startsWith(HTTPS + configuredHost)) { - return true; - } + private static String originHost(String origin) { + try { + URI uri = new URI(origin); + String scheme = uri.getScheme(); + if ("http".equals(scheme) || "https".equals(scheme)) { + return uri.getHost(); } + } catch (URISyntaxException e) { + // Not a valid origin } - return false; + return null; } - private boolean isConfiguredHostPattern(String origin) { + // The host of the origin has to match a configured host exactly, or one of the patterns as a whole + private boolean isConfiguredHost(String host) { + if (host == null) { + return false; + } + if (this.hosts != null && this.hosts.contains(host)) { + return true; + } if (this.hostsPatterns != null && !this.hostsPatterns.isEmpty()) { + // Regex for (Pattern pat : this.hostsPatterns) { - Matcher matcher = pat.matcher(origin); + Matcher matcher = pat.matcher(host); if (matcher.matches()) { return true; } diff --git a/extensions/devui/deployment/src/test/java/io/quarkus/devui/DevUIConfiguredHostTest.java b/extensions/devui/deployment/src/test/java/io/quarkus/devui/DevUIConfiguredHostTest.java index 22cc1ff686257..6140cb3e32d4f 100644 --- a/extensions/devui/deployment/src/test/java/io/quarkus/devui/DevUIConfiguredHostTest.java +++ b/extensions/devui/deployment/src/test/java/io/quarkus/devui/DevUIConfiguredHostTest.java @@ -33,11 +33,20 @@ public void hostThatIsNotConfiguredForDevUIIsRejected() throws Exception { assertThat(preflight("other-machine")).startsWith("HTTP/1.1 403"); } + @Test + public void originThatOnlyStartsWithConfiguredHostIsRejected() throws Exception { + assertThat(preflight("my-machine", "http://my-machine.attacker.example")).startsWith("HTTP/1.1 403"); + } + private static String preflight(String host) throws Exception { + return preflight(host, "http://" + host + ":8080"); + } + + private static String preflight(String host, String origin) throws Exception { try (Socket socket = new Socket("localhost", 8080)) { socket.getOutputStream().write(("OPTIONS /q/dev-ui/configuration-form-editor HTTP/1.1\r\n" + "Host: " + host + ":8080\r\n" - + "Origin: http://" + host + ":8080\r\n" + + "Origin: " + origin + "\r\n" + "Access-Control-Request-Method: GET,POST\r\n" + "Connection: close\r\n\r\n").getBytes(StandardCharsets.US_ASCII)); socket.getOutputStream().flush(); diff --git a/extensions/devui/deployment/src/test/java/io/quarkus/devui/devmcp/DevMcpConfiguredHostCorsTest.java b/extensions/devui/deployment/src/test/java/io/quarkus/devui/devmcp/DevMcpConfiguredHostCorsTest.java new file mode 100644 index 0000000000000..43fe4ebc80c4f --- /dev/null +++ b/extensions/devui/deployment/src/test/java/io/quarkus/devui/devmcp/DevMcpConfiguredHostCorsTest.java @@ -0,0 +1,97 @@ +package io.quarkus.devui.devmcp; + +import static org.hamcrest.Matchers.emptyOrNullString; +import static org.hamcrest.Matchers.nullValue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Comparator; + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.devui.testrunner.HelloResource; +import io.quarkus.test.QuarkusDevModeTest; +import io.restassured.RestAssured; + +public class DevMcpConfiguredHostCorsTest { + + private static Path TEMP_HOME; + private static String OLD_USER_HOME; + + @RegisterExtension + static final QuarkusDevModeTest test = createDevModeTest(); + + private static QuarkusDevModeTest createDevModeTest() { + try { + TEMP_HOME = Files.createTempDirectory("devmcp-home-"); + Path quarkusDir = TEMP_HOME.resolve(".quarkus"); + Files.createDirectories(quarkusDir); + Path props = quarkusDir.resolve("dev-mcp.properties"); + Files.writeString(props, "enabled=true\n", + StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + + OLD_USER_HOME = System.getProperty("user.home"); + System.setProperty("user.home", TEMP_HOME.toString()); + } catch (IOException e) { + throw new RuntimeException("Failed to set up Dev MCP CORS test HOME", e); + } + + return new QuarkusDevModeTest() + .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class) + .addClasses(HelloResource.class) + .addAsResource(new StringAsset("quarkus.dev-mcp.hosts=my-machine\n"), + "application.properties")); + } + + @AfterAll + static void cleanupHome() { + // Restore original user.home + if (OLD_USER_HOME != null) { + System.setProperty("user.home", OLD_USER_HOME); + } + if (TEMP_HOME != null) { + try { + Files.walk(TEMP_HOME) + .sorted(Comparator.reverseOrder()) + .forEach(p -> { + try { + Files.deleteIfExists(p); + } catch (IOException ignored) { + } + }); + } catch (IOException ignored) { + } + } + } + + @Test + public void testPreflightConfiguredHostOriginAllowed() { + String origin = "http://my-machine:8080"; + RestAssured.given() + .header("Origin", origin) + .header("Access-Control-Request-Method", "POST") + .when() + .options("/q/dev-mcp").then() + .statusCode(200) + .header("Access-Control-Allow-Origin", origin); + } + + @Test + public void testPreflightOriginThatOnlyStartsWithConfiguredHostRejected() { + RestAssured.given() + .header("Origin", "http://my-machine.attacker.example") + .header("Access-Control-Request-Method", "POST") + .when() + .options("/q/dev-mcp").then() + .statusCode(403) + .header("Access-Control-Allow-Origin", nullValue()) + .body(emptyOrNullString()); + } +} diff --git a/extensions/devui/runtime/src/main/java/io/quarkus/devui/runtime/DevUICORSFilter.java b/extensions/devui/runtime/src/main/java/io/quarkus/devui/runtime/DevUICORSFilter.java index 4a839bebee2de..f9aa8b52ab2f3 100644 --- a/extensions/devui/runtime/src/main/java/io/quarkus/devui/runtime/DevUICORSFilter.java +++ b/extensions/devui/runtime/src/main/java/io/quarkus/devui/runtime/DevUICORSFilter.java @@ -1,5 +1,7 @@ package io.quarkus.devui.runtime; +import java.net.URI; +import java.net.URISyntaxException; import java.time.Duration; import java.util.ArrayList; import java.util.Collections; @@ -63,7 +65,7 @@ public void handle(RoutingContext event) { String origin = request.getHeader(HttpHeaders.ORIGIN); if (origin == null || isLocalHost(origin)) { corsFilter(null).handle(event); - } else if (isConfiguredHost(origin) || isConfiguredHostPattern(origin)) { + } else if (isConfiguredHost(originHost(origin))) { corsFilter(origin).handle(event); } else { if (!origin.startsWith(CHROME_EXTENSION)) { @@ -80,23 +82,31 @@ private boolean isLocalHost(String origin) { || origin.startsWith(HTTP_LOCAL_HOST_IP) || origin.startsWith(HTTPS_LOCAL_HOST_IP); } - private boolean isConfiguredHost(String origin) { - if (this.hosts != null) { - for (String configuredHost : this.hosts) { - if (origin.startsWith(HTTP + configuredHost) || - origin.startsWith(HTTPS + configuredHost)) { - return true; - } + private static String originHost(String origin) { + try { + URI uri = new URI(origin); + String scheme = uri.getScheme(); + if ("http".equals(scheme) || "https".equals(scheme)) { + return uri.getHost(); } + } catch (URISyntaxException e) { + // Not a valid origin } - return false; + return null; } - private boolean isConfiguredHostPattern(String origin) { + // The host of the origin has to match a configured host exactly, or one of the patterns as a whole + private boolean isConfiguredHost(String host) { + if (host == null) { + return false; + } + if (this.hosts != null && this.hosts.contains(host)) { + return true; + } if (this.hostsPatterns != null && !this.hostsPatterns.isEmpty()) { // Regex for (Pattern pat : this.hostsPatterns) { - Matcher matcher = pat.matcher(origin); + Matcher matcher = pat.matcher(host); if (matcher.matches()) { return true; } From 53b9b40fe3f7ba3f9c5b9100dae19c32a6daeb7c Mon Sep 17 00:00:00 2001 From: Arnab Nandy Date: Fri, 9 Oct 2026 12:53:37 +0530 Subject: [PATCH 113/254] Fix Swagger UI URL behind reverse proxies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Michael Edgar Co-authored-by: Phillip Krüger Signed-off-by: Arnab Nandy --- .../deployment/SwaggerUiProcessor.java | 134 ++++++++++++++---- .../deployment/CustomConfigTest.java | 15 +- .../deployment/CustomHttpRootTest.java | 26 +++- .../deployment/ManagementInterfaceTest.java | 24 ++++ .../swaggerui/deployment/NoConfigTest.java | 29 +++- .../deployment/ReverseProxyPathTest.java | 38 +++++ .../deployment/SwaggerOptionsTest.java | 2 + 7 files changed, 224 insertions(+), 44 deletions(-) create mode 100644 extensions/swagger-ui/deployment/src/test/java/io/quarkus/swaggerui/deployment/ManagementInterfaceTest.java create mode 100644 extensions/swagger-ui/deployment/src/test/java/io/quarkus/swaggerui/deployment/ReverseProxyPathTest.java diff --git a/extensions/swagger-ui/deployment/src/main/java/io/quarkus/swaggerui/deployment/SwaggerUiProcessor.java b/extensions/swagger-ui/deployment/src/main/java/io/quarkus/swaggerui/deployment/SwaggerUiProcessor.java index e6c2fc5b8d5da..67cce09100a24 100644 --- a/extensions/swagger-ui/deployment/src/main/java/io/quarkus/swaggerui/deployment/SwaggerUiProcessor.java +++ b/extensions/swagger-ui/deployment/src/main/java/io/quarkus/swaggerui/deployment/SwaggerUiProcessor.java @@ -3,6 +3,8 @@ import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; +import java.net.URI; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -25,7 +27,6 @@ import io.quarkus.deployment.builditem.HotDeploymentWatchedFileBuildItem; import io.quarkus.deployment.builditem.LaunchModeBuildItem; import io.quarkus.deployment.builditem.ShutdownContextBuildItem; -import io.quarkus.devui.spi.Constants; import io.quarkus.devui.spi.DevContextBuildItem; import io.quarkus.maven.dependency.GACT; import io.quarkus.runtime.configuration.ConfigurationException; @@ -36,6 +37,7 @@ import io.quarkus.vertx.http.deployment.webjar.WebJarBuildItem; import io.quarkus.vertx.http.deployment.webjar.WebJarResourcesFilter; import io.quarkus.vertx.http.deployment.webjar.WebJarResultsBuildItem; +import io.quarkus.vertx.http.runtime.management.ManagementInterfaceBuildTimeConfig; import io.smallrye.openapi.ui.IndexHtmlCreator; import io.smallrye.openapi.ui.Option; import io.smallrye.openapi.ui.ThemeHref; @@ -62,6 +64,7 @@ public class SwaggerUiProcessor { private static final String OIDC_CLIENT_ID = "quarkus.oidc.client-id"; private static final String OIDC_NONCE_KEY = "nonce"; + private static final String DEFAULT_SWAGGER_UI_PLUGIN = "SwaggerUIBundle.plugins.DownloadUrl"; @BuildStep void feature(BuildProducer feature, @@ -91,6 +94,7 @@ public void getSwaggerUiFinalDestination( LaunchModeBuildItem launchMode, SwaggerUiConfig swaggerUiConfig, SmallRyeOpenApiConfig openapi, + ManagementInterfaceBuildTimeConfig managementInterface, Optional devServicesLauncherConfig, BuildProducer webJarBuildProducer) throws Exception { @@ -101,13 +105,7 @@ public void getSwaggerUiFinalDestination( Set.of("quarkus.swagger-ui.path")); } - String devUIContextRoot; - if (devContextBuildItem.isPresent()) { - devUIContextRoot = devContextBuildItem.get().getDevUIContextRoot(); - } else { - devUIContextRoot = ""; - } - + String swaggerUiPath = nonApplicationRootPathBuildItem.resolvePath(swaggerUiConfig.path()); Map urls = new HashMap<>(); openapi.documents().forEach((documentName, documentConfig) -> { String documentPath = documentConfig.path(); @@ -119,21 +117,19 @@ public void getSwaggerUiFinalDestination( Set.of(documentPath, "quarkus.swagger-ui.path")); } - String openApiPath = devUIContextRoot - + nonApplicationRootPathBuildItem.resolvePath(documentPath); - urls.put(documentName, openApiPath); + String openApiPath = nonApplicationRootPathBuildItem.resolvePath(documentPath); + urls.put(documentName, relativize(swaggerUiPath + "/", openApiPath)); }); - String swaggerUiPath = devUIContextRoot + nonApplicationRootPathBuildItem.resolvePath(swaggerUiConfig.path()); ThemeHref theme = swaggerUiConfig.theme().orElse(ThemeHref.feeling_blue); - NonApplicationRootPathBuildItem indexRootPathBuildItem = null; - - byte[] indexHtmlContent = generateIndexHtml(urls, swaggerUiPath, swaggerUiConfig, - indexRootPathBuildItem, + String devUIContextRoot = devContextBuildItem.map(DevContextBuildItem::getDevUIContextRoot).orElse(""); + byte[] indexHtmlContent = generateIndexHtml(urls, swaggerUiPath, devUIContextRoot, + nonApplicationRootPathBuildItem.getNormalizedHttpRootPath(), swaggerUiConfig, launchMode, devServicesLauncherConfig.orElse(null), - swaggerUiUrls); + swaggerUiUrls, + managementInterface.enabled() && openapi.managementEnabled()); webJarBuildProducer.produce( WebJarBuildItem.builder().artifactKey(SWAGGER_UI_WEBJAR_ARTIFACT_KEY) // .root(SWAGGER_UI_WEBJAR_STATIC_RESOURCES_PATH) // @@ -192,20 +188,17 @@ public void registerSwaggerUiHandler(SwaggerUiRecorder recorder, } } - private byte[] generateIndexHtml(Map urls, String swaggerUiPath, SwaggerUiConfig swaggerUiConfig, - NonApplicationRootPathBuildItem nonApplicationRootPath, LaunchModeBuildItem launchMode, + private byte[] generateIndexHtml(Map urls, String swaggerUiPath, String devUIContextRoot, + String httpRootPath, + SwaggerUiConfig swaggerUiConfig, LaunchModeBuildItem launchMode, DevServicesLauncherConfigResultBuildItem devServicesLauncherConfigResultBuildItem, - List swaggerUiUrls) + List swaggerUiUrls, boolean managementInterface) throws IOException { Map options = new HashMap<>(); Map urlsMap = new HashMap<>(); - options.put(Option.selfHref, swaggerUiPath); - if (nonApplicationRootPath != null) { - options.put(Option.backHref, nonApplicationRootPath.resolvePath(Constants.DEV_UI) + "/"); - } else { - options.put(Option.backHref, swaggerUiPath); - } + options.put(Option.selfHref, "."); + options.put(Option.backHref, "."); if (swaggerUiConfig.urls() != null && !swaggerUiConfig.urls().isEmpty()) { urlsMap.putAll(swaggerUiConfig.urls()); @@ -216,7 +209,8 @@ private byte[] generateIndexHtml(Map urls, String swaggerUiPath, } // Only add urls for our own generated OpenAPI documentations if the user or other extensions did not specify urls - if (urlsMap.isEmpty()) { + boolean generatedOpenApiDocuments = urlsMap.isEmpty(); + if (generatedOpenApiDocuments) { if (urls.size() > 1) { urlsMap = urls; } else { @@ -301,7 +295,7 @@ private byte[] generateIndexHtml(Map urls, String swaggerUiPath, if (swaggerUiConfig.oauth2RedirectUrl().isPresent()) { options.put(Option.oauth2RedirectUrl, swaggerUiConfig.oauth2RedirectUrl().get()); } else { - options.put(Option.oauth2RedirectUrl, swaggerUiPath + "/oauth2-redirect.html"); + options.put(Option.oauth2RedirectUrl, devUIContextRoot + swaggerUiPath + "/oauth2-redirect.html"); } if (swaggerUiConfig.requestInterceptor().isPresent()) { @@ -353,10 +347,12 @@ private byte[] generateIndexHtml(Map urls, String swaggerUiPath, options.put(Option.layout, swaggerUiConfig.layout().get()); } - if (swaggerUiConfig.plugins().isPresent()) { - String plugins = swaggerUiConfig.plugins().get().toString(); - options.put(Option.plugins, plugins); + List plugins = new ArrayList<>(swaggerUiConfig.plugins().orElse(List.of(DEFAULT_SWAGGER_UI_PLUGIN))); + if (!managementInterface && generatedOpenApiDocuments) { + String httpRootFromUi = relativize(swaggerUiPath + "/", httpRootPath); + plugins.add(relativeServerPlugin(httpRootFromUi)); } + options.put(Option.plugins, plugins.toString()); if (swaggerUiConfig.scripts().isPresent()) { String scripts = String.join(",", swaggerUiConfig.scripts().get()); @@ -463,7 +459,83 @@ private byte[] generateIndexHtml(Map urls, String swaggerUiPath, return IndexHtmlCreator.createIndexHtml(urlsMap, swaggerUiConfig.urlsPrimaryName().orElse(null), options); } + private static String relativize(String sourceDirectory, String target) { + URI sourceUri = URI.create(sourceDirectory); + URI targetUri = URI.create(target); + List sourceSegments = pathSegments(sourceUri.getRawPath()); + List targetSegments = pathSegments(targetUri.getRawPath()); + + int commonSegments = 0; + while (commonSegments < sourceSegments.size() && commonSegments < targetSegments.size() + && sourceSegments.get(commonSegments).equals(targetSegments.get(commonSegments))) { + commonSegments++; + } + + StringBuilder relative = new StringBuilder(); + for (int i = commonSegments; i < sourceSegments.size(); i++) { + relative.append("../"); + } + for (int i = commonSegments; i < targetSegments.size(); i++) { + relative.append(targetSegments.get(i)); + if (i < targetSegments.size() - 1) { + relative.append('/'); + } + } + if (targetUri.getRawPath().endsWith("/") && !relative.isEmpty() && relative.charAt(relative.length() - 1) != '/') { + relative.append('/'); + } + if (targetUri.getRawQuery() != null) { + relative.append('?').append(targetUri.getRawQuery()); + } + if (targetUri.getRawFragment() != null) { + relative.append('#').append(targetUri.getRawFragment()); + } + return relative.toString(); + } + + private static List pathSegments(String path) { + List segments = new ArrayList<>(); + for (String segment : path.split("/")) { + if (!segment.isEmpty()) { + segments.add(segment); + } + } + return segments; + } + + private static String relativeServerPlugin(String httpRootFromUi) { + return """ + function RelativeServerPlugin() { + var base = new URL("%s", window.location.href).pathname.replace(/\\/$/, ""); + + return { + statePlugins: { + spec: { + wrapActions: { + updateJsonSpec: function (ori) { + return function (spec) { + if (spec && (!spec.servers || spec.servers.length === 0)) { + spec = Object.assign( + {}, + spec, + { + servers: [{ url: base || "/" }] + } + ); + } + return ori(spec); + }; + } + } + } + } + }; + } + """.formatted(httpRootFromUi); + } + private static boolean shouldInclude(LaunchModeBuildItem launchMode, SwaggerUiConfig swaggerUiConfig) { return launchMode.getLaunchMode().isDevOrTest() || swaggerUiConfig.alwaysInclude(); } + } diff --git a/extensions/swagger-ui/deployment/src/test/java/io/quarkus/swaggerui/deployment/CustomConfigTest.java b/extensions/swagger-ui/deployment/src/test/java/io/quarkus/swaggerui/deployment/CustomConfigTest.java index 032b15d441079..d278cd84987aa 100644 --- a/extensions/swagger-ui/deployment/src/test/java/io/quarkus/swaggerui/deployment/CustomConfigTest.java +++ b/extensions/swagger-ui/deployment/src/test/java/io/quarkus/swaggerui/deployment/CustomConfigTest.java @@ -1,6 +1,11 @@ package io.quarkus.swaggerui.deployment; -import static org.hamcrest.Matchers.containsString; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.jboss.shrinkwrap.api.asset.StringAsset; import org.junit.jupiter.api.Test; @@ -11,6 +16,8 @@ public class CustomConfigTest { + private static final Pattern URL_PATTERN = Pattern.compile("\\burl: '([^']+)'"); + @RegisterExtension static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot((jar) -> jar @@ -18,7 +25,9 @@ public class CustomConfigTest { @Test public void shouldUseCustomConfig() { - RestAssured.when().get("/custom").then().statusCode(200).body(containsString("/openapi")); - RestAssured.when().get("/custom/index.html").then().statusCode(200).body(containsString("/openapi")); + String indexHtml = RestAssured.when().get("/custom/index.html").then().statusCode(200).extract().asString(); + Matcher matcher = URL_PATTERN.matcher(indexHtml); + assertTrue(matcher.find()); + assertEquals("/prefix/q/openapi", URI.create("http://h/prefix/custom/").resolve(matcher.group(1)).getPath()); } } diff --git a/extensions/swagger-ui/deployment/src/test/java/io/quarkus/swaggerui/deployment/CustomHttpRootTest.java b/extensions/swagger-ui/deployment/src/test/java/io/quarkus/swaggerui/deployment/CustomHttpRootTest.java index 6256a7b8dd899..c0fc033c445c8 100644 --- a/extensions/swagger-ui/deployment/src/test/java/io/quarkus/swaggerui/deployment/CustomHttpRootTest.java +++ b/extensions/swagger-ui/deployment/src/test/java/io/quarkus/swaggerui/deployment/CustomHttpRootTest.java @@ -1,8 +1,12 @@ package io.quarkus.swaggerui.deployment; -import static org.hamcrest.Matchers.containsString; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.util.regex.Matcher; +import java.util.regex.Pattern; -import org.jboss.shrinkwrap.api.asset.StringAsset; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; @@ -11,14 +15,24 @@ public class CustomHttpRootTest { + private static final Pattern URL_PATTERN = Pattern.compile("\\burl: '([^']+)'"); + private static final Pattern SERVER_BASE_PATTERN = Pattern.compile("var base = new URL\\(\"([^\"]+)\""); + @RegisterExtension static final QuarkusExtensionTest config = new QuarkusExtensionTest() - .withApplicationRoot((jar) -> jar - .addAsResource(new StringAsset("quarkus.http.root-path=/foo"), "application.properties")); + .withEmptyApplication() + .overrideConfigKey("quarkus.http.root-path", "/foo"); @Test public void shouldUseCustomConfig() { - RestAssured.when().get("/q/swagger-ui").then().statusCode(200).body(containsString("/q/openapi")); - RestAssured.when().get("/q/swagger-ui/index.html").then().statusCode(200).body(containsString("/q/openapi")); + String indexHtml = RestAssured.when().get("/q/swagger-ui/index.html").then().statusCode(200).extract().asString(); + assertResolvedPath(indexHtml, URL_PATTERN, "http://h/prefix/foo/q/swagger-ui/", "/prefix/foo/q/openapi"); + assertResolvedPath(indexHtml, SERVER_BASE_PATTERN, "http://h/prefix/foo/q/swagger-ui/", "/prefix/foo/"); + } + + private static void assertResolvedPath(String indexHtml, Pattern pattern, String pageUrl, String expectedPath) { + Matcher matcher = pattern.matcher(indexHtml); + assertTrue(matcher.find()); + assertEquals(expectedPath, URI.create(pageUrl).resolve(matcher.group(1)).getPath()); } } diff --git a/extensions/swagger-ui/deployment/src/test/java/io/quarkus/swaggerui/deployment/ManagementInterfaceTest.java b/extensions/swagger-ui/deployment/src/test/java/io/quarkus/swaggerui/deployment/ManagementInterfaceTest.java new file mode 100644 index 0000000000000..22c373ee4b694 --- /dev/null +++ b/extensions/swagger-ui/deployment/src/test/java/io/quarkus/swaggerui/deployment/ManagementInterfaceTest.java @@ -0,0 +1,24 @@ +package io.quarkus.swaggerui.deployment; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusExtensionTest; +import io.restassured.RestAssured; + +public class ManagementInterfaceTest { + + @RegisterExtension + static final QuarkusExtensionTest config = new QuarkusExtensionTest() + .withEmptyApplication() + .overrideConfigKey("quarkus.management.enabled", "true"); + + @Test + public void shouldNotSetServerForManagementInterface() { + RestAssured.when().get("http://localhost:9001/q/swagger-ui/index.html").then().statusCode(200) + .body(not(containsString("function RelativeServerPlugin()"))); + } +} diff --git a/extensions/swagger-ui/deployment/src/test/java/io/quarkus/swaggerui/deployment/NoConfigTest.java b/extensions/swagger-ui/deployment/src/test/java/io/quarkus/swaggerui/deployment/NoConfigTest.java index dc1d0e95a1486..81a62701e3d66 100644 --- a/extensions/swagger-ui/deployment/src/test/java/io/quarkus/swaggerui/deployment/NoConfigTest.java +++ b/extensions/swagger-ui/deployment/src/test/java/io/quarkus/swaggerui/deployment/NoConfigTest.java @@ -1,23 +1,44 @@ package io.quarkus.swaggerui.deployment; -import static org.hamcrest.Matchers.containsString; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; +import io.quarkus.devui.spi.DevContextBuildItem; import io.quarkus.test.QuarkusExtensionTest; import io.restassured.RestAssured; public class NoConfigTest { + private static final Pattern URL_PATTERN = Pattern.compile("\\burl: '([^']+)'"); + private static final Pattern SERVER_BASE_PATTERN = Pattern.compile("var base = new URL\\(\"([^\"]+)\""); @RegisterExtension static final QuarkusExtensionTest config = new QuarkusExtensionTest() - .withEmptyApplication(); + .withEmptyApplication() + .addBuildChainCustomizer(builder -> builder + .addBuildStep(context -> context.produce(new DevContextBuildItem("/proxy/8080"))) + .produces(DevContextBuildItem.class) + .build()); @Test public void shouldUseDefaultConfig() { - RestAssured.when().get("/q/swagger-ui").then().statusCode(200).body(containsString("/openapi")); - RestAssured.when().get("/q/swagger-ui/index.html").then().statusCode(200).body(containsString("/openapi")); + String indexHtml = RestAssured.when().get("/q/swagger-ui/index.html").then().statusCode(200).extract().asString(); + assertResolvedPath(indexHtml, URL_PATTERN, "http://h/prefix/q/swagger-ui/", "/prefix/q/openapi"); + assertResolvedPath(indexHtml, SERVER_BASE_PATTERN, "http://h/prefix/q/swagger-ui/", "/prefix/"); + assertTrue(indexHtml.contains("/proxy/8080/q/swagger-ui/oauth2-redirect.html")); + assertTrue(indexHtml.contains("id='swaggerUiLogoLink' href='.'")); + assertTrue(indexHtml.contains("id='swaggerUiTitleLink' href='.'")); + } + private static void assertResolvedPath(String indexHtml, Pattern pattern, String pageUrl, String expectedPath) { + Matcher matcher = pattern.matcher(indexHtml); + assertTrue(matcher.find()); + assertEquals(expectedPath, URI.create(pageUrl).resolve(matcher.group(1)).getPath()); } } diff --git a/extensions/swagger-ui/deployment/src/test/java/io/quarkus/swaggerui/deployment/ReverseProxyPathTest.java b/extensions/swagger-ui/deployment/src/test/java/io/quarkus/swaggerui/deployment/ReverseProxyPathTest.java new file mode 100644 index 0000000000000..6183ca8a6f7a2 --- /dev/null +++ b/extensions/swagger-ui/deployment/src/test/java/io/quarkus/swaggerui/deployment/ReverseProxyPathTest.java @@ -0,0 +1,38 @@ +package io.quarkus.swaggerui.deployment; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusExtensionTest; +import io.restassured.RestAssured; + +public class ReverseProxyPathTest { + + private static final Pattern URL_PATTERN = Pattern.compile("\\burl: '([^']+)'"); + private static final Pattern SERVER_BASE_PATTERN = Pattern.compile("var base = new URL\\(\"([^\"]+)\""); + + @RegisterExtension + static final QuarkusExtensionTest config = new QuarkusExtensionTest() + .withEmptyApplication() + .overrideConfigKey("quarkus.http.non-application-root-path", "/"); + + @Test + public void shouldUseRelativeOpenApiUrl() { + String indexHtml = RestAssured.when().get("/swagger-ui/index.html").then().statusCode(200).extract().asString(); + assertResolvedPath(indexHtml, URL_PATTERN, "http://h/prefix/swagger-ui/", "/prefix/openapi"); + assertResolvedPath(indexHtml, SERVER_BASE_PATTERN, "http://h/prefix/swagger-ui/", "/prefix/"); + } + + private static void assertResolvedPath(String indexHtml, Pattern pattern, String pageUrl, String expectedPath) { + Matcher matcher = pattern.matcher(indexHtml); + assertTrue(matcher.find()); + assertEquals(expectedPath, URI.create(pageUrl).resolve(matcher.group(1)).getPath()); + } +} diff --git a/extensions/swagger-ui/deployment/src/test/java/io/quarkus/swaggerui/deployment/SwaggerOptionsTest.java b/extensions/swagger-ui/deployment/src/test/java/io/quarkus/swaggerui/deployment/SwaggerOptionsTest.java index 34166cfc092ee..03842bd200327 100644 --- a/extensions/swagger-ui/deployment/src/test/java/io/quarkus/swaggerui/deployment/SwaggerOptionsTest.java +++ b/extensions/swagger-ui/deployment/src/test/java/io/quarkus/swaggerui/deployment/SwaggerOptionsTest.java @@ -1,6 +1,7 @@ package io.quarkus.swaggerui.deployment; import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; import java.io.IOException; import java.io.StringWriter; @@ -34,6 +35,7 @@ public void customOptions() { containsString("displayRequestDuration: true"), containsString("supportedSubmitMethods: ['get', 'post']"), containsString("plugins: [Plugin1, Plugin2]"), + not(containsString("function RelativeServerPlugin()")), containsString("https://unpkg.com/swagger-ui-plugin-hierarchical-tags"), containsString("/some/local/script.js")); From de4bcf45380c6dce08f6f7ff7969956378073a17 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:56:21 +0000 Subject: [PATCH 114/254] Bump actions/setup-java from 5.7.0 to 6.0.1 Bumps [actions/setup-java](https://github.com/actions/setup-java) from 5.7.0 to 6.0.1. - [Release notes](https://github.com/actions/setup-java/releases) - [Commits](https://github.com/actions/setup-java/compare/v5.7.0...de7274f081f381c8f8158605e0321c36c376e2e6) --- updated-dependencies: - dependency-name: actions/setup-java dependency-version: 6.0.1 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci-actions-incremental.yml | 33 +++++++++---------- .github/workflows/doc-build.yml | 2 +- .../workflows/native-it-selected-graalvm.yml | 8 ++--- .github/workflows/owasp-check.yml | 2 +- .github/workflows/podman-build.yml | 2 +- .github/workflows/populate-cache.yml | 2 +- .github/workflows/preview.yml | 2 +- .github/workflows/release-build.yml | 2 +- .../workflows/reproducibility-checks-it.yml | 2 +- .github/workflows/reproducibility-checks.yml | 2 +- .github/workflows/sonarcloud.yml | 2 +- 11 files changed, 29 insertions(+), 30 deletions(-) diff --git a/.github/workflows/ci-actions-incremental.yml b/.github/workflows/ci-actions-incremental.yml index d03ce45c7915b..d22aa5fa9c437 100644 --- a/.github/workflows/ci-actions-incremental.yml +++ b/.github/workflows/ci-actions-incremental.yml @@ -212,7 +212,7 @@ jobs: echo "Base JDK version: ${BASE_JDK}" rm -rf .base-jdk-checkout - name: Set up JDK 25 - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: 25 @@ -358,7 +358,7 @@ jobs: - name: Print disk usage before build run: .github/ci-disk-usage.sh - name: Set up JDK ${{ needs.configure.outputs.base-jdk-version }} - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: ${{ needs.configure.outputs.base-jdk-version }} @@ -646,7 +646,7 @@ jobs: - name: Set up JDK ${{ env.JAVA_VERSION_GRADLE }} for Gradle (if needed) if: env.JAVA_VERSION_GRADLE != matrix.java.java-version - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: ${{ matrix.java.java-version-gradle }} @@ -660,7 +660,7 @@ jobs: echo "GRADLE_JAVA_HOME=${!GRADLE_JAVA_HOME_VARIABLE}" >> "$GITHUB_ENV" - name: Set up JDK ${{ matrix.java.java-version }} - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: ${{ matrix.java.java-distribution || 'temurin' }} java-version: ${{ matrix.java.java-version }} @@ -822,7 +822,7 @@ jobs: - name: Extract previously uploaded .m2 content run: tar -xzf m2-content.tgz -C ~ - name: Set up JDK ${{ matrix.java.java-version }} - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: ${{ matrix.java.java-distribution || 'temurin' }} java-version: ${{ matrix.java.java-version }} @@ -931,7 +931,7 @@ jobs: - name: Extract previously uploaded .m2 content run: tar -xzf m2-content.tgz -C ~ - name: Set up JDK ${{ matrix.java.java-version }} - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: ${{ matrix.java.java-version }} @@ -1028,7 +1028,7 @@ jobs: - name: Extract previously uploaded .m2 content run: tar -xzf m2-content.tgz -C ~ - name: Set up JDK ${{ matrix.java.java-version }} - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: ${{ matrix.java.java-distribution || 'temurin' }} java-version: ${{ matrix.java.java-version }} @@ -1121,7 +1121,7 @@ jobs: - name: Extract previously uploaded .m2 content run: tar -xzf m2-content.tgz -C ~ - name: Set up JDK ${{ matrix.java.java-version }} - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: ${{ matrix.java.java-distribution || 'temurin' }} java-version: ${{ matrix.java.java-version }} @@ -1210,7 +1210,7 @@ jobs: - name: Extract previously uploaded .m2 content run: tar -xzf m2-content.tgz -C ~ - name: Set up JDK ${{ matrix.java.java-version }} - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: ${{ matrix.java.java-version }} @@ -1326,7 +1326,7 @@ jobs: - name: Extract previously uploaded .m2 content run: tar -xzf m2-content.tgz -C ~ - name: Set up JDK ${{ matrix.java.java-version }} - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: ${{ matrix.java.java-version }} @@ -1419,7 +1419,7 @@ jobs: - name: Extract previously uploaded .m2 content run: tar -xzf m2-content.tgz -C ~ - name: Set up JDK 25 - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: 25 @@ -1487,7 +1487,7 @@ jobs: - name: Reclaim Disk Space run: .github/ci-prerequisites.sh - name: Set up JDK ${{ needs.configure.outputs.base-jdk-version }} - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: ${{ needs.configure.outputs.base-jdk-version }} @@ -1585,7 +1585,7 @@ jobs: run: .github/ci-prerequisites.sh if: ${{ !startsWith(matrix.os-name, 'windows') }} - name: Set up JDK 25 - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: 25 @@ -1793,7 +1793,7 @@ jobs: - name: Extract previously uploaded .m2 content run: tar -xzf m2-content.tgz -C ~ - name: Set up JDK ${{ needs.configure.outputs.base-jdk-version }} - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: ${{ needs.configure.outputs.base-jdk-version }} @@ -2002,8 +2002,7 @@ jobs: apt-get update && apt-get install -y git curl unzip openssl libssl-dev libapr1t64 p7zip-full - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up JDK 21 - # v6 is not supported at the moment as the container image doesn't contain gpg - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: 21 @@ -2070,7 +2069,7 @@ jobs: with: path: build-reports-artifacts - name: Set up JDK 25 - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: 25 diff --git a/.github/workflows/doc-build.yml b/.github/workflows/doc-build.yml index a2a62a6cfd01c..c0472bf031bd9 100644 --- a/.github/workflows/doc-build.yml +++ b/.github/workflows/doc-build.yml @@ -67,7 +67,7 @@ jobs: echo "base-jdk-version=${BASE_JDK}" >> $GITHUB_OUTPUT echo "Base JDK version: ${BASE_JDK}" - name: Set up JDK ${{ steps.base-jdk.outputs.base-jdk-version }} - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: ${{ steps.base-jdk.outputs.base-jdk-version }} diff --git a/.github/workflows/native-it-selected-graalvm.yml b/.github/workflows/native-it-selected-graalvm.yml index b85dd13b46ad9..a0839303ac364 100644 --- a/.github/workflows/native-it-selected-graalvm.yml +++ b/.github/workflows/native-it-selected-graalvm.yml @@ -80,7 +80,7 @@ jobs: - name: Reclaim Disk Space run: .github/ci-prerequisites.sh - name: Set up JDK ${{ steps.base-jdk.outputs.base-jdk-version }} - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: ${{ steps.base-jdk.outputs.base-jdk-version }} @@ -256,7 +256,7 @@ jobs: - name: Extract .m2/repository/io/quarkus run: tar -xzf m2-io-quarkus.tgz -C ~ - name: Set up JDK ${{ needs.initial-build.outputs.base-jdk-version }} - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: ${{ needs.initial-build.outputs.base-jdk-version }} @@ -328,7 +328,7 @@ jobs: - name: Reclaim Disk Space run: .github/ci-prerequisites.sh - name: Set up JDK ${{ needs.initial-build.outputs.base-jdk-version }} - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: ${{ needs.initial-build.outputs.base-jdk-version }} @@ -419,7 +419,7 @@ jobs: with: path: build-reports-artifacts - name: Set up JDK ${{ needs.initial-build.outputs.base-jdk-version }} - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: ${{ needs.initial-build.outputs.base-jdk-version }} diff --git a/.github/workflows/owasp-check.yml b/.github/workflows/owasp-check.yml index 9d5d8cd7139ca..b609350bf8228 100644 --- a/.github/workflows/owasp-check.yml +++ b/.github/workflows/owasp-check.yml @@ -31,7 +31,7 @@ jobs: echo "base-jdk-version=${BASE_JDK}" >> $GITHUB_OUTPUT echo "Base JDK version: ${BASE_JDK}" - name: Setup Java JDK ${{ steps.base-jdk.outputs.base-jdk-version }} - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: ${{ steps.base-jdk.outputs.base-jdk-version }} diff --git a/.github/workflows/podman-build.yml b/.github/workflows/podman-build.yml index 9a3984192574d..22ba7a7c352fb 100644 --- a/.github/workflows/podman-build.yml +++ b/.github/workflows/podman-build.yml @@ -59,7 +59,7 @@ jobs: if: "!startsWith(matrix.java.os-name, 'windows') && !startsWith(matrix.java.os-name, 'macos')" run: .github/ci-prerequisites.sh - name: Set up JDK ${{ steps.base-jdk.outputs.base-jdk-version }} - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: ${{ steps.base-jdk.outputs.base-jdk-version }} diff --git a/.github/workflows/populate-cache.yml b/.github/workflows/populate-cache.yml index 8a4c71ce88b52..69b73bc4f566e 100644 --- a/.github/workflows/populate-cache.yml +++ b/.github/workflows/populate-cache.yml @@ -55,7 +55,7 @@ jobs: echo "base-jdk-version=${BASE_JDK}" >> $GITHUB_OUTPUT echo "Base JDK version: ${BASE_JDK}" - name: Set up JDK ${{ steps.base-jdk.outputs.base-jdk-version }} - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: ${{ steps.base-jdk.outputs.base-jdk-version }} diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 04b4a4e4abe9e..528113f03a4d8 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -72,7 +72,7 @@ jobs: rm -rf quarkus-main - name: Set up JDK - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: java-version: '25' distribution: 'temurin' diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index ca29de8866d68..352649c1da142 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -36,7 +36,7 @@ jobs: echo "base-jdk-version=${BASE_JDK}" >> $GITHUB_OUTPUT echo "Base JDK version: ${BASE_JDK}" - name: Set up JDK ${{ steps.base-jdk.outputs.base-jdk-version }} - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: ${{ steps.base-jdk.outputs.base-jdk-version }} diff --git a/.github/workflows/reproducibility-checks-it.yml b/.github/workflows/reproducibility-checks-it.yml index 463c6272704f7..e316a5e03daac 100644 --- a/.github/workflows/reproducibility-checks-it.yml +++ b/.github/workflows/reproducibility-checks-it.yml @@ -34,7 +34,7 @@ jobs: run: | echo "sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT - name: Set up JDK 21 - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: 21 diff --git a/.github/workflows/reproducibility-checks.yml b/.github/workflows/reproducibility-checks.yml index fd52993381136..79e9c3daeaf1e 100644 --- a/.github/workflows/reproducibility-checks.yml +++ b/.github/workflows/reproducibility-checks.yml @@ -53,7 +53,7 @@ jobs: echo "base-jdk-version=${BASE_JDK}" >> $GITHUB_OUTPUT echo "Base JDK version: ${BASE_JDK}" - name: Set up JDK ${{ steps.base-jdk.outputs.base-jdk-version }} - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: ${{ steps.base-jdk.outputs.base-jdk-version }} diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index abb8c3caacacd..e52ac93c76f85 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -26,7 +26,7 @@ jobs: echo "base-jdk-version=${BASE_JDK}" >> $GITHUB_OUTPUT echo "Base JDK version: ${BASE_JDK}" - name: Set up JDK ${{ steps.base-jdk.outputs.base-jdk-version }} - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: ${{ steps.base-jdk.outputs.base-jdk-version }} From 46f7ec72c7a50239e17af84681ca2c14b64b573c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20=C3=89pardaud?= Date: Fri, 9 Oct 2026 09:37:48 +0000 Subject: [PATCH 115/254] Don't fail Dev UI websocket messages when the JSON-RPC codec is not initialized yet --- .../devui/JsonRpcWebSocketReloadTest.java | 79 +++++++++++++++++++ .../quarkus/devui/ReloadMarkerResource.java | 12 +++ .../runtime/js/DevUIWebSocketHandler.java | 13 ++- 3 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 extensions/devui/deployment/src/test/java/io/quarkus/devui/JsonRpcWebSocketReloadTest.java create mode 100644 extensions/devui/deployment/src/test/java/io/quarkus/devui/ReloadMarkerResource.java diff --git a/extensions/devui/deployment/src/test/java/io/quarkus/devui/JsonRpcWebSocketReloadTest.java b/extensions/devui/deployment/src/test/java/io/quarkus/devui/JsonRpcWebSocketReloadTest.java new file mode 100644 index 0000000000000..64f379d7fa47b --- /dev/null +++ b/extensions/devui/deployment/src/test/java/io/quarkus/devui/JsonRpcWebSocketReloadTest.java @@ -0,0 +1,79 @@ +package io.quarkus.devui; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusDevModeTest; +import io.restassured.RestAssured; +import io.vertx.core.Vertx; +import io.vertx.core.http.WebSocket; +import io.vertx.core.http.WebSocketClient; +import io.vertx.core.http.WebSocketClientOptions; + +/** + * Keeps a Dev UI JSON-RPC websocket open across a hot reload, like a browser tab does, and checks that the connection + * keeps working without Vert.x reporting uncaught exceptions (see issue #57277). + */ +public class JsonRpcWebSocketReloadTest { + + private static final int PORT = Integer.getInteger("quarkus.http.test-port", 8080); + + @RegisterExtension + static final QuarkusDevModeTest config = new QuarkusDevModeTest() + .withApplicationRoot(jar -> jar.addClass(ReloadMarkerResource.class)) + .setLogRecordPredicate(r -> true); + + @Test + public void testSocketSurvivesHotReload() throws Exception { + Vertx vertx = Vertx.vertx(); + try { + WebSocketClient client = vertx.createWebSocketClient( + new WebSocketClientOptions().setDefaultHost("localhost").setDefaultPort(PORT)); + WebSocket socket = client.connect("/q/dev-ui/json-rpc-ws").await(30, TimeUnit.SECONDS); + try { + assertThat(request(socket, 1)).satisfies(m -> assertThat(hasId(m, 1)).isTrue()).doesNotContain("\"error\""); + + config.clearLogRecords(); + config.modifySourceFile(ReloadMarkerResource.class, s -> s.replace("\"before\"", "\"after\"")); + RestAssured.get("/reload-marker").then().statusCode(200); + + // The connection is either still usable, or cleanly closed so that the client can reconnect. + if (!socket.isClosed()) { + String response = request(socket, 2); + assertThat(response == null || hasId(response, 2)).isTrue(); + } + } finally { + client.close().await(30, TimeUnit.SECONDS); + } + assertThat(config.getLogRecords()) + .noneMatch(r -> r.getMessage() != null && r.getMessage().contains("Uncaught exception received by Vert.x")) + .noneMatch(r -> r.getThrown() instanceof NullPointerException); + } finally { + vertx.close().await(30, TimeUnit.SECONDS); + } + } + + private static boolean hasId(String message, int id) { + return Pattern.compile("\"id\"\\s*:\\s*" + id + "\\b").matcher(message).find(); + } + + /** Returns the response text, or null if the socket got closed before answering. */ + private static String request(WebSocket socket, int id) throws Exception { + CompletableFuture response = new CompletableFuture<>(); + // The server also pushes unsolicited messages (e.g. log lines) on this socket, so only look at our response. + socket.textMessageHandler(m -> { + if (hasId(m, id)) { + response.complete(m); + } + }); + socket.closeHandler(v -> response.complete(null)); + socket.writeTextMessage("{\"jsonrpc\":\"2.0\",\"id\":" + id + ",\"method\":\"devui-logstream_history\",\"params\":{}}"); + return response.get(30, TimeUnit.SECONDS); + } +} diff --git a/extensions/devui/deployment/src/test/java/io/quarkus/devui/ReloadMarkerResource.java b/extensions/devui/deployment/src/test/java/io/quarkus/devui/ReloadMarkerResource.java new file mode 100644 index 0000000000000..02cba890e935d --- /dev/null +++ b/extensions/devui/deployment/src/test/java/io/quarkus/devui/ReloadMarkerResource.java @@ -0,0 +1,12 @@ +package io.quarkus.devui; + +import jakarta.enterprise.event.Observes; + +import io.vertx.ext.web.Router; + +public class ReloadMarkerResource { + + void init(@Observes Router router) { + router.get("/reload-marker").handler(rc -> rc.response().end("before")); + } +} diff --git a/extensions/devui/runtime/src/main/java/io/quarkus/devui/runtime/js/DevUIWebSocketHandler.java b/extensions/devui/runtime/src/main/java/io/quarkus/devui/runtime/js/DevUIWebSocketHandler.java index 2be51ac85fb15..5fffa9e41b6b4 100644 --- a/extensions/devui/runtime/src/main/java/io/quarkus/devui/runtime/js/DevUIWebSocketHandler.java +++ b/extensions/devui/runtime/src/main/java/io/quarkus/devui/runtime/js/DevUIWebSocketHandler.java @@ -5,6 +5,7 @@ import org.jboss.logging.Logger; import io.quarkus.devjsonrpc.runtime.comms.JsonRpcRouter; +import io.quarkus.devjsonrpc.runtime.jsonrpc.JsonRpcCodec; import io.quarkus.devjsonrpc.runtime.jsonrpc.JsonRpcRequest; import io.vertx.core.AsyncResult; import io.vertx.core.Handler; @@ -38,12 +39,20 @@ public void handle(AsyncResult event) { private void addSocket(ServerWebSocket socket) { try { - JsonRpcRouter jsonRpcRouter = CDI.current().select(JsonRpcRouter.class).get(); DevUISessionManager sessionManager = CDI.current().select(DevUISessionManager.class).get(); JavaScriptResponseWriter writer = new JavaScriptResponseWriter(socket); sessionManager.addSession(writer); socket.textMessageHandler((e) -> { - JsonRpcRequest jsonRpcRequest = jsonRpcRouter.getJsonRpcCodec().readRequest(e); + // Look the router up for each message rather than capturing it when the socket opens: after a hot + // reload the client can reconnect before the router has been fully initialized. + JsonRpcRouter jsonRpcRouter = CDI.current().select(JsonRpcRouter.class).get(); + JsonRpcCodec codec = jsonRpcRouter.getJsonRpcCodec(); + if (codec == null) { + LOG.debug("Dev UI JSON-RPC codec not initialized yet, closing the websocket so the client reconnects"); + socket.close(); + return; + } + JsonRpcRequest jsonRpcRequest = codec.readRequest(e); jsonRpcRouter.route(jsonRpcRequest, writer); }).closeHandler((e) -> { sessionManager.purge(); From 1dccc549239e4c40cf34912b3031287cd3251ef7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20=C3=89pardaud?= Date: Fri, 9 Oct 2026 12:15:52 +0000 Subject: [PATCH 116/254] Make JsonRpcRouter.codec volatile --- .../java/io/quarkus/devjsonrpc/runtime/comms/JsonRpcRouter.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/devjsonrpc/runtime/src/main/java/io/quarkus/devjsonrpc/runtime/comms/JsonRpcRouter.java b/extensions/devjsonrpc/runtime/src/main/java/io/quarkus/devjsonrpc/runtime/comms/JsonRpcRouter.java index b65765e9f227b..587f64705fd5a 100644 --- a/extensions/devjsonrpc/runtime/src/main/java/io/quarkus/devjsonrpc/runtime/comms/JsonRpcRouter.java +++ b/extensions/devjsonrpc/runtime/src/main/java/io/quarkus/devjsonrpc/runtime/comms/JsonRpcRouter.java @@ -48,7 +48,7 @@ public class JsonRpcRouter { // Map json-rpc subscriptions responses that is recorded private Map recordedSubscriptionsMap; - private JsonRpcCodec codec; + private volatile JsonRpcCodec codec; /** * This gets populated at build time so the the routes knows all json-rpc endpoints. From 3cb9412a4d67d39e429c0e948e6506ac8f7d5ea1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:12:27 +0000 Subject: [PATCH 117/254] Bump com.fasterxml.jackson:jackson-bom from 2.22.2 to 2.22.3 Bumps [com.fasterxml.jackson:jackson-bom](https://github.com/FasterXML/jackson-bom) from 2.22.2 to 2.22.3. - [Commits](https://github.com/FasterXML/jackson-bom/compare/jackson-bom-2.22.2...jackson-bom-2.22.3) --- updated-dependencies: - dependency-name: com.fasterxml.jackson:jackson-bom dependency-version: 2.22.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- bom/application/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bom/application/pom.xml b/bom/application/pom.xml index 8aecf716010bb..2243c0b3ac732 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -92,7 +92,7 @@ 1.10.1 2.3.1 3.1.7 - 2.22.2 + 2.22.3 2.0.0.Final 3.21.0 1.22.1 From f14e3da8b8bd72b4be609c403394a19f1dd09a68 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:13:11 +0000 Subject: [PATCH 118/254] Bump org.eclipse.microprofile.health:microprofile-health-api Bumps [org.eclipse.microprofile.health:microprofile-health-api](https://github.com/eclipse/microprofile-health) from 4.0.1 to 4.0.2. - [Release notes](https://github.com/eclipse/microprofile-health/releases) - [Commits](https://github.com/eclipse/microprofile-health/compare/4.0.1...4.0.2) --- updated-dependencies: - dependency-name: org.eclipse.microprofile.health:microprofile-health-api dependency-version: 4.0.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- bom/application/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bom/application/pom.xml b/bom/application/pom.xml index 8aecf716010bb..0260c0577a4ea 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -38,7 +38,7 @@ 0.22.0 26.0 3.1.1 - 4.0.1 + 4.0.2 1.3 4.1.2 3.0.1 From 90a9aebfa5bed20600850e523ad86a6ff9adbf8b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:17:36 +0000 Subject: [PATCH 119/254] Bump asm.version from 9.10.1 to 9.11 Bumps `asm.version` from 9.10.1 to 9.11. Updates `org.ow2.asm:asm` from 9.10.1 to 9.11 Updates `org.ow2.asm:asm-commons` from 9.10.1 to 9.11 Updates `org.ow2.asm:asm-tree` from 9.10.1 to 9.11 Updates `org.ow2.asm:asm-analysis` from 9.10.1 to 9.11 Updates `org.ow2.asm:asm-util` from 9.10.1 to 9.11 --- updated-dependencies: - dependency-name: org.ow2.asm:asm dependency-version: '9.11' dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: org.ow2.asm:asm-commons dependency-version: '9.11' dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: org.ow2.asm:asm-tree dependency-version: '9.11' dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: org.ow2.asm:asm-analysis dependency-version: '9.11' dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: org.ow2.asm:asm-util dependency-version: '9.11' dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- bom/application/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bom/application/pom.xml b/bom/application/pom.xml index 8aecf716010bb..376da448f900d 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -83,7 +83,7 @@ 4.0.0 4.0.5 4.0.9 - 9.10.1 + 9.11 2.22.0 17.1.0 3.0-alpha-2 From beed80b058431386a788890458d1d15028cf1543 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:17:43 +0000 Subject: [PATCH 120/254] Bump eu.maveniverse.maven.scalpel:extension3 from 0.4.2 to 0.4.3 Bumps [eu.maveniverse.maven.scalpel:extension3](https://github.com/maveniverse/scalpel) from 0.4.2 to 0.4.3. - [Release notes](https://github.com/maveniverse/scalpel/releases) - [Commits](https://github.com/maveniverse/scalpel/compare/0.4.2...0.4.3) --- updated-dependencies: - dependency-name: eu.maveniverse.maven.scalpel:extension3 dependency-version: 0.4.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .mvn/extensions.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.mvn/extensions.xml b/.mvn/extensions.xml index c7eed26696fb1..24b0d3d6ead78 100644 --- a/.mvn/extensions.xml +++ b/.mvn/extensions.xml @@ -22,7 +22,7 @@ eu.maveniverse.maven.scalpel extension3 - 0.4.2 + 0.4.3 eu.maveniverse.maven.nisse From 5f166c3dc0c95b3c8afac961f07151bce0e33234 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:27:25 +0000 Subject: [PATCH 121/254] Bump elasticsearch-opensource-components.version from 9.4.7 to 9.4.8 Bumps `elasticsearch-opensource-components.version` from 9.4.7 to 9.4.8. Updates `co.elastic.clients:elasticsearch-rest5-client` from 9.4.7 to 9.4.8 - [Release notes](https://github.com/elastic/elasticsearch-java/releases) - [Changelog](https://github.com/elastic/elasticsearch-java/blob/main/CHANGELOG.md) - [Commits](https://github.com/elastic/elasticsearch-java/compare/v9.4.7...v9.4.8) Updates `co.elastic.clients:elasticsearch-java` from 9.4.7 to 9.4.8 - [Release notes](https://github.com/elastic/elasticsearch-java/releases) - [Changelog](https://github.com/elastic/elasticsearch-java/blob/main/CHANGELOG.md) - [Commits](https://github.com/elastic/elasticsearch-java/compare/v9.4.7...v9.4.8) --- updated-dependencies: - dependency-name: co.elastic.clients:elasticsearch-rest5-client dependency-version: 9.4.8 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: co.elastic.clients:elasticsearch-java dependency-version: 9.4.8 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- bom/application/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bom/application/pom.xml b/bom/application/pom.xml index 8aecf716010bb..36c3f68580179 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -103,7 +103,7 @@ 2.0.1.Final 3.2.1 8.0.0.Final - 9.4.7 + 9.4.8 2.2.21 2.3.0.Final 2.3.0.Final From b0aecf67cbbe171449ac90866d2597903c427cfc Mon Sep 17 00:00:00 2001 From: Holly Cummins Date: Fri, 9 Oct 2026 19:40:53 +0100 Subject: [PATCH 122/254] Use branch instead of commit to identify PR --- .github/workflows/preview.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 04b4a4e4abe9e..23454bed72e8e 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -49,11 +49,11 @@ jobs: HEAD_SHA: ${{ github.event.workflow_run.head_sha }} run: | # workflow_run.pull_requests is empty for pull requests opened from forks: - # look the pull request up from the head of the documentation build instead + # look the pull request up from the branch the documentation build ran on instead. if [[ -z "$RAW_PR_NUMBER" ]]; then RAW_PR_NUMBER=$(gh api -X GET "repos/${GITHUB_REPOSITORY}/pulls" \ -f state=open -f head="${HEAD_OWNER}:${HEAD_BRANCH}" -f per_page=100 \ - --jq 'map(select(.head.sha == env.HEAD_SHA)) | .[0].number // empty') + --jq '.[0].number // empty') fi if ! [[ "$RAW_PR_NUMBER" =~ ^[0-9]+$ ]]; then echo "Invalid PR number: $RAW_PR_NUMBER" >&2 From d93f24528b0f975b18d900390ed99646ac5fc8d2 Mon Sep 17 00:00:00 2001 From: jnbdz <237008+jnbdz@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:01:57 -0400 Subject: [PATCH 123/254] Stop ignoring split packages in the Kubernetes client The io.fabric8.kubernetes packages were ignored by the split package detection because the fabric8 6.x model jars shared io.fabric8.kubernetes.api.model. With fabric8 8.0.0 no package is split across its jars any more, so the exception is removed and new split packages in the client would be reported again. --- .../arc/deployment/SplitPackageProcessor.java | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/SplitPackageProcessor.java b/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/SplitPackageProcessor.java index 90e23530d7f0f..067ae0bc5d573 100644 --- a/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/SplitPackageProcessor.java +++ b/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/SplitPackageProcessor.java @@ -40,16 +40,6 @@ public class SplitPackageProcessor { private static final Logger LOGGER = Logger.getLogger(SplitPackageProcessor.class); - private static final Predicate IGNORE_PACKAGE = new Predicate<>() { - - @Override - public boolean test(String packageName) { - // Remove the elements from this list when the original issue is fixed - // so that we can detect further issues. - return packageName.startsWith("io.fabric8.kubernetes"); - } - }; - @BuildStep void splitPackageDetection(ApplicationArchivesBuildItem archivesBuildItem, ArcConfig config, @@ -98,9 +88,6 @@ void splitPackageDetection(ApplicationArchivesBuildItem archivesBuildItem, // - "com.me.app.sub" found in [archiveA, archiveB] StringBuilder splitPackagesWarning = new StringBuilder(); for (String packageName : packageToArchiveMap.keySet()) { - if (IGNORE_PACKAGE.test(packageName)) { - continue; - } // skip packages based on pre-built predicates boolean skipEvaluation = false; From 9a4e2b1e201b7f6ab6cbbc3f4d56ac82304d1010 Mon Sep 17 00:00:00 2001 From: Vitaliy Baschlykoff Date: Sat, 10 Oct 2026 22:22:18 +1100 Subject: [PATCH 124/254] Extract quarkus-flyway-common and add MongoClientSchemaReadyBuildItem The ClassPathScanner, FeatureDetector and JarFileClassPathLocationScanner substitutions target flyway-core internals, so any extension built on flyway-core needs them. Keeping them in quarkus-flyway means another extension either duplicates them, which fails the native image build on duplicate @TargetClass declarations when both are present, or depends on the JDBC Flyway extension. Move them to quarkus-flyway-common, following quarkus-liquibase-common. MongoClientSchemaReadyBuildItem lets a schema-management extension signal that the named MongoDB clients have had their migrations applied, mirroring JdbcDataSourceSchemaReadyBuildItem. The producer returns ServiceStartBuildItem from that same runtime-init step, which is what delays application startup. --- bom/application/pom.xml | 5 ++++ extensions/flyway/common/pom.xml | 27 +++++++++++++++++++ .../graal/ClassPathScannerSubstitutions.java | 2 +- .../graal/FeatureDetectorSubstitutions.java | 2 +- ...ClassPathLocationScannerSubstitutions.java | 2 +- extensions/flyway/pom.xml | 1 + extensions/flyway/runtime/pom.xml | 9 +++---- .../spi/MongoClientSchemaReadyBuildItem.java | 23 ++++++++++++++++ 8 files changed, 63 insertions(+), 8 deletions(-) create mode 100644 extensions/flyway/common/pom.xml rename extensions/flyway/{runtime/src/main/java/io/quarkus/flyway/runtime => common/src/main/java/io/quarkus/flyway/common}/graal/ClassPathScannerSubstitutions.java (98%) rename extensions/flyway/{runtime/src/main/java/io/quarkus/flyway/runtime => common/src/main/java/io/quarkus/flyway/common}/graal/FeatureDetectorSubstitutions.java (96%) rename extensions/flyway/{runtime/src/main/java/io/quarkus/flyway/runtime => common/src/main/java/io/quarkus/flyway/common}/graal/JarFileClassPathLocationScannerSubstitutions.java (92%) create mode 100644 extensions/mongodb-client/deployment-spi/src/main/java/io/quarkus/mongodb/deployment/spi/MongoClientSchemaReadyBuildItem.java diff --git a/bom/application/pom.xml b/bom/application/pom.xml index 8aecf716010bb..609f7dd42d7ee 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -1367,6 +1367,11 @@ quarkus-devservices-oidc ${project.version} + + io.quarkus + quarkus-flyway-common + ${project.version} + io.quarkus quarkus-flyway diff --git a/extensions/flyway/common/pom.xml b/extensions/flyway/common/pom.xml new file mode 100644 index 0000000000000..9e268669a8e72 --- /dev/null +++ b/extensions/flyway/common/pom.xml @@ -0,0 +1,27 @@ + + + + quarkus-flyway-parent + io.quarkus + 999-SNAPSHOT + + 4.0.0 + + quarkus-flyway-common + Quarkus - Flyway - Common + Code shared by the Flyway extensions + + + + org.flywaydb + flyway-core + + + org.graalvm.sdk + nativeimage + provided + + + diff --git a/extensions/flyway/runtime/src/main/java/io/quarkus/flyway/runtime/graal/ClassPathScannerSubstitutions.java b/extensions/flyway/common/src/main/java/io/quarkus/flyway/common/graal/ClassPathScannerSubstitutions.java similarity index 98% rename from extensions/flyway/runtime/src/main/java/io/quarkus/flyway/runtime/graal/ClassPathScannerSubstitutions.java rename to extensions/flyway/common/src/main/java/io/quarkus/flyway/common/graal/ClassPathScannerSubstitutions.java index 68b16d18b153f..f6537fce57427 100644 --- a/extensions/flyway/runtime/src/main/java/io/quarkus/flyway/runtime/graal/ClassPathScannerSubstitutions.java +++ b/extensions/flyway/common/src/main/java/io/quarkus/flyway/common/graal/ClassPathScannerSubstitutions.java @@ -1,4 +1,4 @@ -package io.quarkus.flyway.runtime.graal; +package io.quarkus.flyway.common.graal; import java.util.HashMap; import java.util.function.BooleanSupplier; diff --git a/extensions/flyway/runtime/src/main/java/io/quarkus/flyway/runtime/graal/FeatureDetectorSubstitutions.java b/extensions/flyway/common/src/main/java/io/quarkus/flyway/common/graal/FeatureDetectorSubstitutions.java similarity index 96% rename from extensions/flyway/runtime/src/main/java/io/quarkus/flyway/runtime/graal/FeatureDetectorSubstitutions.java rename to extensions/flyway/common/src/main/java/io/quarkus/flyway/common/graal/FeatureDetectorSubstitutions.java index ab9ae613ba82f..3338990be7dd8 100644 --- a/extensions/flyway/runtime/src/main/java/io/quarkus/flyway/runtime/graal/FeatureDetectorSubstitutions.java +++ b/extensions/flyway/common/src/main/java/io/quarkus/flyway/common/graal/FeatureDetectorSubstitutions.java @@ -1,4 +1,4 @@ -package io.quarkus.flyway.runtime.graal; +package io.quarkus.flyway.common.graal; import org.flywaydb.core.internal.util.FeatureDetector; diff --git a/extensions/flyway/runtime/src/main/java/io/quarkus/flyway/runtime/graal/JarFileClassPathLocationScannerSubstitutions.java b/extensions/flyway/common/src/main/java/io/quarkus/flyway/common/graal/JarFileClassPathLocationScannerSubstitutions.java similarity index 92% rename from extensions/flyway/runtime/src/main/java/io/quarkus/flyway/runtime/graal/JarFileClassPathLocationScannerSubstitutions.java rename to extensions/flyway/common/src/main/java/io/quarkus/flyway/common/graal/JarFileClassPathLocationScannerSubstitutions.java index 65c83a27f700c..beaeda27ceb0f 100644 --- a/extensions/flyway/runtime/src/main/java/io/quarkus/flyway/runtime/graal/JarFileClassPathLocationScannerSubstitutions.java +++ b/extensions/flyway/common/src/main/java/io/quarkus/flyway/common/graal/JarFileClassPathLocationScannerSubstitutions.java @@ -1,4 +1,4 @@ -package io.quarkus.flyway.runtime.graal; +package io.quarkus.flyway.common.graal; import org.flywaydb.core.internal.scanner.classpath.JarFileClassPathLocationScanner; diff --git a/extensions/flyway/pom.xml b/extensions/flyway/pom.xml index 98763556e61d4..266673d62a2e9 100644 --- a/extensions/flyway/pom.xml +++ b/extensions/flyway/pom.xml @@ -15,6 +15,7 @@ Quarkus - Flyway pom + common runtime deployment runtime-dev diff --git a/extensions/flyway/runtime/pom.xml b/extensions/flyway/runtime/pom.xml index 02f3b380ee618..067f6d8f4ced0 100644 --- a/extensions/flyway/runtime/pom.xml +++ b/extensions/flyway/runtime/pom.xml @@ -17,6 +17,10 @@ io.quarkus quarkus-core + + io.quarkus + quarkus-flyway-common + org.flywaydb flyway-core @@ -33,11 +37,6 @@ io.quarkus quarkus-datasource-common - - org.graalvm.sdk - nativeimage - provided - io.vertx vertx-web diff --git a/extensions/mongodb-client/deployment-spi/src/main/java/io/quarkus/mongodb/deployment/spi/MongoClientSchemaReadyBuildItem.java b/extensions/mongodb-client/deployment-spi/src/main/java/io/quarkus/mongodb/deployment/spi/MongoClientSchemaReadyBuildItem.java new file mode 100644 index 0000000000000..1841ac53935aa --- /dev/null +++ b/extensions/mongodb-client/deployment-spi/src/main/java/io/quarkus/mongodb/deployment/spi/MongoClientSchemaReadyBuildItem.java @@ -0,0 +1,23 @@ +package io.quarkus.mongodb.deployment.spi; + +import java.util.Collection; + +import io.quarkus.builder.item.MultiBuildItem; + +/** + * Signals that the named MongoDB clients have had their schema prepared + * (migrations applied). Consumers can declare a build-step dependency on + * this item to ensure they initialise after the schema is ready. + */ +public final class MongoClientSchemaReadyBuildItem extends MultiBuildItem { + + private final Collection clientNames; + + public MongoClientSchemaReadyBuildItem(Collection clientNames) { + this.clientNames = clientNames; + } + + public Collection getClientNames() { + return clientNames; + } +} From 8bb39ff6db79f04ff471f32b982c51c26af07939 Mon Sep 17 00:00:00 2001 From: jnbdz <237008+jnbdz@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:37:09 -0400 Subject: [PATCH 125/254] Register the Keycloak admin RESTEasy client startup work as services The client provider reset, the client provider setup and the admin client config validation now run as services instead of recorder calls. The request scoped Keycloak bean is created by a BeanCreator that builds the client from the admin client config, so each request still gets its own client. --- .../KeycloakAdminResteasyClientProcessor.java | 39 +++++++---- .../KeycloakAdminClientInvalidConfigTest.java | 36 ++++++++++ .../KeycloakAdminClientRequestScopeTest.java | 66 +++++++++++++++++++ .../runtime/KeycloakAdminClientCreator.java | 16 +++++ .../KeycloakAdminResteasyClientRecorder.java | 46 +++---------- 5 files changed, 154 insertions(+), 49 deletions(-) create mode 100644 extensions/keycloak-admin-resteasy-client/deployment/src/test/java/io/quarkus/keycloak/admin/resteasy/client/deployment/test/KeycloakAdminClientInvalidConfigTest.java create mode 100644 extensions/keycloak-admin-resteasy-client/deployment/src/test/java/io/quarkus/keycloak/admin/resteasy/client/deployment/test/KeycloakAdminClientRequestScopeTest.java create mode 100644 extensions/keycloak-admin-resteasy-client/runtime/src/main/java/io/quarkus/keycloak/admin/resteasy/client/runtime/KeycloakAdminClientCreator.java diff --git a/extensions/keycloak-admin-resteasy-client/deployment/src/main/java/io/quarkus/keycloak/admin/resteasy/client/deployment/KeycloakAdminResteasyClientProcessor.java b/extensions/keycloak-admin-resteasy-client/deployment/src/main/java/io/quarkus/keycloak/admin/resteasy/client/deployment/KeycloakAdminResteasyClientProcessor.java index 7d93c75dec0b2..affe3af5c42fb 100644 --- a/extensions/keycloak-admin-resteasy-client/deployment/src/main/java/io/quarkus/keycloak/admin/resteasy/client/deployment/KeycloakAdminResteasyClientProcessor.java +++ b/extensions/keycloak-admin-resteasy-client/deployment/src/main/java/io/quarkus/keycloak/admin/resteasy/client/deployment/KeycloakAdminResteasyClientProcessor.java @@ -3,6 +3,7 @@ import jakarta.enterprise.context.RequestScoped; import org.jboss.jandex.DotName; +import org.jboss.jandex.Type; import org.jboss.resteasy.client.jaxrs.internal.ResteasyClientBuilderImpl; import org.jboss.resteasy.client.jaxrs.internal.proxy.ProxyBuilderImpl; import org.keycloak.admin.client.JacksonProvider; @@ -13,20 +14,23 @@ import org.keycloak.json.StringOrArraySerializer; import io.quarkus.arc.deployment.SyntheticBeanBuildItem; +import io.quarkus.core.Phase; +import io.quarkus.core.deployment.service.ServiceRegistrar; import io.quarkus.deployment.Feature; import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; -import io.quarkus.deployment.annotations.ExecutionTime; import io.quarkus.deployment.annotations.Produce; -import io.quarkus.deployment.annotations.Record; import io.quarkus.deployment.builditem.AdditionalApplicationArchiveMarkerBuildItem; import io.quarkus.deployment.builditem.FeatureBuildItem; import io.quarkus.deployment.builditem.ServiceStartBuildItem; import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; import io.quarkus.deployment.builditem.nativeimage.ReflectiveHierarchyIgnoreWarningBuildItem; import io.quarkus.keycloak.admin.client.common.deployment.KeycloakAdminClientInjectionEnabled; +import io.quarkus.keycloak.admin.client.common.runtime.KeycloakAdminClientConfig; +import io.quarkus.keycloak.admin.client.common.runtime.KeycloakAdminClientConfigUtil; +import io.quarkus.keycloak.admin.resteasy.client.runtime.KeycloakAdminClientCreator; import io.quarkus.keycloak.admin.resteasy.client.runtime.KeycloakAdminResteasyClientRecorder; -import io.quarkus.tls.deployment.spi.TlsRegistryBuildItem; +import io.quarkus.tls.TlsConfigurationRegistry; public class KeycloakAdminResteasyClientProcessor { @@ -54,23 +58,33 @@ ReflectiveClassBuildItem reflect() { .methods().build(); } - @Record(ExecutionTime.STATIC_INIT) @BuildStep - void avoidRuntimeInitIssueInClientBuilderWrapper(KeycloakAdminResteasyClientRecorder recorder) { - recorder.avoidRuntimeInitIssueInClientBuilderWrapper(); + void avoidRuntimeInitIssueInClientBuilderWrapper(ServiceRegistrar serviceRegistrar) { + serviceRegistrar + .forService("io.quarkus.keycloak-admin-resteasy-client.reset-client-provider") + .atPhase(Phase.STATIC_INIT) + .onStart(ctx -> KeycloakAdminResteasyClientRecorder.avoidRuntimeInitIssueInClientBuilderWrapper()); } - @Record(ExecutionTime.RUNTIME_INIT) @Produce(ServiceStartBuildItem.class) @BuildStep - public void integrate(KeycloakAdminResteasyClientRecorder recorder, TlsRegistryBuildItem tlsRegistryBuildItem) { - recorder.setClientProvider(tlsRegistryBuildItem.registry()); + public void integrate(ServiceRegistrar serviceRegistrar) { + serviceRegistrar + .forService("io.quarkus.keycloak-admin-resteasy-client.client-provider") + .atPhase(Phase.INIT) + .require(TlsConfigurationRegistry.class) + .require(KeycloakAdminClientConfig.class) + .onStart((ctx, registry, config) -> KeycloakAdminResteasyClientRecorder.setClientProvider(registry, config)); } - @Record(ExecutionTime.RUNTIME_INIT) @BuildStep(onlyIf = KeycloakAdminClientInjectionEnabled.class) - public void registerKeycloakAdminClientBeans(KeycloakAdminResteasyClientRecorder recorder, + public void registerKeycloakAdminClientBeans(ServiceRegistrar serviceRegistrar, BuildProducer syntheticBeanBuildItemBuildProducer) { + serviceRegistrar + .forService("io.quarkus.keycloak-admin-resteasy-client.validate-config") + .atPhase(Phase.INIT) + .require(KeycloakAdminClientConfig.class) + .onStart((ctx, config) -> KeycloakAdminClientConfigUtil.validate(config)); syntheticBeanBuildItemBuildProducer.produce(SyntheticBeanBuildItem .configure(Keycloak.class) // use @RequestScoped as we don't want to keep client connection open too long @@ -79,7 +93,8 @@ public void registerKeycloakAdminClientBeans(KeycloakAdminResteasyClientRecorder .reserve(true) .priority(0) .unremovable() - .supplier(recorder.createAdminClient()) + .creator(KeycloakAdminClientCreator.class) + .addInjectionPoint(Type.create(KeycloakAdminClientConfig.class)) .autoClose(true) .done()); } diff --git a/extensions/keycloak-admin-resteasy-client/deployment/src/test/java/io/quarkus/keycloak/admin/resteasy/client/deployment/test/KeycloakAdminClientInvalidConfigTest.java b/extensions/keycloak-admin-resteasy-client/deployment/src/test/java/io/quarkus/keycloak/admin/resteasy/client/deployment/test/KeycloakAdminClientInvalidConfigTest.java new file mode 100644 index 0000000000000..91228c5f8b07e --- /dev/null +++ b/extensions/keycloak-admin-resteasy-client/deployment/src/test/java/io/quarkus/keycloak/admin/resteasy/client/deployment/test/KeycloakAdminClientInvalidConfigTest.java @@ -0,0 +1,36 @@ +package io.quarkus.keycloak.admin.resteasy.client.deployment.test; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.fail; + +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusExtensionTest; + +public class KeycloakAdminClientInvalidConfigTest { + + @RegisterExtension + final static QuarkusExtensionTest app = new QuarkusExtensionTest() + .withApplicationRoot(jar -> jar + .addAsResource(new StringAsset(""" + quarkus.keycloak.devservices.enabled=false + quarkus.keycloak.admin-client.server-url=http://localhost:1 + quarkus.keycloak.admin-client.grant-type=CLIENT_CREDENTIALS + """), "application.properties")) + .assertException(t -> { + Throwable cause = t; + while (cause != null + && !String.valueOf(cause.getMessage()) + .contains("grant type 'client_credentials' requires client secret")) { + cause = cause.getCause(); + } + assertNotNull(cause, "Unexpected startup failure: " + t); + }); + + @Test + public void test() { + fail("Startup should have failed"); + } +} diff --git a/extensions/keycloak-admin-resteasy-client/deployment/src/test/java/io/quarkus/keycloak/admin/resteasy/client/deployment/test/KeycloakAdminClientRequestScopeTest.java b/extensions/keycloak-admin-resteasy-client/deployment/src/test/java/io/quarkus/keycloak/admin/resteasy/client/deployment/test/KeycloakAdminClientRequestScopeTest.java new file mode 100644 index 0000000000000..230c56fa21fc5 --- /dev/null +++ b/extensions/keycloak-admin-resteasy-client/deployment/src/test/java/io/quarkus/keycloak/admin/resteasy/client/deployment/test/KeycloakAdminClientRequestScopeTest.java @@ -0,0 +1,66 @@ +package io.quarkus.keycloak.admin.resteasy.client.deployment.test; + +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.keycloak.admin.client.Keycloak; + +import io.quarkus.arc.ClientProxy; +import io.quarkus.test.QuarkusExtensionTest; +import io.restassured.RestAssured; + +public class KeycloakAdminClientRequestScopeTest { + + @RegisterExtension + final static QuarkusExtensionTest app = new QuarkusExtensionTest() + .withApplicationRoot(jar -> jar + .addClasses(AdminResource.class) + .addAsResource(new StringAsset(""" + quarkus.keycloak.devservices.enabled=false + quarkus.keycloak.admin-client.server-url=http://localhost:1 + """), "application.properties")); + + @Test + public void testEachRequestGetsItsOwnClient() { + String first = RestAssured.get("/api/admin/client").then().statusCode(200).extract().asString(); + String second = RestAssured.get("/api/admin/client").then().statusCode(200).extract().asString(); + assertNotEquals(first, second); + // the test never closes a client, the container does when the request scope ends + RestAssured.get("/api/admin/closed").then().statusCode(200).body(is("[true, true]")); + } + + @Path("/api/admin") + public static class AdminResource { + + static final List CLIENTS = new CopyOnWriteArrayList<>(); + + @Inject + Keycloak keycloak; + + @GET + @Path("/client") + public String client() { + Keycloak instance = ClientProxy.unwrap(keycloak); + assertFalse(instance.isClosed()); + CLIENTS.add(instance); + return String.valueOf(System.identityHashCode(instance)); + } + + @GET + @Path("/closed") + public String closed() { + return CLIENTS.stream().map(Keycloak::isClosed).toList().toString(); + } + } +} diff --git a/extensions/keycloak-admin-resteasy-client/runtime/src/main/java/io/quarkus/keycloak/admin/resteasy/client/runtime/KeycloakAdminClientCreator.java b/extensions/keycloak-admin-resteasy-client/runtime/src/main/java/io/quarkus/keycloak/admin/resteasy/client/runtime/KeycloakAdminClientCreator.java new file mode 100644 index 0000000000000..99d9ce1ef8f5d --- /dev/null +++ b/extensions/keycloak-admin-resteasy-client/runtime/src/main/java/io/quarkus/keycloak/admin/resteasy/client/runtime/KeycloakAdminClientCreator.java @@ -0,0 +1,16 @@ +package io.quarkus.keycloak.admin.resteasy.client.runtime; + +import org.keycloak.admin.client.Keycloak; + +import io.quarkus.arc.BeanCreator; +import io.quarkus.arc.SyntheticCreationalContext; +import io.quarkus.keycloak.admin.client.common.runtime.KeycloakAdminClientConfig; + +public class KeycloakAdminClientCreator implements BeanCreator { + + @Override + public Keycloak create(SyntheticCreationalContext context) { + return KeycloakAdminResteasyClientRecorder + .createAdminClient(context.getInjectedReference(KeycloakAdminClientConfig.class)); + } +} diff --git a/extensions/keycloak-admin-resteasy-client/runtime/src/main/java/io/quarkus/keycloak/admin/resteasy/client/runtime/KeycloakAdminResteasyClientRecorder.java b/extensions/keycloak-admin-resteasy-client/runtime/src/main/java/io/quarkus/keycloak/admin/resteasy/client/runtime/KeycloakAdminResteasyClientRecorder.java index 5783212a29af5..16a8861d52205 100644 --- a/extensions/keycloak-admin-resteasy-client/runtime/src/main/java/io/quarkus/keycloak/admin/resteasy/client/runtime/KeycloakAdminResteasyClientRecorder.java +++ b/extensions/keycloak-admin-resteasy-client/runtime/src/main/java/io/quarkus/keycloak/admin/resteasy/client/runtime/KeycloakAdminResteasyClientRecorder.java @@ -1,8 +1,5 @@ package io.quarkus.keycloak.admin.resteasy.client.runtime; -import static io.quarkus.keycloak.admin.client.common.runtime.KeycloakAdminClientConfigUtil.validate; - -import java.util.function.Supplier; import java.util.function.UnaryOperator; import javax.net.ssl.SSLContext; @@ -24,37 +21,19 @@ import io.quarkus.keycloak.admin.client.common.runtime.Jackson2JsonNodeModule; import io.quarkus.keycloak.admin.client.common.runtime.KeycloakAdminClientConfig; import io.quarkus.resteasy.common.runtime.jackson.QuarkusJacksonSerializer; -import io.quarkus.runtime.RuntimeValue; -import io.quarkus.runtime.annotations.Recorder; import io.quarkus.tls.TlsConfiguration; import io.quarkus.tls.TlsConfigurationRegistry; import tools.jackson.databind.DeserializationFeature; import tools.jackson.databind.json.JsonMapper; -@Recorder public class KeycloakAdminResteasyClientRecorder { - private final RuntimeValue keycloakAdminClientConfigRuntimeValue; - - public KeycloakAdminResteasyClientRecorder( - RuntimeValue keycloakAdminClientConfigRuntimeValue) { - this.keycloakAdminClientConfigRuntimeValue = keycloakAdminClientConfigRuntimeValue; - } - - public Supplier createAdminClient() { - - final KeycloakAdminClientConfig config = keycloakAdminClientConfigRuntimeValue.getValue(); - validate(config); + public static Keycloak createAdminClient(KeycloakAdminClientConfig config) { if (config.serverUrl().isEmpty()) { - return new Supplier<>() { - @Override - public Keycloak get() { - throw new IllegalStateException( - "'quarkus.keycloak.admin-client.server-url' must be set in order to use the Keycloak admin client as a CDI bean"); - } - }; + throw new IllegalStateException( + "'quarkus.keycloak.admin-client.server-url' must be set in order to use the Keycloak admin client as a CDI bean"); } - final KeycloakBuilder keycloakBuilder = KeycloakBuilder + return KeycloakBuilder .builder() .clientId(config.clientId()) .clientSecret(config.clientSecret().orElse(null)) @@ -63,19 +42,12 @@ public Keycloak get() { .password(config.password().orElse(null)) .realm(config.realm()) .serverUrl(config.serverUrl().get()) - .scope(config.scope().orElse(null)); - return new Supplier() { - @Override - public Keycloak get() { - return keycloakBuilder.build(); - } - }; + .scope(config.scope().orElse(null)) + .build(); } - public void setClientProvider(Supplier registrySupplier) { - var registry = registrySupplier.get(); - var namedTlsConfig = TlsConfiguration.from(registry, - keycloakAdminClientConfigRuntimeValue.getValue().tlsConfigurationName()).orElse(null); + public static void setClientProvider(TlsConfigurationRegistry registry, KeycloakAdminClientConfig config) { + var namedTlsConfig = TlsConfiguration.from(registry, config.tlsConfigurationName()).orElse(null); final boolean globalTrustAll; if (registry.getDefault().isPresent()) { globalTrustAll = registry.getDefault().get().isTrustAll(); @@ -120,7 +92,7 @@ public R targetProxy(WebTarget webTarget, Class aClass) { }); } - public void avoidRuntimeInitIssueInClientBuilderWrapper() { + public static void avoidRuntimeInitIssueInClientBuilderWrapper() { // we set our provider at runtime, it is not used before that // however org.keycloak.admin.client.Keycloak.CLIENT_PROVIDER is initialized during // static init with org.keycloak.admin.client.ClientBuilderWrapper that is not compatible with native mode From 169cc88f193b011614a421c2f41cdfb2ef1eda7d Mon Sep 17 00:00:00 2001 From: Matheus Oliveira Date: Sun, 11 Oct 2026 09:46:41 -0300 Subject: [PATCH 126/254] refactor: migrate Micrometer OpenTelemetry from recorders to services Replace the MicrometerOtelBridgeRecorder with a MicrometerOtelBridgeBeanCreator set on the MeterRegistry synthetic bean, which resolves the injected OpenTelemetry instance as before. The Dev UI presence check now looks for the bean creator class. * Fix #57300 Signed-off-by: Matheus Oliveira --- .../MicrometerOtelBridgeProcessor.java | 8 +--- .../MicrometerOtelBridgeBeanCreator.java | 37 ++++++++++++++++ .../runtime/MicrometerOtelBridgeRecorder.java | 43 ------------------- .../MicrometerMetricsDevUIProcessor.java | 4 +- 4 files changed, 41 insertions(+), 51 deletions(-) create mode 100644 extensions/micrometer-opentelemetry/runtime/src/main/java/io/quarkus/micrometer/opentelemetry/runtime/MicrometerOtelBridgeBeanCreator.java delete mode 100644 extensions/micrometer-opentelemetry/runtime/src/main/java/io/quarkus/micrometer/opentelemetry/runtime/MicrometerOtelBridgeRecorder.java diff --git a/extensions/micrometer-opentelemetry/deployment/src/main/java/io/quarkus/micrometer/opentelemetry/deployment/MicrometerOtelBridgeProcessor.java b/extensions/micrometer-opentelemetry/deployment/src/main/java/io/quarkus/micrometer/opentelemetry/deployment/MicrometerOtelBridgeProcessor.java index 564579fa5796a..b0f53b98e7659 100644 --- a/extensions/micrometer-opentelemetry/deployment/src/main/java/io/quarkus/micrometer/opentelemetry/deployment/MicrometerOtelBridgeProcessor.java +++ b/extensions/micrometer-opentelemetry/deployment/src/main/java/io/quarkus/micrometer/opentelemetry/deployment/MicrometerOtelBridgeProcessor.java @@ -20,15 +20,13 @@ import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; import io.quarkus.deployment.annotations.BuildSteps; -import io.quarkus.deployment.annotations.ExecutionTime; -import io.quarkus.deployment.annotations.Record; import io.quarkus.deployment.builditem.BytecodeTransformerBuildItem; import io.quarkus.deployment.builditem.LogCategoryBuildItem; import io.quarkus.deployment.builditem.RunTimeConfigurationDefaultBuildItem; import io.quarkus.gizmo.ClassTransformer; import io.quarkus.gizmo.MethodDescriptor; import io.quarkus.micrometer.deployment.MicrometerProcessor; -import io.quarkus.micrometer.opentelemetry.runtime.MicrometerOtelBridgeRecorder; +import io.quarkus.micrometer.opentelemetry.runtime.MicrometerOtelBridgeBeanCreator; import io.quarkus.opentelemetry.deployment.OpenTelemetryEnabled; import io.quarkus.opentelemetry.runtime.config.build.OTelBuildConfig; @@ -77,10 +75,8 @@ public ClassVisitor apply(String s, ClassVisitor classVisitor) { } @BuildStep - @Record(ExecutionTime.RUNTIME_INIT) void createBridgeBean( OTelBuildConfig oTelBuildConfig, - MicrometerOtelBridgeRecorder recorder, BuildProducer syntheticBeanProducer) { if (!oTelBuildConfig.enabled()) { @@ -94,7 +90,7 @@ void createBridgeBean( .scope(Singleton.class) .addInjectionPoint(ParameterizedType.create(DotName.createSimple(Instance.class), new Type[] { ClassType.create(DotName.createSimple(OpenTelemetry.class.getName())) }, null)) - .createWith(recorder.createBridge()) + .creator(MicrometerOtelBridgeBeanCreator.class) .done()); } diff --git a/extensions/micrometer-opentelemetry/runtime/src/main/java/io/quarkus/micrometer/opentelemetry/runtime/MicrometerOtelBridgeBeanCreator.java b/extensions/micrometer-opentelemetry/runtime/src/main/java/io/quarkus/micrometer/opentelemetry/runtime/MicrometerOtelBridgeBeanCreator.java new file mode 100644 index 0000000000000..cbc2f8d13a9d4 --- /dev/null +++ b/extensions/micrometer-opentelemetry/runtime/src/main/java/io/quarkus/micrometer/opentelemetry/runtime/MicrometerOtelBridgeBeanCreator.java @@ -0,0 +1,37 @@ +package io.quarkus.micrometer.opentelemetry.runtime; + +import java.util.concurrent.TimeUnit; + +import jakarta.enterprise.inject.Instance; +import jakarta.enterprise.util.TypeLiteral; + +import io.micrometer.core.instrument.Clock; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Metrics; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.instrumentation.micrometer.v1_5.OpenTelemetryMeterRegistry; +import io.quarkus.arc.BeanCreator; +import io.quarkus.arc.SyntheticCreationalContext; + +public class MicrometerOtelBridgeBeanCreator implements BeanCreator { + + @Override + public MeterRegistry create(SyntheticCreationalContext context) { + Instance openTelemetry = context.getInjectedReference(new TypeLiteral<>() { + }); + + if (openTelemetry.isUnsatisfied()) { + throw new IllegalStateException("OpenTelemetry instance not found"); + } + + MeterRegistry meterRegistry = OpenTelemetryMeterRegistry.builder(openTelemetry.get()) + .setPrometheusMode(false) + .setMicrometerHistogramGaugesEnabled(true) + // The time unit should be seconds according to the OpenTelemetry documentation + .setBaseTimeUnit(TimeUnit.SECONDS) + .setClock(Clock.SYSTEM) + .build(); + Metrics.addRegistry(meterRegistry); + return meterRegistry; + } +} diff --git a/extensions/micrometer-opentelemetry/runtime/src/main/java/io/quarkus/micrometer/opentelemetry/runtime/MicrometerOtelBridgeRecorder.java b/extensions/micrometer-opentelemetry/runtime/src/main/java/io/quarkus/micrometer/opentelemetry/runtime/MicrometerOtelBridgeRecorder.java deleted file mode 100644 index 094b25f0f8374..0000000000000 --- a/extensions/micrometer-opentelemetry/runtime/src/main/java/io/quarkus/micrometer/opentelemetry/runtime/MicrometerOtelBridgeRecorder.java +++ /dev/null @@ -1,43 +0,0 @@ -package io.quarkus.micrometer.opentelemetry.runtime; - -import java.util.concurrent.TimeUnit; -import java.util.function.Function; - -import jakarta.enterprise.inject.Instance; -import jakarta.enterprise.util.TypeLiteral; - -import io.micrometer.core.instrument.Clock; -import io.micrometer.core.instrument.MeterRegistry; -import io.micrometer.core.instrument.Metrics; -import io.opentelemetry.api.OpenTelemetry; -import io.opentelemetry.instrumentation.micrometer.v1_5.OpenTelemetryMeterRegistry; -import io.quarkus.arc.SyntheticCreationalContext; -import io.quarkus.runtime.annotations.Recorder; - -@Recorder -public class MicrometerOtelBridgeRecorder { - - public Function, MeterRegistry> createBridge() { - return new Function<>() { - @Override - public MeterRegistry apply(SyntheticCreationalContext context) { - Instance openTelemetry = context.getInjectedReference(new TypeLiteral<>() { - }); - - if (openTelemetry.isUnsatisfied()) { - throw new IllegalStateException("OpenTelemetry instance not found"); - } - - MeterRegistry meterRegistry = OpenTelemetryMeterRegistry.builder(openTelemetry.get()) - .setPrometheusMode(false) - .setMicrometerHistogramGaugesEnabled(true) - // The time unit should be seconds according to the OpenTelemetry documentation - .setBaseTimeUnit(TimeUnit.SECONDS) - .setClock(Clock.SYSTEM) - .build(); - Metrics.addRegistry(meterRegistry); - return meterRegistry; - } - }; - } -} diff --git a/extensions/micrometer/deployment/src/main/java/io/quarkus/micrometer/deployment/devui/MicrometerMetricsDevUIProcessor.java b/extensions/micrometer/deployment/src/main/java/io/quarkus/micrometer/deployment/devui/MicrometerMetricsDevUIProcessor.java index e1b8c734287eb..7acfce9c5be14 100644 --- a/extensions/micrometer/deployment/src/main/java/io/quarkus/micrometer/deployment/devui/MicrometerMetricsDevUIProcessor.java +++ b/extensions/micrometer/deployment/src/main/java/io/quarkus/micrometer/deployment/devui/MicrometerMetricsDevUIProcessor.java @@ -22,8 +22,8 @@ */ public class MicrometerMetricsDevUIProcessor { - // Presence of the bridge runtime recorder means the micrometer->OTel bridge is active. - private static final String BRIDGE_RECORDER = "io.quarkus.micrometer.opentelemetry.runtime.MicrometerOtelBridgeRecorder"; + // Presence of the bridge runtime bean creator means the micrometer->OTel bridge is active. + private static final String BRIDGE_RECORDER = "io.quarkus.micrometer.opentelemetry.runtime.MicrometerOtelBridgeBeanCreator"; @BuildStep(onlyIf = { IsLocalDevelopment.class, MicrometerProcessor.MicrometerEnabled.class }) void registerMicrometerMetricsCapture(BuildProducer additionalBeans, From e41dc53138bb0625644e515e55421a48b329e52b Mon Sep 17 00:00:00 2001 From: Matheus Oliveira Date: Sun, 11 Oct 2026 12:43:15 -0300 Subject: [PATCH 127/254] fix: avoid hardcoded dev services port in container reuse tests The DevServices container reuse tests pinned the dev service to a hardcoded host port (55432). A leftover host-level listener on that port (for example a stale docker-proxy) is not released by the container-based cleanup, so the container failed to start with "address already in use", making the tests flaky on CI. Pick a free port at runtime instead and pass it to the sample application via QUARKUS_DATASOURCE_DEVSERVICES_PORT / QUARKUS_SIMPLE_EXTENSION_DEVSERVICES_PORT. * Fix #57045 Signed-off-by: Matheus Oliveira --- .../test/DevServicesContainerReuseTest.java | 38 +++++++++++++------ ...vServicesPostgresqlContainerReuseTest.java | 38 +++++++++++++------ 2 files changed, 54 insertions(+), 22 deletions(-) diff --git a/integration-tests/devservices/tests/src/test/java/io/quarkus/devservices/test/DevServicesContainerReuseTest.java b/integration-tests/devservices/tests/src/test/java/io/quarkus/devservices/test/DevServicesContainerReuseTest.java index 7cd741f9da9d9..d025a9648f5cd 100644 --- a/integration-tests/devservices/tests/src/test/java/io/quarkus/devservices/test/DevServicesContainerReuseTest.java +++ b/integration-tests/devservices/tests/src/test/java/io/quarkus/devservices/test/DevServicesContainerReuseTest.java @@ -3,6 +3,9 @@ import static org.assertj.core.api.Assertions.assertThat; import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.ServerSocket; import java.util.Arrays; import java.util.List; import java.util.Map; @@ -30,19 +33,22 @@ */ class DevServicesContainerReuseTest extends MojoTestBase { - private static final int FIXED_PORT = 55432; + /** + * A free port chosen at runtime to pin the dev service, so both runs target the + * same port. A hardcoded port is racy: a leftover host-level listener (for + * example a stale docker-proxy) is not released by the container-based cleanup + * and causes "address already in use" failures (see #57045). + */ + private static int fixedPort; @BeforeAll - static void ensurePortIsClear() { - String containerId = findContainerOnPort(FIXED_PORT); - if (containerId != null) { - DockerClientFactory.lazyClient().removeContainerCmd(containerId).withForce(true).exec(); - } + static void choosePort() { + fixedPort = findAvailablePort(); } @AfterAll static void removeContainers() { - String containerId = findContainerOnPort(FIXED_PORT); + String containerId = findContainerOnPort(fixedPort); if (containerId != null) { DockerClientFactory.lazyClient().removeContainerCmd(containerId).withForce(true).exec(); } @@ -51,7 +57,9 @@ static void removeContainers() { @Test void testContainerReusedAcrossRuns() throws Exception { List goals = List.of("clean", "test", "-Dquarkus.analytics.disabled=true"); - Map envVars = Map.of("TESTCONTAINERS_REUSE_ENABLE", "true"); + Map envVars = Map.of( + "TESTCONTAINERS_REUSE_ENABLE", "true", + "QUARKUS_SIMPLE_EXTENSION_DEVSERVICES_PORT", Integer.toString(fixedPort)); File testDir = initProject("projects/devservices-container-reuse", "projects/devservices-container-reuse-run"); @@ -62,9 +70,9 @@ void testContainerReusedAcrossRuns() throws Exception { .isZero(); firstRun.stop(); - String firstContainerId = findContainerOnPort(FIXED_PORT); + String firstContainerId = findContainerOnPort(fixedPort); assertThat(firstContainerId) - .as("A container should be running on port %d after the first run", FIXED_PORT) + .as("A container should be running on port %d after the first run", fixedPort) .isNotNull(); RunningInvoker secondRun = new RunningInvoker(testDir, false); @@ -74,12 +82,20 @@ void testContainerReusedAcrossRuns() throws Exception { .isZero(); secondRun.stop(); - String secondContainerId = findContainerOnPort(FIXED_PORT); + String secondContainerId = findContainerOnPort(fixedPort); assertThat(secondContainerId) .as("The same container should be reused across runs (see #53312)") .isEqualTo(firstContainerId); } + private static int findAvailablePort() { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } catch (IOException e) { + throw new UncheckedIOException("Unable to find a free port", e); + } + } + static String findContainerOnPort(int publicPort) { return DockerClientFactory.lazyClient().listContainersCmd().exec().stream() .filter(container -> hasPublicPort(container, publicPort)) diff --git a/integration-tests/jpa-postgresql/src/test/java/io/quarkus/it/jpa/postgresql/DevServicesPostgresqlContainerReuseTest.java b/integration-tests/jpa-postgresql/src/test/java/io/quarkus/it/jpa/postgresql/DevServicesPostgresqlContainerReuseTest.java index 938657d535d32..52c06b19d10ae 100644 --- a/integration-tests/jpa-postgresql/src/test/java/io/quarkus/it/jpa/postgresql/DevServicesPostgresqlContainerReuseTest.java +++ b/integration-tests/jpa-postgresql/src/test/java/io/quarkus/it/jpa/postgresql/DevServicesPostgresqlContainerReuseTest.java @@ -4,6 +4,9 @@ import static org.assertj.core.api.Assertions.assertThat; import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.ServerSocket; import java.util.Arrays; import java.util.List; import java.util.Map; @@ -31,19 +34,22 @@ */ class DevServicesPostgresqlContainerReuseTest extends MojoTestBase { - private static final int FIXED_PORT = 55432; + /** + * A free port chosen at runtime to pin the dev service, so both runs target the + * same port. A hardcoded port is racy: a leftover host-level listener (for + * example a stale docker-proxy) is not released by the container-based cleanup + * and causes "address already in use" failures (see #57045). + */ + private static int fixedPort; @BeforeAll - static void ensurePortIsClear() { - String containerId = findContainerOnPort(FIXED_PORT); - if (containerId != null) { - DockerClientFactory.lazyClient().removeContainerCmd(containerId).withForce(true).exec(); - } + static void choosePort() { + fixedPort = findAvailablePort(); } @AfterAll static void removeContainers() { - String containerId = findContainerOnPort(FIXED_PORT); + String containerId = findContainerOnPort(fixedPort); if (containerId != null) { DockerClientFactory.lazyClient().removeContainerCmd(containerId).withForce(true).exec(); } @@ -55,7 +61,9 @@ void testContainerReusedAcrossRuns() throws Exception { // DevServicesBuildTimeConfig.reuse() defaults to true, so the PostgreSQL // container is created with .withReuse(true). This env var is the // testcontainers-side switch that must also be enabled for reuse to work. - Map envVars = Map.of("TESTCONTAINERS_REUSE_ENABLE", "true"); + Map envVars = Map.of( + "TESTCONTAINERS_REUSE_ENABLE", "true", + "QUARKUS_DATASOURCE_DEVSERVICES_PORT", Integer.toString(fixedPort)); File testDir = initProject("projects/devservices-postgresql-reuse", "projects/devservices-postgresql-reuse-run"); @@ -67,9 +75,9 @@ void testContainerReusedAcrossRuns() throws Exception { .isZero(); firstRun.stop(); - String firstContainerId = findContainerOnPort(FIXED_PORT); + String firstContainerId = findContainerOnPort(fixedPort); assertThat(firstContainerId) - .as("A container should be running on port %d after the first run", FIXED_PORT) + .as("A container should be running on port %d after the first run", fixedPort) .isNotNull(); // Second run — should reuse the container from the first run. @@ -82,12 +90,20 @@ void testContainerReusedAcrossRuns() throws Exception { .isZero(); secondRun.stop(); - String secondContainerId = findContainerOnPort(FIXED_PORT); + String secondContainerId = findContainerOnPort(fixedPort); assertThat(secondContainerId) .as("The same container should be reused across runs (see #53312)") .isEqualTo(firstContainerId); } + private static int findAvailablePort() { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } catch (IOException e) { + throw new UncheckedIOException("Unable to find a free port", e); + } + } + private static String findContainerOnPort(int publicPort) { return DockerClientFactory.lazyClient().listContainersCmd().exec().stream() .filter(container -> hasPublicPort(container, publicPort)) From 0362a13ec19da784ddfd32c6b97ca1c2f43a1b7e Mon Sep 17 00:00:00 2001 From: Christian Munier Date: Sun, 11 Oct 2026 19:17:49 +0200 Subject: [PATCH 128/254] Fix NPE in Undertow exception handling when a 500 error page is mapped In production mode, QuarkusExceptionHandler is not installed when the application maps its own 500 error page, but the security-aware wrapper still delegated to the previously configured handler, which is null. Fall back to LoggingExceptionHandler.DEFAULT, as Undertow's ServletInitialHandler does when no handler is configured. --- .../test/ErrorPageProdModeTestCase.java | 69 +++++++++++++++++++ .../runtime/UndertowDeploymentRecorder.java | 6 +- 2 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 extensions/undertow/deployment/src/test/java/io/quarkus/undertow/test/ErrorPageProdModeTestCase.java diff --git a/extensions/undertow/deployment/src/test/java/io/quarkus/undertow/test/ErrorPageProdModeTestCase.java b/extensions/undertow/deployment/src/test/java/io/quarkus/undertow/test/ErrorPageProdModeTestCase.java new file mode 100644 index 0000000000000..849339abef296 --- /dev/null +++ b/extensions/undertow/deployment/src/test/java/io/quarkus/undertow/test/ErrorPageProdModeTestCase.java @@ -0,0 +1,69 @@ +package io.quarkus.undertow.test; + +import static org.hamcrest.Matchers.is; + +import java.io.IOException; + +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusProdModeTest; +import io.restassured.RestAssured; + +/** + * In prod mode, an application mapping its own 500 error page does not get the Quarkus exception handler, + * so this cannot be tested with {@code QuarkusExtensionTest}, which runs in test mode. + */ +public class ErrorPageProdModeTestCase { + + static final String WEB_XML = "\n" + + "\n" + + "\n" + + " \n" + + " error-page\n" + + " " + ErrorPageServlet.class.getName() + "\n" + + " \n" + + "\n" + + " \n" + + " error-page\n" + + " /error-page\n" + + " \n" + + "\n" + + " \n" + + " 500\n" + + " /error-page\n" + + " \n" + + ""; + + @RegisterExtension + static final QuarkusProdModeTest runner = new QuarkusProdModeTest() + .withApplicationRoot((jar) -> jar + .addClasses(ErrorServlet.class, ErrorPageServlet.class) + .addAsManifestResource(new StringAsset(WEB_XML), "web.xml")) + .setRun(true); + + @Test + public void testMapped500ErrorPage() { + RestAssured.when().get("/error").then() + .statusCode(500) + .body(is("custom error page")); + } + + public static class ErrorPageServlet extends HttpServlet { + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { + resp.getWriter().write("custom error page"); + } + } +} diff --git a/extensions/undertow/runtime/src/main/java/io/quarkus/undertow/runtime/UndertowDeploymentRecorder.java b/extensions/undertow/runtime/src/main/java/io/quarkus/undertow/runtime/UndertowDeploymentRecorder.java index d39d2e4fab92e..0fd2e8da0a6e0 100644 --- a/extensions/undertow/runtime/src/main/java/io/quarkus/undertow/runtime/UndertowDeploymentRecorder.java +++ b/extensions/undertow/runtime/src/main/java/io/quarkus/undertow/runtime/UndertowDeploymentRecorder.java @@ -87,6 +87,7 @@ import io.undertow.servlet.api.InstanceFactory; import io.undertow.servlet.api.InstanceHandle; import io.undertow.servlet.api.ListenerInfo; +import io.undertow.servlet.api.LoggingExceptionHandler; import io.undertow.servlet.api.LoginConfig; import io.undertow.servlet.api.MimeMapping; import io.undertow.servlet.api.SecurityConstraint; @@ -548,7 +549,10 @@ public void release() { }; } }); - ExceptionHandler existing = info.getValue().getExceptionHandler(); + ExceptionHandler configured = info.getValue().getExceptionHandler(); + // no exception handler is set in prod mode if the application maps its own 500 error page, + // fall back to the Undertow default in this case + ExceptionHandler existing = configured != null ? configured : LoggingExceptionHandler.DEFAULT; info.getValue().setExceptionHandler(new ExceptionHandler() { @Override public boolean handleThrowable(HttpServerExchange exchange, ServletRequest request, ServletResponse response, From 43743772142f40b4f75afd9a42f16760d1cb2829 Mon Sep 17 00:00:00 2001 From: jnbdz <237008+jnbdz@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:59:31 -0400 Subject: [PATCH 129/254] Register the Avro startup work as services The static cache clearing in dev and test mode and the class security validator setup now run as services instead of recorder calls. The validator reset that used a shutdown task is a stop handler of the validator service. --- .../avro/deployment/AvroProcessor.java | 29 +++++++++++++------ .../io/quarkus/avro/runtime/AvroRecorder.java | 18 +++++------- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/extensions/avro/deployment/src/main/java/io/quarkus/avro/deployment/AvroProcessor.java b/extensions/avro/deployment/src/main/java/io/quarkus/avro/deployment/AvroProcessor.java index 1bbdec712f21a..5b443800aa364 100644 --- a/extensions/avro/deployment/src/main/java/io/quarkus/avro/deployment/AvroProcessor.java +++ b/extensions/avro/deployment/src/main/java/io/quarkus/avro/deployment/AvroProcessor.java @@ -16,13 +16,14 @@ import io.quarkus.avro.runtime.AvroRecorder; import io.quarkus.avro.runtime.jackson.SpecificRecordBaseSerializer; import io.quarkus.avro.spi.AvroTrustedClassBuildItem; +import io.quarkus.core.Phase; +import io.quarkus.core.deployment.service.ServiceRegistrar; import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; -import io.quarkus.deployment.annotations.ExecutionTime; -import io.quarkus.deployment.annotations.Record; +import io.quarkus.deployment.annotations.Produce; import io.quarkus.deployment.builditem.CombinedIndexBuildItem; import io.quarkus.deployment.builditem.LaunchModeBuildItem; -import io.quarkus.deployment.builditem.ShutdownContextBuildItem; +import io.quarkus.deployment.builditem.ServiceStartBuildItem; import io.quarkus.deployment.builditem.nativeimage.NativeImageConfigBuildItem; import io.quarkus.deployment.builditem.nativeimage.NativeImageSystemPropertyBuildItem; import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; @@ -31,10 +32,12 @@ public class AvroProcessor { @BuildStep - @Record(ExecutionTime.STATIC_INIT) - void clearCaches(AvroRecorder recorder, LaunchModeBuildItem launchModeBuildItem) { + void clearCaches(ServiceRegistrar serviceRegistrar, LaunchModeBuildItem launchModeBuildItem) { if (launchModeBuildItem.getLaunchMode().isDevOrTest()) { - recorder.clearStaticCaches(); + serviceRegistrar + .forService("io.quarkus.avro.clear-static-caches") + .atPhase(Phase.STATIC_INIT) + .onStart(ctx -> AvroRecorder.clearStaticCaches()); } } @@ -54,16 +57,24 @@ void trustAvroGeneratedClasses(CombinedIndexBuildItem indexBuildItem, } } + @Produce(ServiceStartBuildItem.class) @BuildStep - @Record(ExecutionTime.RUNTIME_INIT) void setupClassSecurityValidator(List trustedClassItems, AvroBuildTimeConfig config, - ShutdownContextBuildItem shutdownContext, AvroRecorder recorder) { + ServiceRegistrar serviceRegistrar) { Set trustedClasses = new TreeSet<>(); for (AvroTrustedClassBuildItem item : trustedClassItems) { trustedClasses.addAll(item.getClassNames()); } trustedClasses.addAll(config.trustedClasses().orElse(List.of())); - recorder.setupClassSecurityValidator(shutdownContext, trustedClasses, config.trustedPackages().orElse(List.of())); + Set classes = Set.copyOf(trustedClasses); + List packages = List.copyOf(config.trustedPackages().orElse(List.of())); + serviceRegistrar + .forService("io.quarkus.avro.class-security-validator") + .atPhase(Phase.INIT) + .onStart(ctx -> { + AvroRecorder.setupClassSecurityValidator(classes, packages); + ctx.onStop(AvroRecorder::resetClassSecurityValidator); + }); } @BuildStep diff --git a/extensions/avro/runtime/src/main/java/io/quarkus/avro/runtime/AvroRecorder.java b/extensions/avro/runtime/src/main/java/io/quarkus/avro/runtime/AvroRecorder.java index 20333130b0dbb..d375ce916b9fd 100644 --- a/extensions/avro/runtime/src/main/java/io/quarkus/avro/runtime/AvroRecorder.java +++ b/extensions/avro/runtime/src/main/java/io/quarkus/avro/runtime/AvroRecorder.java @@ -9,10 +9,6 @@ import org.apache.avro.util.ClassSecurityValidator; import org.jboss.logging.Logger; -import io.quarkus.runtime.ShutdownContext; -import io.quarkus.runtime.annotations.Recorder; - -@Recorder public class AvroRecorder { private static final Logger log = Logger.getLogger(AvroRecorder.class); @@ -30,21 +26,23 @@ public class AvroRecorder { * {@code org.apache.avro.SERIALIZABLE_CLASSES}/{@code org.apache.avro.SERIALIZABLE_PACKAGES} system properties keep * working as an additional escape hatch. *

- * A shutdown task restores the default validator to prevent continuous nesting of validators during reloads in dev mode. + * {@link #resetClassSecurityValidator()} restores the default validator on shutdown to prevent continuous nesting of + * validators during reloads in dev mode. * - * @param shutdownContext used to register the validator reset on shutdown * @param trustedClasses fully qualified names of the trusted classes * @param trustedPackages package names whose classes are trusted */ - public void setupClassSecurityValidator(ShutdownContext shutdownContext, Set trustedClasses, - List trustedPackages) { + public static void setupClassSecurityValidator(Set trustedClasses, List trustedPackages) { QuarkusAvroClassSecurityPredicate predicate = new QuarkusAvroClassSecurityPredicate(trustedClasses, trustedPackages); ClassSecurityValidator.setGlobal(ClassSecurityValidator.composite(predicate, ClassSecurityValidator.getGlobal())); - shutdownContext.addShutdownTask(() -> ClassSecurityValidator.setGlobal(ClassSecurityValidator.DEFAULT)); } - public void clearStaticCaches() { + public static void resetClassSecurityValidator() { + ClassSecurityValidator.setGlobal(ClassSecurityValidator.DEFAULT); + } + + public static void clearStaticCaches() { try { Field instanceField = SpecificData.class.getDeclaredField("INSTANCE"); instanceField.setAccessible(true); From 3a0c457b18fa8adf506b1894b685456e3204558f Mon Sep 17 00:00:00 2001 From: jnbdz <237008+jnbdz@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:16:02 -0400 Subject: [PATCH 130/254] Create the ObservationRegistry bean without a recorder The ObservationRegistry synthetic bean is created by a BeanCreator that receives the tracing flag as a bean parameter, and the print-out handler registration runs as a service that depends on the bean container and the runtime config, instead of recorder calls. --- .../deployment/ObservationProcessor.java | 29 ++++---- .../deployment/ObservationPrintOutTest.java | 34 ++++++++++ .../observation/ObservationRecorder.java | 68 +------------------ .../ObservationRegistryCreator.java | 58 ++++++++++++++++ 4 files changed, 111 insertions(+), 78 deletions(-) create mode 100644 extensions/observation/deployment/src/test/java/io/quarkus/observation/opentelemetry/deployment/ObservationPrintOutTest.java create mode 100644 extensions/observation/runtime/src/main/java/io/quarkus/observation/ObservationRegistryCreator.java diff --git a/extensions/observation/deployment/src/main/java/io/quarkus/observation/deployment/ObservationProcessor.java b/extensions/observation/deployment/src/main/java/io/quarkus/observation/deployment/ObservationProcessor.java index 00301a50b4a45..58056f762314b 100644 --- a/extensions/observation/deployment/src/main/java/io/quarkus/observation/deployment/ObservationProcessor.java +++ b/extensions/observation/deployment/src/main/java/io/quarkus/observation/deployment/ObservationProcessor.java @@ -28,24 +28,26 @@ import io.micrometer.observation.annotation.Observed; import io.quarkus.arc.deployment.AdditionalBeanBuildItem; import io.quarkus.arc.deployment.AnnotationsTransformerBuildItem; -import io.quarkus.arc.deployment.BeanContainerBuildItem; import io.quarkus.arc.deployment.InterceptorBindingRegistrarBuildItem; import io.quarkus.arc.deployment.OpenTelemetrySdkBuildItem; import io.quarkus.arc.deployment.SyntheticBeanBuildItem; import io.quarkus.arc.deployment.SyntheticBeansRuntimeInitBuildItem; import io.quarkus.arc.deployment.UnremovableBeanBuildItem; import io.quarkus.arc.processor.InterceptorBindingRegistrar; +import io.quarkus.arc.runtime.BeanContainer; +import io.quarkus.core.Phase; +import io.quarkus.core.deployment.service.ServiceRegistrar; import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; import io.quarkus.deployment.annotations.BuildSteps; import io.quarkus.deployment.annotations.Consume; -import io.quarkus.deployment.annotations.ExecutionTime; -import io.quarkus.deployment.annotations.Record; import io.quarkus.deployment.builditem.FeatureBuildItem; import io.quarkus.observation.ObservationRecorder; +import io.quarkus.observation.ObservationRegistryCreator; import io.quarkus.observation.cdi.ObservedInterceptor; import io.quarkus.observation.cdi.convention.ObservedInterceptorConvention; import io.quarkus.observation.config.ObservationBuildConfig; +import io.quarkus.observation.config.ObservationRuntimeConfig; import io.quarkus.observation.opentelemetry.handler.OpenTelemetryObservationHandler; import io.quarkus.observation.opentelemetry.handler.PropagatingReceiverTracingObservationHandler; import io.quarkus.observation.opentelemetry.handler.PropagatingSenderTracingObservationHandler; @@ -235,12 +237,8 @@ UnremovableBeanBuildItem ensureUserExtensionClassesRetained() { /** * Create the Observation Registry after all handlers and user extension classes are available. */ - @SuppressWarnings("removal") @BuildStep - @Record(ExecutionTime.RUNTIME_INIT) - SyntheticBeanBuildItem observationRegistryBean( - ObservationRecorder recorder, - Optional openTelemetrySdk) { + SyntheticBeanBuildItem observationRegistryBean(Optional openTelemetrySdk) { boolean tracingEnabled = openTelemetrySdk.isPresent() && openTelemetrySdk.get().isTracingBuildTimeEnabled(); var builder = SyntheticBeanBuildItem.configure(ObservationRegistry.class) @@ -254,14 +252,21 @@ SyntheticBeanBuildItem observationRegistryBean( .addInjectionPoint(ClassType.create(PROPAGATING_SENDER_DOT_NAME)) .addInjectionPoint(ClassType.create(OBSERVATION_OPENTELEMETRY_DOT_NAME)); } - return builder.createWith(recorder.createObservationRegistry(tracingEnabled)).done(); + return builder.creator(ObservationRegistryCreator.class) + .param(ObservationRegistryCreator.HAS_OTEL_TRACING, tracingEnabled) + .done(); } @BuildStep - @Record(ExecutionTime.RUNTIME_INIT) @Consume(SyntheticBeansRuntimeInitBuildItem.class) - void registerPrintOutHandler(ObservationRecorder recorder, BeanContainerBuildItem beanContainer) { - recorder.registerPrintOutHandler(beanContainer.getValue()); + void registerPrintOutHandler(ServiceRegistrar serviceRegistrar) { + serviceRegistrar + .forService("io.quarkus.observation.print-out-handler") + .atPhase(Phase.APPLICATION) + .require(BeanContainer.class) + .require(ObservationRuntimeConfig.class) + .afterBuildItem(SyntheticBeansRuntimeInitBuildItem.class) + .onStart((ctx, beanContainer, config) -> ObservationRecorder.registerPrintOutHandler(beanContainer, config)); } @BuildStep diff --git a/extensions/observation/deployment/src/test/java/io/quarkus/observation/opentelemetry/deployment/ObservationPrintOutTest.java b/extensions/observation/deployment/src/test/java/io/quarkus/observation/opentelemetry/deployment/ObservationPrintOutTest.java new file mode 100644 index 0000000000000..dfcce0b14eca1 --- /dev/null +++ b/extensions/observation/deployment/src/test/java/io/quarkus/observation/opentelemetry/deployment/ObservationPrintOutTest.java @@ -0,0 +1,34 @@ +package io.quarkus.observation.opentelemetry.deployment; + +import static org.assertj.core.api.Assertions.assertThat; + +import jakarta.inject.Inject; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationRegistry; +import io.quarkus.observation.handler.PrintOutHandler; +import io.quarkus.test.QuarkusExtensionTest; + +public class ObservationPrintOutTest { + + @RegisterExtension + static final QuarkusExtensionTest TEST = new QuarkusExtensionTest() + .overrideConfigKey("quarkus.observation.print-out", "true") + .overrideConfigKey("quarkus.otel.traces.exporter", "none") + .overrideConfigKey("quarkus.otel.metrics.exporter", "none") + .setLogRecordPredicate(record -> PrintOutHandler.class.getName().equals(record.getLoggerName())) + .assertLogRecords(records -> assertThat(records) + .as("print-out handler records") + .anyMatch(record -> record.getMessage().startsWith("START"))); + + @Inject + ObservationRegistry registry; + + @Test + public void testObservationIsPrintedOut() { + Observation.start("print-out-test", registry).stop(); + } +} diff --git a/extensions/observation/runtime/src/main/java/io/quarkus/observation/ObservationRecorder.java b/extensions/observation/runtime/src/main/java/io/quarkus/observation/ObservationRecorder.java index 9fc9bfbce3567..cfb1c3e6a045a 100644 --- a/extensions/observation/runtime/src/main/java/io/quarkus/observation/ObservationRecorder.java +++ b/extensions/observation/runtime/src/main/java/io/quarkus/observation/ObservationRecorder.java @@ -1,78 +1,14 @@ package io.quarkus.observation; -import java.util.List; -import java.util.function.Function; - -import io.micrometer.observation.GlobalObservationConvention; -import io.micrometer.observation.Observation; -import io.micrometer.observation.ObservationFilter; -import io.micrometer.observation.ObservationHandler; -import io.micrometer.observation.ObservationHandler.FirstMatchingCompositeObservationHandler; -import io.micrometer.observation.ObservationPredicate; import io.micrometer.observation.ObservationRegistry; -import io.quarkus.arc.Arc; -import io.quarkus.arc.ArcContainer; -import io.quarkus.arc.InstanceHandle; -import io.quarkus.arc.SyntheticCreationalContext; import io.quarkus.arc.runtime.BeanContainer; import io.quarkus.observation.config.ObservationRuntimeConfig; import io.quarkus.observation.handler.PrintOutHandler; -import io.quarkus.observation.opentelemetry.handler.OpenTelemetryObservationHandler; -import io.quarkus.observation.opentelemetry.handler.PropagatingReceiverTracingObservationHandler; -import io.quarkus.observation.opentelemetry.handler.PropagatingSenderTracingObservationHandler; -import io.quarkus.observation.opentelemetry.handler.TracingAwareMeterObservationHandler; -import io.quarkus.observation.propagation.QuarkusObservationRegistry; -import io.quarkus.runtime.RuntimeValue; -import io.quarkus.runtime.annotations.Recorder; -@Recorder public class ObservationRecorder { - private final RuntimeValue runtimeConfig; - - public ObservationRecorder(RuntimeValue runtimeConfig) { - this.runtimeConfig = runtimeConfig; - } - - public Function, ObservationRegistry> createObservationRegistry( - boolean hasOtelTracing) { - return new Function<>() { - @SuppressWarnings("rawtypes") - @Override - public ObservationRegistry apply(SyntheticCreationalContext context) { - ObservationRegistry registry = new QuarkusObservationRegistry(); - - if (hasOtelTracing) { - List> tracingHandlers = List.of( - context.getInjectedReference(PropagatingReceiverTracingObservationHandler.class), - context.getInjectedReference(PropagatingSenderTracingObservationHandler.class), - context.getInjectedReference(OpenTelemetryObservationHandler.class)); - registry.observationConfig().observationHandler( - new FirstMatchingCompositeObservationHandler(tracingHandlers)); - } - - registry.observationConfig().observationHandler( - context.getInjectedReference(TracingAwareMeterObservationHandler.class)); - - ArcContainer container = Arc.container(); - for (InstanceHandle handle : container.listAll(ObservationFilter.class)) { - registry.observationConfig().observationFilter(handle.get()); - } - for (InstanceHandle handle : container.listAll(ObservationPredicate.class)) { - registry.observationConfig().observationPredicate(handle.get()); - } - for (InstanceHandle handle : container - .listAll(GlobalObservationConvention.class)) { - registry.observationConfig().observationConvention(handle.get()); - } - - return registry; - } - }; - } - - public void registerPrintOutHandler(BeanContainer beanContainer) { - if (runtimeConfig.getValue().printOut()) { + public static void registerPrintOutHandler(BeanContainer beanContainer, ObservationRuntimeConfig runtimeConfig) { + if (runtimeConfig.printOut()) { ObservationRegistry registry = beanContainer.beanInstance(ObservationRegistry.class); registry.observationConfig().observationHandler(new PrintOutHandler()); } diff --git a/extensions/observation/runtime/src/main/java/io/quarkus/observation/ObservationRegistryCreator.java b/extensions/observation/runtime/src/main/java/io/quarkus/observation/ObservationRegistryCreator.java new file mode 100644 index 0000000000000..55260c6ec4a8f --- /dev/null +++ b/extensions/observation/runtime/src/main/java/io/quarkus/observation/ObservationRegistryCreator.java @@ -0,0 +1,58 @@ +package io.quarkus.observation; + +import java.util.List; + +import io.micrometer.observation.GlobalObservationConvention; +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationFilter; +import io.micrometer.observation.ObservationHandler; +import io.micrometer.observation.ObservationHandler.FirstMatchingCompositeObservationHandler; +import io.micrometer.observation.ObservationPredicate; +import io.micrometer.observation.ObservationRegistry; +import io.quarkus.arc.Arc; +import io.quarkus.arc.ArcContainer; +import io.quarkus.arc.BeanCreator; +import io.quarkus.arc.InstanceHandle; +import io.quarkus.arc.SyntheticCreationalContext; +import io.quarkus.observation.opentelemetry.handler.OpenTelemetryObservationHandler; +import io.quarkus.observation.opentelemetry.handler.PropagatingReceiverTracingObservationHandler; +import io.quarkus.observation.opentelemetry.handler.PropagatingSenderTracingObservationHandler; +import io.quarkus.observation.opentelemetry.handler.TracingAwareMeterObservationHandler; +import io.quarkus.observation.propagation.QuarkusObservationRegistry; + +public class ObservationRegistryCreator implements BeanCreator { + + public static final String HAS_OTEL_TRACING = "hasOtelTracing"; + + @SuppressWarnings("rawtypes") + @Override + public ObservationRegistry create(SyntheticCreationalContext context) { + ObservationRegistry registry = new QuarkusObservationRegistry(); + + if ((boolean) context.getParams().get(HAS_OTEL_TRACING)) { + List> tracingHandlers = List.of( + context.getInjectedReference(PropagatingReceiverTracingObservationHandler.class), + context.getInjectedReference(PropagatingSenderTracingObservationHandler.class), + context.getInjectedReference(OpenTelemetryObservationHandler.class)); + registry.observationConfig().observationHandler( + new FirstMatchingCompositeObservationHandler(tracingHandlers)); + } + + registry.observationConfig().observationHandler( + context.getInjectedReference(TracingAwareMeterObservationHandler.class)); + + ArcContainer container = Arc.container(); + for (InstanceHandle handle : container.listAll(ObservationFilter.class)) { + registry.observationConfig().observationFilter(handle.get()); + } + for (InstanceHandle handle : container.listAll(ObservationPredicate.class)) { + registry.observationConfig().observationPredicate(handle.get()); + } + for (InstanceHandle handle : container + .listAll(GlobalObservationConvention.class)) { + registry.observationConfig().observationConvention(handle.get()); + } + + return registry; + } +} From 375bc53f2c9625a6cf4477402663df7c446fcf67 Mon Sep 17 00:00:00 2001 From: Matheus Oliveira Date: Sun, 11 Oct 2026 19:20:21 -0300 Subject: [PATCH 131/254] fix(rest): return 400 for malformed multipart body instead of 500 Malformed multipart bodies (header without colon, boundary in content) were throwing a message-less MalformedMessageException that surfaced as HTTP 500. Now properly translated to 400 Bad Request per RFC 7578. Fixes #57346 Signed-off-by: Matheus Oliveira --- .../multipart/MalformedMultipartBodyTest.java | 79 +++++++++++++++++++ .../reactive/common/util/MultipartParser.java | 6 ++ .../multipart/MultiPartParserDefinition.java | 5 ++ 3 files changed, 90 insertions(+) create mode 100644 extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/multipart/MalformedMultipartBodyTest.java diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/multipart/MalformedMultipartBodyTest.java b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/multipart/MalformedMultipartBodyTest.java new file mode 100644 index 0000000000000..28cd71ada341b --- /dev/null +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/multipart/MalformedMultipartBodyTest.java @@ -0,0 +1,79 @@ +package io.quarkus.resteasy.reactive.server.test.multipart; + +import static io.restassured.RestAssured.given; + +import java.nio.charset.StandardCharsets; +import java.util.function.Supplier; + +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +import org.jboss.resteasy.reactive.RestForm; +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusExtensionTest; + +public class MalformedMultipartBodyTest { + + @RegisterExtension + static QuarkusExtensionTest test = new QuarkusExtensionTest() + .setArchiveProducer(new Supplier<>() { + @Override + public JavaArchive get() { + return ShrinkWrap.create(JavaArchive.class) + .addClasses(MalformedMultipartResource.class); + } + + }); + + @Test + public void testMalformedMultipartHeaderNoColon() { + String boundary = "----WebKitFormBoundary0x1234"; + byte[] body = ("--" + boundary + "\r\n" + + "JustNoColonHere\r\n" + + "--" + boundary + "--\r\n").getBytes(StandardCharsets.US_ASCII); + + given() + .contentType("multipart/form-data; boundary=" + boundary) + .body(body) + .when() + .post("/upload") + .then() + .statusCode(400); + } + + @Test + public void testMalformedMultipartBoundaryInContent() { + String boundary = "----WebKitFormBoundary0x1234"; + byte[] body = ("--" + boundary + "\r\n" + + "Content-Disposition: form-data; name=\"file\"; filename=\"a.bin\"\r\n" + + "Content-Type: application/octet-stream\r\n\r\n" + + "hello\r\n--" + boundary + "Xworld\r\n" + + "--" + boundary + "--\r\n").getBytes(StandardCharsets.US_ASCII); + + given() + .contentType("multipart/form-data; boundary=" + boundary) + .body(body) + .when() + .post("/upload") + .then() + .statusCode(400); + } + + @Path("/upload") + public static class MalformedMultipartResource { + + @POST + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Produces(MediaType.TEXT_PLAIN) + public String upload(@RestForm String dummy) { + return "ok"; + } + } +} diff --git a/independent-projects/resteasy-reactive/common/runtime/src/main/java/org/jboss/resteasy/reactive/common/util/MultipartParser.java b/independent-projects/resteasy-reactive/common/runtime/src/main/java/org/jboss/resteasy/reactive/common/util/MultipartParser.java index 8f77fb8e3056d..04d9c4300a5f0 100644 --- a/independent-projects/resteasy-reactive/common/runtime/src/main/java/org/jboss/resteasy/reactive/common/util/MultipartParser.java +++ b/independent-projects/resteasy-reactive/common/runtime/src/main/java/org/jboss/resteasy/reactive/common/util/MultipartParser.java @@ -461,9 +461,15 @@ public void handle(final PartHandler handler, final ByteBuffer rawData) throws I } public static class MalformedMessageException extends RuntimeException { + public MalformedMessageException() { + super("Malformed multipart message"); + } } public static class HeaderTooLargeException extends RuntimeException { + public HeaderTooLargeException() { + super("Multipart header too large"); + } } } diff --git a/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/multipart/MultiPartParserDefinition.java b/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/multipart/MultiPartParserDefinition.java index 4133dc006a6c2..50fd9e758beb9 100644 --- a/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/multipart/MultiPartParserDefinition.java +++ b/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/multipart/MultiPartParserDefinition.java @@ -271,6 +271,8 @@ public FormData parseBlocking() throws Exception { parser.parse(ByteBuffer.wrap(buf, 0, c)); } catch (MultipartParser.HeaderTooLargeException e) { throw new WebApplicationException(Response.Status.REQUEST_ENTITY_TOO_LARGE); + } catch (MultipartParser.MalformedMessageException e) { + throw new WebApplicationException(Response.Status.BAD_REQUEST); } } if (!parser.isComplete()) { @@ -471,6 +473,9 @@ public void run() { } catch (MultipartParser.HeaderTooLargeException e) { exchange.resume( new WebApplicationException(Response.Status.REQUEST_ENTITY_TOO_LARGE)); + } catch (MultipartParser.MalformedMessageException e) { + exchange.resume( + new WebApplicationException(Response.Status.BAD_REQUEST)); } catch (Throwable t) { exchange.resume(t); } From 55dc60dfe8f961c64018f67e6df03c51ccbdc35a Mon Sep 17 00:00:00 2001 From: jnbdz <237008+jnbdz@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:46:38 -0400 Subject: [PATCH 132/254] Register the Narayana LRA startup work as services The coordinator configuration is copied to system properties by a service that requires LRAConfiguration, and the participant registry is created by a static-init service from the participant class names. NarayanaLRARecorder keeps its name with static methods and no longer has @Recorder. --- .../lra/deployment/NarayanaLRAProcessor.java | 25 +++++++++++-------- .../lra/runtime/NarayanaLRARecorder.java | 14 ++--------- 2 files changed, 17 insertions(+), 22 deletions(-) diff --git a/extensions/narayana-lra/deployment/src/main/java/io/quarkus/narayana/lra/deployment/NarayanaLRAProcessor.java b/extensions/narayana-lra/deployment/src/main/java/io/quarkus/narayana/lra/deployment/NarayanaLRAProcessor.java index ed492b78b5de8..b7818d6ea52f7 100644 --- a/extensions/narayana-lra/deployment/src/main/java/io/quarkus/narayana/lra/deployment/NarayanaLRAProcessor.java +++ b/extensions/narayana-lra/deployment/src/main/java/io/quarkus/narayana/lra/deployment/NarayanaLRAProcessor.java @@ -1,8 +1,5 @@ package io.quarkus.narayana.lra.deployment; -import static io.quarkus.deployment.annotations.ExecutionTime.RUNTIME_INIT; -import static io.quarkus.deployment.annotations.ExecutionTime.STATIC_INIT; - import java.lang.reflect.Modifier; import java.util.ArrayList; import java.util.Collection; @@ -20,13 +17,15 @@ import io.narayana.lra.client.internal.proxy.nonjaxrs.jandex.DotNames; import io.quarkus.arc.deployment.AdditionalBeanBuildItem; import io.quarkus.arc.deployment.BeanArchiveIndexBuildItem; +import io.quarkus.core.Phase; +import io.quarkus.core.deployment.service.ServiceRegistrar; import io.quarkus.deployment.Capabilities; import io.quarkus.deployment.Capability; import io.quarkus.deployment.Feature; import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; -import io.quarkus.deployment.annotations.Record; import io.quarkus.deployment.builditem.FeatureBuildItem; +import io.quarkus.narayana.lra.runtime.LRAConfiguration; import io.quarkus.narayana.lra.runtime.NarayanaLRAProducers; import io.quarkus.narayana.lra.runtime.NarayanaLRARecorder; import io.quarkus.smallrye.openapi.deployment.spi.AddToOpenAPIDefinitionBuildItem; @@ -54,14 +53,16 @@ void registerFeature(BuildProducer feature, Capabilities capab } @BuildStep - @Record(RUNTIME_INIT) - public void build(NarayanaLRARecorder recorder) { - recorder.setConfig(); + public void build(ServiceRegistrar serviceRegistrar) { + serviceRegistrar + .forService("io.quarkus.narayana.lra.config") + .atPhase(Phase.INIT) + .require(LRAConfiguration.class) + .onStart((ctx, config) -> NarayanaLRARecorder.setConfig(config)); } @BuildStep() - @Record(STATIC_INIT) - void createLRAParticipantRegistry(NarayanaLRARecorder recorder, + void createLRAParticipantRegistry(ServiceRegistrar serviceRegistrar, BeanArchiveIndexBuildItem beanArchiveIndex) { final List classNames = new ArrayList<>(); @@ -90,7 +91,11 @@ void createLRAParticipantRegistry(NarayanaLRARecorder recorder, classNames.add(classInfo.toString()); } - recorder.setParticipantTypes(classNames); + List participantClassNames = List.copyOf(classNames); + serviceRegistrar + .forService("io.quarkus.narayana.lra.participant-registry") + .atPhase(Phase.STATIC_INIT) + .onStart(ctx -> NarayanaLRARecorder.setParticipantTypes(participantClassNames)); } private boolean isLRAParticipant(IndexView index, ClassInfo classInfo) { diff --git a/extensions/narayana-lra/runtime/src/main/java/io/quarkus/narayana/lra/runtime/NarayanaLRARecorder.java b/extensions/narayana-lra/runtime/src/main/java/io/quarkus/narayana/lra/runtime/NarayanaLRARecorder.java index 1d3b6676dffe6..de6bbe7c42b14 100644 --- a/extensions/narayana-lra/runtime/src/main/java/io/quarkus/narayana/lra/runtime/NarayanaLRARecorder.java +++ b/extensions/narayana-lra/runtime/src/main/java/io/quarkus/narayana/lra/runtime/NarayanaLRARecorder.java @@ -10,23 +10,13 @@ import io.narayana.lra.client.NarayanaLRAClient; import io.narayana.lra.client.internal.proxy.nonjaxrs.LRAParticipant; import io.narayana.lra.client.internal.proxy.nonjaxrs.LRAParticipantRegistry; -import io.quarkus.runtime.RuntimeValue; -import io.quarkus.runtime.annotations.Recorder; -@Recorder public class NarayanaLRARecorder { private static final Logger log = Logger.getLogger(NarayanaLRARecorder.class); static LRAParticipantRegistry registry; - private final RuntimeValue runtimeConfig; - - public NarayanaLRARecorder(final RuntimeValue runtimeConfig) { - this.runtimeConfig = runtimeConfig; - } - - public void setConfig() { - LRAConfiguration lraConfig = runtimeConfig.getValue(); + public static void setConfig(LRAConfiguration lraConfig) { // once this is unified in LRA project, we can move this to RelocateConfigSourceInterceptor setValue(NarayanaLRAClient.LRA_COORDINATOR_URL_KEY, lraConfig.coordinatorURL()); if (lraConfig.baseUri().isPresent()) { @@ -40,7 +30,7 @@ private static void setValue(String key, String value) { } } - public void setParticipantTypes(List classNames) { + public static void setParticipantTypes(List classNames) { Map nonJaxParticipants = new HashMap<>(); for (String className : classNames) { From d8d1487de22132b31955688de1f03ff671baea05 Mon Sep 17 00:00:00 2001 From: jnbdz <237008+jnbdz@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:46:38 -0400 Subject: [PATCH 133/254] Register the Kafka client startup work as services Loading the Snappy native library and the bootstrap servers check for service binding now run as services. The Snappy step produces a ServiceStartBuildItem so the library is loaded before StartupEvent. KafkaRecorder and SnappyRecorder keep their names with static methods and no longer have @Recorder. --- .../client/deployment/KafkaProcessor.java | 28 +++++++++++-------- .../kafka/client/runtime/KafkaRecorder.java | 5 +--- .../kafka/client/runtime/SnappyRecorder.java | 4 +-- 3 files changed, 18 insertions(+), 19 deletions(-) diff --git a/extensions/kafka-client/deployment/src/main/java/io/quarkus/kafka/client/deployment/KafkaProcessor.java b/extensions/kafka-client/deployment/src/main/java/io/quarkus/kafka/client/deployment/KafkaProcessor.java index 22d3abba27b1d..a2b50af81e908 100644 --- a/extensions/kafka-client/deployment/src/main/java/io/quarkus/kafka/client/deployment/KafkaProcessor.java +++ b/extensions/kafka-client/deployment/src/main/java/io/quarkus/kafka/client/deployment/KafkaProcessor.java @@ -44,6 +44,8 @@ import io.quarkus.arc.deployment.AdditionalBeanBuildItem.Builder; import io.quarkus.arc.deployment.UnremovableBeanBuildItem; import io.quarkus.bootstrap.classloading.QuarkusClassLoader; +import io.quarkus.core.Phase; +import io.quarkus.core.deployment.service.ServiceRegistrar; import io.quarkus.deployment.Capabilities; import io.quarkus.deployment.Capability; import io.quarkus.deployment.Feature; @@ -51,8 +53,6 @@ import io.quarkus.deployment.IsProduction; import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; -import io.quarkus.deployment.annotations.ExecutionTime; -import io.quarkus.deployment.annotations.Record; import io.quarkus.deployment.builditem.AdditionalIndexedClassesBuildItem; import io.quarkus.deployment.builditem.BytecodeTransformerBuildItem; import io.quarkus.deployment.builditem.CombinedIndexBuildItem; @@ -65,6 +65,7 @@ import io.quarkus.deployment.builditem.ModuleEnableNativeAccessBuildItem; import io.quarkus.deployment.builditem.NativeImageFeatureBuildItem; import io.quarkus.deployment.builditem.RunTimeConfigurationDefaultBuildItem; +import io.quarkus.deployment.builditem.ServiceStartBuildItem; import io.quarkus.deployment.builditem.nativeimage.NativeImageConfigBuildItem; import io.quarkus.deployment.builditem.nativeimage.NativeImageProxyDefinitionBuildItem; import io.quarkus.deployment.builditem.nativeimage.NativeImageResourceBundleBuildItem; @@ -345,13 +346,14 @@ public void handleSnappyReflection(BuildProducer refle } @BuildStep(onlyIf = HasSnappy.class) - @Record(ExecutionTime.RUNTIME_INIT) - void loadSnappyIfEnabled(LaunchModeBuildItem launch, SnappyRecorder recorder, KafkaBuildTimeConfig config) { - boolean loadFromSharedClassLoader = false; - if (launch.isTest()) { - loadFromSharedClassLoader = config.snappyLoadFromSharedClassLoader(); - } - recorder.loadSnappy(loadFromSharedClassLoader); + ServiceStartBuildItem loadSnappyIfEnabled(LaunchModeBuildItem launch, ServiceRegistrar serviceRegistrar, + KafkaBuildTimeConfig config) { + boolean loadFromSharedClassLoader = launch.isTest() && config.snappyLoadFromSharedClassLoader(); + serviceRegistrar + .forService("io.quarkus.kafka.client.load-snappy") + .atPhase(Phase.INIT) + .onStart(ctx -> SnappyRecorder.loadSnappy(loadFromSharedClassLoader)); + return new ServiceStartBuildItem("kafka-snappy"); } @BuildStep(onlyIf = HasSnappy.class) @@ -360,10 +362,12 @@ ModuleEnableNativeAccessBuildItem snappyEnableNativeAccess() { } @BuildStep(onlyIf = IsProduction.class) - @Record(ExecutionTime.RUNTIME_INIT) - void checkBoostrapServers(KafkaRecorder recorder, Capabilities capabilities) { + void checkBoostrapServers(ServiceRegistrar serviceRegistrar, Capabilities capabilities) { if (capabilities.isPresent(Capability.KUBERNETES_SERVICE_BINDING)) { - recorder.checkBoostrapServers(); + serviceRegistrar + .forService("io.quarkus.kafka.client.check-bootstrap-servers") + .atPhase(Phase.INIT) + .onStart(ctx -> KafkaRecorder.checkBoostrapServers()); } } diff --git a/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/KafkaRecorder.java b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/KafkaRecorder.java index 8520c003c0170..9717f8966bb26 100644 --- a/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/KafkaRecorder.java +++ b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/KafkaRecorder.java @@ -5,12 +5,9 @@ import org.eclipse.microprofile.config.Config; import org.eclipse.microprofile.config.ConfigProvider; -import io.quarkus.runtime.annotations.Recorder; - -@Recorder public class KafkaRecorder { - public void checkBoostrapServers() { + public static void checkBoostrapServers() { Config config = ConfigProvider.getConfig(); Boolean serviceBindingEnabled = config.getValue("quarkus.kubernetes-service-binding.enabled", Boolean.class); if (!serviceBindingEnabled) { diff --git a/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/SnappyRecorder.java b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/SnappyRecorder.java index 68107576d82b3..e025aea714994 100644 --- a/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/SnappyRecorder.java +++ b/extensions/kafka-client/runtime/src/main/java/io/quarkus/kafka/client/runtime/SnappyRecorder.java @@ -10,12 +10,10 @@ import org.xerial.snappy.OSInfo; import io.quarkus.runtime.Application; -import io.quarkus.runtime.annotations.Recorder; -@Recorder public class SnappyRecorder { - public void loadSnappy(boolean loadFromSharedClassLoader) { + public static void loadSnappy(boolean loadFromSharedClassLoader) { if (loadFromSharedClassLoader) { try { Application.class.getClassLoader().loadClass(SnappyLoader.class.getName()); From 6916b346cca485f5def8a7abefb3111022bc6a5d Mon Sep 17 00:00:00 2001 From: jnbdz <237008+jnbdz@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:46:39 -0400 Subject: [PATCH 134/254] Create the Kafka Streams support bean without a recorder KafkaStreamsSupport is now the value of a static-init service, built from the Kafka Streams properties captured as an immutable map, and the RocksDB library is loaded by a service ordered before StartupEvent. KafkaStreamsRecorder keeps its name with static methods and no longer has @Recorder. --- .../deployment/KafkaStreamsProcessor.java | 28 +++++++++++++------ .../streams/runtime/KafkaStreamsRecorder.java | 18 ++++-------- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/extensions/kafka-streams/deployment/src/main/java/io/quarkus/kafka/streams/deployment/KafkaStreamsProcessor.java b/extensions/kafka-streams/deployment/src/main/java/io/quarkus/kafka/streams/deployment/KafkaStreamsProcessor.java index 9941bbb45feaf..502ee7dc5c1df 100644 --- a/extensions/kafka-streams/deployment/src/main/java/io/quarkus/kafka/streams/deployment/KafkaStreamsProcessor.java +++ b/extensions/kafka-streams/deployment/src/main/java/io/quarkus/kafka/streams/deployment/KafkaStreamsProcessor.java @@ -2,7 +2,10 @@ import static io.quarkus.kafka.streams.runtime.KafkaStreamsPropertiesUtil.buildKafkaStreamsProperties; +import java.util.Map; import java.util.Properties; +import java.util.function.Function; +import java.util.stream.Collectors; import jakarta.inject.Singleton; @@ -21,15 +24,16 @@ import io.quarkus.arc.deployment.AdditionalBeanBuildItem; import io.quarkus.arc.deployment.SyntheticBeanBuildItem; import io.quarkus.bootstrap.classloading.QuarkusClassLoader; +import io.quarkus.core.Phase; +import io.quarkus.core.deployment.service.ServiceRegistrar; import io.quarkus.deployment.Feature; import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; -import io.quarkus.deployment.annotations.ExecutionTime; -import io.quarkus.deployment.annotations.Record; import io.quarkus.deployment.builditem.FeatureBuildItem; import io.quarkus.deployment.builditem.LaunchModeBuildItem; import io.quarkus.deployment.builditem.ModuleEnableNativeAccessBuildItem; import io.quarkus.deployment.builditem.NativeImageFeatureBuildItem; +import io.quarkus.deployment.builditem.ServiceStartBuildItem; import io.quarkus.deployment.builditem.nativeimage.JniRuntimeAccessBuildItem; import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; import io.quarkus.deployment.builditem.nativeimage.RuntimeInitializedClassBuildItem; @@ -229,16 +233,21 @@ private void registerDefaultSerde(BuildProducer reflec } @BuildStep - @Record(ExecutionTime.STATIC_INIT) - void processBuildTimeConfig(KafkaStreamsRecorder recorder, LaunchModeBuildItem launchMode, + void processBuildTimeConfig(ServiceRegistrar serviceRegistrar, LaunchModeBuildItem launchMode, BuildProducer syntheticBeanBuildItemBuildProducer, BuildProducer additionalBeans) { Properties kafkaStreamsProperties = buildKafkaStreamsProperties(launchMode.getLaunchMode()); + Map properties = kafkaStreamsProperties.stringPropertyNames().stream() + .collect(Collectors.toUnmodifiableMap(Function.identity(), kafkaStreamsProperties::getProperty)); + serviceRegistrar + .forService(KafkaStreamsSupport.class) + .atPhase(Phase.STATIC_INIT) + .onStart(ctx -> KafkaStreamsRecorder.kafkaStreamsSupport(properties)); // create KafkaStreamsSupport as a synthetic bean syntheticBeanBuildItemBuildProducer.produce(SyntheticBeanBuildItem.configure(KafkaStreamsSupport.class) .scope(Singleton.class) - .supplier(recorder.kafkaStreamsSupportSupplier(kafkaStreamsProperties)) + .serviceValue(KafkaStreamsSupport.class) .done()); // make the producer an unremovable bean @@ -247,11 +256,14 @@ void processBuildTimeConfig(KafkaStreamsRecorder recorder, LaunchModeBuildItem l } @BuildStep - @Record(ExecutionTime.RUNTIME_INIT) - void loadRocksDb(KafkaStreamsRecorder recorder) { + ServiceStartBuildItem loadRocksDb(ServiceRegistrar serviceRegistrar) { // Explicitly loading RocksDB native libs, as that's normally done from within // static initializers which already ran during build - recorder.loadRocksDb(); + serviceRegistrar + .forService("io.quarkus.kafka.streams.load-rocksdb") + .atPhase(Phase.INIT) + .onStart(ctx -> KafkaStreamsRecorder.loadRocksDb()); + return new ServiceStartBuildItem("kafka-streams-rocksdb"); } @BuildStep diff --git a/extensions/kafka-streams/runtime/src/main/java/io/quarkus/kafka/streams/runtime/KafkaStreamsRecorder.java b/extensions/kafka-streams/runtime/src/main/java/io/quarkus/kafka/streams/runtime/KafkaStreamsRecorder.java index 54cfcacc9a396..b8d70fa9b34d3 100644 --- a/extensions/kafka-streams/runtime/src/main/java/io/quarkus/kafka/streams/runtime/KafkaStreamsRecorder.java +++ b/extensions/kafka-streams/runtime/src/main/java/io/quarkus/kafka/streams/runtime/KafkaStreamsRecorder.java @@ -1,25 +1,19 @@ package io.quarkus.kafka.streams.runtime; +import java.util.Map; import java.util.Properties; -import java.util.function.Supplier; import org.rocksdb.RocksDB; -import io.quarkus.runtime.annotations.Recorder; - -@Recorder public class KafkaStreamsRecorder { - public void loadRocksDb() { + public static void loadRocksDb() { RocksDB.loadLibrary(); } - public Supplier kafkaStreamsSupportSupplier(Properties properties) { - return new Supplier() { - @Override - public KafkaStreamsSupport get() { - return new KafkaStreamsSupport(properties); - } - }; + public static KafkaStreamsSupport kafkaStreamsSupport(Map properties) { + Properties kafkaStreamsProperties = new Properties(); + kafkaStreamsProperties.putAll(properties); + return new KafkaStreamsSupport(kafkaStreamsProperties); } } From ecb574c1f6cde5f8a7ce170486f5c7707c13ebf9 Mon Sep 17 00:00:00 2001 From: Julien Ponge Date: Fri, 9 Oct 2026 17:26:34 +0200 Subject: [PATCH 135/254] Bump to Vert.x 5.1.10 and Netty 4.2.19.Final See https://vertx.io/blog/eclipse-vert-x-5-1-10/ and https://netty.io/news/2026/10/06/4-2-19-Final.html for the CVEs fixed by both Vert.x and Netty. Also provides a fix for #55726 since Netty 4.2.19.Final addresses it. Closes: #55726 --- bom/application/pom.xml | 4 +- docs/src/main/asciidoc/rest.adoc | 2 +- .../netty/deployment/NettyProcessor.java | 145 ------------------ .../resteasy-reactive/pom.xml | 2 +- independent-projects/vertx-utils/pom.xml | 2 +- 5 files changed, 5 insertions(+), 150 deletions(-) diff --git a/bom/application/pom.xml b/bom/application/pom.xml index b44cc35999502..727308126e238 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -114,7 +114,7 @@ 2.9.4.Final 2.3.0 3.10.1 - 5.1.8 + 5.1.10 5.6.4 5.4.3 4.5.14 @@ -136,7 +136,7 @@ 6.0.7 3.2.4 2.0.84.Final - 4.2.18.Final + 4.2.19.Final 1.23.0 1.0.4 3.6.3.Final diff --git a/docs/src/main/asciidoc/rest.adoc b/docs/src/main/asciidoc/rest.adoc index bf5fea548518b..02dd22e70048b 100644 --- a/docs/src/main/asciidoc/rest.adoc +++ b/docs/src/main/asciidoc/rest.adoc @@ -15,7 +15,7 @@ include::_attributes.adoc[] :httpspec: https://tools.ietf.org/html/rfc7231 :jsonpapi: https://javadoc.io/doc/jakarta.json/jakarta.json-api/2.1.2/jakarta.json :injectapi: https://javadoc.io/doc/jakarta.inject/jakarta.inject-api/2.0.1/jakarta.inject -:vertxapi: https://javadoc.io/doc/io.vertx/vertx-core/5.1.8 +:vertxapi: https://javadoc.io/doc/io.vertx/vertx-core/5.1.10 :resteasy-reactive-api: https://javadoc.io/doc/io.quarkus.resteasy.reactive/resteasy-reactive/{quarkus-version} :resteasy-reactive-common-api: https://javadoc.io/doc/io.quarkus.resteasy.reactive/resteasy-reactive-common/{quarkus-version} diff --git a/extensions/netty/deployment/src/main/java/io/quarkus/netty/deployment/NettyProcessor.java b/extensions/netty/deployment/src/main/java/io/quarkus/netty/deployment/NettyProcessor.java index c73d7dce681eb..d62ccf30bbe48 100644 --- a/extensions/netty/deployment/src/main/java/io/quarkus/netty/deployment/NettyProcessor.java +++ b/extensions/netty/deployment/src/main/java/io/quarkus/netty/deployment/NettyProcessor.java @@ -754,151 +754,6 @@ public void visit(int version, int access, String name, String signature, .build()); } - /** - * Rewrites {@code PlatformDependent#estimateMaxDirectMemory()} to replace the - * reflection-based VM argument lookup with direct calls. The original code uses - * {@code Class.forName} + {@code MethodHandles.publicLookup().findStatic/findVirtual} - * to call {@code ManagementFactory.getRuntimeMXBean().getInputArguments()} because - * "Android doesn't have these classes". Since we target JDK 17+, these are always available. - *

- * Using ASM's tree API, we surgically replace the instruction sequence that performs - * the reflection with a direct invocation, preserving all surrounding code (the loop, - * the regex matching, the switch, the logging). - */ - @BuildStep - void transformPlatformDependentEstimateMaxDirectMemory(BuildProducer producer) { - String className = PlatformDependent.class.getName(); - - producer.produce(new BytecodeTransformerBuildItem.Builder() - .setClassToTransform(className) - .setCacheable(true) - .setClassReaderOptions(ClassReader.EXPAND_FRAMES) - .setVisitorFunction(new BiFunction<>() { - @Override - public ClassVisitor apply(String s, ClassVisitor classVisitor) { - return new ClassVisitor(Gizmo.ASM_API_VERSION, classVisitor) { - @Override - public MethodVisitor visitMethod(int access, String name, String descriptor, - String signature, String[] exceptions) { - MethodVisitor mv = super.visitMethod(access, name, descriptor, - signature, exceptions); - if ("estimateMaxDirectMemory".equals(name) && "()J".equals(descriptor)) { - MethodVisitor target = mv; - return new MethodNode(Gizmo.ASM_API_VERSION, access, name, descriptor, - signature, exceptions) { - @Override - public void visitEnd() { - super.visitEnd(); - patchEstimateMaxDirectMemory(this.instructions); - this.accept(target); - } - }; - } - return mv; - } - }; - } - }) - .build()); - } - - /** - * Replaces the reflection-based sequence in {@code estimateMaxDirectMemory} with direct calls. - *

- * Finds and replaces: - * - *

-     * invokestatic getSystemClassLoader -> astore X
-     * ldc "java.lang.management.ManagementFactory"
-     * ... Class.forName ...
-     * ... Class.forName ...
-     * ... MethodHandles.publicLookup ...
-     * ... lookup.findStatic ...
-     * ... lookup.findVirtual ...
-     * ... getInputArguments.invoke(getRuntime.invoke()) ...
-     * checkcast List -> astore Y (vmArgs)
-     * 
- * - * with: - * - *
-     * invokestatic ManagementFactory.getRuntimeMXBean()
-     * invokeinterface RuntimeMXBean.getInputArguments()
-     * astore Y (vmArgs)
-     * 
- * - * The approach: find the {@code invokestatic getSystemClassLoader} that starts the block - * and the {@code checkcast java/util/List} that ends it, remove everything in between, - * and insert the direct calls before the checkcast (which becomes a no-op but is harmless). - */ - private static void patchEstimateMaxDirectMemory(org.objectweb.asm.tree.InsnList instructions) { - // Find the invokestatic getSystemClassLoader call that starts the reflection block - AbstractInsnNode startNode = null; - for (AbstractInsnNode node = instructions.getFirst(); node != null; node = node.getNext()) { - if (node instanceof MethodInsnNode methodInsn - && methodInsn.getOpcode() == Opcodes.INVOKESTATIC - && "getSystemClassLoader".equals(methodInsn.name)) { - startNode = node; - break; - } - } - if (startNode == null) { - throw new IllegalStateException( - "Could not find getSystemClassLoader call in PlatformDependent.estimateMaxDirectMemory. " - + "The bytecode pattern may have changed in a Netty update."); - } - - // Find the last MethodHandle.invoke call that ends the reflection block. - // The sequence ends with: invokevirtual MethodHandle.invoke -> astore (vmArgs). - // We keep the astore and replace everything before it. - AbstractInsnNode lastInvoke = null; - for (AbstractInsnNode node = startNode.getNext(); node != null; node = node.getNext()) { - if (node instanceof MethodInsnNode methodInsn - && "invoke".equals(methodInsn.name) - && "java/lang/invoke/MethodHandle".equals(methodInsn.owner)) { - lastInvoke = node; - } - // Stop scanning once we pass the reflection block (next invokestatic is - // getMaxDirectMemorySizeArgPattern) - if (node instanceof MethodInsnNode methodInsn - && "getMaxDirectMemorySizeArgPattern".equals(methodInsn.name)) { - break; - } - } - if (lastInvoke == null) { - throw new IllegalStateException( - "Could not find MethodHandle.invoke in PlatformDependent.estimateMaxDirectMemory. " - + "The bytecode pattern may have changed in a Netty update."); - } - - // The astore after the last invoke stores vmArgs - we keep it - AbstractInsnNode astoreNode = nextRealInsn(lastInvoke); - - // Remove everything from startNode through lastInvoke (inclusive) - AbstractInsnNode current = startNode; - while (current != astoreNode) { - AbstractInsnNode next = current.getNext(); - instructions.remove(current); - current = next; - } - - // Insert direct calls before the astore: - // invokestatic ManagementFactory.getRuntimeMXBean() -> RuntimeMXBean - // invokeinterface RuntimeMXBean.getInputArguments() -> List - instructions.insertBefore(astoreNode, new MethodInsnNode( - Opcodes.INVOKESTATIC, - "java/lang/management/ManagementFactory", - "getRuntimeMXBean", - "()Ljava/lang/management/RuntimeMXBean;", - false)); - instructions.insertBefore(astoreNode, new MethodInsnNode( - Opcodes.INVOKEINTERFACE, - "java/lang/management/RuntimeMXBean", - "getInputArguments", - "()Ljava/util/List;", - true)); - } - /** * Rewrites {@code DefaultChannelId#processHandlePid(ClassLoader)} to avoid reflection as we target Java 21+. *

diff --git a/independent-projects/resteasy-reactive/pom.xml b/independent-projects/resteasy-reactive/pom.xml index 61736eae67236..4c180f566feb4 100644 --- a/independent-projects/resteasy-reactive/pom.xml +++ b/independent-projects/resteasy-reactive/pom.xml @@ -58,7 +58,7 @@ 3.3.0 4.0.2 2.20.0 - 5.1.8 + 5.1.10 6.0.1 2.0.0.Final 3.1.7 diff --git a/independent-projects/vertx-utils/pom.xml b/independent-projects/vertx-utils/pom.xml index b7fcc5a9fe427..e821f1bfb5929 100644 --- a/independent-projects/vertx-utils/pom.xml +++ b/independent-projects/vertx-utils/pom.xml @@ -18,7 +18,7 @@ 3.6.3.Final - 5.1.8 + 5.1.10 From 36304710765c8394f3eca5438e764028b6d01d3f Mon Sep 17 00:00:00 2001 From: Martin Kouba Date: Mon, 12 Oct 2026 10:09:31 +0200 Subject: [PATCH 136/254] Upgrade postgresql-jdbc to 42.7.14 - fixes https://access.redhat.com/security/cve/cve-2026-107314 and https://access.redhat.com/security/cve/cve-2026-107315 --- bom/application/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bom/application/pom.xml b/bom/application/pom.xml index b44cc35999502..2f2c008471e3a 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -122,7 +122,7 @@ 4.1.5 9.2.1 2.5.2 - 42.7.13 + 42.7.14 3.5.10 26.7.0 13.6.0.jre11 From 39bec7037733d1d99a1c37e4e0f4f967372104d0 Mon Sep 17 00:00:00 2001 From: Jakub Sobczynski Date: Mon, 12 Oct 2026 13:03:44 +0200 Subject: [PATCH 137/254] Unwatch Redis cache keys when value loading fails --- .../RedisCacheWithOptimisticLockingTest.java | 51 ++++++++++++++++++- .../redis/deployment/SimpleCachedService.java | 7 +++ .../cache/redis/runtime/RedisCacheImpl.java | 11 +++- 3 files changed, 66 insertions(+), 3 deletions(-) diff --git a/extensions/redis-cache/deployment/src/test/java/io/quarkus/cache/redis/deployment/RedisCacheWithOptimisticLockingTest.java b/extensions/redis-cache/deployment/src/test/java/io/quarkus/cache/redis/deployment/RedisCacheWithOptimisticLockingTest.java index d0589a8645776..ce0f3fc77b546 100644 --- a/extensions/redis-cache/deployment/src/test/java/io/quarkus/cache/redis/deployment/RedisCacheWithOptimisticLockingTest.java +++ b/extensions/redis-cache/deployment/src/test/java/io/quarkus/cache/redis/deployment/RedisCacheWithOptimisticLockingTest.java @@ -2,12 +2,15 @@ import static org.junit.jupiter.api.Assertions.*; +import java.time.Duration; import java.util.List; import java.util.Optional; +import java.util.UUID; import jakarta.inject.Inject; import org.assertj.core.api.Assertions; +import org.eclipse.microprofile.config.ConfigProvider; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; @@ -17,6 +20,12 @@ import io.quarkus.cache.redis.runtime.RedisCache; import io.quarkus.redis.datasource.RedisDataSource; import io.quarkus.test.QuarkusExtensionTest; +import io.vertx.mutiny.core.Vertx; +import io.vertx.mutiny.redis.client.Redis; +import io.vertx.mutiny.redis.client.RedisConnection; +import io.vertx.redis.client.Command; +import io.vertx.redis.client.Request; +import io.vertx.redis.client.Response; public class RedisCacheWithOptimisticLockingTest { @@ -26,11 +35,18 @@ public class RedisCacheWithOptimisticLockingTest { @RegisterExtension static final QuarkusExtensionTest TEST = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar.addClasses(SimpleCachedService.class, TestUtil.class)) - .overrideRuntimeConfigKey("quarkus.cache.redis.use-optimistic-locking", "true"); + .overrideRuntimeConfigKey("quarkus.cache.redis.use-optimistic-locking", "true") + .overrideRuntimeConfigKey("quarkus.redis.max-pool-size", "1"); @Inject SimpleCachedService simpleCachedService; + @Inject + Vertx vertx; + + @Inject + Redis redis; + @Test public void testTypes() { CacheManager cacheManager = Arc.container().select(CacheManager.class).get(); @@ -137,6 +153,39 @@ public void testAllCacheAnnotations() { assertNotEquals(value6, value10); } + @Test + public void testFailedCacheLoadClearsWatchedKeyBeforeConnectionIsReused() { + String key = UUID.randomUUID().toString(); + String watchedKey = "cache:" + SimpleCachedService.FAILING_CACHE_NAME + ":" + key; + String transactionKey = "transaction:" + UUID.randomUUID(); + String redisUrl = ConfigProvider.getConfig().getValue("quarkus.redis.hosts", String.class); + Redis otherRedis = Redis.createClient(vertx, redisUrl); + + try { + Assertions.assertThatThrownBy(() -> simpleCachedService.failingCachedMethod(key) + .await().atMost(Duration.ofSeconds(10))) + .hasMessageContaining("value loader failed"); + + RedisConnection connection = redis.connect().await().atMost(Duration.ofSeconds(10)); + try { + connection.send(Request.cmd(Command.MULTI)).await().atMost(Duration.ofSeconds(10)); + connection.send(Request.cmd(Command.SET).arg(transactionKey).arg("value")) + .await().atMost(Duration.ofSeconds(10)); + + // Changing the failed load's key must not abort an unrelated transaction on the reused connection. + otherRedis.send(Request.cmd(Command.SET).arg(watchedKey).arg("changed")) + .await().atMost(Duration.ofSeconds(10)); + Response result = connection.send(Request.cmd(Command.EXEC)).await().atMost(Duration.ofSeconds(10)); + Assertions.assertThat(result).isNotNull(); + Assertions.assertThat(result.get(0).toString()).isEqualTo("OK"); + } finally { + connection.close().await().atMost(Duration.ofSeconds(10)); + } + } finally { + otherRedis.close(); + } + } + private static String expectedCacheKey(String key) { return "cache:" + SimpleCachedService.CACHE_NAME + ":" + key; } diff --git a/extensions/redis-cache/deployment/src/test/java/io/quarkus/cache/redis/deployment/SimpleCachedService.java b/extensions/redis-cache/deployment/src/test/java/io/quarkus/cache/redis/deployment/SimpleCachedService.java index 88b641e0784d1..199e559f79eb2 100644 --- a/extensions/redis-cache/deployment/src/test/java/io/quarkus/cache/redis/deployment/SimpleCachedService.java +++ b/extensions/redis-cache/deployment/src/test/java/io/quarkus/cache/redis/deployment/SimpleCachedService.java @@ -7,17 +7,24 @@ import io.quarkus.cache.CacheInvalidate; import io.quarkus.cache.CacheInvalidateAll; import io.quarkus.cache.CacheResult; +import io.smallrye.mutiny.Uni; @ApplicationScoped public class SimpleCachedService { static final String CACHE_NAME = "test-cache"; + static final String FAILING_CACHE_NAME = "failing-cache"; @CacheResult(cacheName = CACHE_NAME) public String cachedMethod(String key) { return UUID.randomUUID().toString(); } + @CacheResult(cacheName = FAILING_CACHE_NAME) + public Uni failingCachedMethod(String key) { + return Uni.createFrom().failure(new IllegalStateException("value loader failed")); + } + @CacheInvalidate(cacheName = CACHE_NAME) public void invalidate(String key) { } diff --git a/extensions/redis-cache/runtime/src/main/java/io/quarkus/cache/redis/runtime/RedisCacheImpl.java b/extensions/redis-cache/runtime/src/main/java/io/quarkus/cache/redis/runtime/RedisCacheImpl.java index 3158b40c43f71..aa1288a0bdc4d 100644 --- a/extensions/redis-cache/runtime/src/main/java/io/quarkus/cache/redis/runtime/RedisCacheImpl.java +++ b/extensions/redis-cache/runtime/src/main/java/io/quarkus/cache/redis/runtime/RedisCacheImpl.java @@ -187,7 +187,7 @@ public Uni apply(RedisConnection connection) { startingPoint = new GetFromConnectionSupplier(connection, type, encodedKey, marshaller).get(); } - return startingPoint + Uni result = startingPoint .chain(Unchecked.function(new UncheckedFunction<>() { @Override public Uni apply(V cached) throws Exception { @@ -227,6 +227,7 @@ public Uni apply(V value) { } } })); + return cacheInfo.useOptimisticLocking ? unwatchOnFailureOrCancellation(connection, result) : result; } }) @@ -283,7 +284,7 @@ public Uni apply(RedisConnection connection) { startingPoint = new GetFromConnectionSupplier(connection, type, encodedKey, marshaller).get(); } - return startingPoint + Uni result = startingPoint .chain(cached -> { if (cached != null) { // Unwatch if optimistic locking @@ -308,6 +309,7 @@ public Uni apply(RedisConnection connection) { }); } }); + return cacheInfo.useOptimisticLocking ? unwatchOnFailureOrCancellation(connection, result) : result; } }) .onFailure(RedisCacheImpl::isRecomputableError).recoverWithUni(e -> { @@ -514,6 +516,11 @@ private Uni watch(RedisConnection connection, byte[] keyToWatch) { .replaceWithVoid(); } + private Uni unwatchOnFailureOrCancellation(RedisConnection connection, Uni operation) { + return operation.onFailure().call(() -> connection.send(Request.cmd(Command.UNWATCH))) + .onCancellation().call(() -> connection.send(Request.cmd(Command.UNWATCH))); + } + private Uni doGet(RedisConnection connection, byte[] encoded, Type clazz, Marshaller marshaller) { if (cacheInfo.expireAfterAccess.isPresent()) { From ca08d064e7f74e02e798be179f56294768ea0e27 Mon Sep 17 00:00:00 2001 From: Julien Ponge Date: Mon, 12 Oct 2026 14:25:51 +0200 Subject: [PATCH 138/254] Fix REST client connection leak on cancelled queued Uni request Closes: #57343 --- ...celledQueuedRequestConnectionLeakTest.java | 89 +++++++++++++++++++ .../handlers/ClientSendRequestHandler.java | 2 +- 2 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 extensions/resteasy-reactive/rest-client/deployment/src/test/java/io/quarkus/rest/client/reactive/CancelledQueuedRequestConnectionLeakTest.java diff --git a/extensions/resteasy-reactive/rest-client/deployment/src/test/java/io/quarkus/rest/client/reactive/CancelledQueuedRequestConnectionLeakTest.java b/extensions/resteasy-reactive/rest-client/deployment/src/test/java/io/quarkus/rest/client/reactive/CancelledQueuedRequestConnectionLeakTest.java new file mode 100644 index 0000000000000..8b451cd9dc410 --- /dev/null +++ b/extensions/resteasy-reactive/rest-client/deployment/src/test/java/io/quarkus/rest/client/reactive/CancelledQueuedRequestConnectionLeakTest.java @@ -0,0 +1,89 @@ +package io.quarkus.rest.client.reactive; + +import static org.awaitility.Awaitility.await; + +import java.net.URI; +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +import org.eclipse.microprofile.rest.client.RestClientBuilder; +import org.jboss.resteasy.reactive.client.api.QuarkusRestClientProperties; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusExtensionTest; +import io.quarkus.test.common.http.TestHTTPResource; +import io.smallrye.mutiny.Uni; +import io.smallrye.mutiny.subscription.Cancellable; + +// Reproducer for https://github.com/quarkusio/quarkus/issues/57343 +public class CancelledQueuedRequestConnectionLeakTest { + + @RegisterExtension + static final QuarkusExtensionTest config = new QuarkusExtensionTest() + .withApplicationRoot((jar) -> jar.addClasses(Client.class, Resource.class)); + + @TestHTTPResource + URI uri; + + @Test + public void cancelledQueuedRequestShouldNotBlockConnection() { + Client client = RestClientBuilder.newBuilder() + .baseUri(uri) + .property(QuarkusRestClientProperties.CONNECTION_POOL_SIZE, 1) + .property(QuarkusRestClientProperties.READ_TIMEOUT, 10000L) + .build(Client.class); + + // A: occupies the only connection for ~1.5 s + AtomicBoolean aIsDone = new AtomicBoolean(); + client.slow().subscribe().with(res -> aIsDone.set(true), err -> aIsDone.set(true)); + + // B: queued behind A (pool size = 1), then cancelled + Cancellable b = client.fast().subscribe().with(ignored -> { + // NO-OP + }, Throwable::printStackTrace); + b.cancel(); + + await().atMost(5, TimeUnit.SECONDS).untilTrue(aIsDone); + + // C: should get the connection promptly, not after B's read-timeout + AtomicBoolean cIsDone = new AtomicBoolean(); + client.fast().subscribe().with(res -> cIsDone.set(true), err -> cIsDone.set(true)); + + // Without the fix for #57343: C is blocked ~10s (read-timeout); < 1s with the fix + await().atMost(3, TimeUnit.SECONDS).untilTrue(cIsDone); + } + + @Path("resource") + public interface Client { + + @GET + @Path("slow") + Uni slow(); + + @GET + @Path("fast") + Uni fast(); + } + + @Path("resource") + public static class Resource { + + @GET + @Path("slow") + public Uni slow() { + return Uni.createFrom().item("slow") + .onItem().delayIt().by(Duration.ofMillis(1500)); + } + + @GET + @Path("fast") + public String fast() { + return "fast"; + } + } +} diff --git a/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/handlers/ClientSendRequestHandler.java b/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/handlers/ClientSendRequestHandler.java index 26698aa1a9fc4..dc54f4509da4c 100644 --- a/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/handlers/ClientSendRequestHandler.java +++ b/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/handlers/ClientSendRequestHandler.java @@ -125,7 +125,7 @@ public void handle(RestClientRequestContext requestContext) { @Override public void accept(HttpClientRequest httpClientRequest) { if (requestContext.isUserCanceled()) { - // in this case the user aborted before the request was even created + httpClientRequest.reset(); return; } From edc58cd9c1ad7393e30127d873be469ef05ea5e7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:29:41 +0000 Subject: [PATCH 139/254] Build(deps-dev): Bump com.google.guava:guava Bumps [com.google.guava:guava](https://github.com/google/guava) from 33.7.1-jre to 33.7.2-jre. - [Release notes](https://github.com/google/guava/releases) - [Commits](https://github.com/google/guava/commits) --- updated-dependencies: - dependency-name: com.google.guava:guava dependency-version: 33.7.2-jre dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- independent-projects/bootstrap/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/independent-projects/bootstrap/pom.xml b/independent-projects/bootstrap/pom.xml index 70f06d9e3f59c..9ac0d8378c891 100644 --- a/independent-projects/bootstrap/pom.xml +++ b/independent-projects/bootstrap/pom.xml @@ -55,7 +55,7 @@ 1.22.1 2.22.0 3.21.0 - 33.7.1-jre + 33.7.2-jre 1.0.1 2.8 1.0.0 From 6820f6956863fe2d1692d7806f3ce23ac5bf6066 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20=C3=89pardaud?= Date: Mon, 12 Oct 2026 11:46:44 +0000 Subject: [PATCH 140/254] Notify area/panache and area/data owners on pull requests The panache, data and rest-data-panache triage rules lacked notifyInPullRequest, so the bot only mentioned the configured users on issues, never on pull requests. Co-Authored-By: Claude Sonnet 5.5 --- .github/quarkus-github-bot.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/quarkus-github-bot.yml b/.github/quarkus-github-bot.yml index a15fee9a00bdd..bbb7c92141762 100644 --- a/.github/quarkus-github-bot.yml +++ b/.github/quarkus-github-bot.yml @@ -285,6 +285,7 @@ triage: labels: [area/panache] title: "panache" notify: [FroMage, loicmathieu] + notifyInPullRequest: true directories: - extensions/panache/ - integration-tests/hibernate-orm-panache/ @@ -292,6 +293,7 @@ triage: labels: [area/data] title: "(quarkus.data(?!source)|jakarta data)" notify: [FroMage] # yrodiere/lucamolteni use the lottery instead + notifyInPullRequest: true directories: - extensions/data/ - integration-tests/data/ @@ -299,7 +301,8 @@ triage: - id: rest-data-panache labels: [area/panache] title: "(panache.*resource|rest.data.panache)" - notify: [geoand] + notify: [geoand, FroMage] + notifyInPullRequest: true - id: qute labels: [area/qute] title: "qute" From 57c93a1c00c8df471422c8b729b1e6cd813ada91 Mon Sep 17 00:00:00 2001 From: "David M. Lloyd" Date: Fri, 9 Oct 2026 09:08:35 -0500 Subject: [PATCH 141/254] Dynamically load classes when necessary Fixes #57320 --- .../impl/SerializedCaptureEmitter.java | 47 ++++++++++++ .../main/java/io/quarkus/core/Bootstraps.java | 73 +++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 core/runtime/src/main/java/io/quarkus/core/Bootstraps.java diff --git a/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/SerializedCaptureEmitter.java b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/SerializedCaptureEmitter.java index 428275822d11b..ecf8b6c487690 100644 --- a/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/SerializedCaptureEmitter.java +++ b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/SerializedCaptureEmitter.java @@ -17,13 +17,18 @@ import java.lang.constant.ClassDesc; import java.lang.constant.ConstantDesc; +import java.lang.constant.ConstantDescs; +import java.lang.constant.DirectMethodHandleDesc; +import java.lang.constant.DynamicConstantDesc; import java.lang.constant.MethodTypeDesc; +import java.lang.reflect.Modifier; import java.lang.reflect.RecordComponent; import java.util.Arrays; import java.util.Comparator; import java.util.IdentityHashMap; import java.util.List; +import io.quarkus.core.Bootstraps; import io.smallrye.classfile.CodeBuilder; import io.smallrye.classfile.TypeKind; import io.smallrye.classfile.extras.constant.ExtraConstantDescs; @@ -53,6 +58,11 @@ final class SerializedCaptureEmitter { private static final ClassDesc CD_Map_Entry = ClassDesc.of("java.util.Map$Entry"); private static final ClassDesc CD_ConfigLookup = ClassDesc.of("io.quarkus.runtime.configuration.ConfigLookup"); + private static final ClassDesc CD_Bootstraps = ClassDesc.of("io.quarkus.core.Bootstraps"); + + /** Bootstrap method handle for {@code Bootstraps.loadClass(Lookup, String, Class, String)}. */ + private static final DirectMethodHandleDesc BSM_Bootstraps_loadClass = ConstantDescs.ofConstantBootstrap( + CD_Bootstraps, "loadClass", CD_Class, ConstantDescs.CD_String); /** CollSer tag: unmodifiable list. */ private static final int TAG_LIST = 1; @@ -207,12 +217,49 @@ private static CaptureEmitter emitConfigMapping(SerializedConfigMapping cm) { /** * Emit a Class constant from a SerializedClass node. + *

+ * For accessible, non-array classes, emits a simple {@code ldc} class constant. + * For array types and inaccessible classes (non-public, or in a named module + * that does not export the package), emits a {@code condy} via + * {@link Bootstraps#loadClass} which uses the caller's + * class loader and handles array descriptors. */ private static CaptureEmitter emitClassConstant(SerializedClass sc) { ClassDesc classDesc = sc.descriptor(); + if (needsDynamicLoad(classDesc)) { + DynamicConstantDesc condy = DynamicConstantDesc.of( + BSM_Bootstraps_loadClass, classDesc.descriptorString()); + return code -> code.loadConstant(condy); + } return code -> code.loadConstant(classDesc); } + /** + * Determine whether a class descriptor requires dynamic loading. + * + * @param classDesc the class descriptor to check + * @return {@code true} if the class cannot be safely emitted as an {@code ldc} class constant + */ + private static boolean needsDynamicLoad(ClassDesc classDesc) { + if (classDesc.isArray()) { + return true; + } + if (classDesc.isPrimitive()) { + return false; + } + try { + Class cls = Class.forName( + classDesc.displayName(), false, Thread.currentThread().getContextClassLoader()); + if (!Modifier.isPublic(cls.getModifiers())) { + return true; + } + Module module = cls.getModule(); + return module.isNamed() && !module.isExported(cls.getPackageName()); + } catch (ClassNotFoundException e) { + return true; + } + } + // ── Class loaders ── private static CaptureEmitter emitQuarkusClassLoader(SerializedQuarkusClassLoader scl) { diff --git a/core/runtime/src/main/java/io/quarkus/core/Bootstraps.java b/core/runtime/src/main/java/io/quarkus/core/Bootstraps.java new file mode 100644 index 0000000000000..874d3e369e7af --- /dev/null +++ b/core/runtime/src/main/java/io/quarkus/core/Bootstraps.java @@ -0,0 +1,73 @@ +package io.quarkus.core; + +import java.lang.invoke.MethodHandles; + +import io.smallrye.common.constraint.Assert; + +/** + * Constant bootstraps for Quarkus generated bytecode. + */ +public final class Bootstraps { + private Bootstraps() { + } + + /** + * Load a class by its descriptor, even if it is not directly accessible from the caller. + * The class must be visible to the caller's class loader. + * In a modular Quarkus app, this usually means that the target type must be in a + * package that is exported to the caller. + * This method may be used to load primitive or array types as well. + *

+ * The descriptor is passed as a bootstrap argument rather than the condy name + * because class descriptor strings (e.g. {@code Lio/foo/Bar;}, {@code [I}) + * contain characters that are illegal in JVM member names. + * + * @param lookup the lookup of the caller (must not be {@code null}) + * @param name the condy name (ignored) + * @param type always {@code Class.class} + * @param descriptor the descriptor string of the class to load (must not be {@code null}) + * @return the loaded class + * @throws NoClassDefFoundError if the class could not be loaded + * @throws IllegalArgumentException if {@code type} is the wrong type, {@code descriptor} is invalid, + * or a required argument was {@code null} + */ + public static Class loadClass(MethodHandles.Lookup lookup, String name, Class> type, String descriptor) { + Assert.checkNotNullParam("lookup", lookup); + Assert.checkNotNullParam("name", name); + Assert.checkNotNullParam("type", type); + Assert.checkNotNullParam("descriptor", descriptor); + if (!Class.class.isAssignableFrom(type)) { + throw new IllegalArgumentException("Invalid type for bootstrap"); + } + ClassLoader cl = lookup.lookupClass().getClassLoader(); + return loadClassByDescriptor(cl, descriptor); + } + + private static Class loadClassByName(final String className, final ClassLoader cl) { + try { + return Class.forName(className, false, cl); + } catch (ClassNotFoundException e) { + NoClassDefFoundError error = new NoClassDefFoundError(e.getMessage()); + error.setStackTrace(e.getStackTrace()); + error.initCause(e.getCause()); + throw error; + } + } + + private static Class loadClassByDescriptor(final ClassLoader cl, final String className) { + return switch (className.charAt(0)) { + case 'B' -> byte.class; + case 'C' -> char.class; + case 'D' -> double.class; + case 'F' -> float.class; + case 'I' -> int.class; + case 'J' -> long.class; + case 'S' -> short.class; + case 'V' -> void.class; + case 'Z' -> boolean.class; + case '[' -> loadClassByDescriptor(cl, className.substring(1)).arrayType(); + case 'L' -> loadClassByName(className.substring(1, className.length() - 1).replace('/', '.'), cl); + default -> throw new IllegalArgumentException("Invalid class name: " + className); + }; + } +} From 2be7d900e604430f6342f7da966f66f1b8337ec5 Mon Sep 17 00:00:00 2001 From: yuhaibo Date: Tue, 6 Oct 2026 16:52:38 -0700 Subject: [PATCH 142/254] Keep discovering config mappings after an indexed extension config root discoverConfigMappings() skips extension config roots that are present in the index, but used `break outer` instead of `continue outer`, so every @ConfigMapping that comes after such a root in the combined index was silently ignored and its injection points became unsatisfied. --- .../steps/ConfigGenerationBuildStep.java | 2 +- ...igMappingAfterExtensionConfigRootTest.java | 32 +++++++++++++++++++ .../arc/test/config/DependencyMapping.java | 12 +++++++ 3 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 extensions/arc/deployment/src/test/java/io/quarkus/arc/test/config/ConfigMappingAfterExtensionConfigRootTest.java create mode 100644 extensions/arc/deployment/src/test/java/io/quarkus/arc/test/config/DependencyMapping.java diff --git a/core/deployment/src/main/java/io/quarkus/deployment/steps/ConfigGenerationBuildStep.java b/core/deployment/src/main/java/io/quarkus/deployment/steps/ConfigGenerationBuildStep.java index 74971dea5eff6..09090b3516365 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/steps/ConfigGenerationBuildStep.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/steps/ConfigGenerationBuildStep.java @@ -305,7 +305,7 @@ void discoverConfigMappings( // In that case, it would generate duplicates. for (Class type : configItem.getReadResult().getAllMappingsByClass().keySet()) { if (type.getName().equals(configClass.name().toString())) { - break outer; + continue outer; } } diff --git a/extensions/arc/deployment/src/test/java/io/quarkus/arc/test/config/ConfigMappingAfterExtensionConfigRootTest.java b/extensions/arc/deployment/src/test/java/io/quarkus/arc/test/config/ConfigMappingAfterExtensionConfigRootTest.java new file mode 100644 index 0000000000000..70d545bd47507 --- /dev/null +++ b/extensions/arc/deployment/src/test/java/io/quarkus/arc/test/config/ConfigMappingAfterExtensionConfigRootTest.java @@ -0,0 +1,32 @@ +package io.quarkus.arc.test.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import jakarta.inject.Inject; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusExtensionTest; + +/** + * Extension config roots that end up in the index (here through {@code quarkus.index-dependency}) must be skipped + * individually, without stopping the discovery of application mappings indexed after them. + */ +public class ConfigMappingAfterExtensionConfigRootTest { + @RegisterExtension + static final QuarkusExtensionTest TEST = new QuarkusExtensionTest() + // additional application archives are indexed after the index-dependency archives + .withAdditionalDependency(jar -> jar.addClass(DependencyMapping.class)) + .overrideConfigKey("quarkus.index-dependency.core.group-id", "io.quarkus") + .overrideConfigKey("quarkus.index-dependency.core.artifact-id", "quarkus-core") + .overrideConfigKey("dependency.name", "foo"); + + @Inject + DependencyMapping mapping; + + @Test + void mapping() { + assertEquals("foo", mapping.name()); + } +} diff --git a/extensions/arc/deployment/src/test/java/io/quarkus/arc/test/config/DependencyMapping.java b/extensions/arc/deployment/src/test/java/io/quarkus/arc/test/config/DependencyMapping.java new file mode 100644 index 0000000000000..841452969edf2 --- /dev/null +++ b/extensions/arc/deployment/src/test/java/io/quarkus/arc/test/config/DependencyMapping.java @@ -0,0 +1,12 @@ +package io.quarkus.arc.test.config; + +import io.smallrye.config.ConfigMapping; + +/** + * Not nested in {@link ConfigMappingAfterExtensionConfigRootTest}, so that it is only part of the additional + * dependency and not of the application root. + */ +@ConfigMapping(prefix = "dependency") +public interface DependencyMapping { + String name(); +} From 587a550e668f695e4247c84b91346adf970e4c45 Mon Sep 17 00:00:00 2001 From: Holly Cummins Date: Mon, 12 Oct 2026 14:00:47 -0400 Subject: [PATCH 143/254] Do not use deprecated QuarkusUnitTest --- .../java/io/quarkus/aesh/deployment/AeshContextTest.java | 4 ++-- .../io/quarkus/aesh/deployment/AeshSessionEventsTest.java | 4 ++-- .../aesh/deployment/AutoRuntimeModeDetectionTest.java | 4 ++-- .../aesh/deployment/AutoRuntimeModeGroupDetectionTest.java | 4 ++-- .../aesh/deployment/CdiInjectionInCompleterTest.java | 4 ++-- .../deployment/CliConfigAddExitCommandDisabledTest.java | 4 ++-- .../aesh/deployment/CliConfigEnableAliasAndExportTest.java | 4 ++-- .../aesh/deployment/CliConfigHistorySettingsTest.java | 4 ++-- .../io/quarkus/aesh/deployment/CliConfigPromptTest.java | 4 ++-- .../quarkus/aesh/deployment/CliSettingsCustomizerTest.java | 4 ++-- .../quarkus/aesh/deployment/CompletionGenerationTest.java | 4 ++-- .../aesh/deployment/DuplicateCommandNameValidationTest.java | 4 ++-- .../aesh/deployment/ExplicitConsoleModeConfigTest.java | 4 ++-- .../GroupSubCommandNotAnnotatedValidationTest.java | 4 ++-- .../java/io/quarkus/aesh/deployment/MixinCommandTest.java | 4 ++-- .../aesh/deployment/RemoteTransportForceConsoleTest.java | 6 +++--- .../aesh/deployment/RemoteTransportSkipsConsoleTest.java | 4 ++-- .../quarkus/aesh/deployment/StartConsoleDisabledTest.java | 4 ++-- .../quarkus/aesh/deployment/SubCommandModeConfigTest.java | 4 ++-- .../io/quarkus/aesh/ssh/deployment/AeshSshBuildTest.java | 4 ++-- .../aesh/ssh/deployment/AeshSshCombinedAuthTest.java | 4 ++-- .../aesh/ssh/deployment/AeshSshConcurrentSessionsTest.java | 4 ++-- .../quarkus/aesh/ssh/deployment/AeshSshConnectionTest.java | 4 ++-- .../quarkus/aesh/ssh/deployment/AeshSshCustomHostTest.java | 4 ++-- .../io/quarkus/aesh/ssh/deployment/AeshSshDisabledTest.java | 4 ++-- .../ssh/deployment/AeshSshHealthActiveConnectionsTest.java | 4 ++-- .../quarkus/aesh/ssh/deployment/AeshSshHealthCheckTest.java | 4 ++-- .../aesh/ssh/deployment/AeshSshHealthDisabledTest.java | 4 ++-- .../aesh/ssh/deployment/AeshSshIdleActivityResetTest.java | 4 ++-- .../quarkus/aesh/ssh/deployment/AeshSshIdleTimeoutTest.java | 4 ++-- .../aesh/ssh/deployment/AeshSshMaxConnectionsTest.java | 4 ++-- .../aesh/ssh/deployment/AeshSshNoAuthWarningTest.java | 4 ++-- .../aesh/ssh/deployment/AeshSshPasswordAuthTest.java | 4 ++-- .../aesh/ssh/deployment/AeshSshPublicKeyAuthTest.java | 4 ++-- .../deployment/AeshWebSocketAuthenticatedTest.java | 4 ++-- .../aesh/websocket/deployment/AeshWebSocketBuildTest.java | 4 ++-- .../deployment/AeshWebSocketConcurrentSessionsTest.java | 4 ++-- .../websocket/deployment/AeshWebSocketConnectionTest.java | 4 ++-- .../websocket/deployment/AeshWebSocketCustomPathTest.java | 4 ++-- .../websocket/deployment/AeshWebSocketDisabledTest.java | 4 ++-- .../websocket/deployment/AeshWebSocketHealthCheckTest.java | 4 ++-- .../deployment/AeshWebSocketHealthDisabledTest.java | 4 ++-- .../websocket/deployment/AeshWebSocketIdleTimeoutTest.java | 4 ++-- .../deployment/AeshWebSocketMalformedMessageTest.java | 4 ++-- .../deployment/AeshWebSocketMaxConnectionsTest.java | 4 ++-- .../websocket/deployment/AeshWebSocketRolesAllowedTest.java | 4 ++-- .../reactive/transaction/DisableJTATransactionTest.java | 4 ++-- .../HibernateReactiveStatelessTransactionsTest.java | 4 ++-- .../HibernateReactiveTransactionalRetryTest.java | 4 ++-- .../transaction/HibernateReactiveTransactionsTest.java | 4 ++-- .../hibernate/reactive/transaction/InterleaveTest.java | 4 ++-- .../transaction/SupportOnlyRequiredTransactionTypeTest.java | 4 ++-- .../transaction/mixing/MixStatelessStatefulSessionTest.java | 4 ++-- .../logging/json/NamedConsoleJsonFormatterInheritTest.java | 4 ++-- .../quarkus/logging/json/NamedConsoleJsonFormatterTest.java | 4 ++-- .../io/quarkus/logging/json/NamedFileJsonFormatterTest.java | 4 ++-- .../quarkus/logging/json/NamedSocketJsonFormatterTest.java | 4 ++-- .../quarkus/logging/json/NamedSyslogJsonFormatterTest.java | 4 ++-- .../deployment/devui/MetricsSamplerProdGuardTest.java | 4 ++-- .../java/io/quarkus/mongodb/MongoTracingConfigTest.java | 4 ++-- .../test/java/io/quarkus/mongodb/MongoTracingOffTest.java | 4 ++-- .../io/quarkus/mongodb/MongoTracingSanitizationTest.java | 4 ++-- .../quarkus/TransactionRecoveryExplicitlyDisabledTest.java | 4 ++-- .../TransactionRecoveryExplicitlyEnabledWithoutXaTest.java | 4 ++-- .../quarkus/TransactionRecoveryNotEnabledWithoutXaTest.java | 4 ++-- .../quarkus/TransactionRecoveryXaAutoEnableTest.java | 4 ++-- .../deployment/devui/OpenTelemetryDevUIProdGuardTest.java | 4 ++-- .../deployment/traces/TestModeSamplerDefaultTest.java | 4 ++-- .../test/transaction/MixReactiveTransactionalTest.java | 4 ++-- .../test/transaction/MixWithSessionOnDemandTest.java | 4 ++-- .../panache/test/transaction/MixWithTransactionTest.java | 4 ++-- .../AlternativeExprSyntaxDisabledCustomRootTest.java | 4 ++-- .../AlternativeExprSyntaxEnabledCustomRootTest.java | 4 ++-- .../altexprcommand/AlternativeExprSyntaxTest.java | 4 ++-- .../altexprcommand/BuildItemParserConfigTest.java | 4 ++-- .../altexprcommand/DependencyWithQuteConfigTest.java | 4 ++-- .../propertynotfound/RawCallOnMissingPropertyTest.java | 4 ++-- .../java/io/quarkus/security/webauthn/test/TestUtil.java | 6 +----- .../jwt/test/dev/SmallryeJwtSignLocationDevModeTest.java | 4 ++-- .../properties/deployment/ConfigurationPropertiesTest.java | 4 ++-- .../AccessLogWithNonApplicationRootPathTestCase.java | 2 +- .../http/security/CrossPolicyNormalizationBypassTest.java | 4 ++-- .../vertx/http/security/PathNormalizationBypassTest.java | 4 ++-- .../http/security/WildcardPathNormalizationBypassTest.java | 4 ++-- 84 files changed, 167 insertions(+), 171 deletions(-) diff --git a/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/AeshContextTest.java b/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/AeshContextTest.java index ff63e97a76712..2a03965a8aaf4 100644 --- a/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/AeshContextTest.java +++ b/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/AeshContextTest.java @@ -13,7 +13,7 @@ import io.quarkus.aesh.runtime.AeshContext; import io.quarkus.aesh.runtime.AeshMode; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; /** * Tests that the {@link AeshContext} synthetic bean is produced correctly by the @@ -22,7 +22,7 @@ public class AeshContextTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar.addClasses( GitCommand.class, CloneCommand.class, diff --git a/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/AeshSessionEventsTest.java b/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/AeshSessionEventsTest.java index 9105409ceb5ce..50ba66a2bc53b 100644 --- a/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/AeshSessionEventsTest.java +++ b/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/AeshSessionEventsTest.java @@ -24,7 +24,7 @@ import io.quarkus.builder.BuildChainBuilder; import io.quarkus.builder.BuildContext; import io.quarkus.builder.BuildStep; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; /** * Verifies that session lifecycle CDI events are fired when connections @@ -33,7 +33,7 @@ public class AeshSessionEventsTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar.addClasses( HelloCommand.class, SessionObserver.class, diff --git a/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/AutoRuntimeModeDetectionTest.java b/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/AutoRuntimeModeDetectionTest.java index 65f77339bf9a2..ee1b19dc17a27 100644 --- a/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/AutoRuntimeModeDetectionTest.java +++ b/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/AutoRuntimeModeDetectionTest.java @@ -13,7 +13,7 @@ import io.quarkus.aesh.runtime.AeshContext; import io.quarkus.aesh.runtime.AeshMode; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; /** * Verifies that {@code quarkus.aesh.mode=auto} (the default) with exactly one @@ -23,7 +23,7 @@ public class AutoRuntimeModeDetectionTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar.addClass(SingleCommand.class)) .overrideConfigKey("quarkus.aesh.mode", "auto"); diff --git a/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/AutoRuntimeModeGroupDetectionTest.java b/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/AutoRuntimeModeGroupDetectionTest.java index 25421ea846f7f..25cf995d9f1a2 100644 --- a/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/AutoRuntimeModeGroupDetectionTest.java +++ b/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/AutoRuntimeModeGroupDetectionTest.java @@ -13,7 +13,7 @@ import io.quarkus.aesh.runtime.AeshContext; import io.quarkus.aesh.runtime.AeshMode; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; /** * Verifies that {@code quarkus.aesh.mode=auto} (the default) with a single @@ -23,7 +23,7 @@ public class AutoRuntimeModeGroupDetectionTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar.addClasses( AppCommand.class, RunSubCommand.class, diff --git a/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/CdiInjectionInCompleterTest.java b/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/CdiInjectionInCompleterTest.java index ad347877801a3..20ee971922de0 100644 --- a/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/CdiInjectionInCompleterTest.java +++ b/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/CdiInjectionInCompleterTest.java @@ -22,7 +22,7 @@ import org.junit.jupiter.api.extension.RegisterExtension; import io.quarkus.aesh.runtime.AeshCdiCommandContainerBuilder; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; /** * Tests that CDI injection works correctly in Aesh service providers @@ -36,7 +36,7 @@ public class CdiInjectionInCompleterTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar.addClasses( TaskCommand.class, DoneCommand.class, diff --git a/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/CliConfigAddExitCommandDisabledTest.java b/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/CliConfigAddExitCommandDisabledTest.java index ff29c8fde0c99..0de2dba4e91ca 100644 --- a/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/CliConfigAddExitCommandDisabledTest.java +++ b/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/CliConfigAddExitCommandDisabledTest.java @@ -19,7 +19,7 @@ import io.quarkus.builder.BuildChainBuilder; import io.quarkus.builder.BuildContext; import io.quarkus.builder.BuildStep; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; /** * Tests that {@code quarkus.aesh.add-exit-command=false} removes the built-in @@ -28,7 +28,7 @@ public class CliConfigAddExitCommandDisabledTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar.addClasses(DummyCommand.class)) .overrideConfigKey("quarkus.aesh.add-exit-command", "false") .addBuildChainCustomizer(injectRemoteTransport()); diff --git a/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/CliConfigEnableAliasAndExportTest.java b/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/CliConfigEnableAliasAndExportTest.java index c4967db30afc6..63c9b82411ffe 100644 --- a/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/CliConfigEnableAliasAndExportTest.java +++ b/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/CliConfigEnableAliasAndExportTest.java @@ -19,7 +19,7 @@ import io.quarkus.builder.BuildChainBuilder; import io.quarkus.builder.BuildContext; import io.quarkus.builder.BuildStep; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; /** * Tests that {@code quarkus.aesh.enable-alias=true} and @@ -28,7 +28,7 @@ public class CliConfigEnableAliasAndExportTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar.addClasses(DummyCommand.class)) .overrideConfigKey("quarkus.aesh.enable-alias", "true") .overrideConfigKey("quarkus.aesh.enable-export", "true") diff --git a/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/CliConfigHistorySettingsTest.java b/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/CliConfigHistorySettingsTest.java index 217f9150b54b3..e56746d4c9aba 100644 --- a/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/CliConfigHistorySettingsTest.java +++ b/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/CliConfigHistorySettingsTest.java @@ -11,7 +11,7 @@ import org.junit.jupiter.api.extension.RegisterExtension; import io.quarkus.aesh.runtime.CliConfig; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; /** * Tests that history-related configuration properties ({@code persistHistory}, @@ -20,7 +20,7 @@ public class CliConfigHistorySettingsTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar.addClass(DummyCommand.class)) .overrideConfigKey("quarkus.aesh.persist-history", "true") .overrideConfigKey("quarkus.aesh.history-file", "/tmp/test_aesh_history") diff --git a/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/CliConfigPromptTest.java b/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/CliConfigPromptTest.java index 34620493bef0c..0f95c4dca5ebc 100644 --- a/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/CliConfigPromptTest.java +++ b/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/CliConfigPromptTest.java @@ -19,7 +19,7 @@ import io.quarkus.builder.BuildChainBuilder; import io.quarkus.builder.BuildContext; import io.quarkus.builder.BuildStep; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; /** * Tests that a custom {@code quarkus.aesh.prompt} value appears in remote @@ -28,7 +28,7 @@ public class CliConfigPromptTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar.addClasses(DummyCommand.class)) .overrideConfigKey("quarkus.aesh.prompt", "myapp> ") .addBuildChainCustomizer(injectRemoteTransport()); diff --git a/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/CliSettingsCustomizerTest.java b/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/CliSettingsCustomizerTest.java index ed645d0c414f1..b473404d53c0f 100644 --- a/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/CliSettingsCustomizerTest.java +++ b/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/CliSettingsCustomizerTest.java @@ -20,7 +20,7 @@ import io.quarkus.builder.BuildChainBuilder; import io.quarkus.builder.BuildContext; import io.quarkus.builder.BuildStep; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; /** * Tests that a {@link CliSettings} SPI bean is discovered and invoked when @@ -29,7 +29,7 @@ public class CliSettingsCustomizerTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar.addClasses( DummyCommand.class, TestCliSettings.class, diff --git a/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/CompletionGenerationTest.java b/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/CompletionGenerationTest.java index 5da674377bd12..39e64697dddc6 100644 --- a/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/CompletionGenerationTest.java +++ b/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/CompletionGenerationTest.java @@ -13,7 +13,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; /** * Tests that shell completion script generation works for commands @@ -22,7 +22,7 @@ public class CompletionGenerationTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar.addClasses( CommonOptions.class, DeployCommand.class, diff --git a/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/DuplicateCommandNameValidationTest.java b/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/DuplicateCommandNameValidationTest.java index 5779cbb3608e6..e5253ee06edbf 100644 --- a/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/DuplicateCommandNameValidationTest.java +++ b/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/DuplicateCommandNameValidationTest.java @@ -9,7 +9,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; /** * Tests that duplicate top-level command names are detected at build time. @@ -19,7 +19,7 @@ public class DuplicateCommandNameValidationTest { @RegisterExtension - static final QuarkusUnitTest test = new QuarkusUnitTest() + static final QuarkusExtensionTest test = new QuarkusExtensionTest() .setExpectedException(DeploymentException.class) .withApplicationRoot(jar -> jar.addClasses(GreetCommandA.class, GreetCommandB.class)); diff --git a/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/ExplicitConsoleModeConfigTest.java b/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/ExplicitConsoleModeConfigTest.java index 6cc2a42c28cc7..603ae6f849923 100644 --- a/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/ExplicitConsoleModeConfigTest.java +++ b/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/ExplicitConsoleModeConfigTest.java @@ -12,7 +12,7 @@ import io.quarkus.aesh.runtime.AeshContext; import io.quarkus.aesh.runtime.AeshMode; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; /** * Tests that {@code quarkus.aesh.mode=console} forces console mode even when @@ -21,7 +21,7 @@ public class ExplicitConsoleModeConfigTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar.addClass(SingleCommand.class)) .overrideConfigKey("quarkus.aesh.mode", "console"); diff --git a/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/GroupSubCommandNotAnnotatedValidationTest.java b/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/GroupSubCommandNotAnnotatedValidationTest.java index 491764a64bf19..a6fd544cc32fe 100644 --- a/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/GroupSubCommandNotAnnotatedValidationTest.java +++ b/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/GroupSubCommandNotAnnotatedValidationTest.java @@ -9,7 +9,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; /** * Tests that a {@code @CommandDefinition(groupCommands=...)} referencing a sub-command class @@ -19,7 +19,7 @@ public class GroupSubCommandNotAnnotatedValidationTest { @RegisterExtension - static final QuarkusUnitTest test = new QuarkusUnitTest() + static final QuarkusExtensionTest test = new QuarkusExtensionTest() .setExpectedException(DeploymentException.class) .withApplicationRoot(jar -> jar.addClasses(MyGroup.class, UnannotatedSubCommand.class)); diff --git a/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/MixinCommandTest.java b/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/MixinCommandTest.java index adb3f33d4d3be..65c0312457158 100644 --- a/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/MixinCommandTest.java +++ b/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/MixinCommandTest.java @@ -15,7 +15,7 @@ import org.junit.jupiter.api.extension.RegisterExtension; import io.quarkus.aesh.runtime.AeshContext; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; /** * Tests that aesh @Mixin support works correctly with the Quarkus extension. @@ -24,7 +24,7 @@ public class MixinCommandTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar.addClasses( OutputMixin.class, MixinGreetCommand.class, diff --git a/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/RemoteTransportForceConsoleTest.java b/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/RemoteTransportForceConsoleTest.java index 9b803ab148eaf..46fdd431802b8 100644 --- a/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/RemoteTransportForceConsoleTest.java +++ b/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/RemoteTransportForceConsoleTest.java @@ -18,20 +18,20 @@ import io.quarkus.builder.BuildChainBuilder; import io.quarkus.builder.BuildContext; import io.quarkus.builder.BuildStep; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; /** * Tests that {@code quarkus.aesh.start-console=true} forces the local console * to start even when remote transports are present. *

- * Since this is a QuarkusUnitTest (not prod mode), the CliRunner bean is registered + * Since this is a QuarkusExtensionTest (not prod mode), the CliRunner bean is registered * but not actually started. We verify that the CliRunner bean IS present in the * container, proving the config override works. */ public class RemoteTransportForceConsoleTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar.addClasses(HelloCommand.class)) .overrideConfigKey("quarkus.aesh.start-console", "true") .addBuildChainCustomizer(injectRemoteTransport()); diff --git a/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/RemoteTransportSkipsConsoleTest.java b/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/RemoteTransportSkipsConsoleTest.java index a445dcc9d35b7..376dafbad64e1 100644 --- a/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/RemoteTransportSkipsConsoleTest.java +++ b/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/RemoteTransportSkipsConsoleTest.java @@ -17,7 +17,7 @@ import io.quarkus.builder.BuildChainBuilder; import io.quarkus.builder.BuildContext; import io.quarkus.builder.BuildStep; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; /** * Tests that when a remote transport (e.g. WebSocket or SSH) is present, @@ -27,7 +27,7 @@ public class RemoteTransportSkipsConsoleTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar.addClasses(HelloCommand.class)) .addBuildChainCustomizer(injectRemoteTransport()); diff --git a/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/StartConsoleDisabledTest.java b/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/StartConsoleDisabledTest.java index 994eb2973371d..97ebfa26b9d62 100644 --- a/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/StartConsoleDisabledTest.java +++ b/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/StartConsoleDisabledTest.java @@ -12,7 +12,7 @@ import io.quarkus.aesh.runtime.AeshContext; import io.quarkus.aesh.runtime.AeshMode; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; /** * Tests that {@code quarkus.aesh.start-console=false} disables the local console @@ -22,7 +22,7 @@ public class StartConsoleDisabledTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar.addClasses(HelloCommand.class, GoodbyeCommand.class)) .overrideConfigKey("quarkus.aesh.start-console", "false"); diff --git a/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/SubCommandModeConfigTest.java b/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/SubCommandModeConfigTest.java index 921dd0211b546..eda3c7b1950f9 100644 --- a/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/SubCommandModeConfigTest.java +++ b/extensions/aesh/deployment/src/test/java/io/quarkus/aesh/deployment/SubCommandModeConfigTest.java @@ -11,7 +11,7 @@ import org.junit.jupiter.api.extension.RegisterExtension; import io.quarkus.aesh.runtime.CliConfig; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; /** * Tests that all 6 {@link CliConfig.SubCommandModeConfig} properties bind correctly @@ -20,7 +20,7 @@ public class SubCommandModeConfigTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar.addClass(DummyCommand.class)) .overrideConfigKey("quarkus.aesh.sub-command-mode.enabled", "false") .overrideConfigKey("quarkus.aesh.sub-command-mode.exit-command", "quit") diff --git a/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshBuildTest.java b/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshBuildTest.java index fd2a8f0af3607..5f4f70c87b891 100644 --- a/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshBuildTest.java +++ b/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshBuildTest.java @@ -9,7 +9,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; /** * Verifies the aesh SSH extension builds correctly when CLI commands are present. @@ -20,7 +20,7 @@ public class AeshSshBuildTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar.addClasses( HelloCommand.class, GoodbyeCommand.class)) diff --git a/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshCombinedAuthTest.java b/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshCombinedAuthTest.java index 0655f37181f46..1cc3c7726ec86 100644 --- a/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshCombinedAuthTest.java +++ b/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshCombinedAuthTest.java @@ -24,7 +24,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; /** * Tests that both password and public key authentication work together when @@ -54,7 +54,7 @@ public class AeshSshCombinedAuthTest { } @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar.addClasses( HelloCommand.class, GoodbyeCommand.class)) diff --git a/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshConcurrentSessionsTest.java b/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshConcurrentSessionsTest.java index 773dbc9b271a0..8249f09f7d6ac 100644 --- a/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshConcurrentSessionsTest.java +++ b/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshConcurrentSessionsTest.java @@ -23,7 +23,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; /** * Verifies that multiple SSH clients can connect simultaneously and @@ -35,7 +35,7 @@ public class AeshSshConcurrentSessionsTest { private static final int SESSION_COUNT = 3; @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar.addClasses(HelloCommand.class)) .overrideConfigKey("quarkus.aesh.ssh.port", String.valueOf(SSH_PORT)) .overrideConfigKey("quarkus.aesh.ssh.password", "test-pw"); diff --git a/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshConnectionTest.java b/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshConnectionTest.java index 985d9a9906a57..a24baf1507a52 100644 --- a/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshConnectionTest.java +++ b/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshConnectionTest.java @@ -16,7 +16,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; /** * End-to-end integration test for the aesh SSH terminal extension. @@ -27,7 +27,7 @@ public class AeshSshConnectionTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar.addClasses( HelloCommand.class, GoodbyeCommand.class)) diff --git a/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshCustomHostTest.java b/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshCustomHostTest.java index fbe4852544f4c..25a0a4334b8b9 100644 --- a/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshCustomHostTest.java +++ b/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshCustomHostTest.java @@ -9,7 +9,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; import io.restassured.RestAssured; /** @@ -21,7 +21,7 @@ public class AeshSshCustomHostTest { private static final int SSH_PORT = 12231; @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar.addClasses( HelloCommand.class, GoodbyeCommand.class)) diff --git a/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshDisabledTest.java b/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshDisabledTest.java index d3fd19816450a..2b332b454a9df 100644 --- a/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshDisabledTest.java +++ b/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshDisabledTest.java @@ -11,7 +11,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; /** * Verifies that setting {@code quarkus.aesh.ssh.enabled=false} at build time @@ -23,7 +23,7 @@ public class AeshSshDisabledTest { private static final int SSH_PORT = 12225; @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar.addClasses( HelloCommand.class, GoodbyeCommand.class)) diff --git a/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshHealthActiveConnectionsTest.java b/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshHealthActiveConnectionsTest.java index 738835875344b..c82ae244c472c 100644 --- a/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshHealthActiveConnectionsTest.java +++ b/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshHealthActiveConnectionsTest.java @@ -16,7 +16,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; import io.restassured.RestAssured; /** @@ -26,7 +26,7 @@ public class AeshSshHealthActiveConnectionsTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar.addClasses(HelloCommand.class)) .overrideConfigKey("quarkus.aesh.ssh.port", "12235") .overrideConfigKey("quarkus.http.test-port", "0"); diff --git a/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshHealthCheckTest.java b/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshHealthCheckTest.java index 73b3bb606c5a8..ffc82d982a11e 100644 --- a/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshHealthCheckTest.java +++ b/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshHealthCheckTest.java @@ -9,13 +9,13 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; import io.restassured.RestAssured; public class AeshSshHealthCheckTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar.addClasses( HelloCommand.class, GoodbyeCommand.class)) diff --git a/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshHealthDisabledTest.java b/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshHealthDisabledTest.java index bbf0e833c50c3..64e02b7c11eb5 100644 --- a/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshHealthDisabledTest.java +++ b/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshHealthDisabledTest.java @@ -8,7 +8,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; import io.restassured.RestAssured; /** @@ -18,7 +18,7 @@ public class AeshSshHealthDisabledTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar.addClasses( HelloCommand.class, GoodbyeCommand.class)) diff --git a/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshIdleActivityResetTest.java b/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshIdleActivityResetTest.java index 339082c255fee..ce1acc9d1128d 100644 --- a/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshIdleActivityResetTest.java +++ b/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshIdleActivityResetTest.java @@ -15,7 +15,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; /** * Verifies that sending commands resets the idle timeout timer, @@ -24,7 +24,7 @@ public class AeshSshIdleActivityResetTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar.addClasses(HelloCommand.class)) .overrideConfigKey("quarkus.aesh.ssh.port", "12237") .overrideConfigKey("quarkus.http.test-port", "0") diff --git a/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshIdleTimeoutTest.java b/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshIdleTimeoutTest.java index 6f697f0c75ea6..a71eda2c0ca2a 100644 --- a/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshIdleTimeoutTest.java +++ b/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshIdleTimeoutTest.java @@ -18,7 +18,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; /** * Verifies that the SSH server closes idle sessions after the configured timeout. @@ -28,7 +28,7 @@ public class AeshSshIdleTimeoutTest { private static final int SSH_PORT = 12227; @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar.addClasses(HelloCommand.class)) .overrideConfigKey("quarkus.aesh.ssh.port", String.valueOf(SSH_PORT)) .overrideConfigKey("quarkus.aesh.ssh.password", "test-pw") diff --git a/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshMaxConnectionsTest.java b/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshMaxConnectionsTest.java index c388ce8f11ceb..262fae34181a2 100644 --- a/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshMaxConnectionsTest.java +++ b/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshMaxConnectionsTest.java @@ -16,7 +16,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; /** * Verifies that the SSH server enforces the max-connections limit. @@ -26,7 +26,7 @@ public class AeshSshMaxConnectionsTest { private static final int SSH_PORT = 12226; @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar.addClasses(HelloCommand.class)) .overrideConfigKey("quarkus.aesh.ssh.port", String.valueOf(SSH_PORT)) .overrideConfigKey("quarkus.aesh.ssh.password", "test-pw") diff --git a/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshNoAuthWarningTest.java b/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshNoAuthWarningTest.java index 598e7d1158ac6..003e8f3eb2b20 100644 --- a/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshNoAuthWarningTest.java +++ b/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshNoAuthWarningTest.java @@ -12,7 +12,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; /** * Verifies that a warning is logged when the SSH server starts without @@ -21,7 +21,7 @@ public class AeshSshNoAuthWarningTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar.addClasses( HelloCommand.class, GoodbyeCommand.class)) diff --git a/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshPasswordAuthTest.java b/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshPasswordAuthTest.java index 218a34f99bd6c..3a43f648c446a 100644 --- a/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshPasswordAuthTest.java +++ b/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshPasswordAuthTest.java @@ -11,7 +11,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; /** * Tests SSH password authentication for the aesh SSH extension. @@ -22,7 +22,7 @@ public class AeshSshPasswordAuthTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar.addClasses( HelloCommand.class, GoodbyeCommand.class)) diff --git a/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshPublicKeyAuthTest.java b/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshPublicKeyAuthTest.java index 5d606c82f8921..18a9dd1c077d3 100644 --- a/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshPublicKeyAuthTest.java +++ b/extensions/aesh/ssh/deployment/src/test/java/io/quarkus/aesh/ssh/deployment/AeshSshPublicKeyAuthTest.java @@ -25,7 +25,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; /** * Tests SSH public key authentication for the aesh SSH extension. @@ -56,7 +56,7 @@ public class AeshSshPublicKeyAuthTest { } @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar.addClasses( HelloCommand.class, GoodbyeCommand.class)) diff --git a/extensions/aesh/websocket/deployment/src/test/java/io/quarkus/aesh/websocket/deployment/AeshWebSocketAuthenticatedTest.java b/extensions/aesh/websocket/deployment/src/test/java/io/quarkus/aesh/websocket/deployment/AeshWebSocketAuthenticatedTest.java index b8c783aa67738..cc41820c092d4 100644 --- a/extensions/aesh/websocket/deployment/src/test/java/io/quarkus/aesh/websocket/deployment/AeshWebSocketAuthenticatedTest.java +++ b/extensions/aesh/websocket/deployment/src/test/java/io/quarkus/aesh/websocket/deployment/AeshWebSocketAuthenticatedTest.java @@ -20,7 +20,7 @@ import io.quarkus.security.test.utils.TestIdentityController; import io.quarkus.security.test.utils.TestIdentityProvider; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; import io.quarkus.test.common.http.TestHTTPResource; import io.vertx.core.Vertx; import io.vertx.core.http.WebSocketClient; @@ -33,7 +33,7 @@ public class AeshWebSocketAuthenticatedTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar.addClasses( AeshWebSocketTestHelper.class, HelloCommand.class, diff --git a/extensions/aesh/websocket/deployment/src/test/java/io/quarkus/aesh/websocket/deployment/AeshWebSocketBuildTest.java b/extensions/aesh/websocket/deployment/src/test/java/io/quarkus/aesh/websocket/deployment/AeshWebSocketBuildTest.java index c08119cb5c08c..279433291f107 100644 --- a/extensions/aesh/websocket/deployment/src/test/java/io/quarkus/aesh/websocket/deployment/AeshWebSocketBuildTest.java +++ b/extensions/aesh/websocket/deployment/src/test/java/io/quarkus/aesh/websocket/deployment/AeshWebSocketBuildTest.java @@ -9,7 +9,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; /** * Verifies the aesh WebSocket extension builds correctly when CLI commands are present. @@ -20,7 +20,7 @@ public class AeshWebSocketBuildTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar.addClasses( HelloCommand.class, GoodbyeCommand.class)); diff --git a/extensions/aesh/websocket/deployment/src/test/java/io/quarkus/aesh/websocket/deployment/AeshWebSocketConcurrentSessionsTest.java b/extensions/aesh/websocket/deployment/src/test/java/io/quarkus/aesh/websocket/deployment/AeshWebSocketConcurrentSessionsTest.java index a9dfc9d192665..ebaf32f2b9eb4 100644 --- a/extensions/aesh/websocket/deployment/src/test/java/io/quarkus/aesh/websocket/deployment/AeshWebSocketConcurrentSessionsTest.java +++ b/extensions/aesh/websocket/deployment/src/test/java/io/quarkus/aesh/websocket/deployment/AeshWebSocketConcurrentSessionsTest.java @@ -16,7 +16,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; import io.quarkus.test.common.http.TestHTTPResource; import io.vertx.core.Vertx; import io.vertx.core.http.WebSocketClient; @@ -31,7 +31,7 @@ public class AeshWebSocketConcurrentSessionsTest { private static final int SESSION_COUNT = 3; @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar.addClasses( AeshWebSocketTestHelper.class, HelloCommand.class)) .overrideConfigKey("quarkus.websockets-next.server.traffic-logging.enabled", "true") diff --git a/extensions/aesh/websocket/deployment/src/test/java/io/quarkus/aesh/websocket/deployment/AeshWebSocketConnectionTest.java b/extensions/aesh/websocket/deployment/src/test/java/io/quarkus/aesh/websocket/deployment/AeshWebSocketConnectionTest.java index ecd101f2db463..a050bec402c5b 100644 --- a/extensions/aesh/websocket/deployment/src/test/java/io/quarkus/aesh/websocket/deployment/AeshWebSocketConnectionTest.java +++ b/extensions/aesh/websocket/deployment/src/test/java/io/quarkus/aesh/websocket/deployment/AeshWebSocketConnectionTest.java @@ -15,7 +15,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; import io.quarkus.test.common.http.TestHTTPResource; import io.vertx.core.Vertx; import io.vertx.core.http.WebSocketClient; @@ -27,7 +27,7 @@ public class AeshWebSocketConnectionTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar.addClasses( AeshWebSocketTestHelper.class, HelloCommand.class, diff --git a/extensions/aesh/websocket/deployment/src/test/java/io/quarkus/aesh/websocket/deployment/AeshWebSocketCustomPathTest.java b/extensions/aesh/websocket/deployment/src/test/java/io/quarkus/aesh/websocket/deployment/AeshWebSocketCustomPathTest.java index e833575d71142..30d7592e8df04 100644 --- a/extensions/aesh/websocket/deployment/src/test/java/io/quarkus/aesh/websocket/deployment/AeshWebSocketCustomPathTest.java +++ b/extensions/aesh/websocket/deployment/src/test/java/io/quarkus/aesh/websocket/deployment/AeshWebSocketCustomPathTest.java @@ -9,7 +9,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; import io.restassured.RestAssured; /** @@ -19,7 +19,7 @@ public class AeshWebSocketCustomPathTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar.addClasses( HelloCommand.class, GoodbyeCommand.class)) diff --git a/extensions/aesh/websocket/deployment/src/test/java/io/quarkus/aesh/websocket/deployment/AeshWebSocketDisabledTest.java b/extensions/aesh/websocket/deployment/src/test/java/io/quarkus/aesh/websocket/deployment/AeshWebSocketDisabledTest.java index 5093df7cde4dc..80c3d41cf99ec 100644 --- a/extensions/aesh/websocket/deployment/src/test/java/io/quarkus/aesh/websocket/deployment/AeshWebSocketDisabledTest.java +++ b/extensions/aesh/websocket/deployment/src/test/java/io/quarkus/aesh/websocket/deployment/AeshWebSocketDisabledTest.java @@ -16,7 +16,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; import io.quarkus.test.common.http.TestHTTPResource; import io.vertx.core.Vertx; import io.vertx.core.http.WebSocketClient; @@ -29,7 +29,7 @@ public class AeshWebSocketDisabledTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar.addClasses( HelloCommand.class, GoodbyeCommand.class)) diff --git a/extensions/aesh/websocket/deployment/src/test/java/io/quarkus/aesh/websocket/deployment/AeshWebSocketHealthCheckTest.java b/extensions/aesh/websocket/deployment/src/test/java/io/quarkus/aesh/websocket/deployment/AeshWebSocketHealthCheckTest.java index cf1aecdcae917..77abaa7f46bc9 100644 --- a/extensions/aesh/websocket/deployment/src/test/java/io/quarkus/aesh/websocket/deployment/AeshWebSocketHealthCheckTest.java +++ b/extensions/aesh/websocket/deployment/src/test/java/io/quarkus/aesh/websocket/deployment/AeshWebSocketHealthCheckTest.java @@ -9,13 +9,13 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; import io.restassured.RestAssured; public class AeshWebSocketHealthCheckTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar.addClasses( HelloCommand.class, GoodbyeCommand.class)); diff --git a/extensions/aesh/websocket/deployment/src/test/java/io/quarkus/aesh/websocket/deployment/AeshWebSocketHealthDisabledTest.java b/extensions/aesh/websocket/deployment/src/test/java/io/quarkus/aesh/websocket/deployment/AeshWebSocketHealthDisabledTest.java index d53d9c24f9fe3..ad348b95125ee 100644 --- a/extensions/aesh/websocket/deployment/src/test/java/io/quarkus/aesh/websocket/deployment/AeshWebSocketHealthDisabledTest.java +++ b/extensions/aesh/websocket/deployment/src/test/java/io/quarkus/aesh/websocket/deployment/AeshWebSocketHealthDisabledTest.java @@ -8,7 +8,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; import io.restassured.RestAssured; /** @@ -18,7 +18,7 @@ public class AeshWebSocketHealthDisabledTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar.addClasses( HelloCommand.class)) .overrideConfigKey("quarkus.aesh.websocket.health.enabled", "false"); diff --git a/extensions/aesh/websocket/deployment/src/test/java/io/quarkus/aesh/websocket/deployment/AeshWebSocketIdleTimeoutTest.java b/extensions/aesh/websocket/deployment/src/test/java/io/quarkus/aesh/websocket/deployment/AeshWebSocketIdleTimeoutTest.java index 2aa418bab658b..aee7d42e741ab 100644 --- a/extensions/aesh/websocket/deployment/src/test/java/io/quarkus/aesh/websocket/deployment/AeshWebSocketIdleTimeoutTest.java +++ b/extensions/aesh/websocket/deployment/src/test/java/io/quarkus/aesh/websocket/deployment/AeshWebSocketIdleTimeoutTest.java @@ -15,7 +15,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; import io.quarkus.test.common.http.TestHTTPResource; import io.vertx.core.Vertx; import io.vertx.core.http.WebSocketClient; @@ -27,7 +27,7 @@ public class AeshWebSocketIdleTimeoutTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar.addClasses( AeshWebSocketTestHelper.class, HelloCommand.class)) .overrideConfigKey("quarkus.aesh.websocket.idle-timeout", "2s") diff --git a/extensions/aesh/websocket/deployment/src/test/java/io/quarkus/aesh/websocket/deployment/AeshWebSocketMalformedMessageTest.java b/extensions/aesh/websocket/deployment/src/test/java/io/quarkus/aesh/websocket/deployment/AeshWebSocketMalformedMessageTest.java index 24d9f5daa5c09..c74c3fe0e7bf4 100644 --- a/extensions/aesh/websocket/deployment/src/test/java/io/quarkus/aesh/websocket/deployment/AeshWebSocketMalformedMessageTest.java +++ b/extensions/aesh/websocket/deployment/src/test/java/io/quarkus/aesh/websocket/deployment/AeshWebSocketMalformedMessageTest.java @@ -15,7 +15,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; import io.quarkus.test.common.http.TestHTTPResource; import io.vertx.core.Vertx; import io.vertx.core.http.WebSocketClient; @@ -28,7 +28,7 @@ public class AeshWebSocketMalformedMessageTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar.addClasses(HelloCommand.class)); @TestHTTPResource("/aesh/terminal") diff --git a/extensions/aesh/websocket/deployment/src/test/java/io/quarkus/aesh/websocket/deployment/AeshWebSocketMaxConnectionsTest.java b/extensions/aesh/websocket/deployment/src/test/java/io/quarkus/aesh/websocket/deployment/AeshWebSocketMaxConnectionsTest.java index 82b1b7e4f196f..ec14b214fce2b 100644 --- a/extensions/aesh/websocket/deployment/src/test/java/io/quarkus/aesh/websocket/deployment/AeshWebSocketMaxConnectionsTest.java +++ b/extensions/aesh/websocket/deployment/src/test/java/io/quarkus/aesh/websocket/deployment/AeshWebSocketMaxConnectionsTest.java @@ -18,7 +18,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; import io.quarkus.test.common.http.TestHTTPResource; import io.vertx.core.Vertx; import io.vertx.core.http.WebSocket; @@ -36,7 +36,7 @@ public class AeshWebSocketMaxConnectionsTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar.addClasses(HelloCommand.class)) .overrideConfigKey("quarkus.aesh.websocket.max-connections", "2"); diff --git a/extensions/aesh/websocket/deployment/src/test/java/io/quarkus/aesh/websocket/deployment/AeshWebSocketRolesAllowedTest.java b/extensions/aesh/websocket/deployment/src/test/java/io/quarkus/aesh/websocket/deployment/AeshWebSocketRolesAllowedTest.java index 844e33e24a1de..e4295f63a7764 100644 --- a/extensions/aesh/websocket/deployment/src/test/java/io/quarkus/aesh/websocket/deployment/AeshWebSocketRolesAllowedTest.java +++ b/extensions/aesh/websocket/deployment/src/test/java/io/quarkus/aesh/websocket/deployment/AeshWebSocketRolesAllowedTest.java @@ -20,7 +20,7 @@ import io.quarkus.security.test.utils.TestIdentityController; import io.quarkus.security.test.utils.TestIdentityProvider; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; import io.quarkus.test.common.http.TestHTTPResource; import io.vertx.core.Vertx; import io.vertx.core.http.WebSocketClient; @@ -33,7 +33,7 @@ public class AeshWebSocketRolesAllowedTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar.addClasses( AeshWebSocketTestHelper.class, HelloCommand.class, diff --git a/extensions/hibernate-reactive/deployment/src/test/java/io/quarkus/hibernate/reactive/transaction/DisableJTATransactionTest.java b/extensions/hibernate-reactive/deployment/src/test/java/io/quarkus/hibernate/reactive/transaction/DisableJTATransactionTest.java index 3ddd118cda2e6..56f3f71a67186 100644 --- a/extensions/hibernate-reactive/deployment/src/test/java/io/quarkus/hibernate/reactive/transaction/DisableJTATransactionTest.java +++ b/extensions/hibernate-reactive/deployment/src/test/java/io/quarkus/hibernate/reactive/transaction/DisableJTATransactionTest.java @@ -16,7 +16,7 @@ import io.quarkus.builder.Version; import io.quarkus.maven.dependency.Dependency; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; import io.quarkus.test.vertx.RunOnVertxContext; import io.quarkus.test.vertx.UniAsserter; import io.smallrye.mutiny.Uni; @@ -27,7 +27,7 @@ public class DisableJTATransactionTest { TransactionManager transactionManager; @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class)) .setForcedDependencies(List.of( Dependency.of("io.quarkus", "quarkus-narayana-jta", Version.getVersion()))); diff --git a/extensions/hibernate-reactive/deployment/src/test/java/io/quarkus/hibernate/reactive/transaction/HibernateReactiveStatelessTransactionsTest.java b/extensions/hibernate-reactive/deployment/src/test/java/io/quarkus/hibernate/reactive/transaction/HibernateReactiveStatelessTransactionsTest.java index 371020333e8e0..5fee285c47dc6 100644 --- a/extensions/hibernate-reactive/deployment/src/test/java/io/quarkus/hibernate/reactive/transaction/HibernateReactiveStatelessTransactionsTest.java +++ b/extensions/hibernate-reactive/deployment/src/test/java/io/quarkus/hibernate/reactive/transaction/HibernateReactiveStatelessTransactionsTest.java @@ -10,7 +10,7 @@ import org.junit.jupiter.api.extension.RegisterExtension; import io.quarkus.reactive.transaction.runtime.TransactionalInterceptorRequired; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; import io.quarkus.test.vertx.RunOnVertxContext; import io.quarkus.test.vertx.UniAsserter; import io.quarkus.transaction.annotations.Rollback; @@ -19,7 +19,7 @@ public class HibernateReactiveStatelessTransactionsTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar .addClasses(Hero.class) .addClasses(TransactionalInterceptorRequired.class) diff --git a/extensions/hibernate-reactive/deployment/src/test/java/io/quarkus/hibernate/reactive/transaction/HibernateReactiveTransactionalRetryTest.java b/extensions/hibernate-reactive/deployment/src/test/java/io/quarkus/hibernate/reactive/transaction/HibernateReactiveTransactionalRetryTest.java index f07505b69ef16..e1148db97d04e 100644 --- a/extensions/hibernate-reactive/deployment/src/test/java/io/quarkus/hibernate/reactive/transaction/HibernateReactiveTransactionalRetryTest.java +++ b/extensions/hibernate-reactive/deployment/src/test/java/io/quarkus/hibernate/reactive/transaction/HibernateReactiveTransactionalRetryTest.java @@ -11,7 +11,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; import io.quarkus.test.vertx.RunOnVertxContext; import io.quarkus.test.vertx.UniAsserter; import io.smallrye.mutiny.Uni; @@ -19,7 +19,7 @@ public class HibernateReactiveTransactionalRetryTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar .addClasses(Hero.class) .addAsResource("initialTransactionRetryData.sql", "import.sql")) diff --git a/extensions/hibernate-reactive/deployment/src/test/java/io/quarkus/hibernate/reactive/transaction/HibernateReactiveTransactionsTest.java b/extensions/hibernate-reactive/deployment/src/test/java/io/quarkus/hibernate/reactive/transaction/HibernateReactiveTransactionsTest.java index 60a9b1a854fc2..81444363ae2a4 100644 --- a/extensions/hibernate-reactive/deployment/src/test/java/io/quarkus/hibernate/reactive/transaction/HibernateReactiveTransactionsTest.java +++ b/extensions/hibernate-reactive/deployment/src/test/java/io/quarkus/hibernate/reactive/transaction/HibernateReactiveTransactionsTest.java @@ -10,7 +10,7 @@ import org.junit.jupiter.api.extension.RegisterExtension; import io.quarkus.reactive.transaction.runtime.TransactionalInterceptorRequired; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; import io.quarkus.test.vertx.RunOnVertxContext; import io.quarkus.test.vertx.UniAsserter; import io.smallrye.mutiny.Uni; @@ -19,7 +19,7 @@ public class HibernateReactiveTransactionsTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar .addClasses(Hero.class) .addClasses(TransactionalInterceptorRequired.class) diff --git a/extensions/hibernate-reactive/deployment/src/test/java/io/quarkus/hibernate/reactive/transaction/InterleaveTest.java b/extensions/hibernate-reactive/deployment/src/test/java/io/quarkus/hibernate/reactive/transaction/InterleaveTest.java index 4b22e0a4ba603..6b08086d95ad1 100644 --- a/extensions/hibernate-reactive/deployment/src/test/java/io/quarkus/hibernate/reactive/transaction/InterleaveTest.java +++ b/extensions/hibernate-reactive/deployment/src/test/java/io/quarkus/hibernate/reactive/transaction/InterleaveTest.java @@ -16,7 +16,7 @@ import io.quarkus.hibernate.reactive.runtime.OpenedSessionsState; import io.quarkus.reactive.transaction.runtime.TransactionalInterceptorRequired; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; import io.quarkus.test.vertx.RunOnVertxContext; import io.quarkus.test.vertx.UniAsserter; import io.smallrye.mutiny.Uni; @@ -49,7 +49,7 @@ public class InterleaveTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar .addClasses(Hero.class) .addClasses(TransactionalInterceptorRequired.class) diff --git a/extensions/hibernate-reactive/deployment/src/test/java/io/quarkus/hibernate/reactive/transaction/SupportOnlyRequiredTransactionTypeTest.java b/extensions/hibernate-reactive/deployment/src/test/java/io/quarkus/hibernate/reactive/transaction/SupportOnlyRequiredTransactionTypeTest.java index 9c2af4d8bc204..4f3b996e65544 100644 --- a/extensions/hibernate-reactive/deployment/src/test/java/io/quarkus/hibernate/reactive/transaction/SupportOnlyRequiredTransactionTypeTest.java +++ b/extensions/hibernate-reactive/deployment/src/test/java/io/quarkus/hibernate/reactive/transaction/SupportOnlyRequiredTransactionTypeTest.java @@ -12,14 +12,14 @@ import io.quarkus.reactive.transaction.runtime.TransactionalInterceptorNotSupported; import io.quarkus.reactive.transaction.runtime.TransactionalInterceptorRequiresNew; import io.quarkus.reactive.transaction.runtime.TransactionalInterceptorSupports; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; import io.quarkus.test.vertx.RunOnVertxContext; import io.smallrye.mutiny.Uni; public class SupportOnlyRequiredTransactionTypeTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar.addDefaultPackage() .addClasses( TransactionalInterceptorNever.class, diff --git a/extensions/hibernate-reactive/deployment/src/test/java/io/quarkus/hibernate/reactive/transaction/mixing/MixStatelessStatefulSessionTest.java b/extensions/hibernate-reactive/deployment/src/test/java/io/quarkus/hibernate/reactive/transaction/mixing/MixStatelessStatefulSessionTest.java index b4e1b70144b1e..8b9b84a4725c9 100644 --- a/extensions/hibernate-reactive/deployment/src/test/java/io/quarkus/hibernate/reactive/transaction/mixing/MixStatelessStatefulSessionTest.java +++ b/extensions/hibernate-reactive/deployment/src/test/java/io/quarkus/hibernate/reactive/transaction/mixing/MixStatelessStatefulSessionTest.java @@ -10,7 +10,7 @@ import org.junit.jupiter.api.extension.RegisterExtension; import io.quarkus.hibernate.reactive.transaction.Hero; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; import io.quarkus.test.vertx.RunOnVertxContext; import io.quarkus.test.vertx.UniAsserter; import io.smallrye.mutiny.Uni; @@ -23,7 +23,7 @@ public class MixStatelessStatefulSessionTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(jar -> jar.addClasses(Hero.class)) .withConfigurationResource("application-reactive-transaction.properties"); diff --git a/extensions/logging-json/deployment/src/test/java/io/quarkus/logging/json/NamedConsoleJsonFormatterInheritTest.java b/extensions/logging-json/deployment/src/test/java/io/quarkus/logging/json/NamedConsoleJsonFormatterInheritTest.java index 464dc7f67626c..3ce6cac8ce007 100644 --- a/extensions/logging-json/deployment/src/test/java/io/quarkus/logging/json/NamedConsoleJsonFormatterInheritTest.java +++ b/extensions/logging-json/deployment/src/test/java/io/quarkus/logging/json/NamedConsoleJsonFormatterInheritTest.java @@ -9,12 +9,12 @@ import org.junit.jupiter.api.extension.RegisterExtension; import io.quarkus.logging.json.runtime.JsonFormatter; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; public class NamedConsoleJsonFormatterInheritTest extends AbstractNamedJsonFormatterTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withConfigurationResource("application-named-console-json-formatter-inherit.properties"); @Test diff --git a/extensions/logging-json/deployment/src/test/java/io/quarkus/logging/json/NamedConsoleJsonFormatterTest.java b/extensions/logging-json/deployment/src/test/java/io/quarkus/logging/json/NamedConsoleJsonFormatterTest.java index ef636e7ba179e..375767cc6825c 100644 --- a/extensions/logging-json/deployment/src/test/java/io/quarkus/logging/json/NamedConsoleJsonFormatterTest.java +++ b/extensions/logging-json/deployment/src/test/java/io/quarkus/logging/json/NamedConsoleJsonFormatterTest.java @@ -10,12 +10,12 @@ import org.junit.jupiter.api.extension.RegisterExtension; import io.quarkus.logging.json.runtime.JsonFormatter; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; public class NamedConsoleJsonFormatterTest extends AbstractNamedJsonFormatterTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withConfigurationResource("application-named-console-json-formatter.properties"); @Test diff --git a/extensions/logging-json/deployment/src/test/java/io/quarkus/logging/json/NamedFileJsonFormatterTest.java b/extensions/logging-json/deployment/src/test/java/io/quarkus/logging/json/NamedFileJsonFormatterTest.java index dcda2a1798e69..dd0dc198f0202 100644 --- a/extensions/logging-json/deployment/src/test/java/io/quarkus/logging/json/NamedFileJsonFormatterTest.java +++ b/extensions/logging-json/deployment/src/test/java/io/quarkus/logging/json/NamedFileJsonFormatterTest.java @@ -10,12 +10,12 @@ import org.junit.jupiter.api.extension.RegisterExtension; import io.quarkus.logging.json.runtime.JsonFormatter; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; public class NamedFileJsonFormatterTest extends AbstractNamedJsonFormatterTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withConfigurationResource("application-named-file-json-formatter.properties"); @Test diff --git a/extensions/logging-json/deployment/src/test/java/io/quarkus/logging/json/NamedSocketJsonFormatterTest.java b/extensions/logging-json/deployment/src/test/java/io/quarkus/logging/json/NamedSocketJsonFormatterTest.java index 6c87311102c60..34954c73113bf 100644 --- a/extensions/logging-json/deployment/src/test/java/io/quarkus/logging/json/NamedSocketJsonFormatterTest.java +++ b/extensions/logging-json/deployment/src/test/java/io/quarkus/logging/json/NamedSocketJsonFormatterTest.java @@ -10,12 +10,12 @@ import org.junit.jupiter.api.extension.RegisterExtension; import io.quarkus.logging.json.runtime.JsonFormatter; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; public class NamedSocketJsonFormatterTest extends AbstractNamedJsonFormatterTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withConfigurationResource("application-named-socket-json-formatter.properties"); @Test diff --git a/extensions/logging-json/deployment/src/test/java/io/quarkus/logging/json/NamedSyslogJsonFormatterTest.java b/extensions/logging-json/deployment/src/test/java/io/quarkus/logging/json/NamedSyslogJsonFormatterTest.java index 992f4697fca64..3f1aa74211e41 100644 --- a/extensions/logging-json/deployment/src/test/java/io/quarkus/logging/json/NamedSyslogJsonFormatterTest.java +++ b/extensions/logging-json/deployment/src/test/java/io/quarkus/logging/json/NamedSyslogJsonFormatterTest.java @@ -10,12 +10,12 @@ import org.junit.jupiter.api.extension.RegisterExtension; import io.quarkus.logging.json.runtime.JsonFormatter; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; public class NamedSyslogJsonFormatterTest extends AbstractNamedJsonFormatterTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withConfigurationResource("application-named-syslog-json-formatter.properties"); @Test diff --git a/extensions/micrometer/deployment/src/test/java/io/quarkus/micrometer/deployment/devui/MetricsSamplerProdGuardTest.java b/extensions/micrometer/deployment/src/test/java/io/quarkus/micrometer/deployment/devui/MetricsSamplerProdGuardTest.java index 2928c4c8df95d..3564e8915d423 100644 --- a/extensions/micrometer/deployment/src/test/java/io/quarkus/micrometer/deployment/devui/MetricsSamplerProdGuardTest.java +++ b/extensions/micrometer/deployment/src/test/java/io/quarkus/micrometer/deployment/devui/MetricsSamplerProdGuardTest.java @@ -9,12 +9,12 @@ import org.junit.jupiter.api.extension.RegisterExtension; import io.quarkus.micrometer.runtime.devui.DevUiMetricsSampler; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; public class MetricsSamplerProdGuardTest { @RegisterExtension - static final QuarkusUnitTest test = new QuarkusUnitTest() + static final QuarkusExtensionTest test = new QuarkusExtensionTest() .overrideConfigKey("quarkus.redis.devservices.enabled", "false") .withApplicationRoot((JavaArchive jar) -> { }); diff --git a/extensions/mongodb-client/deployment/src/test/java/io/quarkus/mongodb/MongoTracingConfigTest.java b/extensions/mongodb-client/deployment/src/test/java/io/quarkus/mongodb/MongoTracingConfigTest.java index 1120bbc0270fd..896458eadb877 100644 --- a/extensions/mongodb-client/deployment/src/test/java/io/quarkus/mongodb/MongoTracingConfigTest.java +++ b/extensions/mongodb-client/deployment/src/test/java/io/quarkus/mongodb/MongoTracingConfigTest.java @@ -8,12 +8,12 @@ import org.junit.jupiter.api.extension.RegisterExtension; import io.quarkus.mongodb.tracing.MongoTracingCommandListener; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; public class MongoTracingConfigTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withConfigurationResource("application-tracing-mongoclient.properties"); @Inject diff --git a/extensions/mongodb-client/deployment/src/test/java/io/quarkus/mongodb/MongoTracingOffTest.java b/extensions/mongodb-client/deployment/src/test/java/io/quarkus/mongodb/MongoTracingOffTest.java index f50b89c90e29e..ef14b2f11cca0 100644 --- a/extensions/mongodb-client/deployment/src/test/java/io/quarkus/mongodb/MongoTracingOffTest.java +++ b/extensions/mongodb-client/deployment/src/test/java/io/quarkus/mongodb/MongoTracingOffTest.java @@ -11,12 +11,12 @@ import io.quarkus.mongodb.runtime.MongoConfig; import io.quarkus.mongodb.runtime.MongoTracingRuntimeConfig.CommandDetailLevel; import io.quarkus.mongodb.tracing.MongoTracingCommandListener; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; public class MongoTracingOffTest { @RegisterExtension - static QuarkusUnitTest runner = new QuarkusUnitTest() + static QuarkusExtensionTest runner = new QuarkusExtensionTest() .withConfigurationResource("application-tracing-off.properties"); @Inject diff --git a/extensions/mongodb-client/deployment/src/test/java/io/quarkus/mongodb/MongoTracingSanitizationTest.java b/extensions/mongodb-client/deployment/src/test/java/io/quarkus/mongodb/MongoTracingSanitizationTest.java index 04ef7c59792ec..1f0ce5f948c46 100644 --- a/extensions/mongodb-client/deployment/src/test/java/io/quarkus/mongodb/MongoTracingSanitizationTest.java +++ b/extensions/mongodb-client/deployment/src/test/java/io/quarkus/mongodb/MongoTracingSanitizationTest.java @@ -11,12 +11,12 @@ import io.quarkus.mongodb.runtime.MongoConfig; import io.quarkus.mongodb.runtime.MongoTracingRuntimeConfig.CommandDetailLevel; import io.quarkus.mongodb.tracing.MongoTracingCommandListener; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; public class MongoTracingSanitizationTest { @RegisterExtension - static QuarkusUnitTest runner = new QuarkusUnitTest() + static QuarkusExtensionTest runner = new QuarkusExtensionTest() .withConfigurationResource("application-tracing-sanitized.properties"); @Inject diff --git a/extensions/narayana-jta/deployment/src/test/java/io/quarkus/narayana/quarkus/TransactionRecoveryExplicitlyDisabledTest.java b/extensions/narayana-jta/deployment/src/test/java/io/quarkus/narayana/quarkus/TransactionRecoveryExplicitlyDisabledTest.java index f32f3c021463b..7dd8e256d9b10 100644 --- a/extensions/narayana-jta/deployment/src/test/java/io/quarkus/narayana/quarkus/TransactionRecoveryExplicitlyDisabledTest.java +++ b/extensions/narayana-jta/deployment/src/test/java/io/quarkus/narayana/quarkus/TransactionRecoveryExplicitlyDisabledTest.java @@ -10,7 +10,7 @@ import io.quarkus.builder.Version; import io.quarkus.maven.dependency.Dependency; import io.quarkus.narayana.jta.runtime.QuarkusRecoveryService; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; /** * Tests that explicitly setting {@code enable-recovery=false} prevents the recovery @@ -19,7 +19,7 @@ public class TransactionRecoveryExplicitlyDisabledTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot((jar) -> jar .addAsResource("xa-recovery-auto-enable.properties", "application.properties")) .setForcedDependencies(List.of( diff --git a/extensions/narayana-jta/deployment/src/test/java/io/quarkus/narayana/quarkus/TransactionRecoveryExplicitlyEnabledWithoutXaTest.java b/extensions/narayana-jta/deployment/src/test/java/io/quarkus/narayana/quarkus/TransactionRecoveryExplicitlyEnabledWithoutXaTest.java index 0a78256345b3a..5916b8191b668 100644 --- a/extensions/narayana-jta/deployment/src/test/java/io/quarkus/narayana/quarkus/TransactionRecoveryExplicitlyEnabledWithoutXaTest.java +++ b/extensions/narayana-jta/deployment/src/test/java/io/quarkus/narayana/quarkus/TransactionRecoveryExplicitlyEnabledWithoutXaTest.java @@ -8,7 +8,7 @@ import org.junit.jupiter.api.extension.RegisterExtension; import io.quarkus.narayana.jta.runtime.QuarkusRecoveryService; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; /** * Tests that explicitly setting {@code enable-recovery=true} starts the recovery @@ -17,7 +17,7 @@ public class TransactionRecoveryExplicitlyEnabledWithoutXaTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class)) .overrideConfigKey("quarkus.transaction-manager.enable-recovery", "true"); diff --git a/extensions/narayana-jta/deployment/src/test/java/io/quarkus/narayana/quarkus/TransactionRecoveryNotEnabledWithoutXaTest.java b/extensions/narayana-jta/deployment/src/test/java/io/quarkus/narayana/quarkus/TransactionRecoveryNotEnabledWithoutXaTest.java index 91118dc1854cd..448f5b888a857 100644 --- a/extensions/narayana-jta/deployment/src/test/java/io/quarkus/narayana/quarkus/TransactionRecoveryNotEnabledWithoutXaTest.java +++ b/extensions/narayana-jta/deployment/src/test/java/io/quarkus/narayana/quarkus/TransactionRecoveryNotEnabledWithoutXaTest.java @@ -8,7 +8,7 @@ import org.junit.jupiter.api.extension.RegisterExtension; import io.quarkus.narayana.jta.runtime.QuarkusRecoveryService; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; /** * Tests that the recovery service is NOT started when no XA datasources @@ -17,7 +17,7 @@ public class TransactionRecoveryNotEnabledWithoutXaTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class)); @Test diff --git a/extensions/narayana-jta/deployment/src/test/java/io/quarkus/narayana/quarkus/TransactionRecoveryXaAutoEnableTest.java b/extensions/narayana-jta/deployment/src/test/java/io/quarkus/narayana/quarkus/TransactionRecoveryXaAutoEnableTest.java index 53f0169e4a796..d54d265ecc848 100644 --- a/extensions/narayana-jta/deployment/src/test/java/io/quarkus/narayana/quarkus/TransactionRecoveryXaAutoEnableTest.java +++ b/extensions/narayana-jta/deployment/src/test/java/io/quarkus/narayana/quarkus/TransactionRecoveryXaAutoEnableTest.java @@ -10,7 +10,7 @@ import io.quarkus.builder.Version; import io.quarkus.maven.dependency.Dependency; import io.quarkus.narayana.jta.runtime.QuarkusRecoveryService; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; /** * Tests that the recovery service is automatically enabled when XA datasources @@ -19,7 +19,7 @@ public class TransactionRecoveryXaAutoEnableTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot((jar) -> jar .addAsResource("xa-recovery-auto-enable.properties", "application.properties")) .setForcedDependencies(List.of( diff --git a/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/devui/OpenTelemetryDevUIProdGuardTest.java b/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/devui/OpenTelemetryDevUIProdGuardTest.java index d0790a345374d..69b48d48b0e00 100644 --- a/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/devui/OpenTelemetryDevUIProdGuardTest.java +++ b/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/devui/OpenTelemetryDevUIProdGuardTest.java @@ -14,7 +14,7 @@ import io.opentelemetry.sdk.trace.SpanProcessor; import io.quarkus.opentelemetry.deployment.common.TestUtil; import io.quarkus.opentelemetry.runtime.devui.DevUiTracesSpanProcessor; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; /** * Guards the design's "not a bean outside dev": in a non-dev build the capture @@ -26,7 +26,7 @@ public class OpenTelemetryDevUIProdGuardTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot((JavaArchive jar) -> jar.addClass(TestUtil.class)) // Keep the test hermetic: no OTLP export and no LGTM/observability dev // service container (which would also override the OTLP exporter). diff --git a/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/traces/TestModeSamplerDefaultTest.java b/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/traces/TestModeSamplerDefaultTest.java index cd6076d01aaf5..8876232a358d2 100644 --- a/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/traces/TestModeSamplerDefaultTest.java +++ b/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/traces/TestModeSamplerDefaultTest.java @@ -7,14 +7,14 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; public class TestModeSamplerDefaultTest { // No application.properties: the app uses the default sampler // (parentbased_traceidratio) and does NOT set sampler.arg. @RegisterExtension - static final QuarkusUnitTest TEST = new QuarkusUnitTest() + static final QuarkusExtensionTest TEST = new QuarkusExtensionTest() .withApplicationRoot((JavaArchive jar) -> jar.addClasses()); @Test diff --git a/extensions/panache/hibernate-reactive-panache/deployment/src/test/java/io/quarkus/hibernate/reactive/panache/test/transaction/MixReactiveTransactionalTest.java b/extensions/panache/hibernate-reactive-panache/deployment/src/test/java/io/quarkus/hibernate/reactive/panache/test/transaction/MixReactiveTransactionalTest.java index 49bcf24f93389..698584ea0f2f5 100644 --- a/extensions/panache/hibernate-reactive-panache/deployment/src/test/java/io/quarkus/hibernate/reactive/panache/test/transaction/MixReactiveTransactionalTest.java +++ b/extensions/panache/hibernate-reactive-panache/deployment/src/test/java/io/quarkus/hibernate/reactive/panache/test/transaction/MixReactiveTransactionalTest.java @@ -10,7 +10,7 @@ import io.quarkus.hibernate.reactive.panache.common.runtime.ReactiveTransactional; import io.quarkus.hibernate.reactive.runtime.transaction.HibernateActionsStrategy; import io.quarkus.reactive.transaction.runtime.TransactionalInterceptorRequired; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; import io.quarkus.test.vertx.RunOnVertxContext; import io.quarkus.test.vertx.UniAsserter; import io.smallrye.mutiny.Uni; @@ -18,7 +18,7 @@ public class MixReactiveTransactionalTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot((jar) -> jar .addClasses(TransactionalInterceptorRequired.class, HibernateActionsStrategy.class)); diff --git a/extensions/panache/hibernate-reactive-panache/deployment/src/test/java/io/quarkus/hibernate/reactive/panache/test/transaction/MixWithSessionOnDemandTest.java b/extensions/panache/hibernate-reactive-panache/deployment/src/test/java/io/quarkus/hibernate/reactive/panache/test/transaction/MixWithSessionOnDemandTest.java index 747353d7d81c9..0d46c2d6f824e 100644 --- a/extensions/panache/hibernate-reactive-panache/deployment/src/test/java/io/quarkus/hibernate/reactive/panache/test/transaction/MixWithSessionOnDemandTest.java +++ b/extensions/panache/hibernate-reactive-panache/deployment/src/test/java/io/quarkus/hibernate/reactive/panache/test/transaction/MixWithSessionOnDemandTest.java @@ -10,7 +10,7 @@ import io.quarkus.hibernate.reactive.panache.common.WithSessionOnDemand; import io.quarkus.hibernate.reactive.runtime.transaction.HibernateActionsStrategy; import io.quarkus.reactive.transaction.runtime.TransactionalInterceptorRequired; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; import io.quarkus.test.vertx.RunOnVertxContext; import io.quarkus.test.vertx.UniAsserter; import io.smallrye.mutiny.Uni; @@ -18,7 +18,7 @@ public class MixWithSessionOnDemandTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot((jar) -> jar .addClasses(TransactionalInterceptorRequired.class, HibernateActionsStrategy.class)); diff --git a/extensions/panache/hibernate-reactive-panache/deployment/src/test/java/io/quarkus/hibernate/reactive/panache/test/transaction/MixWithTransactionTest.java b/extensions/panache/hibernate-reactive-panache/deployment/src/test/java/io/quarkus/hibernate/reactive/panache/test/transaction/MixWithTransactionTest.java index b6546d796d6cf..2cab787ae5341 100644 --- a/extensions/panache/hibernate-reactive-panache/deployment/src/test/java/io/quarkus/hibernate/reactive/panache/test/transaction/MixWithTransactionTest.java +++ b/extensions/panache/hibernate-reactive-panache/deployment/src/test/java/io/quarkus/hibernate/reactive/panache/test/transaction/MixWithTransactionTest.java @@ -10,7 +10,7 @@ import io.quarkus.hibernate.reactive.panache.common.WithTransaction; import io.quarkus.hibernate.reactive.runtime.transaction.HibernateActionsStrategy; import io.quarkus.reactive.transaction.runtime.TransactionalInterceptorRequired; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; import io.quarkus.test.vertx.RunOnVertxContext; import io.quarkus.test.vertx.UniAsserter; import io.smallrye.mutiny.Uni; @@ -18,7 +18,7 @@ public class MixWithTransactionTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot((jar) -> jar .addClasses(TransactionalInterceptorRequired.class, HibernateActionsStrategy.class)); diff --git a/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/altexprcommand/AlternativeExprSyntaxDisabledCustomRootTest.java b/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/altexprcommand/AlternativeExprSyntaxDisabledCustomRootTest.java index 84b2566e23194..34f39556d66e3 100644 --- a/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/altexprcommand/AlternativeExprSyntaxDisabledCustomRootTest.java +++ b/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/altexprcommand/AlternativeExprSyntaxDisabledCustomRootTest.java @@ -15,12 +15,12 @@ import io.quarkus.builder.BuildStep; import io.quarkus.qute.Template; import io.quarkus.qute.deployment.TemplateRootBuildItem; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; public class AlternativeExprSyntaxDisabledCustomRootTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(root -> root .addAsResource(new StringAsset("{=foo.toLowerCase}{ignored}"), "templates/testik.html") // This config file is completely ignored diff --git a/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/altexprcommand/AlternativeExprSyntaxEnabledCustomRootTest.java b/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/altexprcommand/AlternativeExprSyntaxEnabledCustomRootTest.java index 6175518ea57db..104db161986e9 100644 --- a/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/altexprcommand/AlternativeExprSyntaxEnabledCustomRootTest.java +++ b/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/altexprcommand/AlternativeExprSyntaxEnabledCustomRootTest.java @@ -15,12 +15,12 @@ import io.quarkus.builder.BuildStep; import io.quarkus.qute.Template; import io.quarkus.qute.deployment.TemplateRootBuildItem; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; public class AlternativeExprSyntaxEnabledCustomRootTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(root -> root .addAsResource(new StringAsset("{foo.toLowerCase}{=ignored}"), "templates/testik.html") // This config file is completely ignored diff --git a/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/altexprcommand/AlternativeExprSyntaxTest.java b/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/altexprcommand/AlternativeExprSyntaxTest.java index 9ce0c7d9090fb..9787fce965801 100644 --- a/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/altexprcommand/AlternativeExprSyntaxTest.java +++ b/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/altexprcommand/AlternativeExprSyntaxTest.java @@ -9,12 +9,12 @@ import org.junit.jupiter.api.extension.RegisterExtension; import io.quarkus.qute.Template; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; public class AlternativeExprSyntaxTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(root -> root .addAsResource(new StringAsset("{=foo.toLowerCase}{ignored}"), "templates/testik.html")) .overrideConfigKey("quarkus.qute.alt-expr-syntax", "true"); diff --git a/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/altexprcommand/BuildItemParserConfigTest.java b/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/altexprcommand/BuildItemParserConfigTest.java index b632bc3b48d98..5bf77f7cbe081 100644 --- a/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/altexprcommand/BuildItemParserConfigTest.java +++ b/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/altexprcommand/BuildItemParserConfigTest.java @@ -15,12 +15,12 @@ import io.quarkus.qute.Engine; import io.quarkus.qute.ParserConfig; import io.quarkus.qute.deployment.TemplatePathBuildItem; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; public class BuildItemParserConfigTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withEmptyApplication() .addBuildChainCustomizer(buildCustomizer()); diff --git a/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/altexprcommand/DependencyWithQuteConfigTest.java b/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/altexprcommand/DependencyWithQuteConfigTest.java index 324dbc3a1e764..1d3a9d026ae0e 100644 --- a/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/altexprcommand/DependencyWithQuteConfigTest.java +++ b/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/altexprcommand/DependencyWithQuteConfigTest.java @@ -9,12 +9,12 @@ import org.junit.jupiter.api.extension.RegisterExtension; import io.quarkus.qute.Template; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; public class DependencyWithQuteConfigTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(root -> root.addAsResource(new StringAsset("Hello {name}!"), "templates/hello.html")) .withAdditionalDependency(dependency -> dependency .addAsResource(new StringAsset("alt-expr-syntax = true"), "templates/.qute") diff --git a/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/propertynotfound/RawCallOnMissingPropertyTest.java b/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/propertynotfound/RawCallOnMissingPropertyTest.java index 749cbc0e565d0..32f3678cfcc9d 100644 --- a/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/propertynotfound/RawCallOnMissingPropertyTest.java +++ b/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/propertynotfound/RawCallOnMissingPropertyTest.java @@ -12,7 +12,7 @@ import io.quarkus.qute.Engine; import io.quarkus.qute.Template; import io.quarkus.qute.Variant; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; /** * Reproducer for a bug when calling .raw on a missing bean property in an HTML template, the value resolver converts @@ -25,7 +25,7 @@ public class RawCallOnMissingPropertyTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusExtensionTest config = new QuarkusExtensionTest() .withApplicationRoot(root -> root .addClass(Guide.class)) .overrideConfigKey("quarkus.qute.property-not-found-strategy", "NOOP") diff --git a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/TestUtil.java b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/TestUtil.java index bd95d6aa439a6..b51136115a512 100644 --- a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/TestUtil.java +++ b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/TestUtil.java @@ -1,16 +1,12 @@ package io.quarkus.security.webauthn.test; -import io.quarkus.test.AbstractQuarkusExtensionTest; import io.quarkus.test.QuarkusExtensionTest; -import io.quarkus.test.QuarkusUnitTest; public class TestUtil { static boolean isTestThread() { for (StackTraceElement stackTraceElement : Thread.currentThread().getStackTrace()) { String className = stackTraceElement.getClassName(); - if (QuarkusUnitTest.class.getName().equals(className) - || QuarkusExtensionTest.class.getName().equals(className) - || AbstractQuarkusExtensionTest.class.getName().equals(className)) + if (QuarkusExtensionTest.class.getName().equals(className)) return true; } return false; diff --git a/extensions/smallrye-jwt/deployment/src/test/java/io/quarkus/jwt/test/dev/SmallryeJwtSignLocationDevModeTest.java b/extensions/smallrye-jwt/deployment/src/test/java/io/quarkus/jwt/test/dev/SmallryeJwtSignLocationDevModeTest.java index 6d1257087a4ac..2084a0f990cef 100644 --- a/extensions/smallrye-jwt/deployment/src/test/java/io/quarkus/jwt/test/dev/SmallryeJwtSignLocationDevModeTest.java +++ b/extensions/smallrye-jwt/deployment/src/test/java/io/quarkus/jwt/test/dev/SmallryeJwtSignLocationDevModeTest.java @@ -16,12 +16,12 @@ import io.quarkus.builder.BuildStep; import io.quarkus.deployment.builditem.DevServicesResultBuildItem; import io.quarkus.deployment.builditem.FeatureBuildItem; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; public class SmallryeJwtSignLocationDevModeTest { @RegisterExtension - static QuarkusUnitTest unitTest = new QuarkusUnitTest() + static QuarkusExtensionTest unitTest = new QuarkusExtensionTest() .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class) .addAsResource(new StringAsset( """ diff --git a/extensions/spring-boot-properties/deployment/src/test/java/io/quarkus/spring/boot/properties/deployment/ConfigurationPropertiesTest.java b/extensions/spring-boot-properties/deployment/src/test/java/io/quarkus/spring/boot/properties/deployment/ConfigurationPropertiesTest.java index 0e8870883b109..551b5bac71423 100644 --- a/extensions/spring-boot-properties/deployment/src/test/java/io/quarkus/spring/boot/properties/deployment/ConfigurationPropertiesTest.java +++ b/extensions/spring-boot-properties/deployment/src/test/java/io/quarkus/spring/boot/properties/deployment/ConfigurationPropertiesTest.java @@ -16,11 +16,11 @@ import org.junit.jupiter.api.extension.RegisterExtension; import org.springframework.boot.context.properties.ConfigurationProperties; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; class ConfigurationPropertiesTest { @RegisterExtension - static final QuarkusUnitTest TEST = new QuarkusUnitTest() + static final QuarkusExtensionTest TEST = new QuarkusExtensionTest() .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class) .addAsServiceProvider(Converter.class, ServerConverter.class) .addAsResource(new StringAsset( diff --git a/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/accesslog/AccessLogWithNonApplicationRootPathTestCase.java b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/accesslog/AccessLogWithNonApplicationRootPathTestCase.java index a138af23eb2bc..8a008a9528a43 100644 --- a/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/accesslog/AccessLogWithNonApplicationRootPathTestCase.java +++ b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/accesslog/AccessLogWithNonApplicationRootPathTestCase.java @@ -96,7 +96,7 @@ public void execute(BuildContext context) { }; } - // Injected at runtime so the value survives the QuarkusUnitTest classloader boundary + // Injected at runtime so the value survives the QuarkusExtensionTest classloader boundary // (a static field set by the archive producer would not be visible to the test method). @ConfigProperty(name = "quarkus.http.access-log.log-directory") Path logDirectory; diff --git a/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/CrossPolicyNormalizationBypassTest.java b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/CrossPolicyNormalizationBypassTest.java index 2ffd341a90439..d37b64664bb05 100644 --- a/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/CrossPolicyNormalizationBypassTest.java +++ b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/CrossPolicyNormalizationBypassTest.java @@ -22,7 +22,7 @@ import io.quarkus.security.test.utils.TestIdentityController; import io.quarkus.security.test.utils.TestIdentityProvider; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; import io.quarkus.test.common.http.TestHTTPResource; import io.quarkus.vertx.http.runtime.security.HttpSecurityPolicy; import io.vertx.core.Vertx; @@ -56,7 +56,7 @@ public class CrossPolicyNormalizationBypassTest { """; @RegisterExtension - static QuarkusUnitTest test = new QuarkusUnitTest().setArchiveProducer(() -> ShrinkWrap + static QuarkusExtensionTest test = new QuarkusExtensionTest().setArchiveProducer(() -> ShrinkWrap .create(JavaArchive.class) .addClasses(TestIdentityController.class, TestIdentityProvider.class, RouteHandler.class, CountingPolicySetup.class) diff --git a/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/PathNormalizationBypassTest.java b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/PathNormalizationBypassTest.java index 8c90e62134392..b915dc9a54795 100644 --- a/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/PathNormalizationBypassTest.java +++ b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/PathNormalizationBypassTest.java @@ -22,7 +22,7 @@ import io.quarkus.security.test.utils.TestIdentityController; import io.quarkus.security.test.utils.TestIdentityProvider; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; import io.quarkus.test.common.http.TestHTTPResource; import io.vertx.core.Vertx; import io.vertx.ext.web.Router; @@ -53,7 +53,7 @@ public class PathNormalizationBypassTest { """; @RegisterExtension - static QuarkusUnitTest test = new QuarkusUnitTest().setArchiveProducer(() -> ShrinkWrap + static QuarkusExtensionTest test = new QuarkusExtensionTest().setArchiveProducer(() -> ShrinkWrap .create(JavaArchive.class) .addClasses(TestIdentityController.class, TestIdentityProvider.class, RouteHandlers.class) .addAsResource("static-secret.html", "META-INF/resources/static-secret.html") diff --git a/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/WildcardPathNormalizationBypassTest.java b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/WildcardPathNormalizationBypassTest.java index 2e8d62b67e6eb..12b9d3872f540 100644 --- a/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/WildcardPathNormalizationBypassTest.java +++ b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/WildcardPathNormalizationBypassTest.java @@ -22,7 +22,7 @@ import io.quarkus.security.test.utils.TestIdentityController; import io.quarkus.security.test.utils.TestIdentityProvider; -import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.QuarkusExtensionTest; import io.quarkus.test.common.http.TestHTTPResource; import io.vertx.core.Vertx; import io.vertx.ext.web.Router; @@ -42,7 +42,7 @@ public class WildcardPathNormalizationBypassTest { """; @RegisterExtension - static QuarkusUnitTest test = new QuarkusUnitTest().setArchiveProducer(() -> ShrinkWrap + static QuarkusExtensionTest test = new QuarkusExtensionTest().setArchiveProducer(() -> ShrinkWrap .create(JavaArchive.class) .addClasses(TestIdentityController.class, TestIdentityProvider.class, WildcardRouteHandler.class) .addAsResource(new StringAsset(APP_PROPS), "application.properties")); From e3eefaa70bd4975dcbb6797b167b116ad3b89078 Mon Sep 17 00:00:00 2001 From: Guillaume Smet Date: Tue, 6 Oct 2026 11:34:15 +0200 Subject: [PATCH 144/254] Prevent ResteasyReactiveRecorder from capturing TCCL in anonymous lambda The anonymous RequestContextFactory lambda in createDeployment() captured the TCCL (Thread.currentThread().getContextClassLoader()) as a final field (val$tccl). This TCCL is the runtime classloader at recorder time. When the app restarts, that classloader is closed, but the old Deployment object (stored in currentDeployment on the previous classloader's ResteasyReactiveRecorder class) still holds the lambda with the captured reference, preventing the runtime classloader from being collected. The fix replaces the anonymous lambda with a named static inner class (QuarkusResteasyReactiveRequestContextFactory) that stores the TCCL in a mutable volatile field. A shutdown task nulls the field via resetTccl(), releasing the classloader reference when the app stops. Assisted-By: Claude Opus 4.6 (1M context) --- .../runtime/ResteasyReactiveRecorder.java | 51 +++++++++++++------ 1 file changed, 36 insertions(+), 15 deletions(-) diff --git a/extensions/resteasy-reactive/rest/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/ResteasyReactiveRecorder.java b/extensions/resteasy-reactive/rest/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/ResteasyReactiveRecorder.java index 76d5da88a3edd..697dd9e903b7d 100644 --- a/extensions/resteasy-reactive/rest/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/ResteasyReactiveRecorder.java +++ b/extensions/resteasy-reactive/rest/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/ResteasyReactiveRecorder.java @@ -139,22 +139,12 @@ public void accept(Closeable closeable) { }; CurrentIdentityAssociation currentIdentityAssociation = Arc.container().select(CurrentIdentityAssociation.class) .orNull(); - ClassLoader tccl = Thread.currentThread().getContextClassLoader(); if (contextFactory == null) { - contextFactory = new RequestContextFactory() { - @Override - public ResteasyReactiveRequestContext createContext(Deployment deployment, - Object context, ThreadSetupAction requestContext, - ServerRestHandler[] handlerChain, byte[] handlerKinds, ServerRestHandler[] abortHandlerChain) { - return new QuarkusResteasyReactiveRequestContext(deployment, (RoutingContext) context, - requestContext, - handlerChain, - handlerKinds, - abortHandlerChain, launchMode == LaunchMode.DEVELOPMENT ? tccl : null, currentIdentityAssociation, - dispatcher); - } - - }; + QuarkusResteasyReactiveRequestContextFactory quarkusContextFactory = new QuarkusResteasyReactiveRequestContextFactory( + launchMode == LaunchMode.DEVELOPMENT ? Thread.currentThread().getContextClassLoader() : null, + currentIdentityAssociation, dispatcher); + contextFactory = quarkusContextFactory; + shutdownContext.addLastShutdownTask(quarkusContextFactory::resetTccl); } RuntimeDeploymentManager runtimeDeploymentManager = new RuntimeDeploymentManager(info, EXECUTOR_SUPPLIER, @@ -454,4 +444,35 @@ public void accept(RoutingContext event, Throwable throwable) { } } + private static final class QuarkusResteasyReactiveRequestContextFactory implements RequestContextFactory { + + private volatile ClassLoader devModeTccl; + private final CurrentIdentityAssociation currentIdentityAssociation; + private final ServerRestHandlerDispatcher dispatcher; + + QuarkusResteasyReactiveRequestContextFactory(ClassLoader devModeTccl, + CurrentIdentityAssociation currentIdentityAssociation, + ServerRestHandlerDispatcher dispatcher) { + this.devModeTccl = devModeTccl; + this.currentIdentityAssociation = currentIdentityAssociation; + this.dispatcher = dispatcher; + } + + void resetTccl() { + devModeTccl = null; + } + + @Override + public ResteasyReactiveRequestContext createContext(Deployment deployment, + Object context, ThreadSetupAction requestContext, + ServerRestHandler[] handlerChain, byte[] handlerKinds, ServerRestHandler[] abortHandlerChain) { + return new QuarkusResteasyReactiveRequestContext(deployment, (RoutingContext) context, + requestContext, + handlerChain, + handlerKinds, + abortHandlerChain, devModeTccl, currentIdentityAssociation, + dispatcher); + } + } + } From d5b38fa02e886b391dadbbb11e0cec74d7c93c91 Mon Sep 17 00:00:00 2001 From: jnbdz <237008+jnbdz@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:01:01 -0400 Subject: [PATCH 145/254] Create the mailer beans without a recorder MailerSupport is now the value of a static-init service, and the mail client, mailer and mock mailbox beans are created by BeanCreator classes that take the mailer name as a bean parameter, so MailerRecorder is removed. --- .../mailer/deployment/MailerProcessor.java | 52 ++++++++------ .../mailer/runtime/MailerBeanCreators.java | 64 +++++++++++++++++ .../mailer/runtime/MailerRecorder.java | 71 ------------------- 3 files changed, 94 insertions(+), 93 deletions(-) create mode 100644 extensions/mailer/runtime/src/main/java/io/quarkus/mailer/runtime/MailerBeanCreators.java delete mode 100644 extensions/mailer/runtime/src/main/java/io/quarkus/mailer/runtime/MailerRecorder.java diff --git a/extensions/mailer/deployment/src/main/java/io/quarkus/mailer/deployment/MailerProcessor.java b/extensions/mailer/deployment/src/main/java/io/quarkus/mailer/deployment/MailerProcessor.java index 52f8eadf12723..f4bc71e466e5a 100644 --- a/extensions/mailer/deployment/src/main/java/io/quarkus/mailer/deployment/MailerProcessor.java +++ b/extensions/mailer/deployment/src/main/java/io/quarkus/mailer/deployment/MailerProcessor.java @@ -28,11 +28,11 @@ import io.quarkus.arc.deployment.ValidationPhaseBuildItem.ValidationErrorBuildItem; import io.quarkus.arc.processor.BuildExtension; import io.quarkus.arc.processor.InjectionPointInfo; +import io.quarkus.core.Phase; +import io.quarkus.core.deployment.service.ServiceRegistrar; import io.quarkus.deployment.Feature; import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; -import io.quarkus.deployment.annotations.ExecutionTime; -import io.quarkus.deployment.annotations.Record; import io.quarkus.deployment.builditem.CombinedIndexBuildItem; import io.quarkus.deployment.builditem.ExtensionSslNativeSupportBuildItem; import io.quarkus.deployment.builditem.FeatureBuildItem; @@ -48,7 +48,7 @@ import io.quarkus.mailer.reactive.ReactiveMailer; import io.quarkus.mailer.runtime.MailTemplateMailerName; import io.quarkus.mailer.runtime.MailTemplateProducer; -import io.quarkus.mailer.runtime.MailerRecorder; +import io.quarkus.mailer.runtime.MailerBeanCreators; import io.quarkus.mailer.runtime.MailerSupport; import io.quarkus.mailer.runtime.Mailers; import io.quarkus.mailer.runtime.MailersBuildTimeConfig; @@ -56,7 +56,7 @@ import io.quarkus.qute.deployment.CheckedTemplateAdapterBuildItem; import io.quarkus.qute.deployment.QuteProcessor; import io.quarkus.qute.deployment.TemplatePathBuildItem; -import io.quarkus.tls.deployment.spi.TlsRegistryBuildItem; +import io.quarkus.tls.TlsConfigurationRegistry; import io.vertx.ext.mail.MailClient; public class MailerProcessor { @@ -98,9 +98,8 @@ void registerBeans(BuildProducer beans) { .build()); } - @Record(ExecutionTime.STATIC_INIT) @BuildStep - MailersBuildItem generateMailerSupportBean(MailerRecorder recorder, + MailersBuildItem generateMailerSupportBean(ServiceRegistrar serviceRegistrar, CombinedIndexBuildItem index, BeanDiscoveryFinishedBuildItem beans, BuildProducer syntheticBeans) { @@ -120,10 +119,14 @@ MailersBuildItem generateMailerSupportBean(MailerRecorder recorder, .map(ai -> ai.value().asString()) .collect(Collectors.toSet()); - MailerSupport mailerSupport = new MailerSupport(hasDefaultMailer, namedMailers); + Set mailerNames = Set.copyOf(namedMailers); + serviceRegistrar + .forService(MailerSupport.class) + .atPhase(Phase.STATIC_INIT) + .onStart(ctx -> new MailerSupport(hasDefaultMailer, mailerNames)); syntheticBeans.produce(SyntheticBeanBuildItem.configure(MailerSupport.class) - .supplier(recorder.mailerSupportSupplier(mailerSupport)) + .serviceValue(MailerSupport.class) .scope(Singleton.class) .unremovable() .done()); @@ -160,19 +163,15 @@ private boolean isTypeSafeMailTemplateFound(IndexView index) { return false; } - @Record(ExecutionTime.RUNTIME_INIT) @BuildStep - void generateMailerBeans(MailerRecorder recorder, - MailersBuildItem mailers, - BuildProducer syntheticBeans, - // Just to make sure it is initialized - TlsRegistryBuildItem tlsRegistryBuildItem) { + void generateMailerBeans(MailersBuildItem mailers, + BuildProducer syntheticBeans) { if (mailers.hasDefaultMailer()) { - generateMailerBeansForName(Mailers.DEFAULT_MAILER_NAME, recorder, syntheticBeans); + generateMailerBeansForName(Mailers.DEFAULT_MAILER_NAME, syntheticBeans); } for (String name : mailers.getNamedMailers()) { - generateMailerBeansForName(name, recorder, syntheticBeans); + generateMailerBeansForName(name, syntheticBeans); } } @@ -182,7 +181,6 @@ AnnotationsTransformerBuildItem annotationsTransformer() { } private void generateMailerBeansForName(String name, - MailerRecorder recorder, BuildProducer syntheticBeans) { AnnotationInstance qualifier; if (Mailers.DEFAULT_MAILER_NAME.equals(name)) { @@ -198,7 +196,9 @@ private void generateMailerBeansForName(String name, .defaultBean() .setRuntimeInit() .addInjectionPoint(ClassType.create(DotName.createSimple(Mailers.class))) - .createWith(recorder.mailClientFunction(name)) + .addInjectionPoint(ClassType.create(DotName.createSimple(TlsConfigurationRegistry.class))) + .creator(MailerBeanCreators.MailClientCreator.class) + .param(MailerBeanCreators.MAILER_NAME_PARAM, name) .done()); syntheticBeans.produce(SyntheticBeanBuildItem.configure(io.vertx.mutiny.ext.mail.MailClient.class) .scope(Singleton.class) @@ -207,7 +207,9 @@ private void generateMailerBeansForName(String name, .defaultBean() .setRuntimeInit() .addInjectionPoint(ClassType.create(DotName.createSimple(Mailers.class))) - .createWith(recorder.reactiveMailClientFunction(name)) + .addInjectionPoint(ClassType.create(DotName.createSimple(TlsConfigurationRegistry.class))) + .creator(MailerBeanCreators.ReactiveMailClientCreator.class) + .param(MailerBeanCreators.MAILER_NAME_PARAM, name) .done()); syntheticBeans.produce(SyntheticBeanBuildItem.configure(Mailer.class) .scope(Singleton.class) @@ -216,7 +218,9 @@ private void generateMailerBeansForName(String name, .defaultBean() .setRuntimeInit() .addInjectionPoint(ClassType.create(DotName.createSimple(Mailers.class))) - .createWith(recorder.mailerFunction(name)) + .addInjectionPoint(ClassType.create(DotName.createSimple(TlsConfigurationRegistry.class))) + .creator(MailerBeanCreators.MailerCreator.class) + .param(MailerBeanCreators.MAILER_NAME_PARAM, name) .done()); syntheticBeans.produce(SyntheticBeanBuildItem.configure(ReactiveMailer.class) .scope(Singleton.class) @@ -225,7 +229,9 @@ private void generateMailerBeansForName(String name, .defaultBean() .setRuntimeInit() .addInjectionPoint(ClassType.create(DotName.createSimple(Mailers.class))) - .createWith(recorder.reactiveMailerFunction(name)) + .addInjectionPoint(ClassType.create(DotName.createSimple(TlsConfigurationRegistry.class))) + .creator(MailerBeanCreators.ReactiveMailerCreator.class) + .param(MailerBeanCreators.MAILER_NAME_PARAM, name) .done()); syntheticBeans.produce(SyntheticBeanBuildItem.configure(MockMailbox.class) .scope(Singleton.class) @@ -234,7 +240,9 @@ private void generateMailerBeansForName(String name, .defaultBean() .setRuntimeInit() .addInjectionPoint(ClassType.create(DotName.createSimple(Mailers.class))) - .createWith(recorder.mockMailboxFunction(name)) + .addInjectionPoint(ClassType.create(DotName.createSimple(TlsConfigurationRegistry.class))) + .creator(MailerBeanCreators.MockMailboxCreator.class) + .param(MailerBeanCreators.MAILER_NAME_PARAM, name) .done()); } diff --git a/extensions/mailer/runtime/src/main/java/io/quarkus/mailer/runtime/MailerBeanCreators.java b/extensions/mailer/runtime/src/main/java/io/quarkus/mailer/runtime/MailerBeanCreators.java new file mode 100644 index 0000000000000..dd4c283f54848 --- /dev/null +++ b/extensions/mailer/runtime/src/main/java/io/quarkus/mailer/runtime/MailerBeanCreators.java @@ -0,0 +1,64 @@ +package io.quarkus.mailer.runtime; + +import io.quarkus.arc.BeanCreator; +import io.quarkus.arc.SyntheticCreationalContext; +import io.quarkus.mailer.Mailer; +import io.quarkus.mailer.MockMailbox; +import io.quarkus.mailer.reactive.ReactiveMailer; +import io.vertx.ext.mail.MailClient; + +public final class MailerBeanCreators { + + public static final String MAILER_NAME_PARAM = "mailerName"; + + private MailerBeanCreators() { + } + + private static Mailers mailers(SyntheticCreationalContext context) { + return context.getInjectedReference(Mailers.class); + } + + private static String name(SyntheticCreationalContext context) { + Object name = context.getParams().get(MAILER_NAME_PARAM); + if (!(name instanceof String)) { + throw new IllegalStateException("Missing bean parameter: " + MAILER_NAME_PARAM); + } + return (String) name; + } + + public static class MailClientCreator implements BeanCreator { + @Override + public MailClient create(SyntheticCreationalContext context) { + return mailers(context).mailClientFromName(name(context)); + } + } + + public static class ReactiveMailClientCreator implements BeanCreator { + @Override + public io.vertx.mutiny.ext.mail.MailClient create( + SyntheticCreationalContext context) { + return mailers(context).reactiveMailClientFromName(name(context)); + } + } + + public static class MailerCreator implements BeanCreator { + @Override + public Mailer create(SyntheticCreationalContext context) { + return mailers(context).mailerFromName(name(context)); + } + } + + public static class ReactiveMailerCreator implements BeanCreator { + @Override + public ReactiveMailer create(SyntheticCreationalContext context) { + return mailers(context).reactiveMailerFromName(name(context)); + } + } + + public static class MockMailboxCreator implements BeanCreator { + @Override + public MockMailbox create(SyntheticCreationalContext context) { + return mailers(context).mockMailboxFromName(name(context)); + } + } +} diff --git a/extensions/mailer/runtime/src/main/java/io/quarkus/mailer/runtime/MailerRecorder.java b/extensions/mailer/runtime/src/main/java/io/quarkus/mailer/runtime/MailerRecorder.java deleted file mode 100644 index 24b20b6840b30..0000000000000 --- a/extensions/mailer/runtime/src/main/java/io/quarkus/mailer/runtime/MailerRecorder.java +++ /dev/null @@ -1,71 +0,0 @@ -package io.quarkus.mailer.runtime; - -import java.util.function.Function; -import java.util.function.Supplier; - -import io.quarkus.arc.SyntheticCreationalContext; -import io.quarkus.mailer.Mailer; -import io.quarkus.mailer.MockMailbox; -import io.quarkus.mailer.reactive.ReactiveMailer; -import io.quarkus.runtime.annotations.Recorder; -import io.vertx.ext.mail.MailClient; - -@Recorder -public class MailerRecorder { - - public Supplier mailerSupportSupplier(MailerSupport mailerSupport) { - return new Supplier() { - @Override - public MailerSupport get() { - return mailerSupport; - } - }; - } - - public Function, MailClient> mailClientFunction(String name) { - return new Function, MailClient>() { - @Override - public MailClient apply(SyntheticCreationalContext context) { - return context.getInjectedReference(Mailers.class).mailClientFromName(name); - } - }; - } - - public Function, io.vertx.mutiny.ext.mail.MailClient> reactiveMailClientFunction( - String name) { - return new Function, io.vertx.mutiny.ext.mail.MailClient>() { - @Override - public io.vertx.mutiny.ext.mail.MailClient apply( - SyntheticCreationalContext context) { - return context.getInjectedReference(Mailers.class).reactiveMailClientFromName(name); - } - }; - } - - public Function, Mailer> mailerFunction(String name) { - return new Function, Mailer>() { - @Override - public Mailer apply(SyntheticCreationalContext context) { - return context.getInjectedReference(Mailers.class).mailerFromName(name); - } - }; - } - - public Function, ReactiveMailer> reactiveMailerFunction(String name) { - return new Function, ReactiveMailer>() { - @Override - public ReactiveMailer apply(SyntheticCreationalContext context) { - return context.getInjectedReference(Mailers.class).reactiveMailerFromName(name); - } - }; - } - - public Function, MockMailbox> mockMailboxFunction(String name) { - return new Function, MockMailbox>() { - @Override - public MockMailbox apply(SyntheticCreationalContext context) { - return context.getInjectedReference(Mailers.class).mockMailboxFromName(name); - } - }; - } -} From 4b3d01c8d0a03834c23d30b67467f367edc92d2d Mon Sep 17 00:00:00 2001 From: Rolfe Dlugy-Hegwer Date: Mon, 12 Oct 2026 21:52:19 -0400 Subject: [PATCH 146/254] Correct the Ansible JDK default and the Jib working directory note Release 1.0.3 of the Ansible collection for Quarkus installs OpenJDK 21 by default (quarkus_java_version: 21 in the role defaults), not OpenJDK 17. The override example, -e quarkus_java_package_version: java-17-openjdk, has a space after the colon, so the shell passes java-17-openjdk as a separate argument. Setting quarkus_java_version changes both the package and quarkus_java_home. /home/jboss is not the WORKDIR of the base images, which use /home/default. It is where Jib puts the application and the working directory that Jib sets, the default of quarkus.jib.working-directory. --- docs/src/main/asciidoc/ansible.adoc | 5 +++-- docs/src/main/asciidoc/container-image.adoc | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/src/main/asciidoc/ansible.adoc b/docs/src/main/asciidoc/ansible.adoc index 0dda6459b0df5..082ca096b5d5b 100644 --- a/docs/src/main/asciidoc/ansible.adoc +++ b/docs/src/main/asciidoc/ansible.adoc @@ -161,12 +161,13 @@ With those requirements in place, the jar can be deployed on the target, along w [NOTE] ==== -By default, the Ansible collection for Quarkus will install and use the OpenJDK 17 available in the Yum repositories of the target host (or the controller). If you want to use a different version of OpenJDK, define the following variable: +By default, the Ansible collection for Quarkus installs and uses the OpenJDK 21 available in the Yum repositories of the target host (or the controller). +To use a different version of OpenJDK, set the `quarkus_java_version` variable, which sets both the package that the collection installs and `quarkus_java_home`: [source,bash] ---- $ ansible-playbook -i inventory ... - -e quarkus_java_package_version: java-17-openjdk + -e quarkus_java_version=25 ---- ==== diff --git a/docs/src/main/asciidoc/container-image.adoc b/docs/src/main/asciidoc/container-image.adoc index 5954de66b271c..cdf69935a3829 100644 --- a/docs/src/main/asciidoc/container-image.adoc +++ b/docs/src/main/asciidoc/container-image.adoc @@ -88,7 +88,8 @@ java \ -jar quarkus-run.jar ---- -NOTE: `/home/jboss` is the WORKDIR for all quarkus binaries in the base images `ubi9/openjdk-17-runtime` and `ubi10/openjdk-21-runtime` (https://catalog.redhat.com/en/software/containers/ubi10/openjdk-21-runtime/690ca3b700d71ac7397b98c6?container-tabs=dockerfile[Dockerfile for ubi10/openjdk-21-runtime, window="_blank"]) +NOTE: Jib puts the application, including `quarkus-run.jar`, in `/home/jboss` and makes it the working directory of the image. +To use another directory, set `quarkus.jib.working-directory`. ==== Multi-module projects and layering From ffa9ed8921a2c4ddcea505aa61effcac85666092 Mon Sep 17 00:00:00 2001 From: jnbdz <237008+jnbdz@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:38:03 -0400 Subject: [PATCH 147/254] Register the Keycloak admin REST client startup work as services The client provider reset, the client provider setup and the admin client config validation now run as services instead of recorder calls. The request scoped Keycloak bean is created by a BeanCreator that builds the client from the admin client config, so each request still gets its own client. --- .../KeycloakAdminRestClientProcessor.java | 39 +++++++---- .../KeycloakAdminClientInvalidConfigTest.java | 36 ++++++++++ .../KeycloakAdminClientRequestScopeTest.java | 66 +++++++++++++++++++ .../runtime/KeycloakAdminClientCreator.java | 16 +++++ .../KeycloakAdminRestClientRecorder.java | 47 +++---------- 5 files changed, 154 insertions(+), 50 deletions(-) create mode 100644 extensions/keycloak-admin-rest-client/deployment/src/test/java/io/quarkus/keycloak/admin/rest/client/deployment/test/KeycloakAdminClientInvalidConfigTest.java create mode 100644 extensions/keycloak-admin-rest-client/deployment/src/test/java/io/quarkus/keycloak/admin/rest/client/deployment/test/KeycloakAdminClientRequestScopeTest.java create mode 100644 extensions/keycloak-admin-rest-client/runtime/src/main/java/io/quarkus/keycloak/admin/rest/client/runtime/KeycloakAdminClientCreator.java diff --git a/extensions/keycloak-admin-rest-client/deployment/src/main/java/io/quarkus/keycloak/admin/rest/client/deployment/KeycloakAdminRestClientProcessor.java b/extensions/keycloak-admin-rest-client/deployment/src/main/java/io/quarkus/keycloak/admin/rest/client/deployment/KeycloakAdminRestClientProcessor.java index 2e97d917eccb2..9ee0fd3449e45 100644 --- a/extensions/keycloak-admin-rest-client/deployment/src/main/java/io/quarkus/keycloak/admin/rest/client/deployment/KeycloakAdminRestClientProcessor.java +++ b/extensions/keycloak-admin-rest-client/deployment/src/main/java/io/quarkus/keycloak/admin/rest/client/deployment/KeycloakAdminRestClientProcessor.java @@ -3,6 +3,7 @@ import jakarta.enterprise.context.RequestScoped; import org.jboss.jandex.DotName; +import org.jboss.jandex.Type; import org.keycloak.admin.client.Keycloak; import org.keycloak.admin.client.spi.ResteasyClientProvider; import org.keycloak.common.util.MultivaluedHashMap; @@ -11,12 +12,12 @@ import org.keycloak.json.StringOrArraySerializer; import io.quarkus.arc.deployment.SyntheticBeanBuildItem; +import io.quarkus.core.Phase; +import io.quarkus.core.deployment.service.ServiceRegistrar; import io.quarkus.deployment.Feature; import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; -import io.quarkus.deployment.annotations.ExecutionTime; import io.quarkus.deployment.annotations.Produce; -import io.quarkus.deployment.annotations.Record; import io.quarkus.deployment.builditem.AdditionalApplicationArchiveMarkerBuildItem; import io.quarkus.deployment.builditem.FeatureBuildItem; import io.quarkus.deployment.builditem.ServiceStartBuildItem; @@ -24,9 +25,12 @@ import io.quarkus.deployment.builditem.nativeimage.ReflectiveHierarchyIgnoreWarningBuildItem; import io.quarkus.deployment.builditem.nativeimage.ServiceProviderBuildItem; import io.quarkus.keycloak.admin.client.common.deployment.KeycloakAdminClientInjectionEnabled; +import io.quarkus.keycloak.admin.client.common.runtime.KeycloakAdminClientConfig; +import io.quarkus.keycloak.admin.client.common.runtime.KeycloakAdminClientConfigUtil; +import io.quarkus.keycloak.admin.rest.client.runtime.KeycloakAdminClientCreator; import io.quarkus.keycloak.admin.rest.client.runtime.KeycloakAdminRestClientProvider; import io.quarkus.keycloak.admin.rest.client.runtime.KeycloakAdminRestClientRecorder; -import io.quarkus.tls.deployment.spi.TlsRegistryBuildItem; +import io.quarkus.tls.TlsConfigurationRegistry; public class KeycloakAdminRestClientProcessor { @@ -59,23 +63,33 @@ public void nativeImage(BuildProducer serviceProviderP DotName.createSimple(MultivaluedHashMap.class.getName())))); } - @Record(ExecutionTime.STATIC_INIT) @BuildStep - void avoidRuntimeInitIssueInClientBuilderWrapper(KeycloakAdminRestClientRecorder recorder) { - recorder.avoidRuntimeInitIssueInClientBuilderWrapper(); + void avoidRuntimeInitIssueInClientBuilderWrapper(ServiceRegistrar serviceRegistrar) { + serviceRegistrar + .forService("io.quarkus.keycloak-admin-rest-client.reset-client-provider") + .atPhase(Phase.STATIC_INIT) + .onStart(ctx -> KeycloakAdminRestClientRecorder.avoidRuntimeInitIssueInClientBuilderWrapper()); } - @Record(ExecutionTime.RUNTIME_INIT) @Produce(ServiceStartBuildItem.class) @BuildStep - public void integrate(KeycloakAdminRestClientRecorder recorder, TlsRegistryBuildItem tlsRegistryBuildItem) { - recorder.setClientProvider(tlsRegistryBuildItem.registry()); + public void integrate(ServiceRegistrar serviceRegistrar) { + serviceRegistrar + .forService("io.quarkus.keycloak-admin-rest-client.client-provider") + .atPhase(Phase.INIT) + .require(TlsConfigurationRegistry.class) + .require(KeycloakAdminClientConfig.class) + .onStart((ctx, registry, config) -> KeycloakAdminRestClientRecorder.setClientProvider(registry, config)); } - @Record(ExecutionTime.RUNTIME_INIT) @BuildStep(onlyIf = KeycloakAdminClientInjectionEnabled.class) - public void registerKeycloakAdminClientBeans(KeycloakAdminRestClientRecorder recorder, + public void registerKeycloakAdminClientBeans(ServiceRegistrar serviceRegistrar, BuildProducer syntheticBeanBuildItemBuildProducer) { + serviceRegistrar + .forService("io.quarkus.keycloak-admin-rest-client.validate-config") + .atPhase(Phase.INIT) + .require(KeycloakAdminClientConfig.class) + .onStart((ctx, config) -> KeycloakAdminClientConfigUtil.validate(config)); syntheticBeanBuildItemBuildProducer.produce(SyntheticBeanBuildItem .configure(Keycloak.class) // use @RequestScoped as we don't want to keep client connection open too long @@ -84,7 +98,8 @@ public void registerKeycloakAdminClientBeans(KeycloakAdminRestClientRecorder rec .reserve(true) .priority(0) .unremovable() - .supplier(recorder.createAdminClient()) + .creator(KeycloakAdminClientCreator.class) + .addInjectionPoint(Type.create(KeycloakAdminClientConfig.class)) .autoClose(true) .done()); } diff --git a/extensions/keycloak-admin-rest-client/deployment/src/test/java/io/quarkus/keycloak/admin/rest/client/deployment/test/KeycloakAdminClientInvalidConfigTest.java b/extensions/keycloak-admin-rest-client/deployment/src/test/java/io/quarkus/keycloak/admin/rest/client/deployment/test/KeycloakAdminClientInvalidConfigTest.java new file mode 100644 index 0000000000000..f6e9ea0472786 --- /dev/null +++ b/extensions/keycloak-admin-rest-client/deployment/src/test/java/io/quarkus/keycloak/admin/rest/client/deployment/test/KeycloakAdminClientInvalidConfigTest.java @@ -0,0 +1,36 @@ +package io.quarkus.keycloak.admin.rest.client.deployment.test; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.fail; + +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusExtensionTest; + +public class KeycloakAdminClientInvalidConfigTest { + + @RegisterExtension + final static QuarkusExtensionTest app = new QuarkusExtensionTest() + .withApplicationRoot(jar -> jar + .addAsResource(new StringAsset(""" + quarkus.keycloak.devservices.enabled=false + quarkus.keycloak.admin-client.server-url=http://localhost:1 + quarkus.keycloak.admin-client.grant-type=CLIENT_CREDENTIALS + """), "application.properties")) + .assertException(t -> { + Throwable cause = t; + while (cause != null + && !String.valueOf(cause.getMessage()) + .contains("grant type 'client_credentials' requires client secret")) { + cause = cause.getCause(); + } + assertNotNull(cause, "Unexpected startup failure: " + t); + }); + + @Test + public void test() { + fail("Startup should have failed"); + } +} diff --git a/extensions/keycloak-admin-rest-client/deployment/src/test/java/io/quarkus/keycloak/admin/rest/client/deployment/test/KeycloakAdminClientRequestScopeTest.java b/extensions/keycloak-admin-rest-client/deployment/src/test/java/io/quarkus/keycloak/admin/rest/client/deployment/test/KeycloakAdminClientRequestScopeTest.java new file mode 100644 index 0000000000000..998994e7ada13 --- /dev/null +++ b/extensions/keycloak-admin-rest-client/deployment/src/test/java/io/quarkus/keycloak/admin/rest/client/deployment/test/KeycloakAdminClientRequestScopeTest.java @@ -0,0 +1,66 @@ +package io.quarkus.keycloak.admin.rest.client.deployment.test; + +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.keycloak.admin.client.Keycloak; + +import io.quarkus.arc.ClientProxy; +import io.quarkus.test.QuarkusExtensionTest; +import io.restassured.RestAssured; + +public class KeycloakAdminClientRequestScopeTest { + + @RegisterExtension + final static QuarkusExtensionTest app = new QuarkusExtensionTest() + .withApplicationRoot(jar -> jar + .addClasses(AdminResource.class) + .addAsResource(new StringAsset(""" + quarkus.keycloak.devservices.enabled=false + quarkus.keycloak.admin-client.server-url=http://localhost:1 + """), "application.properties")); + + @Test + public void testEachRequestGetsItsOwnClient() { + String first = RestAssured.get("/api/admin/client").then().statusCode(200).extract().asString(); + String second = RestAssured.get("/api/admin/client").then().statusCode(200).extract().asString(); + assertNotEquals(first, second); + // the test never closes a client, the container does when the request scope ends + RestAssured.get("/api/admin/closed").then().statusCode(200).body(is("[true, true]")); + } + + @Path("/api/admin") + public static class AdminResource { + + static final List CLIENTS = new CopyOnWriteArrayList<>(); + + @Inject + Keycloak keycloak; + + @GET + @Path("/client") + public String client() { + Keycloak instance = ClientProxy.unwrap(keycloak); + assertFalse(instance.isClosed()); + CLIENTS.add(instance); + return String.valueOf(System.identityHashCode(instance)); + } + + @GET + @Path("/closed") + public String closed() { + return CLIENTS.stream().map(Keycloak::isClosed).toList().toString(); + } + } +} diff --git a/extensions/keycloak-admin-rest-client/runtime/src/main/java/io/quarkus/keycloak/admin/rest/client/runtime/KeycloakAdminClientCreator.java b/extensions/keycloak-admin-rest-client/runtime/src/main/java/io/quarkus/keycloak/admin/rest/client/runtime/KeycloakAdminClientCreator.java new file mode 100644 index 0000000000000..c9798a24da3c6 --- /dev/null +++ b/extensions/keycloak-admin-rest-client/runtime/src/main/java/io/quarkus/keycloak/admin/rest/client/runtime/KeycloakAdminClientCreator.java @@ -0,0 +1,16 @@ +package io.quarkus.keycloak.admin.rest.client.runtime; + +import org.keycloak.admin.client.Keycloak; + +import io.quarkus.arc.BeanCreator; +import io.quarkus.arc.SyntheticCreationalContext; +import io.quarkus.keycloak.admin.client.common.runtime.KeycloakAdminClientConfig; + +public class KeycloakAdminClientCreator implements BeanCreator { + + @Override + public Keycloak create(SyntheticCreationalContext context) { + return KeycloakAdminRestClientRecorder + .createAdminClient(context.getInjectedReference(KeycloakAdminClientConfig.class)); + } +} diff --git a/extensions/keycloak-admin-rest-client/runtime/src/main/java/io/quarkus/keycloak/admin/rest/client/runtime/KeycloakAdminRestClientRecorder.java b/extensions/keycloak-admin-rest-client/runtime/src/main/java/io/quarkus/keycloak/admin/rest/client/runtime/KeycloakAdminRestClientRecorder.java index 3fe3fc72acce2..2efb22a083212 100644 --- a/extensions/keycloak-admin-rest-client/runtime/src/main/java/io/quarkus/keycloak/admin/rest/client/runtime/KeycloakAdminRestClientRecorder.java +++ b/extensions/keycloak-admin-rest-client/runtime/src/main/java/io/quarkus/keycloak/admin/rest/client/runtime/KeycloakAdminRestClientRecorder.java @@ -1,32 +1,16 @@ package io.quarkus.keycloak.admin.rest.client.runtime; -import static io.quarkus.keycloak.admin.client.common.runtime.KeycloakAdminClientConfigUtil.validate; - -import java.util.function.Supplier; - import org.keycloak.admin.client.Keycloak; import org.keycloak.admin.client.KeycloakBuilder; import io.quarkus.keycloak.admin.client.common.runtime.KeycloakAdminClientConfig; -import io.quarkus.runtime.RuntimeValue; -import io.quarkus.runtime.annotations.Recorder; import io.quarkus.tls.TlsConfiguration; import io.quarkus.tls.TlsConfigurationRegistry; -@Recorder public class KeycloakAdminRestClientRecorder { - private final RuntimeValue keycloakAdminClientConfigRuntimeValue; - - public KeycloakAdminRestClientRecorder( - RuntimeValue keycloakAdminClientConfigRuntimeValue) { - this.keycloakAdminClientConfigRuntimeValue = keycloakAdminClientConfigRuntimeValue; - } - - public void setClientProvider(Supplier registrySupplier) { - var registry = registrySupplier.get(); - var namedTlsConfig = TlsConfiguration.from(registry, - keycloakAdminClientConfigRuntimeValue.getValue().tlsConfigurationName()); + public static void setClientProvider(TlsConfigurationRegistry registry, KeycloakAdminClientConfig config) { + var namedTlsConfig = TlsConfiguration.from(registry, config.tlsConfigurationName()); if (namedTlsConfig.isPresent()) { Keycloak.setClientProvider(new KeycloakAdminRestClientProvider(namedTlsConfig.get())); } else { @@ -40,20 +24,12 @@ public void setClientProvider(Supplier registrySupplie } } - public Supplier createAdminClient() { - - final KeycloakAdminClientConfig config = keycloakAdminClientConfigRuntimeValue.getValue(); - validate(config); + public static Keycloak createAdminClient(KeycloakAdminClientConfig config) { if (config.serverUrl().isEmpty()) { - return new Supplier<>() { - @Override - public Keycloak get() { - throw new IllegalStateException( - "'quarkus.keycloak.admin-client.server-url' must be set in order to use the Keycloak admin client as a CDI bean"); - } - }; + throw new IllegalStateException( + "'quarkus.keycloak.admin-client.server-url' must be set in order to use the Keycloak admin client as a CDI bean"); } - final KeycloakBuilder keycloakBuilder = KeycloakBuilder + return KeycloakBuilder .builder() .clientId(config.clientId()) .clientSecret(config.clientSecret().orElse(null)) @@ -62,16 +38,11 @@ public Keycloak get() { .password(config.password().orElse(null)) .realm(config.realm()) .serverUrl(config.serverUrl().get()) - .scope(config.scope().orElse(null)); - return new Supplier() { - @Override - public Keycloak get() { - return keycloakBuilder.build(); - } - }; + .scope(config.scope().orElse(null)) + .build(); } - public void avoidRuntimeInitIssueInClientBuilderWrapper() { + public static void avoidRuntimeInitIssueInClientBuilderWrapper() { // we set our provider at runtime, it is not used before that // however org.keycloak.admin.client.Keycloak.CLIENT_PROVIDER is initialized during // static init with org.keycloak.admin.client.ClientBuilderWrapper that is not compatible with native mode From c40e24383c7adb563e0a558e88eba950d806fdd3 Mon Sep 17 00:00:00 2001 From: Matheus Oliveira Date: Sat, 10 Oct 2026 17:54:08 -0300 Subject: [PATCH 148/254] fix: prevent concurrent creation of multiple client proxies Two threads resolving a normal-scoped bean for the first time at the same moment could both see the null proxy field and each create a separate client proxy. QuarkusMock then reaches only the proxy cached by the bean, so a mock installed for the bean type is not visible through the other proxy. Publish the proxy in the generated proxy() method with a compare-and-set on the proxy field, so that a normal-scoped bean has exactly one client proxy. A thread that loses the race discards its instance and uses the winner's. * Fix #57344 Signed-off-by: Matheus Oliveira --- .../quarkus/arc/processor/BeanGenerator.java | 50 ++++++++++++- .../ClientProxyConcurrentResolutionTest.java | 74 +++++++++++++++++++ 2 files changed, 121 insertions(+), 3 deletions(-) create mode 100644 independent-projects/arc/tests/src/test/java/io/quarkus/arc/test/clientproxy/ClientProxyConcurrentResolutionTest.java diff --git a/independent-projects/arc/processor/src/main/java/io/quarkus/arc/processor/BeanGenerator.java b/independent-projects/arc/processor/src/main/java/io/quarkus/arc/processor/BeanGenerator.java index 1a6da71744164..55d2ecaf5f411 100644 --- a/independent-projects/arc/processor/src/main/java/io/quarkus/arc/processor/BeanGenerator.java +++ b/independent-projects/arc/processor/src/main/java/io/quarkus/arc/processor/BeanGenerator.java @@ -24,6 +24,7 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.concurrent.atomic.AtomicReferenceFieldUpdater; import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Predicate; @@ -78,6 +79,7 @@ import io.quarkus.gizmo2.LocalVar; import io.quarkus.gizmo2.ParamVar; import io.quarkus.gizmo2.Reflection2Gizmo; +import io.quarkus.gizmo2.StaticFieldVar; import io.quarkus.gizmo2.Var; import io.quarkus.gizmo2.creator.BlockCreator; import io.quarkus.gizmo2.creator.ClassCreator; @@ -102,6 +104,11 @@ public class BeanGenerator extends AbstractGenerator { protected static final String FIELD_NAME_QUALIFIERS = "qualifiers"; protected static final String FIELD_NAME_STEREOTYPES = "stereotypes"; protected static final String FIELD_NAME_PROXY = "proxy"; + private static final String FIELD_NAME_PROXY_UPDATER = "PROXY_UPDATER"; + private static final MethodDesc NEW_UPDATER = MethodDesc.of(AtomicReferenceFieldUpdater.class, "newUpdater", + AtomicReferenceFieldUpdater.class, Class.class, Class.class, String.class); + private static final MethodDesc UPDATER_CAS = MethodDesc.of(AtomicReferenceFieldUpdater.class, "compareAndSet", + boolean.class, Object.class, Object.class, Object.class); protected final AnnotationLiteralProcessor annotationLiterals; protected final Predicate applicationClassPredicate; @@ -337,6 +344,33 @@ private void generateBean(Gizmo gizmo, BeanInfo bean, String generatedName, Stri }); } + /** + * Generates the lazy creation of the client proxy of a normal-scoped bean. The generated code looks like this: + * + *

+     * private static final AtomicReferenceFieldUpdater PROXY_UPDATER = AtomicReferenceFieldUpdater
+     *         .newUpdater(Foo_Bean.class, Foo_ClientProxy.class, "proxy");
+     *
+     * private volatile Foo_ClientProxy proxy;
+     *
+     * private Foo_ClientProxy proxy() {
+     *     Foo_ClientProxy proxy = this.proxy;
+     *     if (proxy == null) {
+     *         Foo_ClientProxy newProxy = new Foo_ClientProxy(beanIdentifier);
+     *         if (PROXY_UPDATER.compareAndSet(this, null, newProxy)) {
+     *             return newProxy;
+     *         }
+     *         proxy = this.proxy;
+     *     }
+     *     return proxy;
+     * }
+     * 
+ * + * A bean must have exactly one client proxy. Otherwise, a mock installed with {@code QuarkusMock} only reaches the + * proxy that is cached in the {@code proxy} field, and a bean that holds a different proxy still calls the real bean. + * That is why the proxy is published with a compare-and-set. A thread that loses the race discards its instance and + * uses the winner's. + */ private void generateProxy(ClassCreator cc, BeanInfo bean, String baseName) { if (bean.getDeployment().hasRuntimeDeferredUnproxyableError(bean)) { return; @@ -349,16 +383,26 @@ private void generateProxy(ClassCreator cc, BeanInfo bean, String baseName) { fc.volatile_(); fc.setType(proxyType); }); + StaticFieldVar proxyUpdater = cc.staticField(FIELD_NAME_PROXY_UPDATER, fc -> { + fc.private_(); + fc.final_(); + fc.setType(AtomicReferenceFieldUpdater.class); + fc.setInitializer(bc -> { + bc.yield(bc.invokeStatic(NEW_UPDATER, Const.of(cc.type()), Const.of(proxyType), + Const.of(FIELD_NAME_PROXY))); + }); + }); cc.method(FIELD_NAME_PROXY, mc -> { mc.private_(); mc.returning(proxyType); mc.body(b0 -> { LocalVar proxy = b0.localVar("proxy", cc.this_().field(proxyField)); - // Create a new proxy instance, atomicity does not really matter here b0.ifNull(proxy, b1 -> { - b1.set(proxy, b1.new_(proxyType, Const.of(bean.getIdentifier()))); - b1.set(cc.this_().field(proxyField), proxy); + LocalVar newProxy = b1.localVar("newProxy", b1.new_(proxyType, Const.of(bean.getIdentifier()))); + b1.if_(b1.invokeVirtual(UPDATER_CAS, proxyUpdater, cc.this_(), Const.ofNull(proxyType), newProxy), + b2 -> b2.return_(newProxy)); + b1.set(proxy, cc.this_().field(proxyField)); }); b0.return_(proxy); }); diff --git a/independent-projects/arc/tests/src/test/java/io/quarkus/arc/test/clientproxy/ClientProxyConcurrentResolutionTest.java b/independent-projects/arc/tests/src/test/java/io/quarkus/arc/test/clientproxy/ClientProxyConcurrentResolutionTest.java new file mode 100644 index 0000000000000..8c4052d887013 --- /dev/null +++ b/independent-projects/arc/tests/src/test/java/io/quarkus/arc/test/clientproxy/ClientProxyConcurrentResolutionTest.java @@ -0,0 +1,74 @@ +package io.quarkus.arc.test.clientproxy; + +import static org.junit.jupiter.api.Assertions.assertSame; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import jakarta.enterprise.context.ApplicationScoped; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.arc.Arc; +import io.quarkus.arc.test.ArcTestContainer; + +/** + * Reproduces the race in the lazily created client proxy of a normal-scoped bean: two threads that resolve + * the same bean for the first time at the same moment both see {@code null} and each create their own proxy + * instance. {@code QuarkusMock} then only reaches the proxy cached by the bean and misses the other one. + */ +public class ClientProxyConcurrentResolutionTest { + + @RegisterExtension + public ArcTestContainer container = new ArcTestContainer(Client.class); + + @Test + public void concurrentResolutionReturnsSingleClientProxy() throws Exception { + CountDownLatch bothInside = new CountDownLatch(2); + CountDownLatch mayComplete = new CountDownLatch(1); + Client.install(bothInside, mayComplete); + + AtomicReference first = new AtomicReference<>(); + AtomicReference second = new AtomicReference<>(); + + Thread firstThread = new Thread(() -> first.set(Arc.container().instance(Client.class).get())); + firstThread.start(); + Thread secondThread = new Thread(() -> second.set(Arc.container().instance(Client.class).get())); + secondThread.start(); + + // The client proxy extends Client, so its constructor runs inside the generated proxy() method, between + // the null check and the field write. Both threads get here before either one publishes its proxy, so + // only the atomic publication can make them agree on a single instance. + bothInside.await(2, TimeUnit.SECONDS); + mayComplete.countDown(); + + firstThread.join(); + secondThread.join(); + + assertSame(first.get(), second.get()); + } + + @ApplicationScoped + static class Client { + + private static volatile CountDownLatch bothInside; + private static volatile CountDownLatch mayComplete; + + static void install(CountDownLatch bothInside, CountDownLatch mayComplete) { + Client.bothInside = bothInside; + Client.mayComplete = mayComplete; + } + + Client() { + bothInside.countDown(); + try { + mayComplete.await(); + } catch (InterruptedException e) { + // interruption has no meaning in this test + throw new IllegalStateException(e); + } + } + } +} From 0aa6053ed47e9efbe0ee1e57e8ae8998e69f13d8 Mon Sep 17 00:00:00 2001 From: Luca Molteni Date: Tue, 22 Sep 2026 14:31:02 +0200 Subject: [PATCH 149/254] Introduce the reference guide and streamline installation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Yoann Rodière --- .../main/asciidoc/quarkus-data-hibernate.adoc | 105 ++++-------------- 1 file changed, 21 insertions(+), 84 deletions(-) diff --git a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc index 3ca6cd569cb13..3d842a0339cbc 100644 --- a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc +++ b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc @@ -3,9 +3,9 @@ This guide is maintained in the main Quarkus repository and pull requests should be submitted there: https://github.com/quarkusio/quarkus/tree/main/docs/src/main/asciidoc //// -= Simplified Hibernate with Quarkus Data Hibernate += Quarkus Data Hibernate reference guide include::_attributes.adoc[] -:summary: Hibernate is the de facto Jakarta Persistence implementation and offers you the full breadth of an Object Relational Mapper. It makes complex mappings possible, but it does not make simple and common mappings trivial. Quarkus Data Hibernate focuses on making your entities trivial and fun to write. +:summary: Hibernate is the de facto Jakarta Persistence implementation and offers you the full breadth of an Object Relational Mapper. Quarkus Data Hibernate focuses on making simple and common mappings trivial. :config-file: application.properties :topics: data,hibernate-orm,hibernate-reactive,panache,sql,jdbc :extensions: io.quarkus:quarkus-data-hibernate,io.quarkus:quarkus-hibernate-orm,io.quarkus:quarkus-hibernate-reactive @@ -14,6 +14,17 @@ include::_attributes.adoc[] Quarkus Data Hibernate is the perfect solution if you want to get started using Hibernate in Quarkus. +If you are new to Quarkus Data Hibernate, start with the +xref:quarkus-data-hibernate-getting-started.adoc[Quarkus Data Hibernate getting started tutorial]. + +[NOTE] +==== +This guide does not go into the specifics of using either +xref:hibernate-orm.adoc[Hibernate ORM], or its underlying +link:{jakarta-persistence-spec-url}[Jakarta Persistence specification]. +Refer to their documentation for more in-depth knowledge. +==== + include::{includes}/extension-status.adoc[] WARNING: This extension is currently experimental, so its API may change, in particular names of @@ -21,92 +32,18 @@ classes or packages may change, even the module name. We are in the process of m to obtain public feedback, so please report feedback either on link:https://quarkusio.zulipchat.com/#narrow/channel/187038-dev/topic/WG.20-.20Panache.2ENext/with/562916276[Zulip] or via link:https://github.com/orgs/quarkusio/projects/50/views/1[GitHub issues]. -== Walk-through - -Let's take a progressive approach to learning how to use Quarkus Data Hibernate, and start with a simple entity. - -This guide is not going to go into the specifics of using either -xref:hibernate-orm.adoc[Hibernate], or its underlying -link:{jakarta-persistence-spec-url}[Jakarta Persistence specification], -because both already have great documentation that you can and should use for more in-depth knowledge. - -== Importing the extension, and configuration - -[source,xml,role="primary asciidoc-tabs-target-sync-cli asciidoc-tabs-target-sync-maven"] -.pom.xml ----- - - - io.quarkus - quarkus-data-hibernate - - - - io.quarkus - quarkus-jdbc-postgresql - ----- - -[source,gradle,role="secondary asciidoc-tabs-target-sync-gradle"] -.build.gradle ----- -// Import the Quarkus Data Hibernate extension -implementation("io.quarkus:quarkus-data-hibernate") +== Installation -// Pick your database driver -implementation("io.quarkus:quarkus-jdbc-postgresql") ----- +Quarkus Data Hibernate uses an annotation processor to inspect your entities and repository interfaces at build time. +It validates repository methods and queries, then generates the repository implementations that Quarkus makes +available as CDI beans. This catches many mapping and query errors during the build instead of at runtime. -You also have to configure the Hibernate processor, which is required: +Enable the annotation processor in your build configuration: -[source,xml,role="primary asciidoc-tabs-target-sync-cli asciidoc-tabs-target-sync-maven"] -.pom.xml ----- - - maven-compiler-plugin - - - true - - - io.quarkus - quarkus-data-processor - - - - - - - ----- +include::{includes}/quarkus-data-processor-config.adoc[] -[source,gradle,role="secondary asciidoc-tabs-target-sync-gradle"] -.build.gradle ----- -// Enforce the version management of your annotation processor dependencies, -// so that there's no need to define an explicit version of the quarkus-data-processor -annotationProcessor enforcedPlatform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}") -annotationProcessor 'io.quarkus:quarkus-data-processor' ----- - -TIP: You can use any of the xref:hibernate-orm.adoc#setting-up-and-configuring-hibernate-orm[supported JDBC drivers]. - -Add the relevant configuration properties in `{config-file}`. - -[source,properties] -.Example `{config-file}` ----- -quarkus.datasource.db-kind = postgresql <1> - -%prod.quarkus.datasource.username = hibernate -%prod.quarkus.datasource.password = hibernate -%prod.quarkus.datasource.jdbc.url = jdbc:postgresql://localhost:5432/hibernate_db -%prod.quarkus.hibernate-orm.schema-management.strategy=create <2> ----- -<1> xref:datasource.adoc[Configure the datasource] for production, relying on xref:datasource#dev-services[Dev Services] for connection information in tests / dev mode. -<2> Configure Hibernate ORM to create the schema on startup in production, which is useful for experimentation, but rely on xref:hibernate-orm.adoc#dev-mode[convenient defaults in tests / dev mode]. +For step-by-step instructions, including the required extensions, database driver, and datasource configuration, see the +xref:quarkus-data-hibernate-getting-started.adoc[Quarkus Data Hibernate getting started tutorial]. == Our first entity From 54f68cedf6df2e3fe35cdfdb8607d59d3dae01eb Mon Sep 17 00:00:00 2001 From: Luca Molteni Date: Tue, 22 Sep 2026 14:31:02 +0200 Subject: [PATCH 150/254] Organize entity definitions, field access, and identifiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Yoann Rodière --- .../main/asciidoc/quarkus-data-hibernate.adoc | 208 ++++++++---------- 1 file changed, 86 insertions(+), 122 deletions(-) diff --git a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc index 3d842a0339cbc..4a4954abc7c13 100644 --- a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc +++ b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc @@ -45,10 +45,20 @@ include::{includes}/quarkus-data-processor-config.adoc[] For step-by-step instructions, including the required extensions, database driver, and datasource configuration, see the xref:quarkus-data-hibernate-getting-started.adoc[Quarkus Data Hibernate getting started tutorial]. -== Our first entity +== Entities -What this guide will focus on is the basics of how to use Quarkus Data Hibernate, so let's start with how to -create an entity: +An entity is a Java class that represents data stored in a database. Typically, the class maps to a table, each entity +instance maps to a row, and its fields map to columns. Quarkus Data Hibernate works with entities defined using +Jakarta Persistence annotations. + +=== Defining an entity + +To map a Java class to a database table: + +- Write a class. +- Annotate it with link:{jakarta-persistence-javadocs-url}/jakarta/persistence/entity[`@Entity`]. +- Optionally, make it extend `ManagedEntity` or `RecordEntity` (see <>). +- Make its fields `public`. <>. [source,java] ---- @@ -69,45 +79,92 @@ public class Cat extends ManagedEntity { } ---- -In order to map a Java class to a Database table, just: +Instances of `ManagedEntity` are managed by Hibernate ORM. After an instance is persisted, changes to its fields are +automatically sent to the database without an explicit `update` instruction. + +[[entity-field-access]] +=== Entity field access + +Quarkus enhances entity bytecode at build time, so public fields can be accessed directly without writing getters and +setters. This includes tracking changes to managed entities. See +xref:hibernate-orm.adoc#automatic-build-time-enhancement[Automatic build time enhancement]. +Public fields are a convenience, not a requirement: private fields with accessors and Jakarta Persistence property +access are also supported. + +[[about-entity-identifiers]] +=== Entity identifiers + +`ManagedEntity` and `RecordEntity` come with a default generated database identifier because they extend `WithId.AutoLong`, which +provides a generated database identifier of type `Long`. + +You can choose to extend `WithId.AutoString` for a `String` identifier, `WithId.AutoUUID` for a `UUID` identifier, +or `WithId.AutoUUIDv7` for a time-sortable `UUID` identifier. The following table lists the identifier supertypes: + +[cols="1,1,1"] +|=== +|ID type|Supertype|Generation + +|`Long` +|`WithId.AutoLong` +|`@GeneratedValue` + +|`String` +|`WithId.AutoString` +|`@UuidGenerator` + +|`UUID` +|`WithId.AutoUUID` +|`@UuidGenerator` -- Write a class -- Annotate it with link:{jakarta-persistence-javadocs-url}/jakarta/persistence/entity[`@Entity`] -- Make it extend `ManagedEntity` -- Make its fields `public` +|`UUID` +|`WithId.AutoUUIDv7` +|`@UuidGenerator(style = UuidGenerator.Style.VERSION_7)` + +|`Id` +|`WithId` +|Assigned by the application +|=== -And that's it, now you can start creating instances of your entity, persist it to your database, after which point -all changes to its fields will be automatically sent to the database (no need for any explicit `update` instruction), -and even remove it from the database: +For example, extend `WithId.AutoUUIDv7` to use generated UUIDv7 identifiers: [source,java] ---- -import jakarta.transaction.Transactional; - -import java.time.LocalDate; -import java.util.List; +import io.quarkus.data.hibernate.WithId; +import jakarta.persistence.Entity; -public class Code { +@Entity +public class CatWithUUID extends WithId.AutoUUIDv7 { + public String name; +} +---- - @Transactional <1> - public void method() { - Cat cat = new Cat(); - cat.name = "Lucky"; - cat.birth = LocalDate.of(2015, 01, 12); - cat.breed = Cat.Breed.CUTE; +The entity inherits an `id` field of type `java.util.UUID`. When the entity is persisted, Hibernate automatically +generates a time-sortable version 7 UUID for this field. You do not need to declare or assign the identifier yourself. - // Persist the cat - cat.persist(); +You can also provide your own database identifier explicitly and implement the `ManagedEntity.CustomId` interface in +your entity, as well as use the `ManagedRepository.CustomId` interface for your repository, to specify the database +identifier type: - // Make a change, no need to update it - cat.name = "Luckynou"; +[source,java] +---- +@Entity +public class CatWithId implements ManagedEntity.CustomId { + @Id + public String id; + public String name; - // Delete our cat - cat.delete(); + public interface Repo extends ManagedRepository.CustomId { + @Find + CatWithId findByName(String name); } } ---- -<1> This is required on any method which interacts with the database, in order to run the operations in a transaction. + +WARNING: The custom identifier in this example has no generator, so you must set its value before persisting the entity. + +You do not have to extend an identifier supertype if you define your own identifier. To combine a generated identifier +superclass with managed or record operations, implement the corresponding `CustomId` entity interface and extend the +corresponding `CustomId` repository interface; see <>. == Our first repository @@ -381,99 +438,6 @@ List byNameThenBirth = repo.findAll() .list(); ---- -[[about-entity-identifiers]] -== About entity identifiers - -In the example above, we extended the `ManagedEntity` type, and did not define any database identifier, that's -because `ManagedEntity` comes with a default generated database identifier, so you don't have to worry about it. It -does this by extending the `WithId.AutoLong` class, which provides a generated database identifier of type `Long`. - -You can choose to extend the `WithId.AutoString` for a `String` identifier, `WithId.AutoUUID` for a `UUID` -identifier, or `WithId.AutoUUIDv7` for a Unique by time sortable `UUID` -identifier to automatically get an attribute of the form: - -[source,java] ----- -@Id -public IdType id; ----- - -WARNING: The identifier will not be automatically generated in this case, so you will have to set its value manually -on creation. - -Naturally, you can also provide your own database identifier explicitly and implement the `ManagedEntity.CustomId` -interface in your entity, as well as use the `ManagedRepository.CustomId` interface for your repository, in order to -specify you database identifier type: - -[source,java] ----- -import io.quarkus.data.hibernate.ManagedEntity; -import io.quarkus.data.hibernate.ManagedRepository; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import org.hibernate.annotations.processing.Find; -import org.hibernate.annotations.processing.HQL; - -import java.time.LocalDate; -import java.util.List; - -@Entity -public class CatWithId implements ManagedEntity.CustomId { - @Id - public String id; - public String name; - public LocalDate birth; - public Breed breed; - - public enum Breed { - CUTE, HAIRLESS; - } - - public interface Repo extends ManagedRepository.CustomId { - @Find - CatWithId findByName(String name); - - @HQL("where breed = CUTE") - List findCute(); - - @HQL("delete from Cat where name = :name") - long deleteByName(String name); - - @HQL("delete from Cat where breed = HAIRLESS") - long deleteHairless(); - } -} ----- - -Here is a list of entity supertypes you can use and the ID type they provide, but keep in mind you do not have to -extend any of these types if you define you own ID: - -[cols="1,1,1"] -|=== -|ID type|Supertype|Shortcut type - -|`Long` -|`WithId.AutoLong` -|`ManagedEntity` - -|`UUID` -|`WithId.AutoUUID` -| - -|`UUID` -|`WithId.AutoUUIDv7` -| - -|`String` -|`WithId.String` -| - -|`T` -|`WithId` -| - -|=== - == Use stateless sessions Out of the box, your subtype of `ManagedEntity` will be managed by Hibernate ORM, and every change to the entity will From d772bfeae9d2a7f74854d4123436ec9e6fed5b1b Mon Sep 17 00:00:00 2001 From: Luca Molteni Date: Tue, 22 Sep 2026 14:31:02 +0200 Subject: [PATCH 151/254] Organize repository definitions, operations, and query examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Yoann Rodière --- .../main/asciidoc/quarkus-data-hibernate.adoc | 346 +++++++++--------- 1 file changed, 179 insertions(+), 167 deletions(-) diff --git a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc index 4a4954abc7c13..84f4eeef66463 100644 --- a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc +++ b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc @@ -166,26 +166,21 @@ You do not have to extend an identifier supertype if you define your own identif superclass with managed or record operations, implement the corresponding `CustomId` entity interface and extend the corresponding `CustomId` repository interface; see <>. -== Our first repository +== Repositories -Now that we know how to create, update and delete our entities, let's see how we can query our database to find them, -or even run delete queries. +A Quarkus Data Hibernate repository is an interface containing annotated finder, query, or lifecycle methods. +The annotation processor generates its implementation. A repository may also declare `default` methods with custom +implementations. -Because query operations do not belong on instances of our entities, we will place these operations on another type -called a `Repository`, and because those queries are intimately tied with the entities they operate on, we recommend -placing them on an interface nested in the entity: +A repository does not need to extend another interface, but Quarkus Data provides a few repository supertypes worth +considering for convenience. The usual conventions are to nest the repository in its entity and extend +`ManagedRepository` for managed entities, or `RecordRepository` for record entities. These supertypes +provide ready-made persistence and query operations in addition to your declared methods. + +.Managed entity and repository [source,java] ---- -import io.quarkus.data.hibernate.ManagedEntity; -import io.quarkus.data.hibernate.ManagedRepository; -import jakarta.persistence.Entity; -import org.hibernate.annotations.processing.Find; -import org.hibernate.annotations.processing.HQL; - -import java.time.LocalDate; -import java.util.List; - @Entity public class Cat extends ManagedEntity { public String name; @@ -200,235 +195,252 @@ public class Cat extends ManagedEntity { @Find Cat findByName(String name); - @HQL("where breed = CUTE") + @Query("where breed = CUTE") List findCute(); - @HQL("delete from Cat where name = :name") + @Query("delete from Cat where name = :name") long deleteByName(String name); - @HQL("delete from Cat where breed = HAIRLESS") + @Query("delete from Cat where breed = HAIRLESS") long deleteHairless(); } } ---- -A Quarkus Data Hibernate repository is: +.Record entity and repository +[source,java] +---- +@Entity +public class CatRecord extends RecordEntity { + public String name; -- An interface nested in the entity (though it could be a toplevel interface if you prefer) -- Which extends the `ManagedRepository` interface, with the entity in question as type parameter -- And contains query methods, annotated with either `@Find` or `@HQL` or even `@SQL` (for native queries), or - `default` methods with implementation of queries + public interface Repo extends RecordRepository { + @Find + CatRecord findByName(String name); + } +} +---- -You can use `@Find` methods to find single instances, or collections of entities, by using the parameters of the -method to build a query. The Hibernate documentation has -https://docs.jboss.org/hibernate/orm/7.1/introduction/html_single/Hibernate_Introduction.html#generated-finder-methods[all the information you need about this]. +The examples use `jakarta.data.repository.Find` and `jakarta.data.repository.Query`. +For the behavioral differences between managed and record entities, see +<>. For standalone repositories and entities without Quarkus Data +supertypes, see <>. + +=== Generated finder and query methods + +You can use `@Find` methods to find single instances or collections of entities by using the parameters of the method +to build a query. The Hibernate documentation contains the complete +link:https://docs.hibernate.org/orm/{hibernate-orm-version-major-minor}/introduction/html_single/#generated-finder-methods[generated finder method reference]. .A few common examples [source,java] ---- @Find -public List findAllCats(); <1> +List findAllCats(); <1> @Find -public long countAllCats(); <2> +long countAllCats(); <2> @Find -public Cat findCatByNameAndBreed(String name, Breed breed); <3> +Cat findCatByNameAndBreed(String name, Breed breed); <3> ---- <1> Finder methods may return collections of entities <2> Or generate `COUNT` queries <3> Or single entities, and any number of parameters which must all match the queried entities. -Alternately, you can use `@HQL` or `@SQL` queries, which support HQL and SQL queries. Once again -the Hibernate documentation has https://docs.jboss.org/hibernate/orm/7.1/introduction/html_single/Hibernate_Introduction.html#generated-query-methods[all the information you need]. +Alternatively, use Jakarta Data's `@Query` for entity queries or Jakarta Persistence's +`jakarta.persistence.query.NativeQuery` for native SQL. Jakarta Data's `@Query` supports JPQL and its portable +Jakarta Data Query Language subset (JDQL). Hibernate also accepts HQL extensions in `@Query`. The Hibernate documentation contains the complete +link:https://docs.hibernate.org/orm/{hibernate-orm-version-major-minor}/introduction/html_single/#generated-query-methods[generated query method reference]. -.A few common examples +.Examples of `@Query` and `@NativeQuery` usage [source,java] ---- -@HQL("from Cat") -public List findAllCats(); <1> +@Query("from Cat") +List findAllCats(); <1> -@HQL("select min(birth) from Cat") -public LocalDate oldestCat(); <2> +@Query("select min(birth) from Cat") +LocalDate oldestCat(); <2> -@HQL("where name = :name and breed = :breed") -public Cat findCatByNameAndBreed(String name, Breed breed); <3> +@Query("where name = :name and breed = :breed") +Cat findCatByNameAndBreed(String name, Breed breed); <3> + +@NativeQuery("select name from Cat where name = :name") +List findNames(String name); <4> ---- <1> Query methods may return collections of entities <2> Or projections of a single column, even multiple columns with `Object[]` and `List` types <3> Queries can of course refer to method parameters. +<4> Native queries refer to database tables and columns instead of entity names and fields. -The beauty of generated finder and query methods is that they are type-checked at build time: they will be validated -and guarantee that you did not make any typo in the entity names, their fields, the HQL/SQL syntax, or the name of -their parameters. +Generated finder and HQL query methods are type-checked at build time. They are validated for entity names, fields, +HQL syntax, and parameter names. Native SQL is validated by the database when the query is executed. -=== Using the repository +=== Obtaining and using a repository -Using the repository can be done by simply injecting it where you intend to use it: +Repositories are CDI beans and can be injected where you intend to use them: [source,java] ---- -import jakarta.inject.Inject; -import jakarta.transaction.Transactional; +@Inject +Cat.Repo repo; -import java.time.LocalDate; -import java.util.List; - -public class Code { - - @Inject - Cat.Repo repo; +@Transactional +public Cat findCat(String name) { + return repo.findByName(name); +} +---- - @Transactional - public void method() { - Cat cat = new Cat(); - cat.name = "Lucky"; - cat.birth = LocalDate.of(2015, 01, 12); - cat.breed = Cat.Breed.CUTE; +You can also access a nested repository by using a generated static method on the +link:https://docs.hibernate.org/orm/{hibernate-orm-version-major-minor}/introduction/html_single/#static-metamodel[generated metamodel] +of your entity: - // Persist the cat - cat.persist(); +[source,java] +---- +Cat cat = Cat_.repo().findByName("Lucky"); +---- - // Make a change, no need to update it - cat.name = "Luckynou"; +All repositories defined as nested interfaces of an entity are available under their generated metamodel class through +accessor methods with the same name as the repository. This is equivalent to injecting the repository type and uses CDI +under the hood to look up the repository. - // Find our cat - cat = repo.findByName("Luckynou"); +=== The `ManagedRepository` supertype - // Find cute cats - List cuteCats = repo.findCute(); +The `ManagedRepository` type provides most of the operations needed to work with an entity, such as `persist()`, +`delete()`, `isPersistent()`, and `listAll()`. - // Delete our cat - cat.delete(); +Here is an example of how it can be used: - // Delete queries - repo.deleteByName("Lucky"); - repo.deleteHairless(); - } +[source,java] +---- +@Inject +Cat.Repo repo; + +@Transactional +public void repositoryOperations(Cat cat) { + // entity operations + repo.persist(cat); + repo.delete(cat); + boolean isPersistent = repo.isPersistent(cat); + // operations on all entities + long count = repo.count(); + long deleted = repo.deleteAll(); + List allCats = repo.listAll(); + repo.streamAll().forEach(kitty -> kitty.name = kitty.name.toUpperCase()); + BlockingDataQuery catQuery = repo.findAll(); + List sortedCats = repo.findAll().sort(Sort.asc("name")).list(); + // operations on the Hibernate session + repo.flush(); + Session session = repo.getSession(); + // ID-related operations + boolean wasDeleted = repo.deleteById(cat.id); + Cat foundCat = repo.findById(cat.id); + Optional optionalCat = repo.findByIdOptional(cat.id); } ---- -Now, as an added benefit, you can also access the repository using a handy shortcut generated static method on the -https://docs.jboss.org/hibernate/orm/7.1/introduction/html_single/Hibernate_Introduction.html#static-metamodel[generated Metamodel] -of your entity, which is super useful for discovery of operations (just type `Cat_.repo().` and see all your methods) -and to avoid making a detour outside of your method to add an injected field, such as in this startup code that -will delete all your cats (don't do this in production!!): +=== Dynamically built queries + +When defining finder and query operations on a repository, queries are validated and generated at build time. +For dynamic filtering and sorting, prefer type-safe restrictions and ordering before using query strings. + +==== Type-safe restrictions and ordering + +Jakarta Data 1.1 provides `Restriction` parameters for finder and query methods. For example, add this method to +`Cat.Repo`: [source,java] ---- -import io.quarkus.runtime.Startup; -import jakarta.transaction.Transactional; +import jakarta.data.Order; +import jakarta.data.restrict.Restriction; +import jakarta.data.repository.Find; -public class OnStart { - @Startup - @Transactional - public void startupMethod() { - Cat_.repo().deleteAll(); - } -} +@Find +List search(Restriction restriction, Order order); ---- -All the repositories you define as nested interfaces of your entity will be available under their generated metamodel -class under accessor methods of the same name as the repository. This is strictly equivalent to injecting the repository -type, and in fact uses CDI under the hood to look up the repository. +The annotation processor generates two different metamodels for `Cat`: -== The `ManagedRepository` super type +- `_Cat`, with a leading underscore, is the Jakarta Data metamodel. Its typed attributes build restrictions and sort criteria. +- `Cat_`, with a trailing underscore, is the Jakarta Persistence metamodel. Its string attribute constants are used in the next subsection. -Just like in previous versions of Hibernate ORM and Hibernate Reactive with Panache, the `ManagedRepository` type comes -packed with most of the operations you need to work on your entity, such as, out of the box: +Both are generated in the same package as `Cat`; the examples below assume `org.acme`. +Use `_Cat` for type-safe restrictions and ordering: [source,java] ---- -import io.quarkus.data.hibernate.blocking.BlockingDataQuery; -import jakarta.data.Order; -import jakarta.data.Sort; -import jakarta.inject.Inject; -import jakarta.transaction.Transactional; -import org.hibernate.Session; +import jakarta.data.restrict.Restrict; +import org.acme._Cat; // Automatically generated Jakarta Data metamodel (leading underscore) -import java.time.LocalDate; -import java.util.List; -import java.util.Optional; - -public class Code { - - @Inject - Cat.Repo repo; +@Inject +Cat.Repo repo; - @Transactional - public void repositoryOperations(Cat cat) { - // entity operations - repo.persist(cat); - repo.delete(cat); - boolean isPersistent = repo.isPersistent(cat); - // operations on all entities - long count = repo.count(); - long deleted = repo.deleteAll(); - List allCats = repo.listAll(); - repo.streamAll().forEach(kitty -> kitty.name = kitty.name.toUpperCase()); - PanacheBlockingQuery catQuery = repo.findAll(); - List sortedCats = repo.findAll().sort(Sort.asc("name")).list(); - // operations on the Hibernate session - repo.flush(); - Session session = repo.getSession(); - // ID-related operations - boolean wasDeleted = repo.deleteById(cat.id); - Cat foundCat = repo.findById(cat.id); - Optional optionalCat = repo.findByIdOptional(cat.id); - } +@Transactional +public List typeSafeQueries() { + return repo.search( + Restrict.all(_Cat.name.startsWith("Luck"), _Cat.breed.equalTo(Cat.Breed.CUTE)), + Order.by(_Cat.name.asc(), _Cat.birth.desc())); } ---- -== Non-type-safe queries +The attribute types determine which comparisons are available and which values they accept. Restrictions can be +combined conditionally to build a query from user-supplied search criteria. See the Hibernate documentation on +link:https://docs.hibernate.org/orm/{hibernate-orm-version-major-minor}/introduction/html_single/#restrictions-and-ordering[restrictions and ordering] +and the link:{jakarta-data-spec-url}[Jakarta Data specification] for the full APIs. + +==== Queries using metamodel attribute constants -With generated finder and query methods, as we've previously shown, everything is validated at build-time, but if -you want to write non-type-safe queries, you can always use the provided methods of `ManagedRepository`: +The query methods provided by `ManagedRepository` and `RecordRepository` can use attribute constants from the +generated Jakarta Persistence metamodel `Cat_`. These constants contain attribute names as strings; unlike the +typed attributes of `_Cat`, they do not make query parameters or expressions type-safe. [source,java] ---- -import io.quarkus.data.hibernate.blocking.BlockingDataQuery; -import jakarta.data.Order; -import jakarta.data.Sort; -import jakarta.inject.Inject; -import jakarta.transaction.Transactional; -import org.hibernate.Session; +import org.acme.Cat_; // Automatically generated Jakarta Persistence metamodel (trailing underscore) + +@Inject +Cat.Repo repo; + +@Transactional +public void metamodelAttributeQueries() { + // Find a cat by name + Cat cat = repo.find(Cat_.NAME, "Lucky").singleResult(); + // All cute cats + List cuteCats = repo.list(Cat_.BREED, Cat.Breed.CUTE); + // Cats with no known birth date + repo.stream(Cat_.BIRTH + " is null").forEach(kitty -> System.err.println(kitty)); + // Get rid of non-cute cats + long deleted = repo.delete(Cat_.BREED, Cat.Breed.HAIRLESS); +} +---- -import java.time.LocalDate; -import java.util.List; -import java.util.Optional; +==== String-based queries -public class Code { +For queries that cannot be expressed with restrictions, use the query methods provided by `ManagedRepository` or +`RecordRepository`. These methods accept query strings, so they are not type-safe. - @Inject - Cat.Repo repo; - - @Transactional - public void nonTypeSafeQueries() { - // Find a cat by name - Cat cat = repo.find(Cat_.NAME, "Lucky").singleResult(); - // All cute cats - List cuteCats = repo.list(Cat_.BREED, Cat.Breed.CUTE); - // Cats with no known birth date - repo.stream(Cat_.BIRTH+" is null").forEach(kitty -> System.err.println(kitty)); - // Get rid of non-cute cats - long deleted = repo.delete(Cat_.BREED, Cat.Breed.HAIRLESS); - // Count ugly cats with no name - repo.count("breed = ?1 and name is null", Cat.Breed.HAIRLESS); - // Make every cat cute - repo.update("breed = CUTE"); - // Sort results using PanacheQuery - List sortedByName = repo.findAll().sort(Sort.asc("name")).list(); - List sortedMatches = repo.find("breed = ?1", Cat.Breed.CUTE) - .sort(Sort.desc("name")) - .list(); - } +[source,java] +---- +@Inject +Cat.Repo repo; + +@Transactional +public void nonTypeSafeQueries() { + // Count ugly cats with no name + repo.count("breed = ?1 and name is null", Cat.Breed.HAIRLESS); + // Make every cat cute + repo.update("breed = CUTE"); + // Sort results using BlockingDataQuery + List sortedByName = repo.findAll().sort(Sort.asc("name")).list(); + List sortedMatches = repo.find("breed = ?1", Cat.Breed.CUTE) + .sort(Sort.desc("name")) + .list(); } ---- -Sorting is applied on the `PanacheQuery` returned by `find()` and `findAll()`, not on the shortcut -`list()`, `listAll()`, `stream()`, or `streamAll()` methods. For a single criterion, pass a `Sort` -directly — there is no need to wrap it in `Order.by()`. Use `Order` only when you need multiple -criteria, or when receiving an `Order` from a REST endpoint: +Sorting is applied on the `BlockingDataQuery` returned by `find()` and `findAll()`, not on the shortcut `list()`, +`listAll()`, `stream()`, or `streamAll()` methods. For a single criterion, pass a `Sort` directly. Use `Order` only +when you need multiple criteria, or when receiving an `Order` from a REST endpoint: [source,java] ---- From 095c7d27242868792a67fe959ac851fea373bd62 Mon Sep 17 00:00:00 2001 From: Luca Molteni Date: Tue, 22 Sep 2026 14:31:02 +0200 Subject: [PATCH 152/254] Organize managed, record, and reactive session models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Yoann Rodière --- .../main/asciidoc/quarkus-data-hibernate.adoc | 449 +++++------------- 1 file changed, 107 insertions(+), 342 deletions(-) diff --git a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc index 84f4eeef66463..1a6009222fd2b 100644 --- a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc +++ b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc @@ -450,341 +450,166 @@ List byNameThenBirth = repo.findAll() .list(); ---- -== Use stateless sessions +[[session-and-access-models]] +== Session and access models -Out of the box, your subtype of `ManagedEntity` will be managed by Hibernate ORM, and every change to the entity will -be automatically sent to the database without requiring any explicit `update` operation. +Quarkus Data Hibernate supports managed and stateless sessions with blocking and reactive APIs. -If on the other hand, you wish to make every `update` operation explicit, then you need to use what Hibernate ORM calls -a _stateless session_. +[[use-stateless-sessions]] +=== Record entities and stateless sessions -In this case, you need to extend the `RecordEntity` class and extend the -`RecordRepository` interface: +Subtypes of `ManagedEntity` are managed by Hibernate ORM, and changes to the entity are automatically sent to the +database without requiring an explicit `update` operation. -[source,java] ----- -import io.quarkus.data.hibernate.RecordEntity; -import io.quarkus.data.hibernate.RecordRepository; -import io.quarkus.data.hibernate.WithId; -import jakarta.persistence.Entity; -import org.hibernate.annotations.processing.Find; -import org.hibernate.annotations.processing.HQL; +To make every update operation explicit, use a Hibernate ORM _stateless session_. Extend `RecordEntity` for the entity +and `RecordRepository` for its repository. Compared with a managed entity: -import java.time.LocalDate; -import java.util.List; - -@Entity -public class Cat extends RecordEntity { - public String name; - public LocalDate birth; - public Breed breed; - - public enum Breed { - CUTE, HAIRLESS; - } +- Changes to entity fields must be explicitly pushed to the database by calling `update()` on the entity or repository. +- Call `insert()` instead of `persist()` to insert the entity. +- The session type is `StatelessSession` instead of `Session`. +- Accessing an unfetched lazy association does not trigger automatic loading. Fetch the associations you need as part +of a repository query, using a `join fetch` clause in `@Query`. Alternatively, fetch an association explicitly with +`repo.getSession().fetch(association)`. Managed blocking sessions can load lazy associations automatically while the +session is open. - public interface Repo extends RecordRepository { - @Find - Cat findByName(String name); +Queries and the way you obtain entities remain the same. Stateless sessions can also use <>. - @HQL("where breed = CUTE") - List findCute(); +[[lets-get-reactive]] +=== Reactive access - @HQL("delete from Cat where name = :name") - long deleteByName(String name); +==== Reactive managed entities - @HQL("delete from Cat where breed = HAIRLESS") - long deleteHairless(); - } -} ----- +For reactive access: -As you can see, your entity definition is exactly the same, besides the two interfaces. But now, your entities will -not be _managed_, so every update operation has to be explicit: +- The entity extends `ManagedEntity.Reactive`. +- The repository extends `ManagedRepository.Reactive`. +- Operations return `Uni` instead of `T`, which is the standard Mutiny reactive type. +- Use `jakarta.transaction.Transactional` for transaction boundaries and compose operations as usual with Mutiny. [source,java] ---- -import jakarta.inject.Inject; -import jakarta.transaction.Transactional; -import org.hibernate.StatelessSession; - -import java.time.LocalDate; -import java.util.List; - -public class Code { - - @Inject - Cat.Repo repo; - - @Transactional - public void method() { - Cat cat = new Cat(); - cat.name = "Lucky"; - cat.birth = LocalDate.of(2015, 01, 12); - cat.breed = Cat.Breed.CUTE; - - // Persist the cat - cat.insert(); - - // Make a change, we need to update it - cat.name = "Luckynou"; - cat.update(); - - // Find our cat - cat = repo.findByName("Luckynou"); - - // Find cute cats - List cuteCats = repo.findCute(); - - // Delete our cat - cat.delete(); +@Entity +public class ReactiveCat extends ManagedEntity.Reactive { + public String name; - // Delete queries - repo.deleteByName("Lucky"); - repo.deleteHairless(); + public interface Repo extends ManagedRepository.Reactive { + @Find + Uni findByName(String name); } } ---- -As you can see, the only differences with a managed entity are: - -- Any change to the entity instance fields must be explicitly pushed to the database by calling `update()` either on -the entity or its repository -- You need to call `insert()` instead of `persist()` to insert the entity in the database -- Your session will be of type `StatelessSession` instead of `Session`. - -But that's mostly it. Everything else stays the same, in particular for queries and how to obtain entities. - -TIP: If you're using stateless sessions, you can also use the <> type of repositories. - -[[lets-get-reactive]] -== Let's get reactive - -You want to start using your entities in a reactive application? Let's start by importing Hibernate Reactive in your -`pom.xml` as well as a data source for your database: - -[source,xml,role="primary asciidoc-tabs-target-sync-cli asciidoc-tabs-target-sync-maven"] -.pom.xml ----- - - - io.quarkus - quarkus-hibernate-reactive - - - - io.quarkus - quarkus-reactive-pg-client - - - - io.quarkus - quarkus-hibernate-reactive-panache-common - ----- +With an injected `ReactiveCat.Repo repo`, operations are composed in a transaction: -[source,gradle,role="secondary asciidoc-tabs-target-sync-gradle"] -.build.gradle +[source,java] ---- -// Enable Hibernate Reactive support -implementation("io.quarkus:quarkus-hibernate-reactive") -// Pick your database reactive driver -implementation("io.quarkus:quarkus-reactive-pg-client") -// FIXME: this will not be required in the future -implementation("io.quarkus:quarkus-hibernate-reactive-panache-common") +@Transactional +public Uni renameCat(String name, String newName) { + return repo.findByName(name) + .invoke(cat -> cat.name = newName) + .replaceWithVoid(); +} ---- -TIP: You can use any of the xref:hibernate-reactive.adoc#hr-getting-started[supported reactive drivers]. +Changes to the managed entity are automatically flushed before the transaction commits. In reactive sessions, lazy associations +must be fetched explicitly, even for managed entities; see xref:hibernate-reactive.adoc[Hibernate Reactive]. -Make sure you configure your reactive datasource in your `application.properties` (although you don't have to in DEV -mode: if you don't, a dev service will be provided for you): +[[reactive-and-stateless]] +==== Reactive record entities and stateless sessions -[source,properties] ----- -quarkus.datasource.username = quarkus_test -quarkus.datasource.password = quarkus_test -quarkus.datasource.reactive.url = vertx-reactive:postgresql://localhost/quarkus_test ----- - -Now, in your code, the only differences with a regular managed session entity are: - -- Your entity extends `ManagedEntity.Reactive` -- Your repository extends `ManagedRepository.Reactive` -- All the operations return a `Uni` instead of `T`, which is the standard Mutiny reactive type +Use `RecordEntity.Reactive` and `RecordRepository.Reactive` for reactive stateless sessions. Changes must be explicitly +pushed by calling `update()`, and insertion uses `insert()` instead of `persist()`. [source,java] ---- -import io.quarkus.data.hibernate.ManagedEntity; -import io.quarkus.data.hibernate.ManagedRepository; -import io.quarkus.data.hibernate.WithId; -import io.smallrye.mutiny.Uni; -import jakarta.persistence.Entity; -import org.hibernate.annotations.processing.Find; -import org.hibernate.annotations.processing.HQL; - -import java.time.LocalDate; -import java.util.List; - @Entity -public class Cat extends ManagedEntity.Reactive { +public class ReactiveCatRecord extends RecordEntity.Reactive { public String name; - public LocalDate birth; - public Breed breed; - public enum Breed { - CUTE, HAIRLESS; - } - - public interface Repo extends ManagedRepository.Reactive { + public interface Repo extends RecordRepository.Reactive { @Find - Uni findByName(String name); - - @HQL("where breed = CUTE") - Uni> findCute(); - - @HQL("delete from Cat where name = :name") - Uni deleteByName(String name); - - @HQL("delete from Cat where breed = HAIRLESS") - Uni deleteHairless(); + Uni findByName(String name); } } ---- -And now when you want to use your entity or its repository, besides using `@WithTransaction` in place of -`@Transactional`, you have to compose operations as you normally do in reactive code with Mutiny, but the operations are -exactly the same otherwise: +With an injected `ReactiveCatRecord.Repo repo`, explicitly update the modified entity: [source,java] ---- -import io.quarkus.hibernate.reactive.panache.common.WithTransaction; -import io.smallrye.mutiny.Uni; -import jakarta.inject.Inject; - -import java.time.LocalDate; -import java.util.List; -import java.util.Optional; - -public class Code { - - @Inject - Cat.Repo repo; - - @WithTransaction - public Uni method() { - Cat cat = new Cat(); - cat.name = "Lucky"; - cat.birth = LocalDate.of(2015, 01, 12); - cat.breed = Cat.Breed.CUTE; - - // Persist the cat - return cat.persist() - // Make a change, no need to update it - .invoke(() -> cat.name = "Luckynou") - // Find our cat - .chain(() -> repo.findByName("Luckynou")) - // Find cute cats - .chain((Cat foundCat) -> repo.findCute()) - // Delete our cat - .chain((List cuteCats) -> cat.delete()) - // Delete queries - .chain(v -> repo.deleteByName("Lucky")) - .chain((Integer deletedCount) -> repo.deleteHairless()) - .replaceWithVoid(); - } +@Transactional +public Uni renameCat(String name, String newName) { + return repo.findByName(name) + .chain(cat -> { + cat.name = newName; + return cat.update(); + }); } ---- -== Reactive and stateless - -If you want to manage manually your entity changes, then you can use a stateless session by switching to -the `RecordEntity.Reactive` for your entity, and `RecordRepository.Reactive`: - -[source,java] ----- -import io.quarkus.data.hibernate.RecordEntity; -import io.quarkus.data.hibernate.RecordRepository; -import io.quarkus.data.hibernate.WithId; -import io.smallrye.mutiny.Uni; -import jakarta.persistence.Entity; -import org.hibernate.annotations.processing.Find; -import org.hibernate.annotations.processing.HQL; - -import java.time.LocalDate; -import java.util.List; - -@Entity -public class Cat extends RecordEntity.Reactive { - public String name; - public LocalDate birth; - public Breed breed; +The xref:quarkus-data-hibernate-getting-started.adoc#reactive[getting started tutorial] describes the +required dependencies and reactive datasource configuration. You can use any of the +xref:hibernate-reactive.adoc#hr-getting-started[supported reactive drivers]. - public enum Breed { - CUTE, HAIRLESS; - } +[[entity-and-repository-type-matrix]] +=== Entity and repository type matrix - public interface Repo extends RecordRepository.Reactive { - @Find - Uni findByName(String name); - - @HQL("where breed = CUTE") - Uni> findCute(); +The following table lists the entity and repository supertypes for each operation model and identifier type. You do not +have to extend `WithId` if you define your own entity identifier field: - @HQL("delete from Cat where name = :name") - Uni deleteByName(String name); +[cols="1,1,1,1,1"] +|=== +|Session type|ID type|Entity superclass|Entity superinterface|Repository supertype - @HQL("delete from Cat where breed = HAIRLESS") - Uni deleteHairless(); - } -} ----- +|`Session` (managed, blocking) +|`Long` +|`ManagedEntity` +| +|`ManagedRepository` -And for your usage code, the only change is the standard use of manually calling `update()`, and using `insert()` -instead of `persist()`: +|`Session` (managed, blocking) +|`Id` +|`WithId` +|`ManagedEntity.CustomId` +|`ManagedRepository.CustomId` -[source,java] ----- -import io.quarkus.hibernate.reactive.panache.common.WithTransaction; -import io.smallrye.mutiny.Uni; -import jakarta.inject.Inject; +|`StatelessSession` (stateless, blocking) +|`Long` +|`RecordEntity` +| +|`RecordRepository` -import java.time.LocalDate; -import java.util.List; +|`StatelessSession` (stateless, blocking) +|`Id` +|`WithId` +|`RecordEntity.CustomId` +|`RecordRepository.CustomId` -public class Code { +|`Mutiny.Session` (managed, reactive) +|`Long` +|`ManagedEntity.Reactive` +| +|`ManagedRepository.Reactive` - @Inject - Cat.Repo repo; +|`Mutiny.Session` (managed, reactive) +|`Id` +|`WithId` +|`ManagedEntity.Reactive.CustomId` +|`ManagedRepository.Reactive.CustomId` - @WithTransaction - public Uni method() { - Cat cat = new Cat(); - cat.name = "Lucky"; - cat.birth = LocalDate.of(2015, 01, 12); - cat.breed = Cat.Breed.CUTE; +|`Mutiny.StatelessSession` (stateless, reactive) +|`Long` +|`RecordEntity.Reactive` +| +|`RecordRepository.Reactive` - // Persist the cat - return cat.insert() - // Make a change, we need to update it - .chain(() -> { - cat.name = "Luckynou"; - return cat.update(); - }) - // Find our cat - .chain(() -> repo.findByName("Luckynou")) - // Find cute cats - .chain((Cat foundCat) -> repo.findCute()) - // Delete our cat - .chain((List cuteCats) -> cat.delete()) - // Delete queries - .chain(v -> repo.deleteByName("Lucky")) - .chain((Integer deletedCount) -> repo.deleteHairless()) - .replaceWithVoid(); - } -} ----- +|`Mutiny.StatelessSession` (stateless, reactive) +|`Id` +|`WithId` +|`RecordEntity.Reactive.CustomId` +|`RecordRepository.Reactive.CustomId` -TIP: If you're using stateless sessions, you can also use the <> type of repositories. +|=== == Combining blocking, reactive, managed, stateless code @@ -956,66 +781,6 @@ public class Code { NOTE: you can of course also use the generated metamodel accessors instead of injecting these repositories, with `Cat_.repo()` and `Cat_.theOtherRepo()`. -== Recap - -Here is a table listing all the options you have for your entity and repository supertypes, depending on which -model of operation you prefer, and which ID type you want. Remember you don't have to extend `WithId` if you -define your own ID entity field: - -[cols="1,1,1,1,1"] -|=== -|Session type|ID type|Entity superclass|Entity superinterface|Repository supertype - -|`Session` (managed, blocking) -|`Long` -|`ManagedEntity` -| -|`ManagedRepository` - -|`Session` (managed, blocking) -|`Id` -|`WithId` -|`ManagedEntity.CustomId` -|`ManagedRepository.CustomId` - -|`StatelessSession` (stateless, blocking) -|`Long` -|`RecordEntity` -| -|`RecordRepository` - -|`StatelessSession` (stateless, blocking) -|`Id` -|`WithId` -|`RecordEntity.CustomId` -|`RecordRepository.CustomId` - -|`Mutiny.Session` (managed, reactive) -|`Long` -|`ManagedEntity.Reactive` -| -|`ManagedRepository.Reactive` - -|`Mutiny.Session` (managed, reactive) -|`Id` -|`WithId` -|`ManagedEntity.Reactive.CustomId` -|`ManagedRepository.Reactive.CustomId` - -|`Mutiny.StatelessSession` (stateless, reactive) -|`Long` -|`RecordEntity.Reactive` -| -|`RecordRepository.Reactive` - -|`Mutiny.StatelessSession` (stateless, reactive) -|`Id` -|`WithId` -|`RecordEntity.Reactive.CustomId` -|`RecordRepository.Reactive.CustomId` - -|=== - == Startup code For blocking operations, invoking startup code is pretty trivial: From 3ebc660163b989ae6a994938320b4f830f012fc9 Mon Sep 17 00:00:00 2001 From: Luca Molteni Date: Tue, 22 Sep 2026 14:31:02 +0200 Subject: [PATCH 153/254] Simplify blocking and reactive startup examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Yoann Rodière --- .../main/asciidoc/quarkus-data-hibernate.adoc | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc index 1a6009222fd2b..6b1ed6210f62c 100644 --- a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc +++ b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc @@ -783,8 +783,10 @@ NOTE: you can of course also use the generated metamodel accessors instead of in == Startup code -For blocking operations, invoking startup code is pretty trivial: +For startup code, use `jakarta.transaction.Transactional` for both blocking and reactive operations. See the +xref:lifecycle.adoc#startup_annotation[application lifecycle guide] for information about `@Startup`. +.Blocking startup [source,java] ---- import io.quarkus.runtime.Startup; @@ -799,19 +801,18 @@ public class OnStart { } ---- -For reactive operations, it is fairly similar: - +.Reactive startup [source,java] ---- -import io.quarkus.hibernate.reactive.panache.common.WithTransaction; +import jakarta.transaction.Transactional; import io.quarkus.runtime.Startup; import io.smallrye.mutiny.Uni; -public class OnStart { +public class OnStartReactive { @Startup - @WithTransaction - Uni startupMethod(){ - return Cat_.repo().deleteAll().replaceWithVoid(); + @Transactional + public Uni startupMethod() { + return ReactiveCat_.repo().deleteAll().replaceWithVoid(); } } ---- From 5bf27b8caf3a2a21677052f873f516e276fbffc9 Mon Sep 17 00:00:00 2001 From: Luca Molteni Date: Tue, 22 Sep 2026 14:31:02 +0200 Subject: [PATCH 154/254] Update the Jakarta Data integration reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Yoann Rodière --- .../main/asciidoc/quarkus-data-hibernate.adoc | 70 ++++--------------- 1 file changed, 15 insertions(+), 55 deletions(-) diff --git a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc index 6b1ed6210f62c..ce2d6cfec1727 100644 --- a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc +++ b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc @@ -817,50 +817,19 @@ public class OnStartReactive { } ---- +[[jakarta-data]] == Jakarta Data -Naturally, you can also use link:{jakarta-data-spec-url}[Jakarta Data] to define your repositories. For this you need to import the module: +Quarkus Data Hibernate builds on the link:{jakarta-data-spec-url}[Jakarta Data] repository model and supports its +annotations directly. Entities can extend a variant of `ManagedEntity` or `RecordEntity`, or neither. The Jakarta Data +API is provided by the `quarkus-data-hibernate` extension, so no additional dependency is required. -[source,xml,role="primary asciidoc-tabs-target-sync-cli asciidoc-tabs-target-sync-maven"] -.pom.xml ----- - - jakarta.data - jakarta.data-api - ----- - -[source,gradle,role="secondary asciidoc-tabs-target-sync-gradle"] -.build.gradle ----- -implementation 'jakarta.data:jakarta.data-api' ----- - -And then you can write your entity as described before, extending a variant of `ManagedEntity` or `RecordEntity` -or not, as you wish. - -As for the repositories, given that Jakarta Data 1.0 only supports the stateless variants (managed entities are -being added to the upcoming 1.1 version), we recommend that you stick to the `ManagedRepository` or -`RecordRepository` variants in order -to support stateless or managed entities. Both blocking and reactive variants are supported, though. - -Otherwise, just replace `@HQL` with `@Query` and change the package import of `@Find`, and you can even write -`@Delete` finder methods: +For portable repository methods, use the Jakarta Data `@Find`, `@Query`, and `@Delete` annotations. Quarkus Data +Hibernate also supports the Jakarta Persistence-specific `@NativeQuery` annotation, which can be used alongside +Jakarta Data annotations in the same repository. The following example uses only Jakarta Data annotations: [source,java] ---- -import io.quarkus.data.hibernate.RecordRepository; - -import jakarta.data.repository.Delete; -import jakarta.data.repository.Find; -import jakarta.data.repository.Query; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.Id; - -import java.time.LocalDate; -import java.util.List; - @Entity public class Cat { @Id @@ -874,7 +843,7 @@ public class Cat { CUTE, HAIRLESS; } - public interface Repo extends RecordRepository { + public interface Repo extends RecordRepository { @Find <1> Cat findByName(String name); @@ -889,24 +858,15 @@ public class Cat { } } ---- -<1> This is a regular finder method -<2> Use `@Query` in place of `@HQL`, they're the same everything -<3> You can write `delete` finder methods. Just like finder methods, except their action is to delete entities, optionally -returning how many were deleted. -<4> You can always explicitly write delete methods as query methods. - -But otherwise you can still make your entity extend `ManagedEntity` or `RecordEntity` or any of its variants, -and you still get your repository injectable, or via the `Cat_.repo()` accessor. - -Please refer to the corresponding https://hibernate.org/repositories/[Hibernate Data Repositories] -and link:{jakarta-data-spec-url}[Jakarta Data] -guides to learn what else they have to offer. +<1> `@Find` defines a finder method from its parameters. +<2> `@Query` defines a query using the Jakarta Data Query Language. +<3> `@Delete` defines a finder-style delete method and can return the number of deleted entities. +<4> A delete operation can also be written explicitly with `@Query`. -TIP: You can also use the reactive variants by returning `Uni` types from your repository methods, or add other -types of repositories in your entity to support managed entities. +Repositories are CDI beans and are also available through generated metamodel accessors such as `Cat_.repo()`. -TIP: You can also use <> or -<>. +Refer to the link:https://hibernate.org/repositories/[Hibernate Data Repositories] and +link:{jakarta-data-spec-url}[Jakarta Data] documentation for the complete repository API. == REST integration From 9175260482911839002b7f53281d12f3d05f9ef5 Mon Sep 17 00:00:00 2001 From: Luca Molteni Date: Tue, 22 Sep 2026 14:31:02 +0200 Subject: [PATCH 155/254] Clarify REST parameters and pagination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Yoann Rodière --- .../main/asciidoc/quarkus-data-hibernate.adoc | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc index ce2d6cfec1727..aafb5ea68ba1a 100644 --- a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc +++ b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc @@ -870,9 +870,9 @@ link:{jakarta-data-spec-url}[Jakarta Data] documentation for the complete reposi == REST integration -When your application uses both `quarkus-data-hibernate` and `quarkus-rest-jackson`, Quarkus automatically -adds REST support for common Jakarta Data parameter types. This lets you declare Jakarta Data types directly as -REST endpoint parameters, and they will be populated from HTTP query parameters without any manual parsing. +When your application uses both `quarkus-data-hibernate` and `quarkus-rest-jackson`, Quarkus automatically adds REST +support for common Jakarta Data parameter types. You can declare these types directly as REST endpoint parameters, and +they are populated from HTTP query parameters without manual parsing. === Supported parameter types @@ -890,7 +890,7 @@ The following Jakarta Data types are automatically mapped from query parameters: |`Sort` |`sort` |`null` -|`?sort=name` (asc) or `?sort=-name` (desc) +|`?sort=name` (ascending) or `?sort=-name` (descending) |`Order` |`sort` (repeating) @@ -911,19 +911,17 @@ The following Jakarta Data types are automatically mapped from query parameters: For `Sort` and `Order`, prefix the property name with `-` to indicate descending order. -When binding sort parameters from REST endpoints to `PanacheQuery`, prefer `Order` over -`Sort`. A missing `sort` query parameter yields `null` for `Sort` but an empty -`Order.by()` for `Order`. If you use `Sort` and the parameter is `null`, you can -safely call `.sort(null)` and it is a no-op; `Order` is still preferred when multiple columns -may be requested. +When binding sort parameters from REST endpoints to `BlockingDataQuery`, prefer `Order` over `Sort`. A +missing `sort` query parameter yields `null` for `Sort` but an empty `Order.by()` for `Order`. If the +`Sort` parameter is `null`, `.sort(null)` is a no-op. `Order` is preferred when multiple columns can be requested. -NOTE: Cursor-based pagination (`PageRequest.Mode.CURSOR_NEXT` / `CURSOR_PREVIOUS`) is not currently supported -by this REST integration. Only offset-based pagination is available via query parameters. +NOTE: Cursor-based pagination (`PageRequest.Mode.CURSOR_NEXT` and `CURSOR_PREVIOUS`) is not currently supported by this +REST integration. Only offset-based pagination is available through query parameters. === Page serialization -When a REST endpoint returns a `jakarta.data.page.Page`, it is serialized as a JSON object with pagination -metadata, rather than as a plain array: +When a REST endpoint returns a `jakarta.data.page.Page`, it is serialized as a JSON object with pagination metadata, +rather than as a plain array: [source,json] ---- @@ -936,7 +934,7 @@ metadata, rather than as a plain array: } ---- -The `totalElements` and `totalPages` fields are only included when the `PageRequest` was created with +The `totalElements` and `totalPages` fields are included only when the `PageRequest` was created with `requestTotal=true`. === Example endpoint From d63baeed0d7eff046d1103bce76be7b00fa51f41 Mon Sep 17 00:00:00 2001 From: Luca Molteni Date: Tue, 22 Sep 2026 14:31:02 +0200 Subject: [PATCH 156/254] Clarify query languages and HQL shorthand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Yoann Rodière --- .../main/asciidoc/quarkus-data-hibernate.adoc | 59 ++++++++++--------- 1 file changed, 31 insertions(+), 28 deletions(-) diff --git a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc index aafb5ea68ba1a..f57c522016989 100644 --- a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc +++ b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc @@ -976,43 +976,46 @@ GET /cats?page=1&size=20&sort=name&sort=-birth This produces a `PageRequest.ofPage(1, 20, true)` and `Order.by(Sort.asc("name"), Sort.desc("birth"))`. -== HQL, JD-QL, Jakarta-QL, Panache-QL - -There are different query languages used in this API: - -- The link:https://docs.hibernate.org/orm/{hibernate-orm-version-major-minor}/querylanguage/html_single/[Hibernate Query Language] (HQL), -which is the query language used by Hibernate, supported by the `@HQL` annotation. -- The link:{jakarta-persistence-spec-url}#a4665[Jakarta Persistence Query Language] (JP-QL), which is a subset of the Hibernate -Query Language. It is currently being moved to its own spec called -link:https://jakarta.ee/specifications/query/1.0/[Jakarta Query]. -- The link:{jakarta-data-spec-url}#_jakarta_data_query_language[Jakarta Data Query Language] (JD-QL), which is a subset of the -Jakarta Persistence Query Language, supported by the `@Query` annotation. -- The Panache Query Language, which is a superset of the Hibernate Query Language adding very few shortcuts, which -is supported in all the `ManagedRepository` and `RecordRepository` queries. +== Query languages + +There are multiple technologies to express queries in Quarkus Data Hibernate. They are related languages rather than +separate query engines: each standard defines a different level of functionality or portability. + +- The link:https://docs.hibernate.org/orm/{hibernate-orm-version-major-minor}/querylanguage/html_single/[Hibernate Query Language] + (HQL) is Hibernate's full query language. Hibernate ORM accepts HQL in Jakarta Data's `@Query`, including features + beyond the standard query languages. +- The link:{jakarta-persistence-spec-url}#a4665[Jakarta Persistence Query Language] (JPQL) is the standard query language + defined by Jakarta Persistence and is a subset of HQL. A valid JPQL query is also a valid HQL query. This language is + evolving into the standalone link:https://jakarta.ee/specifications/query/1.0/[Jakarta Query specification]. +- The link:{jakarta-data-spec-url}#_jakarta_data_query_language[Jakarta Data Query Language] (JDQL) is the portable + subset of JPQL used by Jakarta Data's `@Query` annotation. Restrict queries to JDQL when the repository should remain + independent of Hibernate-specific query features. +- Panache Query Language adds concise forms for common HQL queries. It is accepted by query methods such as `find()`, + `list()`, `update()`, and `delete()` on `ManagedRepository` and `RecordRepository`, and is expanded to HQL before + execution. === Panache Query Language -Normally, most HQL queries are of this form: `from EntityName [where ...] [order by ...]`, with optional elements -at the end. +Most HQL select queries have the form `from EntityName [where ...] [order by ...]`, with optional elements at the end. -If your select query does not start with `from`, `select` or `with`, we support the following additional forms: +If a select query does not start with `from`, `select`, or `with`, the following additional forms are supported: -- `order by ...` which will expand to `from EntityName order by ...` -- `` (and single parameter) which will expand to `from EntityName where = ?` -- `where ` will expand to `from EntityName where ` -- `` will expand to `from EntityName where ` +- `order by ...` expands to `from EntityName order by ...`. +- `` with a single parameter expands to `from EntityName where = ?`. +- `where ` expands to `from EntityName where `. +- `` expands to `from EntityName where `. -If your update query does not start with `update`, we support the following additional forms: +If an update query does not start with `update`, the following additional forms are supported: -- `from EntityName ...` which will expand to `update EntityName ...` -- `set? ` (and single parameter) which will expand to `update EntityName set = ?` -- `set? ` will expand to `update EntityName set ` +- `from EntityName ...` expands to `update EntityName ...`. +- `set? ` with a single parameter expands to `update EntityName set = ?`. +- `set? ` expands to `update EntityName set `. -If your delete query does not start with `delete`, we support the following additional forms: +If a delete query does not start with `delete`, the following additional forms are supported: -- `from EntityName ...` which will expand to `delete from EntityName ...` -- `` (and single parameter) which will expand to `delete from EntityName where = ?` -- `` will expand to `delete from EntityName where ` +- `from EntityName ...` expands to `delete from EntityName ...`. +- `` with a single parameter expands to `delete from EntityName where = ?`. +- `` expands to `delete from EntityName where `. ==== Secure Panache repositories From 783234f1f158f7840f5ace2bf6152f728dcd3702 Mon Sep 17 00:00:00 2001 From: Luca Molteni Date: Tue, 22 Sep 2026 14:31:02 +0200 Subject: [PATCH 157/254] Clarify repository security and inherited-method limitations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Yoann Rodière --- .../main/asciidoc/quarkus-data-hibernate.adoc | 98 ++++--------------- 1 file changed, 19 insertions(+), 79 deletions(-) diff --git a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc index f57c522016989..df1a3c7ffc02e 100644 --- a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc +++ b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc @@ -1017,102 +1017,42 @@ If a delete query does not start with `delete`, the following additional forms a - `` with a single parameter expands to `delete from EntityName where = ?`. - `` expands to `delete from EntityName where `. -==== Secure Panache repositories +== Repository security -Quarkus Security provides an initial support for securing Panache Repositories with security annotations. +Quarkus Security provides initial support for securing repositories with security annotations. Security annotations can +be placed on methods directly declared by a repository. -.Example inner repository with method-level security annotations [source,java] ---- -@Entity -public class Cat extends ManagedEntity.AutoLong { - public String name; - - public interface SecuredRepo { - @PermissionsAllowed("cat:list") - @PermissionsAllowed("cat:read") - @Find - List findByName(String name); - } -} +@PermissionsAllowed("cat:list") +@PermissionsAllowed("cat:read") +@Find +List findByName(String name); ---- -.Example standalone repository with class-level security annotation +You can also place a security annotation on the repository interface: + [source,java] ---- @RolesAllowed("admin") -public interface SecuredCatRepo extends ManagedRepository.AutoLong { - +public interface SecuredCatRepo extends ManagedRepository { @Find List findByName(String name); - } ---- [WARNING] ==== -In the example above, only methods directly declared on the `SecuredCatRepo` interface are secured. -Methods inherited from `ManagedRepository`, such as `findById`, `persist`, or `deleteAll`, are not secured because type-level annotations only affect the type they annotate. -==== +Only methods directly declared on an annotated repository interface are secured. Methods inherited from +`ManagedRepository`, such as `findById`, `persist`, or `deleteAll`, are not secured because type-level annotations only +affect the type they annotate. -[IMPORTANT] +If you need security in the repository layer, consider <> that do not extend Quarkus Data ones, or roll out your own repository super interfaces, adding security annotations wherever relevant for your security model. ==== -Currently, generic interface methods using type variables or wildcards cannot be secured reliably with standard security annotations. -Therefore, you must not attempt to secure such methods and instead use two alternatives described below. - -.Example repository with a generic method using a type variable -[source,java] ----- -@Entity -public class Cat extends ManagedEntity { - public String name; - - public interface ParentRepo { - - @Find - List findAll(Order order); - - } - - public interface ChildRepo extends ParentRepo { - } -} ----- -Alternative 1: Apply a security annotation to the calling method in your REST layer: +Generic interface methods using type variables or wildcards cannot currently be secured reliably with standard security +annotations. Apply the security annotation to the calling method, such as a REST endpoint, or redeclare the repository +method with a concrete type. -[source,java] ----- -@Path("cat") -public class CatResource { - - @Inject - Cat.ChildRepo childRepo; - - @PermissionsAllowed("cat:list") - @GET - public List findAll() { - return childRepo.findAll(Order.by()); - } - -} ----- - -Alternative 2: Replace the type variable `T` with a concrete type in the repository interface: - -[source,java] ----- -@Entity -public class Cat extends ManagedEntity { - public String name; - - public interface Repo { - - @PermissionsAllowed("cat:list") - @Find - List findAll(Order order); - - } -} ----- -==== +See the xref:security-authorize-web-endpoints-reference.adoc[authorization of web endpoints reference] for information +about Quarkus Security annotations. From fd683159b59051c25d1a307800f52e6fc2f771bd Mon Sep 17 00:00:00 2001 From: Luca Molteni Date: Tue, 22 Sep 2026 14:31:02 +0200 Subject: [PATCH 158/254] Group standalone repositories and model switching under advanced usage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Yoann Rodière --- .../main/asciidoc/quarkus-data-hibernate.adoc | 267 +++++++----------- 1 file changed, 97 insertions(+), 170 deletions(-) diff --git a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc index df1a3c7ffc02e..ba0b0b98bb6ae 100644 --- a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc +++ b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc @@ -611,176 +611,6 @@ have to extend `WithId` if you define your own entity identifier field: |=== -== Combining blocking, reactive, managed, stateless code - -Let's say you want to use an entity in a blocking managed session code, but also in a reactive stateless code: this -is possible no matter which supertype of your entity you picked. You should pick the supertype that represents your -majority of use-cases, but any one you pick, you can always obtain the alternative operations by using the -`.statelessReactive()` method on the entity: - -[source,java] ----- -import io.quarkus.hibernate.reactive.panache.common.WithTransaction; -import io.smallrye.mutiny.Uni; -import jakarta.transaction.Transactional; - -import java.time.LocalDate; - -public class Code { - - @Transactional - public void blockingManagedMethod() { - Cat cat = new Cat(); - cat.name = "Lucky"; - cat.birth = LocalDate.of(2015, 01, 12); - cat.breed = Cat.Breed.CUTE; - - // Persist the cat - cat.persist(); - - // Make a change, no need to update it - cat.name = "Luckynou"; - } - - @WithTransaction - public Uni reactiveStatelessMethod(Long catId) { - Cat cat = new Cat(); - cat.name = "Lucky"; - cat.birth = LocalDate.of(2015, 01, 12); - cat.breed = Cat.Breed.CUTE; - - // Insert the cat - return cat.statelessReactive().insert() - .chain(() -> { - // Make a change, we need to update it - cat.name = "Luckynou"; - return cat.statelessReactive().update(); - }); - - } -} ----- - -All the alternative entity operations are available from these methods: - -[cols="1,1,1"] -|=== -|Session type|Entity accessor|Equivalent entity type - -|`Session` -|`.managedBlocking()` -|`ManagedEntity` - -|`StatelessSession` -|`.statelessBlocking()` -|`RecordEntity` - -|`Mutiny.Session` -|`.managedReactive()` -|`ManagedEntity.Reactive` - -|`Mutiny.StatelessSession` -|`.statelessReactive()` -|`RecordEntity.Reactive` - -|=== - -Similarly, for all the repository operations, you can obtain alternate repositories for your entity from the -generated metamodel accessors, or by injecting them with `@Inject`: - -[cols="1,1,1"] -|=== -|Session type|Metamodel accessor|Repository type - -|`Session` -|`Cat_.managedBlocking()` -|`ManagedRepository` - -|`StatelessSession` -|`Cat_.statelessBlocking()` -|`RecordRepository` - -|`Mutiny.Session` -|`Cat_.managedReactive()` -|`ManagedRepository.Reactive` - -|`Mutiny.StatelessSession` -|`Cat_.statelessReactive()` -|`RecordRepository.Reactive` - -|=== - -But you can also define any number of repositories for your custom operations within your entity: - -[source,java] ----- -import io.quarkus.data.hibernate.ManagedEntity; -import io.quarkus.data.hibernate.ManagedRepository; -import io.quarkus.data.hibernate.RecordRepository; -import io.smallrye.mutiny.Uni; -import jakarta.persistence.Entity; -import org.hibernate.annotations.processing.Find; -import org.hibernate.annotations.processing.HQL; - -import java.time.LocalDate; -import java.util.List; - -@Entity -public class Cat extends ManagedEntity { - public String name; - public LocalDate birth; - public Breed breed; - - public enum Breed { - CUTE, HAIRLESS; - } - - public interface Repo extends ManagedRepository { - @Find - Cat findByName(String name); - } - - public interface TheOtherRepo extends RecordRepository { - @Find - Uni findByName(String name); - } -} ----- - -And then you can inject both types of repositories: - -[source,java] ----- -import io.quarkus.hibernate.reactive.panache.common.WithTransaction; -import io.smallrye.mutiny.Uni; -import jakarta.inject.Inject; -import jakarta.transaction.Transactional; - -public class Code { - - @Inject - Cat.Repo repo; - - @Inject - Cat.TheOtherRepo theOtherRepo; - - @Transactional - public void blockingManagedMethod() { - Cat cat = repo.findByName("Lucky"); - } - - @WithTransaction - public Uni reactiveStatelessMethod() { - return theOtherRepo.findByName("Lucky") - .invoke(cat -> System.err.println(cat)) - .replaceWithVoid(); - } -} ----- - -NOTE: you can of course also use the generated metamodel accessors instead of injecting these repositories, with -`Cat_.repo()` and `Cat_.theOtherRepo()`. - == Startup code For startup code, use `jakarta.transaction.Transactional` for both blocking and reactive operations. See the @@ -1056,3 +886,100 @@ method with a concrete type. See the xref:security-authorize-web-endpoints-reference.adoc[authorization of web endpoints reference] for information about Quarkus Security annotations. + +== Advanced usage + +[[alternative-repository-definitions]] +=== Standalone repositories and entities without supertypes + +Neither an entity nor its repository has to extend a Quarkus Data supertype. This is useful when an entity already +extends an application-specific superclass, or when the repository should expose only explicitly declared operations: + +[source,java] +---- +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; + +@Entity +public class PlainCat { + @Id + @GeneratedValue + public Long id; + public String name; +} +---- + +A repository can also be a top-level interface. Annotate standalone repositories with `jakarta.data.repository.Repository`: + +[source,java] +---- +import jakarta.data.repository.Find; +import jakarta.data.repository.Repository; + +@Repository +public interface PlainCatRepository { + @Find + PlainCat findByName(String name); +} +---- + +The generated repository remains a CDI bean and can be injected as `PlainCatRepository`. It only exposes the declared +finder, because it does not inherit persistence or query operations. A standalone repository can also extend +`ManagedRepository` or `RecordRepository` to inherit those operations. Conversely, a nested repository +can omit the supertype; nesting and inheritance are independent choices. + +=== Switching between managed and record models + +You can access an entity using any supported session model, regardless of the entity supertype. Pick the supertype that +represents the majority of your use cases and use the following accessors for alternative operations: + +[cols="1,1,1"] +|=== +|Session type|Entity accessor|Equivalent entity type + +|`Session` +|`.managedBlocking()` +|`ManagedEntity` + +|`StatelessSession` +|`.statelessBlocking()` +|`RecordEntity` + +|`Mutiny.Session` +|`.managedReactive()` +|`ManagedEntity.Reactive` + +|`Mutiny.StatelessSession` +|`.statelessReactive()` +|`RecordEntity.Reactive` + +|=== + +For repository operations, obtain alternative repositories from the generated metamodel accessors, or inject them with +`@Inject`: + +[cols="1,1,1"] +|=== +|Session type|Metamodel accessor|Repository type + +|`Session` +|`Cat_.managedBlocking()` +|`ManagedRepository` + +|`StatelessSession` +|`Cat_.statelessBlocking()` +|`RecordRepository` + +|`Mutiny.Session` +|`Cat_.managedReactive()` +|`ManagedRepository.Reactive` + +|`Mutiny.StatelessSession` +|`Cat_.statelessReactive()` +|`RecordRepository.Reactive` + +|=== + +You can also define any number of repositories for custom operations within an entity. All nested repositories are +available through generated metamodel accessors, such as `Cat_.repo()` and `Cat_.theOtherRepo()`. From 1e4013df05c6b1dde0590590f46762064c71ef92 Mon Sep 17 00:00:00 2001 From: Luca Molteni Date: Thu, 8 Oct 2026 15:04:36 +0200 Subject: [PATCH 159/254] Clarify enhanced field access and custom accessors --- docs/src/main/asciidoc/quarkus-data-hibernate.adoc | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc index ba0b0b98bb6ae..bd99fab5c10a2 100644 --- a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc +++ b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc @@ -86,7 +86,10 @@ automatically sent to the database without an explicit `update` instruction. === Entity field access Quarkus enhances entity bytecode at build time, so public fields can be accessed directly without writing getters and -setters. This includes tracking changes to managed entities. See +setters. Missing getters and setters are generated, and public-field access is rewritten to invoke the corresponding +accessor methods. For example, reading `cat.name` invokes `getName()`, and assigning `cat.name` invokes `setName()`. +You can write these accessors yourself to add custom logic; callers can continue using the public field without changes. +Enhancement also tracks changes to managed entities. See xref:hibernate-orm.adoc#automatic-build-time-enhancement[Automatic build time enhancement]. Public fields are a convenience, not a requirement: private fields with accessors and Jakarta Persistence property access are also supported. From 0b555a71d3221562689609e8a1da4dc1d455b28c Mon Sep 17 00:00:00 2001 From: Luca Molteni Date: Thu, 8 Oct 2026 15:10:45 +0200 Subject: [PATCH 160/254] Clarify that WithId identifiers are assigned by the application --- docs/src/main/asciidoc/quarkus-data-hibernate.adoc | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc index bd99fab5c10a2..0aeda09474652 100644 --- a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc +++ b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc @@ -128,6 +128,9 @@ or `WithId.AutoUUIDv7` for a time-sortable `UUID` identifier. The following tabl |Assigned by the application |=== +NOTE: `WithId` provides an inherited `@Id` field without an identifier generator. You must assign its value before +persisting the entity. + For example, extend `WithId.AutoUUIDv7` to use generated UUIDv7 identifiers: [source,java] From 29f6c9ed17a3c2a3f7030774db8310b3bd657309 Mon Sep 17 00:00:00 2001 From: Luca Molteni Date: Thu, 8 Oct 2026 15:16:51 +0200 Subject: [PATCH 161/254] Provide managed operations in the UUIDv7 entity example --- docs/src/main/asciidoc/quarkus-data-hibernate.adoc | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc index 0aeda09474652..d6b87205728ef 100644 --- a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc +++ b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc @@ -135,17 +135,19 @@ For example, extend `WithId.AutoUUIDv7` to use generated UUIDv7 identifiers: [source,java] ---- +import io.quarkus.data.hibernate.ManagedEntity; import io.quarkus.data.hibernate.WithId; import jakarta.persistence.Entity; @Entity -public class CatWithUUID extends WithId.AutoUUIDv7 { +public class CatWithUUID extends WithId.AutoUUIDv7 implements ManagedEntity.CustomId { public String name; } ---- The entity inherits an `id` field of type `java.util.UUID`. When the entity is persisted, Hibernate automatically generates a time-sortable version 7 UUID for this field. You do not need to declare or assign the identifier yourself. +Implementing `ManagedEntity.CustomId` also provides managed entity operations such as `persist()` and `delete()`. You can also provide your own database identifier explicitly and implement the `ManagedEntity.CustomId` interface in your entity, as well as use the `ManagedRepository.CustomId` interface for your repository, to specify the database From 4308e01f6801af148e8d4f004e21cc3ed038c304 Mon Sep 17 00:00:00 2001 From: Luca Molteni Date: Thu, 8 Oct 2026 15:18:51 +0200 Subject: [PATCH 162/254] Clarify that custom default repository methods are unannotated --- docs/src/main/asciidoc/quarkus-data-hibernate.adoc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc index d6b87205728ef..c9d8a294e42cd 100644 --- a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc +++ b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc @@ -177,8 +177,8 @@ corresponding `CustomId` repository interface; see < Date: Thu, 8 Oct 2026 15:21:30 +0200 Subject: [PATCH 163/254] Restore explicit imports in reference guide examples --- .../main/asciidoc/quarkus-data-hibernate.adoc | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc index c9d8a294e42cd..e1ce25a6d0356 100644 --- a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc +++ b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc @@ -155,6 +155,12 @@ identifier type: [source,java] ---- +import io.quarkus.data.hibernate.ManagedEntity; +import io.quarkus.data.hibernate.ManagedRepository; +import jakarta.data.repository.Find; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; + @Entity public class CatWithId implements ManagedEntity.CustomId { @Id @@ -189,6 +195,15 @@ provide ready-made persistence and query operations in addition to your declared [source,java] ---- +import io.quarkus.data.hibernate.ManagedEntity; +import io.quarkus.data.hibernate.ManagedRepository; +import jakarta.data.repository.Find; +import jakarta.data.repository.Query; +import jakarta.persistence.Entity; + +import java.time.LocalDate; +import java.util.List; + @Entity public class Cat extends ManagedEntity { public String name; @@ -218,6 +233,11 @@ public class Cat extends ManagedEntity { .Record entity and repository [source,java] ---- +import io.quarkus.data.hibernate.RecordEntity; +import io.quarkus.data.hibernate.RecordRepository; +import jakarta.data.repository.Find; +import jakarta.persistence.Entity; + @Entity public class CatRecord extends RecordEntity { public String name; @@ -243,6 +263,11 @@ link:https://docs.hibernate.org/orm/{hibernate-orm-version-major-minor}/introduc .A few common examples [source,java] ---- +import jakarta.data.repository.Find; +import org.acme.Cat.Breed; + +import java.util.List; + @Find List findAllCats(); <1> @@ -264,6 +289,13 @@ link:https://docs.hibernate.org/orm/{hibernate-orm-version-major-minor}/introduc .Examples of `@Query` and `@NativeQuery` usage [source,java] ---- +import jakarta.data.repository.Query; +import jakarta.persistence.query.NativeQuery; +import org.acme.Cat.Breed; + +import java.time.LocalDate; +import java.util.List; + @Query("from Cat") List findAllCats(); <1> @@ -290,6 +322,9 @@ Repositories are CDI beans and can be injected where you intend to use them: [source,java] ---- +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; + @Inject Cat.Repo repo; @@ -321,6 +356,15 @@ Here is an example of how it can be used: [source,java] ---- +import io.quarkus.data.hibernate.blocking.BlockingDataQuery; +import jakarta.data.Sort; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; +import org.hibernate.Session; + +import java.util.List; +import java.util.Optional; + @Inject Cat.Repo repo; @@ -363,6 +407,8 @@ import jakarta.data.Order; import jakarta.data.restrict.Restriction; import jakarta.data.repository.Find; +import java.util.List; + @Find List search(Restriction restriction, Order order); ---- @@ -377,9 +423,14 @@ Use `_Cat` for type-safe restrictions and ordering: [source,java] ---- +import jakarta.data.Order; import jakarta.data.restrict.Restrict; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; import org.acme._Cat; // Automatically generated Jakarta Data metamodel (leading underscore) +import java.util.List; + @Inject Cat.Repo repo; @@ -404,8 +455,12 @@ typed attributes of `_Cat`, they do not make query parameters or expressions typ [source,java] ---- +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; import org.acme.Cat_; // Automatically generated Jakarta Persistence metamodel (trailing underscore) +import java.util.List; + @Inject Cat.Repo repo; @@ -429,6 +484,12 @@ For queries that cannot be expressed with restrictions, use the query methods pr [source,java] ---- +import jakarta.data.Sort; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; + +import java.util.List; + @Inject Cat.Repo repo; @@ -452,6 +513,11 @@ when you need multiple criteria, or when receiving an `Order` from a REST endpoi [source,java] ---- +import jakarta.data.Order; +import jakarta.data.Sort; + +import java.util.List; + List byName = repo.findAll().sort(Sort.asc("name")).list(); List byNameThenBirth = repo.findAll() .sort(Order.by(Sort.asc("name"), Sort.desc("birth"))) @@ -496,6 +562,12 @@ For reactive access: [source,java] ---- +import io.quarkus.data.hibernate.ManagedEntity; +import io.quarkus.data.hibernate.ManagedRepository; +import io.smallrye.mutiny.Uni; +import jakarta.data.repository.Find; +import jakarta.persistence.Entity; + @Entity public class ReactiveCat extends ManagedEntity.Reactive { public String name; @@ -511,6 +583,9 @@ With an injected `ReactiveCat.Repo repo`, operations are composed in a transacti [source,java] ---- +import io.smallrye.mutiny.Uni; +import jakarta.transaction.Transactional; + @Transactional public Uni renameCat(String name, String newName) { return repo.findByName(name) @@ -530,6 +605,12 @@ pushed by calling `update()`, and insertion uses `insert()` instead of `persist( [source,java] ---- +import io.quarkus.data.hibernate.RecordEntity; +import io.quarkus.data.hibernate.RecordRepository; +import io.smallrye.mutiny.Uni; +import jakarta.data.repository.Find; +import jakarta.persistence.Entity; + @Entity public class ReactiveCatRecord extends RecordEntity.Reactive { public String name; @@ -545,6 +626,9 @@ With an injected `ReactiveCatRecord.Repo repo`, explicitly update the modified e [source,java] ---- +import io.smallrye.mutiny.Uni; +import jakarta.transaction.Transactional; + @Transactional public Uni renameCat(String name, String newName) { return repo.findByName(name) @@ -668,6 +752,17 @@ Jakarta Data annotations in the same repository. The following example uses only [source,java] ---- +import io.quarkus.data.hibernate.RecordRepository; +import jakarta.data.repository.Delete; +import jakarta.data.repository.Find; +import jakarta.data.repository.Query; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; + +import java.time.LocalDate; +import java.util.List; + @Entity public class Cat { @Id @@ -862,6 +957,11 @@ be placed on methods directly declared by a repository. [source,java] ---- +import io.quarkus.security.PermissionsAllowed; +import jakarta.data.repository.Find; + +import java.util.List; + @PermissionsAllowed("cat:list") @PermissionsAllowed("cat:read") @Find @@ -872,6 +972,12 @@ You can also place a security annotation on the repository interface: [source,java] ---- +import io.quarkus.data.hibernate.ManagedRepository; +import jakarta.annotation.security.RolesAllowed; +import jakarta.data.repository.Find; + +import java.util.List; + @RolesAllowed("admin") public interface SecuredCatRepo extends ManagedRepository { @Find From 920b71ed1b4dc7afeeb48759613c3515bfd2b20c Mon Sep 17 00:00:00 2001 From: Luca Molteni Date: Thu, 8 Oct 2026 15:23:13 +0200 Subject: [PATCH 164/254] Identify NativeQuery as an annotation in the reference guide --- docs/src/main/asciidoc/quarkus-data-hibernate.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc index e1ce25a6d0356..a1d344ce14837 100644 --- a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc +++ b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc @@ -282,7 +282,7 @@ Cat findCatByNameAndBreed(String name, Breed breed); <3> <3> Or single entities, and any number of parameters which must all match the queried entities. Alternatively, use Jakarta Data's `@Query` for entity queries or Jakarta Persistence's -`jakarta.persistence.query.NativeQuery` for native SQL. Jakarta Data's `@Query` supports JPQL and its portable +`@jakarta.persistence.query.NativeQuery` for native SQL. Jakarta Data's `@Query` supports JPQL and its portable Jakarta Data Query Language subset (JDQL). Hibernate also accepts HQL extensions in `@Query`. The Hibernate documentation contains the complete link:https://docs.hibernate.org/orm/{hibernate-orm-version-major-minor}/introduction/html_single/#generated-query-methods[generated query method reference]. From 4dd91d59e64f7e253e009e81dd62d43ffa80c890 Mon Sep 17 00:00:00 2001 From: Luca Molteni Date: Thu, 8 Oct 2026 15:24:39 +0200 Subject: [PATCH 165/254] Introduce finder-style deletion with Delete --- docs/src/main/asciidoc/quarkus-data-hibernate.adoc | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc index a1d344ce14837..0634a2f6b8336 100644 --- a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc +++ b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc @@ -281,6 +281,17 @@ Cat findCatByNameAndBreed(String name, Breed breed); <3> <2> Or generate `COUNT` queries <3> Or single entities, and any number of parameters which must all match the queried entities. +Use `@Delete` instead of `@Find` to delete entities matching the method parameters. The method can return the number +of deleted entities: + +[source,java] +---- +import jakarta.data.repository.Delete; + +@Delete +long deleteByName(String name); +---- + Alternatively, use Jakarta Data's `@Query` for entity queries or Jakarta Persistence's `@jakarta.persistence.query.NativeQuery` for native SQL. Jakarta Data's `@Query` supports JPQL and its portable Jakarta Data Query Language subset (JDQL). Hibernate also accepts HQL extensions in `@Query`. The Hibernate documentation contains the complete From cc45096d6ff41a1582f5dcd0765d88af55d43542 Mon Sep 17 00:00:00 2001 From: Luca Molteni Date: Thu, 8 Oct 2026 15:26:07 +0200 Subject: [PATCH 166/254] Document bulk update and delete query methods --- docs/src/main/asciidoc/quarkus-data-hibernate.adoc | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc index 0634a2f6b8336..8a4515e19d049 100644 --- a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc +++ b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc @@ -324,6 +324,19 @@ List findNames(String name); <4> <3> Queries can of course refer to method parameters. <4> Native queries refer to database tables and columns instead of entity names and fields. +`@Query` also supports bulk `UPDATE` and `DELETE` queries. These methods can return the number of affected entities: + +[source,java] +---- +import jakarta.data.repository.Query; + +@Query("update Cat set name = :newName where name = :name") +long renameCats(String name, String newName); + +@Query("delete from Cat where name = :name") +long deleteByName(String name); +---- + Generated finder and HQL query methods are type-checked at build time. They are validated for entity names, fields, HQL syntax, and parameter names. Native SQL is validated by the database when the query is executed. From 64c1d0eab0b40f3db2660bbb098859b4595f037a Mon Sep 17 00:00:00 2001 From: Luca Molteni Date: Thu, 8 Oct 2026 15:26:55 +0200 Subject: [PATCH 167/254] Clarify that native SQL validation occurs at run time --- docs/src/main/asciidoc/quarkus-data-hibernate.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc index 8a4515e19d049..7c0dcadbe1efc 100644 --- a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc +++ b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc @@ -338,7 +338,7 @@ long deleteByName(String name); ---- Generated finder and HQL query methods are type-checked at build time. They are validated for entity names, fields, -HQL syntax, and parameter names. Native SQL is validated by the database when the query is executed. +HQL syntax, and parameter names. Native SQL is validated by the database when the query is executed at run time. === Obtaining and using a repository From d8e63c6aa93577b952e1d4394dce2dcfba8fcd9f Mon Sep 17 00:00:00 2001 From: Luca Molteni Date: Thu, 8 Oct 2026 15:47:12 +0200 Subject: [PATCH 168/254] Introduce generated metamodels in a dedicated section --- .../main/asciidoc/quarkus-data-hibernate.adoc | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc index 7c0dcadbe1efc..49d35a2d0e056 100644 --- a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc +++ b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc @@ -415,6 +415,16 @@ public void repositoryOperations(Cat cat) { } ---- +[[generated-metamodels]] +=== Generated metamodels + +The annotation processor generates two different metamodels for `Cat`: + +- `_Cat`, with a leading underscore, is the Jakarta Data metamodel. Its typed attributes build restrictions and sort criteria. +- `Cat_`, with a trailing underscore, is the Jakarta Persistence metamodel. Its string attribute constants can be used in repository queries. + +Both are generated in the same package as `Cat`; the examples below assume `org.acme`. + === Dynamically built queries When defining finder and query operations on a repository, queries are validated and generated at build time. @@ -437,13 +447,7 @@ import java.util.List; List search(Restriction restriction, Order order); ---- -The annotation processor generates two different metamodels for `Cat`: - -- `_Cat`, with a leading underscore, is the Jakarta Data metamodel. Its typed attributes build restrictions and sort criteria. -- `Cat_`, with a trailing underscore, is the Jakarta Persistence metamodel. Its string attribute constants are used in the next subsection. - -Both are generated in the same package as `Cat`; the examples below assume `org.acme`. -Use `_Cat` for type-safe restrictions and ordering: +Use the <> `_Cat` for type-safe restrictions and ordering: [source,java] ---- From 304e7502f57db80c898122e929f856d39364accd Mon Sep 17 00:00:00 2001 From: Luca Molteni Date: Thu, 8 Oct 2026 15:48:57 +0200 Subject: [PATCH 169/254] Remove redundant Jakarta Data metamodel import --- docs/src/main/asciidoc/quarkus-data-hibernate.adoc | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc index 49d35a2d0e056..4648c75351ec0 100644 --- a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc +++ b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc @@ -455,7 +455,6 @@ import jakarta.data.Order; import jakarta.data.restrict.Restrict; import jakarta.inject.Inject; import jakarta.transaction.Transactional; -import org.acme._Cat; // Automatically generated Jakarta Data metamodel (leading underscore) import java.util.List; From e0424eee5bc283d217a78193c9e7d52213dfbec6 Mon Sep 17 00:00:00 2001 From: Luca Molteni Date: Thu, 8 Oct 2026 15:52:10 +0200 Subject: [PATCH 170/254] Remove redundant Jakarta Persistence metamodel import --- docs/src/main/asciidoc/quarkus-data-hibernate.adoc | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc index 4648c75351ec0..ef798288c49d1 100644 --- a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc +++ b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc @@ -484,7 +484,6 @@ typed attributes of `_Cat`, they do not make query parameters or expressions typ ---- import jakarta.inject.Inject; import jakarta.transaction.Transactional; -import org.acme.Cat_; // Automatically generated Jakarta Persistence metamodel (trailing underscore) import java.util.List; From 19c1b291926fa444aa08af570fc4d60f1f7d6f3a Mon Sep 17 00:00:00 2001 From: Luca Molteni Date: Thu, 8 Oct 2026 15:58:49 +0200 Subject: [PATCH 171/254] Separate managed and record session explanations --- docs/src/main/asciidoc/quarkus-data-hibernate.adoc | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc index ef798288c49d1..b6bc75332a5eb 100644 --- a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc +++ b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc @@ -555,12 +555,16 @@ List byNameThenBirth = repo.findAll() Quarkus Data Hibernate supports managed and stateless sessions with blocking and reactive APIs. +[[use-stateful-sessions]] +=== Managed entities and stateful sessions + +Use `ManagedEntity` and `ManagedRepository` for Hibernate ORM _stateful sessions_. Entities persisted or obtained +through a stateful session are managed by Hibernate ORM, and changes to their fields are automatically sent to the +database without requiring an explicit `update` operation. The session type is `Session`. + [[use-stateless-sessions]] === Record entities and stateless sessions -Subtypes of `ManagedEntity` are managed by Hibernate ORM, and changes to the entity are automatically sent to the -database without requiring an explicit `update` operation. - To make every update operation explicit, use a Hibernate ORM _stateless session_. Extend `RecordEntity` for the entity and `RecordRepository` for its repository. Compared with a managed entity: From 3e676589b45a04c376bcb642fc5e9d1c77b66c77 Mon Sep 17 00:00:00 2001 From: Luca Molteni Date: Thu, 8 Oct 2026 16:01:41 +0200 Subject: [PATCH 172/254] Clarify that the session determines entity management --- docs/src/main/asciidoc/quarkus-data-hibernate.adoc | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc index b6bc75332a5eb..69a020e059349 100644 --- a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc +++ b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc @@ -578,6 +578,14 @@ session is open. Queries and the way you obtain entities remain the same. Stateless sessions can also use <>. +[NOTE] +==== +Whether an entity is managed depends on the session used to persist or obtain it, not on its Java superclass. +For example, a `ManagedEntity` obtained through a `RecordRepository` is not managed, because that repository uses a +stateless session. See <> +for alternative entity and repository APIs. +==== + [[lets-get-reactive]] === Reactive access @@ -1073,6 +1081,7 @@ finder, because it does not inherit persistence or query operations. A standalon `ManagedRepository` or `RecordRepository` to inherit those operations. Conversely, a nested repository can omit the supertype; nesting and inheritance are independent choices. +[[switching-between-managed-and-record-models]] === Switching between managed and record models You can access an entity using any supported session model, regardless of the entity supertype. Pick the supertype that From d992f582c703c4ff616751af598ee1f8e20bb983 Mon Sep 17 00:00:00 2001 From: Luca Molteni Date: Thu, 8 Oct 2026 16:02:53 +0200 Subject: [PATCH 173/254] Mention update queries in the Jakarta Data reference --- docs/src/main/asciidoc/quarkus-data-hibernate.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc index 69a020e059349..b1629ee67bb36 100644 --- a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc +++ b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc @@ -832,7 +832,7 @@ public class Cat { <1> `@Find` defines a finder method from its parameters. <2> `@Query` defines a query using the Jakarta Data Query Language. <3> `@Delete` defines a finder-style delete method and can return the number of deleted entities. -<4> A delete operation can also be written explicitly with `@Query`. +<4> A delete operation can also be written explicitly with `@Query`, which also supports update queries. Repositories are CDI beans and are also available through generated metamodel accessors such as `Cat_.repo()`. From 512b9e5354f8dd1133e19aa2fb3d09afc08fa656 Mon Sep 17 00:00:00 2001 From: Luca Molteni Date: Thu, 8 Oct 2026 16:08:52 +0200 Subject: [PATCH 174/254] Use DataQuery in general REST sorting guidance --- docs/src/main/asciidoc/quarkus-data-hibernate.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc index b1629ee67bb36..a2d7d6afbbe65 100644 --- a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc +++ b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc @@ -882,7 +882,7 @@ The following Jakarta Data types are automatically mapped from query parameters: For `Sort` and `Order`, prefix the property name with `-` to indicate descending order. -When binding sort parameters from REST endpoints to `BlockingDataQuery`, prefer `Order` over `Sort`. A +When binding sort parameters from REST endpoints to `DataQuery`, prefer `Order` over `Sort`. A missing `sort` query parameter yields `null` for `Sort` but an empty `Order.by()` for `Order`. If the `Sort` parameter is `null`, `.sort(null)` is a no-op. `Order` is preferred when multiple columns can be requested. From 1622cee6ce2739219325514d15b0eab402597dd5 Mon Sep 17 00:00:00 2001 From: Luca Molteni Date: Thu, 8 Oct 2026 16:09:41 +0200 Subject: [PATCH 175/254] Restore original pagination metadata wording --- docs/src/main/asciidoc/quarkus-data-hibernate.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc index a2d7d6afbbe65..7c716da746ca9 100644 --- a/docs/src/main/asciidoc/quarkus-data-hibernate.adoc +++ b/docs/src/main/asciidoc/quarkus-data-hibernate.adoc @@ -905,7 +905,7 @@ rather than as a plain array: } ---- -The `totalElements` and `totalPages` fields are included only when the `PageRequest` was created with +The `totalElements` and `totalPages` fields are only included when the `PageRequest` was created with `requestTotal=true`. === Example endpoint From bc5c7b25f8095cefeb38433eade9de79d57a4413 Mon Sep 17 00:00:00 2001 From: Martin Kouba Date: Tue, 13 Oct 2026 11:38:29 +0200 Subject: [PATCH 176/254] Manage guava in application BOM - fixes https://nvd.nist.gov/vuln/detail/cve-2026-102554 --- bom/application/pom.xml | 18 ++++++++++++++++++ independent-projects/bootstrap/pom.xml | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/bom/application/pom.xml b/bom/application/pom.xml index 0dd2e10b6ce03..2cd55fbf013ed 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -227,6 +227,7 @@ 1.23.2 3.8.7 1.5.7-16 + 33.7.2-jre @@ -7051,6 +7052,23 @@ webauthn4j-metadata-async ${webauthn4j.version}
+ + + + com.google.guava + guava + ${guava.version} + + + com.google.code.findbugs + jsr305 + + + com.google.guava + listenablefuture + + + diff --git a/independent-projects/bootstrap/pom.xml b/independent-projects/bootstrap/pom.xml index 9ac0d8378c891..d8302d4b8f673 100644 --- a/independent-projects/bootstrap/pom.xml +++ b/independent-projects/bootstrap/pom.xml @@ -55,7 +55,7 @@ 1.22.1 2.22.0 3.21.0 - 33.7.2-jre + 33.7.2-jre 1.0.1 2.8 1.0.0 From 271d38354530b591990639acbfea44e93c213c9b Mon Sep 17 00:00:00 2001 From: zsewasdsdsd Date: Tue, 13 Oct 2026 21:03:14 +0800 Subject: [PATCH 177/254] Use Netty's default allocator for RESTEasy Classic response buffers Avoid allocating RESTEasy Classic response buffers from a separate PooledByteBufAllocator.DEFAULT pool when Vert.x uses ByteBufAllocator.DEFAULT. Fixes quarkusio/quarkus#57373 --- .../runtime/standalone/ResteasyStandaloneRecorder.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/extensions/resteasy-classic/resteasy/runtime/src/main/java/io/quarkus/resteasy/runtime/standalone/ResteasyStandaloneRecorder.java b/extensions/resteasy-classic/resteasy/runtime/src/main/java/io/quarkus/resteasy/runtime/standalone/ResteasyStandaloneRecorder.java index 21d6a8f50e557..99ea8e73c0793 100644 --- a/extensions/resteasy-classic/resteasy/runtime/src/main/java/io/quarkus/resteasy/runtime/standalone/ResteasyStandaloneRecorder.java +++ b/extensions/resteasy-classic/resteasy/runtime/src/main/java/io/quarkus/resteasy/runtime/standalone/ResteasyStandaloneRecorder.java @@ -28,7 +28,7 @@ import org.jboss.resteasy.spi.ResteasyDeployment; import io.netty.buffer.ByteBuf; -import io.netty.buffer.PooledByteBufAllocator; +import io.netty.buffer.ByteBufAllocator; import io.quarkus.resteasy.runtime.NonJaxRsClassMappings; import io.quarkus.resteasy.runtime.ResteasyVertxConfig; import io.quarkus.runtime.LaunchMode; @@ -365,9 +365,9 @@ public ByteBuf allocateBuffer(int bufferSize) { @Override public ByteBuf allocateBuffer(boolean direct, int bufferSize) { if (direct) { - return PooledByteBufAllocator.DEFAULT.directBuffer(bufferSize); + return ByteBufAllocator.DEFAULT.directBuffer(bufferSize); } else { - return PooledByteBufAllocator.DEFAULT.heapBuffer(bufferSize); + return ByteBufAllocator.DEFAULT.heapBuffer(bufferSize); } } From de815c5ea30a5e5ecdf8e7da60e345f1eaffddff Mon Sep 17 00:00:00 2001 From: Holly Cummins Date: Mon, 12 Oct 2026 14:01:04 -0400 Subject: [PATCH 178/254] Remove deprecated QuarkusUnitTest and flatten hierarchy --- .../QuarkusTestResourceLifecycleManager.java | 2 +- .../jacoco/deployment/JacocoProcessor.java | 2 +- .../test/AbstractQuarkusExtensionTest.java | 1420 ---------------- .../io/quarkus/test/ExclusivityChecker.java | 4 +- .../io/quarkus/test/QuarkusExtensionTest.java | 1508 ++++++++++++++++- .../java/io/quarkus/test/QuarkusUnitTest.java | 173 -- .../test/ReproducibilityTestLauncher.java | 2 +- ...usExtensionTestParameterizedClassTest.java | 6 +- 8 files changed, 1440 insertions(+), 1677 deletions(-) delete mode 100644 test-framework/junit-internal/src/main/java/io/quarkus/test/AbstractQuarkusExtensionTest.java delete mode 100644 test-framework/junit-internal/src/main/java/io/quarkus/test/QuarkusUnitTest.java diff --git a/test-framework/common/src/main/java/io/quarkus/test/common/QuarkusTestResourceLifecycleManager.java b/test-framework/common/src/main/java/io/quarkus/test/common/QuarkusTestResourceLifecycleManager.java index 7eaa175e4d398..7daf3d35a5baf 100644 --- a/test-framework/common/src/main/java/io/quarkus/test/common/QuarkusTestResourceLifecycleManager.java +++ b/test-framework/common/src/main/java/io/quarkus/test/common/QuarkusTestResourceLifecycleManager.java @@ -18,7 +18,7 @@ * loader is used it should not also be annotated as this will result in it being executed * twice. * - * Note that when using these with QuarkusExtensionTest or QuarkusUnitTest (rather than @QuarkusTest) they run + * Note that when using these with QuarkusExtensionTest (rather than @QuarkusTest) they run * before the ClassLoader has been setup. This means injection may not work * as expected. *

diff --git a/test-framework/jacoco/deployment/src/main/java/io/quarkus/jacoco/deployment/JacocoProcessor.java b/test-framework/jacoco/deployment/src/main/java/io/quarkus/jacoco/deployment/JacocoProcessor.java index a3df9ff9edfc7..0e38377f63d52 100644 --- a/test-framework/jacoco/deployment/src/main/java/io/quarkus/jacoco/deployment/JacocoProcessor.java +++ b/test-framework/jacoco/deployment/src/main/java/io/quarkus/jacoco/deployment/JacocoProcessor.java @@ -150,7 +150,7 @@ void transform(BuildProducer bytecodeTransformers, // In a single-module Quarkus app/extension all we need is to prevent multiple shutdown hook registrations // for a single test suite execution (all tests executed in a single module). // Note that for @QuarkusTest a new build can be triggered e.g. by @TestProfile. - // And for @QuarkusUnitTest or @QuarkusExtensionTest each test class triggers a separate build. + // And for @QuarkusExtensionTest each test class triggers a separate build. // So the system property check below will help for single module. // However, it will not help for multi-module projects if a shared data file is used: // diff --git a/test-framework/junit-internal/src/main/java/io/quarkus/test/AbstractQuarkusExtensionTest.java b/test-framework/junit-internal/src/main/java/io/quarkus/test/AbstractQuarkusExtensionTest.java deleted file mode 100644 index c9c18cab9ac6a..0000000000000 --- a/test-framework/junit-internal/src/main/java/io/quarkus/test/AbstractQuarkusExtensionTest.java +++ /dev/null @@ -1,1420 +0,0 @@ -package io.quarkus.test; - -import static io.quarkus.test.ExportUtil.APPLICATION_PROPERTIES; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.junit.jupiter.api.Assertions.fail; - -import java.lang.reflect.Constructor; -import java.lang.reflect.Field; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import java.lang.reflect.Modifier; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.Properties; -import java.util.ServiceLoader; -import java.util.Set; -import java.util.Timer; -import java.util.TimerTask; -import java.util.UUID; -import java.util.function.BiConsumer; -import java.util.function.Consumer; -import java.util.function.Predicate; -import java.util.function.Supplier; -import java.util.logging.Handler; -import java.util.logging.LogManager; -import java.util.logging.LogRecord; - -import org.jboss.logmanager.Level; -import org.jboss.logmanager.Logger; -import org.jboss.shrinkwrap.api.ShrinkWrap; -import org.jboss.shrinkwrap.api.exporter.ExplodedExporter; -import org.jboss.shrinkwrap.api.spec.JavaArchive; -import org.junit.jupiter.api.extension.AfterAllCallback; -import org.junit.jupiter.api.extension.AfterEachCallback; -import org.junit.jupiter.api.extension.BeforeAllCallback; -import org.junit.jupiter.api.extension.BeforeEachCallback; -import org.junit.jupiter.api.extension.ExtensionContext; -import org.junit.jupiter.api.extension.InvocationInterceptor; -import org.junit.jupiter.api.extension.ParameterContext; -import org.junit.jupiter.api.extension.ParameterResolutionException; -import org.junit.jupiter.api.extension.ParameterResolver; -import org.junit.jupiter.api.extension.ReflectiveInvocationContext; -import org.junit.jupiter.api.extension.RegisterExtension; -import org.junit.jupiter.api.extension.TestInstantiationException; -import org.opentest4j.TestAbortedException; - -import io.quarkus.bootstrap.app.AdditionalDependency; -import io.quarkus.bootstrap.app.CuratedApplication; -import io.quarkus.bootstrap.app.QuarkusBootstrap; -import io.quarkus.bootstrap.app.RunningQuarkusApplication; -import io.quarkus.bootstrap.classloading.ClassLoaderEventListener; -import io.quarkus.bootstrap.classloading.ClassPathElement; -import io.quarkus.bootstrap.classloading.QuarkusClassLoader; -import io.quarkus.bootstrap.model.ApplicationModel; -import io.quarkus.bootstrap.util.IoUtils; -import io.quarkus.builder.BuildChainBuilder; -import io.quarkus.builder.BuildContext; -import io.quarkus.builder.BuildException; -import io.quarkus.builder.BuildStep; -import io.quarkus.builder.item.BuildItem; -import io.quarkus.deployment.builditem.ApplicationClassPredicateBuildItem; -import io.quarkus.deployment.builditem.ReproducibilityCheckBuildItem; -import io.quarkus.deployment.util.FileUtil; -import io.quarkus.maven.dependency.ArtifactKey; -import io.quarkus.maven.dependency.Dependency; -import io.quarkus.runner.bootstrap.AugmentActionImpl; -import io.quarkus.runner.bootstrap.StartupActionImpl; -import io.quarkus.runtime.LaunchMode; -import io.quarkus.test.common.GroovyClassValue; -import io.quarkus.test.common.PathTestHelper; -import io.quarkus.test.common.RestAssuredStateManager; -import io.quarkus.test.common.TestConfigUtil; -import io.quarkus.test.common.TestResourceManager; -import io.quarkus.test.common.http.TestHTTPResourceManager; -import io.quarkus.test.junit.common.ClearCache; -import io.quarkus.value.registry.ValueRegistry; -import io.smallrye.common.process.ProcessBuilder; -import io.smallrye.common.process.ProcessUtil; - -/** - * A test extension for testing Quarkus internals, not intended for end user consumption. - *

- * This abstract class will be dropped as soon as we get rid of the deprecated {@link QuarkusUnitTest}, - * and its content incorporated into {@link QuarkusExtensionTest}. - *

- * Do NOT use or extend this class. - */ -public abstract class AbstractQuarkusExtensionTest> - implements BeforeAllCallback, AfterAllCallback, BeforeEachCallback, AfterEachCallback, - InvocationInterceptor, ParameterResolver { - - public static final String THE_BUILD_WAS_EXPECTED_TO_FAIL = "The build was expected to fail"; - private static final String APP_ROOT = "app-root"; - private static final String REPRODUCIBILITY_CHECK_PROPERTY_NAME = "quarkus-internal.test.reproducibility-check"; - private static final String REPRODUCIBILITY_CROSS_JVM_PROPERTY_NAME = "quarkus-internal.test.reproducibility-check.cross-jvm"; - private static final String REPRODUCIBILITY_DUMP_DIR_PROPERTY_NAME = "quarkus-internal.test.reproducibility-check.dump-dir"; - private static final String CACHED_APPLICATION_MODEL_KEY = "cachedApplicationModel"; - - private static final Logger rootLogger; - private Handler[] originalHandlers; - - static { - System.setProperty("java.util.logging.manager", "org.jboss.logmanager.LogManager"); - rootLogger = (Logger) LogManager.getLogManager().getLogger(""); - } - - protected boolean started = false; - - private Path deploymentDir; - private Consumer assertException; - private Supplier archiveProducer; - private List additionalDependencies = new ArrayList<>(); - - private List> buildChainCustomizers = new ArrayList<>(); - private Runnable afterUndeployListener; - - private InMemoryLogHandler inMemoryLogHandler = new InMemoryLogHandler((r) -> false); - private Consumer> assertLogRecords; - - private Timer timeoutTimer; - private volatile TimerTask timeoutTask; - private Properties customApplicationProperties; - private String configResourceName; - private Map customRuntimeApplicationProperties; - private Runnable beforeAllCustomizer; - private Runnable afterAllCustomizer; - private CuratedApplication curatedApplication; - private RunningQuarkusApplication runningQuarkusApplication; - private QuarkusClassLoader quarkusUnitTestClassLoader; - private ClassLoader originalClassLoader; - private List forcedDependencies = Collections.emptyList(); - private Set excludedDependencies = Collections.emptySet(); - - private boolean useSecureConnection; - - private Class actualTestClass; - protected Object actualTestInstance; - private String[] commandLineParameters = new String[0]; - - private boolean allowTestClassOutsideDeployment; - private boolean flatClassPath; - private final List classLoadListeners = new ArrayList<>(); - - private List testMethodInvokers; - - private final List> bootstrapCustomizers = new ArrayList<>(); - - private boolean debugBytecode = false; - private boolean reproducibilityCheckActive = false; - private final List traceCategories = new ArrayList<>(); - private final Map systemPropertiesToRestore = new HashMap<>(); - private final Map loggerLevelsToRestore = new HashMap<>(); - - public S setExpectedException(Class expectedException) { - return setExpectedException(expectedException, false); - } - - public S setExpectedException(Class expectedException, boolean logMessage) { - return assertException(t -> { - Throwable i = t; - boolean found = false; - while (i != null) { - if (i.getClass().getName().equals(expectedException.getName())) { - found = true; - break; - } - i = i.getCause(); - } - if (found && logMessage) { - System.out.println("Build failed with the expected exception:" + i); - } - assertTrue(found, "Build failed with a wrong exception, expected " + expectedException + " but got " + t); - }); - } - - public AbstractQuarkusExtensionTest() { - this(false); - } - - protected AbstractQuarkusExtensionTest(boolean useSecureConnection) { - this.useSecureConnection = useSecureConnection; - } - - public S assertException(Consumer assertException) { - if (this.assertException != null) { - throw new IllegalStateException("Don't set the asserted or excepted exception twice" - + " to avoid shadowing out the first call."); - } - this.assertException = assertException; - return (S) this; - } - - public Supplier getArchiveProducer() { - return archiveProducer; - } - - /** - * - * @param archiveProducer - * @return self - * @see #withApplicationRoot(Consumer) - */ - public S setArchiveProducer(Supplier archiveProducer) { - this.archiveProducer = Objects.requireNonNull(archiveProducer); - return (S) this; - } - - /** - * Customize the application root. - * - * @param applicationRootConsumer - * @return self - */ - public S withApplicationRoot(Consumer applicationRootConsumer) { - Objects.requireNonNull(applicationRootConsumer); - return setArchiveProducer(() -> { - JavaArchive jar = ShrinkWrap.create(JavaArchive.class); - applicationRootConsumer.accept(jar); - return jar; - }); - } - - /** - * Use an empty application for the test - * - * @return self - */ - public S withEmptyApplication() { - return withApplicationRoot(new Consumer() { - @Override - public void accept(JavaArchive javaArchive) { - - } - }); - } - - /** - * Add the java archive as an additional dependency. This dependency is always considered an application archive, even if it - * would not otherwise be one. - * - * @param archive - * @return self - */ - public S addAdditionalDependency(JavaArchive archive) { - this.additionalDependencies.add(Objects.requireNonNull(archive)); - return (S) this; - } - - /** - * Add the java archive as an additional dependency. This dependency is always considered an application archive, even if it - * would not otherwise be one. - * - * @param dependencyConsumer - * @return self - */ - public S withAdditionalDependency(Consumer dependencyConsumer) { - JavaArchive dependency = ShrinkWrap.create(JavaArchive.class); - Objects.requireNonNull(dependencyConsumer).accept(dependency); - return addAdditionalDependency(dependency); - } - - public S addBuildChainCustomizer(Consumer customizer) { - this.buildChainCustomizers.add(customizer); - return (S) this; - } - - public S addClassLoaderEventListener(ClassLoaderEventListener listener) { - this.classLoadListeners.add(listener); - return (S) this; - } - - public S setLogRecordPredicate(Predicate predicate) { - this.inMemoryLogHandler = new InMemoryLogHandler(predicate); - return (S) this; - } - - /** - * If this test should use a single ClassLoader to load all the classes. - * - * This is sometimes necessary when testing Quarkus itself, and we want the test classes - * and Quarkus classes to be in the same CL. - * - */ - public S setFlatClassPath(boolean flatClassPath) { - this.flatClassPath = flatClassPath; - return (S) this; - } - - public S assertLogRecords(Consumer> assertLogRecords) { - if (this.assertLogRecords != null) { - throw new IllegalStateException("Don't set the a log record assertion twice" - + " to avoid shadowing out the first call."); - } - this.assertLogRecords = assertLogRecords; - return (S) this; - } - - // set a Runnable that will run before ANYTHING else is done - public S setBeforeAllCustomizer(Runnable beforeAllCustomizer) { - this.beforeAllCustomizer = beforeAllCustomizer; - return (S) this; - } - - // set a Runnable that will run after EVERYTHING else is done - public S setAfterAllCustomizer(Runnable afterAllCustomizer) { - this.afterAllCustomizer = afterAllCustomizer; - return (S) this; - } - - /** - * Provides a convenient way to either add additional dependencies to the application (if it doesn't already contain a - * dependency), or override a version (if the dependency already exists) - */ - public S setForcedDependencies(List forcedDependencies) { - this.forcedDependencies = forcedDependencies; - return (S) this; - } - - /** - * Provides a convenient way to exclude dependencies from the application. - * Dependencies listed here will be removed from the test classpath. - */ - public S setExcludedDependencies(Set excludedDependencies) { - this.excludedDependencies = excludedDependencies; - return (S) this; - } - - public String[] getCommandLineParameters() { - return commandLineParameters; - } - - public S setCommandLineParameters(String... commandLineParameters) { - this.commandLineParameters = commandLineParameters; - return (S) this; - } - - /** - * Normally access to any test classes that are not packaged in the deployment will result - * in a ClassNotFoundException. If this is true then access is allowed, which can be useful - * when testing shutdown behaviour. - */ - public S setAllowTestClassOutsideDeployment(boolean allowTestClassOutsideDeployment) { - this.allowTestClassOutsideDeployment = allowTestClassOutsideDeployment; - return (S) this; - } - - /** - * An advanced option that allows tests to customize the {@link QuarkusBootstrap.Builder} that will be used to create the - * {@link CuratedApplication} - */ - public S addBootstrapCustomizer(Consumer consumer) { - this.bootstrapCustomizers.add(consumer); - return (S) this; - } - - private void exportArchives(Path deploymentDir, Class testClass) { - try { - JavaArchive archive = getArchiveProducerOrDefault(); - Class c = testClass; - archive.addClasses(c.getClasses()); - while (c != Object.class) { - archive.addClass(c); - c = c.getSuperclass(); - } - - if (configResourceName != null) { - if (archive.get(APPLICATION_PROPERTIES) != null) { - // Asset added explicitly to the archive must be completely replaced with custom config resource - ExportUtil.deleteApplicationProperties(archive); - } - archive.addAsResource(configResourceName, APPLICATION_PROPERTIES); - } - if (customApplicationProperties != null) { - ExportUtil.mergeCustomApplicationProperties(archive, customApplicationProperties); - } - - archive.as(ExplodedExporter.class).exportExplodedInto(deploymentDir.resolve(APP_ROOT).toFile()); - - for (JavaArchive dependency : additionalDependencies) { - dependency.as(ExplodedExporter.class).exportExplodedInto(deploymentDir.resolve(dependency.getName()).toFile()); - } - - //debugging code - ExportUtil.exportToQuarkusDeploymentPath(archive); - } catch (Exception e) { - throw new RuntimeException("Unable to create the archive", e); - } - } - - private JavaArchive getArchiveProducerOrDefault() { - if (archiveProducer == null) { - return ShrinkWrap.create(JavaArchive.class); - } else { - return archiveProducer.get(); - } - } - - @Override - public void interceptBeforeAllMethod(Invocation invocation, ReflectiveInvocationContext invocationContext, - ExtensionContext extensionContext) throws Throwable { - if (assertException == null) { - runExtensionMethod(invocationContext, extensionContext, false); - invocation.skip(); - } else { - invocation.proceed(); - } - } - - @Override - public void interceptBeforeEachMethod(Invocation invocation, ReflectiveInvocationContext invocationContext, - ExtensionContext extensionContext) throws Throwable { - if (assertException == null) { - runExtensionMethod(invocationContext, extensionContext, true); - invocation.skip(); - } else { - invocation.proceed(); - } - } - - @Override - public T interceptTestFactoryMethod(Invocation invocation, - ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) throws Throwable { - if (assertException == null) { - @SuppressWarnings("unchecked") - T result = (T) runExtensionMethod(invocationContext, extensionContext, false); - invocation.skip(); - return result; - } else { - return invocation.proceed(); - } - } - - @Override - public void interceptAfterEachMethod(Invocation invocation, ReflectiveInvocationContext invocationContext, - ExtensionContext extensionContext) throws Throwable { - if (assertException == null) { - runExtensionMethod(invocationContext, extensionContext, true); - invocation.skip(); - } else { - invocation.proceed(); - } - } - - @Override - public void interceptAfterAllMethod(Invocation invocation, ReflectiveInvocationContext invocationContext, - ExtensionContext extensionContext) throws Throwable { - if (assertException == null) { - runExtensionMethod(invocationContext, extensionContext, false); - } - invocation.skip(); - } - - @Override - public void interceptTestMethod(Invocation invocation, ReflectiveInvocationContext invocationContext, - ExtensionContext extensionContext) throws Throwable { - if (assertException == null) { - runExtensionMethod(invocationContext, extensionContext, true); - } - invocation.skip(); - } - - @Override - public void interceptTestTemplateMethod(Invocation invocation, ReflectiveInvocationContext invocationContext, - ExtensionContext extensionContext) throws Throwable { - if (assertException == null) { - runExtensionMethod(invocationContext, extensionContext, false); - } - invocation.skip(); - } - - /** - * Called immediately before each test method is invoked on the Quarkus test instance. - * Subclasses may override to perform per-invocation setup (e.g. copying parameter fields). - * The default implementation does nothing. - */ - protected void onBeforeMethodInvocation(ExtensionContext extensionContext) { - } - - private Object runExtensionMethod(ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext, - boolean testMethodInvokersAllowed) throws Throwable { - Method newMethod = null; - Class c = actualTestClass; - while ((c != Object.class) && newMethod == null) { - Method[] declaredMethods = c.getDeclaredMethods(); - for (Method declaredMethod : declaredMethods) { - if (!declaredMethod.getName().equals(invocationContext.getExecutable().getName())) { - continue; - } - boolean parametersMatch = true; - for (Class declaredMethodParameterType : declaredMethod.getParameterTypes()) { - boolean parameterTypeFound = false; - for (Class executionContextParameterType : invocationContext.getExecutable().getParameterTypes()) { - if (declaredMethodParameterType.getName().equals(executionContextParameterType.getName())) { - parameterTypeFound = true; - break; - } - } - if (!parameterTypeFound) { - parametersMatch = false; - break; - } - } - if (parametersMatch) { - newMethod = declaredMethod; - break; - } - } - c = c.getSuperclass(); - } - if (newMethod == null) { - throw new RuntimeException("Could not find method " + invocationContext.getExecutable() + " on test class"); - } - - Object testMethodInvokerToUse = null; - if (testMethodInvokersAllowed) { - for (Object testMethodInvoker : testMethodInvokers) { - boolean supportsMethod = (boolean) testMethodInvoker.getClass() - .getMethod("supportsMethod", Class.class, Method.class).invoke(testMethodInvoker, - extensionContext.getRequiredTestClass(), invocationContext.getExecutable()); - if (supportsMethod) { - testMethodInvokerToUse = testMethodInvoker; - break; - } - } - } - - onBeforeMethodInvocation(extensionContext); - try { - newMethod.setAccessible(true); - if (testMethodInvokerToUse != null) { - List effectiveArguments = new ArrayList<>(invocationContext.getArguments().size()); - Class[] parameterTypes = newMethod.getParameterTypes(); - if (parameterTypes.length != invocationContext.getArguments().size()) { - throw new IllegalStateException( - "Improper integration of '" + testMethodInvokerToUse.getClass() + "' detected"); - } - for (int i = 0; i < invocationContext.getArguments().size(); i++) { - Object originalValue = invocationContext.getArguments().get(i); - if ((originalValue == null) - && (testMethodInvokerHandlesParamType(testMethodInvokerToUse, parameterTypes[i].getName()))) { - effectiveArguments - .add(testMethodInvokerParamInstance(testMethodInvokerToUse, parameterTypes[i].getName())); - } else { - effectiveArguments.add(originalValue); - } - } - return testMethodInvokerToUse.getClass() - .getMethod("invoke", Object.class, Method.class, List.class, String.class) - .invoke(testMethodInvokerToUse, actualTestInstance, newMethod, effectiveArguments, - extensionContext.getRequiredTestClass().getName()); - } else { - return newMethod.invoke(actualTestInstance, invocationContext.getArguments().toArray()); - } - } catch (InvocationTargetException e) { - throw e.getCause(); - } catch (IllegalAccessException e) { - throw new RuntimeException(e); - } - } - - @Override - public void beforeAll(ExtensionContext extensionContext) throws Exception { - doBeforeAll(extensionContext); - } - - protected void doBeforeAll(ExtensionContext extensionContext) throws Exception { - TestConfigUtil.cleanUp(); - GroovyClassValue.disable(); - //set the right launch mode in the outer CL, used by the HTTP host config source - LaunchMode.set(LaunchMode.TEST); - if (beforeAllCustomizer != null) { - beforeAllCustomizer.run(); - } - String reproducibilityProp = System.getProperty(REPRODUCIBILITY_CHECK_PROPERTY_NAME); - boolean reproducibilityCheck = reproducibilityProp != null; - int reproducibilityRuns = 0; - if (reproducibilityCheck) { - if (reproducibilityProp.isEmpty() || Boolean.parseBoolean(reproducibilityProp)) { - reproducibilityRuns = 3; - } else { - reproducibilityRuns = Integer.parseInt(reproducibilityProp); - if (reproducibilityRuns < 2) { - throw new IllegalArgumentException( - "%s must be >= 2, got: %s".formatted(REPRODUCIBILITY_CHECK_PROPERTY_NAME, reproducibilityRuns)); - } - } - } - if (reproducibilityCheck) { - reproducibilityCheckActive = true; - // pin the output timestamp so that build time values are stable across runs - overrideConfigKey("quarkus.package.output-timestamp", "1970-01-02T00:00:00Z"); - } - if (debugBytecode) { - // Use a unique ID to avoid overriding dumps between test classes (and re-execution of flaky tests). - var testRunId = extensionContext.getRequiredTestClass().getName() + "/" + UUID.randomUUID(); - System.out.println("[AbstractQuarkusExtensionTest] Debug dumps enabled. Test run ID: " + testRunId); - // This needs to be set as system properties; see BootstrapDebug.java. - // Note these paths are considered standard and may be taken advantage of in Quarkus CI (to collect dumps). - overrideSystemProperty("quarkus.debug.transformed-classes-dir", - "target/debug/" + testRunId + "/transformed-classes"); - overrideSystemProperty("quarkus.debug.generated-classes-dir", "target/debug/" + testRunId + "/generated-classes"); - overrideSystemProperty("quarkus.debug.generated-sources-dir", "target/debug/" + testRunId + "/generated-sources"); - } - if (!traceCategories.isEmpty()) { - // This needs to be set very early (e.g. as system properties) in order to affect the build; - // needs to be set programmatically in order to not leak to other tests (for some reason?). - // See https://quarkusio.zulipchat.com/#narrow/stream/187038-dev/topic/Build.20logs - for (String category : traceCategories) { - overrideLoggerLevel(category, Level.TRACE); - } - } - originalClassLoader = Thread.currentThread().getContextClassLoader(); - originalHandlers = rootLogger.getHandlers(); - rootLogger.addHandler(inMemoryLogHandler); - - timeoutTask = new TimerTask() { - @Override - public void run() { - System.err.println("Test has been running for more than 5 minutes, thread dump is:"); - for (Map.Entry i : Thread.getAllStackTraces().entrySet()) { - System.err.println("\n"); - System.err.println(i.toString()); - System.err.println("\n"); - for (StackTraceElement j : i.getValue()) { - System.err.println(j); - } - } - } - }; - timeoutTimer = new Timer("Test thread dump timer"); - timeoutTimer.schedule(timeoutTask, 1000 * 60 * 5); - ExtensionContext.Store store = extensionContext.getRoot().getStore(ExtensionContext.Namespace.GLOBAL); - - ExclusivityChecker.checkTestType(extensionContext, AbstractQuarkusExtensionTest.class); - - TestResourceManager testResourceManager = (TestResourceManager) store.get(TestResourceManager.class.getName()); - if (testResourceManager == null) { - testResourceManager = new TestResourceManager(extensionContext.getRequiredTestClass()); - testResourceManager.init(null); - testResourceManager.start(); - TestResourceManager tm = testResourceManager; - store.put(TestResourceManager.class.getName(), testResourceManager); - store.put(TestResourceManager.CLOSEABLE_NAME, new AutoCloseable() { - - @Override - public void close() throws Exception { - tm.close(); - } - }); - } - - Class testClass = extensionContext.getRequiredTestClass(); - - try { - deploymentDir = Files.createTempDirectory("quarkus-unit-test"); - - exportArchives(deploymentDir, testClass); - - List> customizers = new ArrayList<>(buildChainCustomizers); - - try { - //this is a bit of a hack to avoid requiring a dep on the arc extension, - //as this would mean we cannot use this to test the extension - Class buildItem = Class - .forName("io.quarkus.arc.deployment.AdditionalBeanBuildItem").asSubclass(BuildItem.class); - customizers.add(new Consumer() { - @Override - public void accept(BuildChainBuilder buildChainBuilder) { - buildChainBuilder.addBuildStep(new BuildStep() { - @Override - public void execute(BuildContext context) { - try { - Method factoryMethod = buildItem.getMethod("unremovableOf", Class.class); - context.produce((BuildItem) factoryMethod.invoke(null, testClass)); - } catch (Exception e) { - throw new RuntimeException(e); - } - } - }).produces(buildItem) - .build(); - - buildChainBuilder.addBuildStep(new BuildStep() { - @Override - public void execute(BuildContext context) { - //we need to make sure all hot reloadable classes are application classes - context.produce(new ApplicationClassPredicateBuildItem(new Predicate() { - @Override - public boolean test(String className) { - return QuarkusClassLoader.isApplicationClass(className); - } - })); - } - }).produces(ApplicationClassPredicateBuildItem.class).build(); - } - }); - } catch (ClassNotFoundException e) { - System.err.println("Couldn't make the test class " + testClass.getSimpleName() + " an unremovable bean" - + " (probably because a dependency on io.quarkus:quarkus-arc-deployment is missing);" - + " other beans may also be removed and injection may not work as expected"); - } - - try { - final Path testLocation = PathTestHelper.getTestClassesLocation(testClass); - final Path projectDir = Path.of("").normalize().toAbsolutePath(); - if (!allowTestClassOutsideDeployment) { - quarkusUnitTestClassLoader = QuarkusClassLoader - .builder("AbstractQuarkusExtensionTest ClassLoader for " + extensionContext.getDisplayName(), - getClass().getClassLoader(), false) - .addClassLoaderEventListeners(this.classLoadListeners) - .addBannedElement(ClassPathElement.fromPath(testLocation, true)).build(); - } - - String reproducibilityDumpDir = System.getProperty(REPRODUCIBILITY_DUMP_DIR_PROPERTY_NAME); - if (reproducibilityDumpDir != null) { - runReproducibilityDump(extensionContext, testLocation, projectDir, customizers, - Path.of(reproducibilityDumpDir)); - } - - if (reproducibilityCheck) { - runReproducibilityCheck(extensionContext, testLocation, projectDir, customizers, reproducibilityRuns); - } - - // Normal path: single augmentation, start app, run tests - // Reuse the cached ApplicationModel to skip dependency resolution - ApplicationModel cachedModel = null; - if (isApplicationModelCacheable()) { - cachedModel = (ApplicationModel) store.get(CACHED_APPLICATION_MODEL_KEY); - } - curatedApplication = createCuratedApplication(extensionContext, testLocation, projectDir, cachedModel); - if (cachedModel == null && isApplicationModelCacheable()) { - store.put(CACHED_APPLICATION_MODEL_KEY, curatedApplication.getApplicationModel()); - } - - StartupActionImpl startupAction = new AugmentActionImpl(curatedApplication, customizers, classLoadListeners) - .createInitialRuntimeApplication(); - - Map overriddenConfig = new HashMap<>(testResourceManager.getConfigProperties()); - if (customRuntimeApplicationProperties != null) { - overriddenConfig.putAll(customRuntimeApplicationProperties); - } - startupAction.overrideConfig(overriddenConfig); - runningQuarkusApplication = startupAction.run(commandLineParameters); - //we restore the CL at the end of the test - Thread.currentThread().setContextClassLoader(runningQuarkusApplication.getClassLoader()); - if (assertException != null) { - fail(THE_BUILD_WAS_EXPECTED_TO_FAIL); - } - started = true; - ValueRegistry valueRegistry = runningQuarkusApplication.valueRegistry(); - try { - actualTestClass = Class.forName(testClass.getName(), true, Thread.currentThread().getContextClassLoader()); - actualTestInstance = runningQuarkusApplication.instance(actualTestClass); - Class resM = runningQuarkusApplication.getClassLoader() - .loadClass(TestHTTPResourceManager.class.getName()); - resM.getDeclaredMethod("inject", Object.class, ValueRegistry.class).invoke(null, actualTestInstance, - valueRegistry); - populateTestMethodInvokers(startupAction.getClassLoader()); - } catch (Exception e) { - throw new TestInstantiationException("Failed to create test instance", e); - } - - extensionContext.getStore(ExtensionContext.Namespace.GLOBAL).put(testClass.getName(), actualTestInstance); - } catch (Throwable e) { - started = false; - if (assertException != null) { - if (e instanceof AssertionError && e.getMessage().equals(THE_BUILD_WAS_EXPECTED_TO_FAIL)) { - //don't pass the 'no failure' assertion into the assert exception function - throw e; - } - if (e instanceof TestAbortedException) { - // reproducibility check passed; don't confuse it with an expected build failure - throw e; - } - if (e instanceof RuntimeException) { - Throwable cause = e.getCause(); - if (cause != null && cause instanceof BuildException) { - assertException.accept(unwrapException(cause.getCause())); - } else if (cause != null) { - assertException.accept(unwrapException(cause)); - } else { - assertException.accept(e); - } - } else { - assertException.accept(e); - } - } else { - throw e; - } - } - } catch (RuntimeException e) { - throw e; - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - private Throwable unwrapException(Throwable cause) { - //TODO: huge hack - try { - Class localVer = AbstractQuarkusExtensionTest.class.getClassLoader().loadClass(cause.getClass().getName()); - if (localVer != cause.getClass()) { - Constructor ctor = localVer.getConstructor(String.class, Throwable.class); - return (Throwable) ctor.newInstance(cause.getMessage(), cause.getCause()); - } - } catch (Exception e) { - //failed to unwrap - } - return cause; - } - - private void overrideSystemProperty(String key, String value) { - // IMPORTANT: Not logging the value in case it's a secret. - System.out.printf("[AbstractQuarkusExtensionTest] Overriding system property '%s'%n", key); - systemPropertiesToRestore.putIfAbsent(key, System.getProperty(key)); - System.setProperty(key, value); - } - - private void overrideLoggerLevel(String category, Level level) { - System.out.printf("[AbstractQuarkusExtensionTest] Overriding logger category '%s'; setting level '%s'%n", category, - level); - var logger = LogManager.getLogManager().getLogger(category); - loggerLevelsToRestore.putIfAbsent(category, logger.getLevel()); - logger.setLevel(level); - } - - @Override - public void afterAll(ExtensionContext extensionContext) throws Exception { - doAfterAll(extensionContext); - } - - protected void doAfterAll(ExtensionContext extensionContext) throws Exception { - actualTestClass = null; - actualTestInstance = null; - started = false; - List records = null; - if (assertLogRecords != null) { - records = new ArrayList<>(inMemoryLogHandler.records); - } - if (originalHandlers != null) { - rootLogger.setHandlers(originalHandlers); - } - inMemoryLogHandler.clearRecords(); - inMemoryLogHandler.setFilter(null); - if (testMethodInvokers != null) { - testMethodInvokers.clear(); - } - - try { - if (runningQuarkusApplication != null) { - runningQuarkusApplication.close(); - runningQuarkusApplication = null; - } - if (afterUndeployListener != null && !reproducibilityCheckActive) { - afterUndeployListener.run(); - } - if (curatedApplication != null) { - curatedApplication.close(); - curatedApplication = null; - } - } finally { - Thread.currentThread().setContextClassLoader(originalClassLoader); - originalClassLoader = null; - if (quarkusUnitTestClassLoader != null) { - quarkusUnitTestClassLoader.close(); - quarkusUnitTestClassLoader = null; - } - if (timeoutTask != null) { - timeoutTask.cancel(); - timeoutTask = null; - } - if (timeoutTimer != null) { - timeoutTimer.cancel(); - timeoutTimer = null; - } - if (deploymentDir != null) { - FileUtil.deleteDirectory(deploymentDir); - } - - if (afterAllCustomizer != null) { - afterAllCustomizer.run(); - } - systemPropertiesToRestore.forEach((key, previousValue) -> { - if (previousValue == null) { - System.clearProperty(key); - } else { - System.setProperty(key, previousValue); - } - }); - systemPropertiesToRestore.clear(); - loggerLevelsToRestore.forEach((category, previousLevel) -> Logger.getLogger(category).setLevel(previousLevel)); - loggerLevelsToRestore.clear(); - ClearCache.clearCaches(); - TestConfigUtil.cleanUp(); - } - // skip log assertions during reproducibility checks — the app was never started - if (records != null && !reproducibilityCheckActive) { - assertLogRecords.accept(records); - } - } - - private void runReproducibilityCheck(ExtensionContext extensionContext, Path testLocation, Path projectDir, - List> customizers, int runs) throws Exception { - if (System.getProperty(REPRODUCIBILITY_CROSS_JVM_PROPERTY_NAME) != null) { - runReproducibilityCheckCrossJvm(extensionContext, runs); - } else { - runReproducibilityCheckInsideJvm(extensionContext, testLocation, projectDir, customizers, runs); - } - } - - private void runReproducibilityCheckInsideJvm(ExtensionContext extensionContext, Path testLocation, Path projectDir, - List> customizers, int runs) throws Exception { - String testName = extensionContext.getRequiredTestClass().getSimpleName(); - System.out.printf("[AbstractQuarkusExtensionTest] Reproducibility check (%d runs) for %s%n", runs, testName); - List> reproducibilityCustomizers = new ArrayList<>(customizers); - reproducibilityCustomizers.add(buildChainBuilder -> buildChainBuilder - .addBuildStep(context -> context.produce(new ReproducibilityCheckBuildItem())) - .produces(ReproducibilityCheckBuildItem.class) - .build()); - Map referenceInMemoryClasses = null; - ApplicationModel appModel = null; - for (int run = 1; run <= runs; run++) { - CuratedApplication currentCuratedApplication = null; - StartupActionImpl currentStartupAction = null; - try { - resetLegacyGizmoFunctionCounters(); - currentCuratedApplication = createCuratedApplication(extensionContext, testLocation, projectDir, appModel); - if (appModel == null) { - // Reuse the resolved model so this check focuses on repeated augmentation bytecode. - appModel = currentCuratedApplication.getApplicationModel(); - } - currentStartupAction = new AugmentActionImpl(currentCuratedApplication, reproducibilityCustomizers, - classLoadListeners).createInitialRuntimeApplication(); - - Map currentInMemoryClasses = currentStartupAction.getClassLoader().getInMemoryClassBytes(); - if (referenceInMemoryClasses == null) { - referenceInMemoryClasses = currentInMemoryClasses; - } else { - var diff = BytecodeTools.diff(referenceInMemoryClasses, currentInMemoryClasses); - if (!diff.isEmpty()) { - String testRunId = extensionContext.getRequiredTestClass().getName() - + "/" + UUID.randomUUID(); - Path dumpDir = Path.of("target", "debug").resolve(testRunId) - .resolve("reproducibility-mismatch"); - Path mismatchDumpPath = BytecodeTools.dumpReproducibilityMismatch(diff, - referenceInMemoryClasses, currentInMemoryClasses, run, dumpDir); - throw new AssertionError("Build reproducibility check failed on run " + run + "/" - + runs + ": " + diff + ". Dumped to " + mismatchDumpPath); - } - } - } finally { - if (currentStartupAction != null) { - currentStartupAction.getClassLoader().close(); - } - if (currentCuratedApplication != null) { - currentCuratedApplication.close(); - } - inMemoryLogHandler.clearRecords(); - } - } - System.out.printf("[AbstractQuarkusExtensionTest] Reproducibility check passed for %s%n", testName); - throw new TestAbortedException("Reproducibility check passed (" + runs + " runs). Test execution skipped."); - } - - /** - * Legacy Gizmo {@code createFunction()} uses a {@code static} counter keyed by generated class name, - * which leaks between reproducibility runs in the same JVM. This affects generators that emit {@code $$function$$} - * helper classes, such as: - *
    - *
  • {@code io.quarkus.rest.data.panache.deployment.JaxRsResourceImplementor#implement}
  • - *
  • {@code io.quarkus.hibernate.reactive.rest.data.panache.deployment.ResourceImplementor#implement}
  • - *
  • {@code io.quarkus.security.jpa.reactive.deployment.QuarkusSecurityJpaReactiveProcessor#generateIdentityProvider}
  • - *
  • {@code io.quarkus.security.jpa.reactive.deployment.QuarkusSecurityJpaReactiveProcessor#generateTrustedIdentityProvider}
  • - *
- */ - private static void resetLegacyGizmoFunctionCounters() { - try { - Class bytecodeCreatorImpl = Class.forName("io.quarkus.gizmo.BytecodeCreatorImpl"); - Field countersField = bytecodeCreatorImpl.getDeclaredField("functionCountersByClass"); - countersField.setAccessible(true); - ((Map) countersField.get(null)).clear(); - } catch (ReflectiveOperationException e) { - throw new RuntimeException("Unable to reset legacy Gizmo function counters", e); - } - } - - private void runReproducibilityCheckCrossJvm(ExtensionContext extensionContext, int runs) throws Exception { - String testClassName = extensionContext.getRequiredTestClass().getName(); - String testName = extensionContext.getRequiredTestClass().getSimpleName(); - System.out.printf("[AbstractQuarkusExtensionTest] Reproducibility check (%d runs) for %s%n", runs, testName); - - Path baseDir = Path.of("target", "reproducibility", testName); - IoUtils.recursiveDelete(baseDir); - Files.createDirectories(baseDir); - - List baseArgs = new ArrayList<>(); - baseArgs.add("-cp"); - baseArgs.add(System.getProperty("java.class.path")); - for (String key : System.getProperties().stringPropertyNames()) { - if (key.equals(REPRODUCIBILITY_DUMP_DIR_PROPERTY_NAME)) { - continue; - } - baseArgs.add("-D" + key + "=" + System.getProperty(key)); - } - - Map referenceClasses = null; - for (int run = 1; run <= runs; run++) { - Path runDumpDir = baseDir.resolve("run-" + run); - - List args = new ArrayList<>(baseArgs); - args.add("-D" + REPRODUCIBILITY_DUMP_DIR_PROPERTY_NAME + "=" + runDumpDir); - args.add(ReproducibilityTestLauncher.class.getName()); - args.add(testClassName); - - System.out.printf("[AbstractQuarkusExtensionTest] Starting run %d/%d for %s%n", run, runs, testName); - ProcessBuilder.newBuilder(ProcessUtil.pathOfJava()) - .arguments(args) - .output().inherited() - .error().inherited() - .run(); - - if (!Files.isDirectory(runDumpDir)) { - // this should never happen, the dump process creates the directory before running augmentation - throw new AssertionError("Reproducibility run " + run + "/" + runs - + " did not produce dump directory " + runDumpDir); - } - - Map runClasses = BytecodeTools.loadClassBytes(runDumpDir); - if (referenceClasses == null) { - referenceClasses = runClasses; - } else { - var diff = BytecodeTools.diff(referenceClasses, runClasses); - if (!diff.isEmpty()) { - Path mismatchDir = baseDir.resolve("mismatch-run-" + run); - BytecodeTools.dumpReproducibilityMismatch(diff, referenceClasses, runClasses, run, mismatchDir); - throw new AssertionError("Reproducibility check failed on run " + run + "/" - + runs + ": " + diff + ". Dumped to " + mismatchDir); - } - } - } - System.out.printf("[AbstractQuarkusExtensionTest] Reproducibility check passed for %s%n", testName); - throw new TestAbortedException("Reproducibility check passed (" + runs + " runs). Test execution skipped."); - } - - private void runReproducibilityDump(ExtensionContext extensionContext, Path testLocation, Path projectDir, - List> customizers, Path dumpDir) throws Exception { - - IoUtils.recursiveDelete(dumpDir); - Files.createDirectories(dumpDir); - - List> dumpCustomizers = new ArrayList<>(customizers); - dumpCustomizers.add( - buildChainBuilder -> buildChainBuilder - .addBuildStep(context -> context.produce(new ReproducibilityCheckBuildItem())) - .produces(ReproducibilityCheckBuildItem.class).build()); - - CuratedApplication curatedApplication = null; - StartupActionImpl startupAction = null; - try { - curatedApplication = createCuratedApplication(extensionContext, testLocation, projectDir, null); - startupAction = new AugmentActionImpl(curatedApplication, dumpCustomizers, classLoadListeners) - .createInitialRuntimeApplication(); - BytecodeTools.dumpClassBytes(startupAction.getClassLoader().getInMemoryClassBytes(), dumpDir); - } finally { - if (startupAction != null) { - startupAction.getClassLoader().close(); - } - if (curatedApplication != null) { - curatedApplication.close(); - } - } - throw new TestAbortedException("Reproducibility dump complete (" + dumpDir + "). Test execution skipped."); - } - - private CuratedApplication createCuratedApplication(ExtensionContext extensionContext, Path testLocation, Path projectDir, - ApplicationModel existingModel) throws Exception { - QuarkusBootstrap.Builder builder = QuarkusBootstrap.builder() - .setBaseName(extensionContext.getDisplayName() + " (AbstractQuarkusExtensionTest)") - .setApplicationRoot(deploymentDir.resolve(APP_ROOT)) - .setMode(QuarkusBootstrap.Mode.TEST) - .setTest(true) - .addExcludedPath(testLocation) - .setProjectRoot(projectDir) - .setTargetDirectory(PathTestHelper.getProjectBuildDir(projectDir, testLocation)) - .setFlatClassPath(flatClassPath) - .setForcedDependencies(forcedDependencies) - .setExcludedDependencies(excludedDependencies); - for (JavaArchive dependency : additionalDependencies) { - builder.addAdditionalApplicationArchive( - new AdditionalDependency(deploymentDir.resolve(dependency.getName()), false, true)); - } - if (hasDependencyCustomizations()) { - //if we have forced/excluded dependencies we can't use the cache - //as it can screw everything up - builder.setDisableClasspathCache(true); - } - if (quarkusUnitTestClassLoader != null) { - builder.setBaseClassLoader(quarkusUnitTestClassLoader); - } - builder.addClassLoaderEventListeners(this.classLoadListeners); - for (Consumer bootstrapCustomizer : bootstrapCustomizers) { - bootstrapCustomizer.accept(builder); - } - if (existingModel != null) { - builder.setExistingModel(existingModel); - } - return builder.build().bootstrap(); - } - - /** - * The resolved {@link ApplicationModel} can only be cached and reused if there are no forced or excluded dependencies - * and no bootstrap customizers that could alter model resolution (e.g. localProjectDiscovery, targetDirectory). - */ - protected boolean isApplicationModelCacheable() { - return !hasDependencyCustomizations() && bootstrapCustomizers.isEmpty(); - } - - /** - * @return {@code true} if there are forced or excluded dependencies, {@code false} otherwise - */ - protected boolean hasDependencyCustomizations() { - return !forcedDependencies.isEmpty() || !excludedDependencies.isEmpty(); - } - - @Override - public void afterEach(ExtensionContext context) throws Exception { - if (runningQuarkusApplication != null) { - //this kinda sucks, but everything is isolated, so we need to hook into everything via reflection - runningQuarkusApplication.getClassLoader().loadClass(RestAssuredStateManager.class.getName()) - .getDeclaredMethod("clearState") - .invoke(null); - } - } - - @Override - public void beforeEach(ExtensionContext context) throws Exception { - if (assertException != null) { - // Build failed as expected - test methods are not invoked - return; - } - if (runningQuarkusApplication != null) { - runningQuarkusApplication.getClassLoader().loadClass(RestAssuredStateManager.class.getName()) - .getDeclaredMethod("setURL", boolean.class).invoke(null, useSecureConnection); - } else { - Optional> testClass = context.getTestClass(); - if (testClass.isPresent()) { - Field extensionField = Arrays.stream(testClass.get().getDeclaredFields()).filter( - f -> f.isAnnotationPresent(RegisterExtension.class) - && AbstractQuarkusExtensionTest.class.equals(f.getType())) - .findAny().orElse(null); - if (extensionField != null && !Modifier.isStatic(extensionField.getModifiers())) { - throw new IllegalStateException( - "Test application not started - AbstractQuarkusExtensionTest must be used with a static field: " - + extensionField); - } - } - throw new IllegalStateException("Test application not started for an unknown reason"); - } - } - - public Runnable getAfterUndeployListener() { - return afterUndeployListener; - } - - public S setAfterUndeployListener(Runnable afterUndeployListener) { - this.afterUndeployListener = afterUndeployListener; - return (S) this; - } - - /** - * Add an {@code application.properties} asset loaded from the specified resource file in the test {@link JavaArchive}. - *

- * If an {@code application.properties} asset was already added explicitly to the archive (for instance through - * {@link JavaArchive#addAsResource(String)}), this formet asset is removed and completely replaced by the one given here. - *

- * Configuration properties added with {@link #overrideConfigKey(String, String)} take precedence over the properties from - * the specified resource file. - * - * @param resourceName - * @return the test configuration - */ - public S withConfigurationResource(String resourceName) { - this.configResourceName = Objects.requireNonNull(resourceName); - return (S) this; - } - - /** - * Adds build-time configuration properties using a text block. - * - * @param configBlock text block containing {@code key=value} configuration entries - */ - public S withConfiguration(String configBlock) { - applyConfigurationBlock(configBlock, this::overrideConfigKey); - return (S) this; - } - - /** - * Adds runtime configuration properties using a text block. - * - * @param configBlock text block containing {@code key=value} configuration entries - */ - public S withRuntimeConfiguration(String configBlock) { - applyConfigurationBlock(configBlock, this::overrideRuntimeConfigKey); - return (S) this; - } - - /** - * Parses a configuration text block and applies the given action for each {@code key=value} entry. - * - * @param configBlock the configuration text block to parse - * @param keyValueConsumer action applied to each parsed key/value pair - * @return (S) this instance for fluent chaining - */ - private AbstractQuarkusExtensionTest applyConfigurationBlock(String configBlock, - BiConsumer keyValueConsumer) { - Objects.requireNonNull(configBlock, "Configuration text block must not be null"); - Objects.requireNonNull(keyValueConsumer, "Key/value consumer must not be null"); - - configBlock.lines() - .map(String::trim) - .filter(line -> !line.isEmpty() && !line.startsWith("#")) - .forEach(line -> { - int idx = line.indexOf('='); - if (idx <= 0) { - throw new IllegalArgumentException( - "Invalid configuration line: '" + line + "'. Expected format 'key=value'"); - } - String key = line.substring(0, idx).trim(); - String value = line.substring(idx + 1).trim(); - keyValueConsumer.accept(key, value); - }); - - return (S) this; - } - - /** - * Overriden configuration properties take precedence over an {@code application.properties} asset added in the test - * {@link JavaArchive}. - * - * @param propertyKey - * @param propertyValue - * @return the test configuration - */ - public S overrideConfigKey(final String propertyKey, final String propertyValue) { - if (customApplicationProperties == null) { - customApplicationProperties = new Properties(); - } - customApplicationProperties.put(propertyKey, propertyValue); - return (S) this; - } - - public S overrideRuntimeConfigKey(final String propertyKey, final String propertyValue) { - if (customRuntimeApplicationProperties == null) { - customRuntimeApplicationProperties = new HashMap<>(); - } - customRuntimeApplicationProperties.put(propertyKey, propertyValue); - return (S) this; - } - - /** - * Resets mutable per-invocation state on this extension instance. - *

- * This method is intended for use with JUnit 5's {@link org.junit.jupiter.params.ParameterizedClass}: - * call it from a {@link org.junit.jupiter.params.BeforeParameterizedClassInvocation} method to clear - * configuration and log-record state left by the previous parameterization, so each invocation - * starts with a clean slate. - * - * @return {@code this}, for method chaining. - */ - public S resetForParameterizedClass() { - this.customApplicationProperties = null; - this.customRuntimeApplicationProperties = null; - this.configResourceName = null; - this.assertLogRecords = null; - this.inMemoryLogHandler = new InMemoryLogHandler(r -> false); - this.forcedDependencies = Collections.emptyList(); - this.excludedDependencies = Collections.emptySet(); - this.additionalDependencies = new ArrayList<>(); - this.bootstrapCustomizers.clear(); - this.traceCategories.clear(); - this.assertException = null; - this.beforeAllCustomizer = null; - this.afterAllCustomizer = null; - this.afterUndeployListener = null; - this.buildChainCustomizers = new ArrayList<>(); - this.commandLineParameters = new String[0]; - return (S) this; - } - - /** - * Controls bytecode-related debug dumping. - *

- * When enabled, each Quarkus startup will have configuration properties - * such as {@code quarkus.debug.generated-classes-dir} set - * so that generated code gets dumped in {@code target/debug}, - * within a unique subdirectory for each test execution. - *

- * Look at the logs of a particular test to identify the corresponding dump directory. - * - * @param debugBytecode {@code true} if debug should be enabled - * @return {@code this}, for method chaining. - */ - public S debugBytecode(boolean debugBytecode) { - this.debugBytecode = debugBytecode; - return (S) this; - } - - /** - * Enables trace logs for the given categories, - * during both build and runtime. - * - * @param categories The categories for which to enable trace logging. - * @return {@code this}, for method chaining. - */ - public S traceCategories(String... categories) { - Collections.addAll(this.traceCategories, categories); - return (S) this; - } - - @Override - public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) - throws ParameterResolutionException { - boolean isConstructor = parameterContext.getDeclaringExecutable() instanceof Constructor; - if (isConstructor) { - return true; - } - if (!(parameterContext.getDeclaringExecutable() instanceof Method)) { - return false; - } - if (testMethodInvokers == null) { - return false; - } - for (Object testMethodInvoker : testMethodInvokers) { - boolean handlesMethodParamType = testMethodInvokerHandlesParamType(testMethodInvoker, - parameterContext.getParameter().getType().getName()); - if (handlesMethodParamType) { - return true; - } - } - return false; - } - - /** - * We don't actually have to resolve the parameter (thus the default values in the implementation) - * since the class instance that is passed to JUnit isn't really used. - * The actual test instance that is used is the one that is pulled from Arc, which of course will already have its - * constructor parameters properly resolved - */ - @Override - public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) - throws ParameterResolutionException { - if ((parameterContext.getDeclaringExecutable() instanceof Method) && (testMethodInvokers != null)) { - for (Object testMethodInvoker : testMethodInvokers) { - if (testMethodInvokerHandlesParamType(testMethodInvoker, - parameterContext.getParameter().getType().getName())) { - return null; // don't return the actual value since it leads to class loading issues - } - } - } - String className = parameterContext.getParameter().getType().getName(); - switch (className) { - case "boolean": - return false; - case "byte": - case "short": - case "int": - return 0; - case "long": - return 0L; - case "float": - return 0.0f; - case "double": - return 0.0d; - case "char": - return '\u0000'; - default: - return null; - } - } - - // we need to use reflection because the instances of TestMethodInvoker are loaded from the QuarkusClassLoader - private boolean testMethodInvokerHandlesParamType(Object testMethodInvoker, String parameterTypeName) { - try { - return (boolean) testMethodInvoker.getClass().getMethod("handlesMethodParamType", String.class) - .invoke(testMethodInvoker, parameterTypeName); - } catch (IllegalAccessException | InvocationTargetException | NoSuchMethodException e) { - throw new IllegalStateException("Unable to determine if TestMethodInvoker supports parameter", e); - } - } - - // we need to use reflection because the instances of TestMethodInvoker are loaded from the QuarkusClassLoader - private Object testMethodInvokerParamInstance(Object testMethodInvoker, String parameterTypeName) { - try { - return testMethodInvoker.getClass().getMethod("methodParamInstance", String.class) - .invoke(testMethodInvoker, parameterTypeName); - } catch (IllegalAccessException | InvocationTargetException | NoSuchMethodException e) { - throw new IllegalStateException("Unable to obtain instance of parameter using TestMethodInvoker", e); - } - } - - private void populateTestMethodInvokers(ClassLoader quarkusClassLoader) { - testMethodInvokers = new ArrayList<>(); - try { - ServiceLoader loader = ServiceLoader.load(quarkusClassLoader.loadClass(TestMethodInvoker.class.getName()), - quarkusClassLoader); - for (Object testMethodInvoker : loader) { - testMethodInvokers.add(testMethodInvoker); - } - } catch (ClassNotFoundException e) { - throw new RuntimeException(e); - } - } -} diff --git a/test-framework/junit-internal/src/main/java/io/quarkus/test/ExclusivityChecker.java b/test-framework/junit-internal/src/main/java/io/quarkus/test/ExclusivityChecker.java index 9253670e0ca25..73bfb7b034cc5 100644 --- a/test-framework/junit-internal/src/main/java/io/quarkus/test/ExclusivityChecker.java +++ b/test-framework/junit-internal/src/main/java/io/quarkus/test/ExclusivityChecker.java @@ -10,9 +10,7 @@ public static void checkTestType(ExtensionContext extensionContext, Class cur Class testType = store.get(IO_QUARKUS_TESTING_TYPE, Class.class); if (testType != null) { - if (testType != QuarkusUnitTest.class - && testType != QuarkusExtensionTest.class - && testType != AbstractQuarkusExtensionTest.class + if (testType != QuarkusExtensionTest.class && testType != QuarkusDevModeTest.class && testType != QuarkusProdModeTest.class) { throw new IllegalStateException( diff --git a/test-framework/junit-internal/src/main/java/io/quarkus/test/QuarkusExtensionTest.java b/test-framework/junit-internal/src/main/java/io/quarkus/test/QuarkusExtensionTest.java index 0fbdec9258e50..afbd9c4b60858 100644 --- a/test-framework/junit-internal/src/main/java/io/quarkus/test/QuarkusExtensionTest.java +++ b/test-framework/junit-internal/src/main/java/io/quarkus/test/QuarkusExtensionTest.java @@ -1,88 +1,1392 @@ package io.quarkus.test; +import static io.quarkus.test.ExportUtil.APPLICATION_PROPERTIES; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + import java.lang.reflect.Constructor; import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; import java.lang.reflect.Modifier; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Properties; +import java.util.ServiceLoader; +import java.util.Set; +import java.util.Timer; +import java.util.TimerTask; +import java.util.UUID; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Predicate; +import java.util.function.Supplier; +import java.util.logging.Handler; +import java.util.logging.LogManager; +import java.util.logging.LogRecord; +import org.jboss.logmanager.Level; +import org.jboss.logmanager.Logger; +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.exporter.ExplodedExporter; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.extension.AfterAllCallback; import org.junit.jupiter.api.extension.AfterClassTemplateInvocationCallback; +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeAllCallback; import org.junit.jupiter.api.extension.BeforeClassTemplateInvocationCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.InvocationInterceptor; import org.junit.jupiter.api.extension.ParameterContext; import org.junit.jupiter.api.extension.ParameterResolutionException; +import org.junit.jupiter.api.extension.ParameterResolver; +import org.junit.jupiter.api.extension.ReflectiveInvocationContext; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.api.extension.TestInstantiationException; import org.junit.jupiter.params.Parameter; import org.junit.jupiter.params.ParameterizedClass; import org.junit.platform.commons.support.AnnotationSupport; +import org.opentest4j.TestAbortedException; + +import io.quarkus.bootstrap.app.AdditionalDependency; +import io.quarkus.bootstrap.app.CuratedApplication; +import io.quarkus.bootstrap.app.QuarkusBootstrap; +import io.quarkus.bootstrap.app.RunningQuarkusApplication; +import io.quarkus.bootstrap.classloading.ClassLoaderEventListener; +import io.quarkus.bootstrap.classloading.ClassPathElement; +import io.quarkus.bootstrap.classloading.QuarkusClassLoader; +import io.quarkus.bootstrap.model.ApplicationModel; +import io.quarkus.bootstrap.util.IoUtils; +import io.quarkus.builder.BuildChainBuilder; +import io.quarkus.builder.BuildContext; +import io.quarkus.builder.BuildException; +import io.quarkus.builder.BuildStep; +import io.quarkus.builder.item.BuildItem; +import io.quarkus.deployment.builditem.ApplicationClassPredicateBuildItem; +import io.quarkus.deployment.builditem.ReproducibilityCheckBuildItem; +import io.quarkus.deployment.util.FileUtil; +import io.quarkus.maven.dependency.ArtifactKey; +import io.quarkus.maven.dependency.Dependency; +import io.quarkus.runner.bootstrap.AugmentActionImpl; +import io.quarkus.runner.bootstrap.StartupActionImpl; +import io.quarkus.runtime.LaunchMode; +import io.quarkus.test.common.GroovyClassValue; +import io.quarkus.test.common.PathTestHelper; +import io.quarkus.test.common.RestAssuredStateManager; +import io.quarkus.test.common.TestConfigUtil; +import io.quarkus.test.common.TestResourceManager; +import io.quarkus.test.common.http.TestHTTPResourceManager; +import io.quarkus.test.junit.common.ClearCache; +import io.quarkus.value.registry.ValueRegistry; +import io.smallrye.common.process.ProcessBuilder; +import io.smallrye.common.process.ProcessUtil; /** * A test extension for testing Quarkus internals, not intended for end user consumption. - *

- * Supports both standard test classes and JUnit 5 {@link ParameterizedClass} tests. */ -public class QuarkusExtensionTest extends AbstractQuarkusExtensionTest - implements BeforeClassTemplateInvocationCallback, AfterClassTemplateInvocationCallback { +public class QuarkusExtensionTest + implements BeforeAllCallback, AfterAllCallback, BeforeEachCallback, AfterEachCallback, + InvocationInterceptor, ParameterResolver, BeforeClassTemplateInvocationCallback, AfterClassTemplateInvocationCallback { + + public static final String THE_BUILD_WAS_EXPECTED_TO_FAIL = "The build was expected to fail"; + private static final String APP_ROOT = "app-root"; + private static final String REPRODUCIBILITY_CHECK_PROPERTY_NAME = "quarkus-internal.test.reproducibility-check"; + private static final String REPRODUCIBILITY_CROSS_JVM_PROPERTY_NAME = "quarkus-internal.test.reproducibility-check.cross-jvm"; + private static final String REPRODUCIBILITY_DUMP_DIR_PROPERTY_NAME = "quarkus-internal.test.reproducibility-check.dump-dir"; + private static final String CACHED_APPLICATION_MODEL_KEY = "cachedApplicationModel"; + + private static final Logger rootLogger; + private Handler[] originalHandlers; + + static { + System.setProperty("java.util.logging.manager", "org.jboss.logmanager.LogManager"); + rootLogger = (Logger) LogManager.getLogManager().getLogger(""); + } + + protected boolean started = false; + + private Path deploymentDir; + private Consumer assertException; + private Supplier archiveProducer; + private List additionalDependencies = new ArrayList<>(); + + private List> buildChainCustomizers = new ArrayList<>(); + private Runnable afterUndeployListener; + + private InMemoryLogHandler inMemoryLogHandler = new InMemoryLogHandler((r) -> false); + private Consumer> assertLogRecords; + + private Timer timeoutTimer; + private volatile TimerTask timeoutTask; + private Properties customApplicationProperties; + private String configResourceName; + private Map customRuntimeApplicationProperties; + private Runnable beforeAllCustomizer; + private Runnable afterAllCustomizer; + private CuratedApplication curatedApplication; + private RunningQuarkusApplication runningQuarkusApplication; + private QuarkusClassLoader quarkusUnitTestClassLoader; + private ClassLoader originalClassLoader; + private List forcedDependencies = Collections.emptyList(); + private Set excludedDependencies = Collections.emptySet(); + + private boolean useSecureConnection; + + private Class actualTestClass; + protected Object actualTestInstance; + private String[] commandLineParameters = new String[0]; + + private boolean allowTestClassOutsideDeployment; + private boolean flatClassPath; + private final List classLoadListeners = new ArrayList<>(); + + private List testMethodInvokers; + + private final List> bootstrapCustomizers = new ArrayList<>(); + + private boolean debugBytecode = false; + private boolean reproducibilityCheckActive = false; + private final List traceCategories = new ArrayList<>(); + private final Map systemPropertiesToRestore = new HashMap<>(); + private final Map loggerLevelsToRestore = new HashMap<>(); + + public QuarkusExtensionTest setExpectedException(Class expectedException) { + return setExpectedException(expectedException, false); + } + + public QuarkusExtensionTest setExpectedException(Class expectedException, boolean logMessage) { + return assertException(t -> { + Throwable i = t; + boolean found = false; + while (i != null) { + if (i.getClass().getName().equals(expectedException.getName())) { + found = true; + break; + } + i = i.getCause(); + } + if (found && logMessage) { + System.out.println("Build failed with the expected exception:" + i); + } + assertTrue(found, "Build failed with a wrong exception, expected " + expectedException + " but got " + t); + }); + } public QuarkusExtensionTest() { - super(); + this(false); } - public QuarkusExtensionTest(boolean useSecureConnection) { - super(useSecureConnection); + protected QuarkusExtensionTest(boolean useSecureConnection) { + this.useSecureConnection = useSecureConnection; } public static QuarkusExtensionTest withSecuredConnection() { return new QuarkusExtensionTest(true); } - private static boolean isParameterizedClass(ExtensionContext context) { - return context.getTestClass() - .map(c -> AnnotationSupport.isAnnotated(c, ParameterizedClass.class)) - .orElse(false); + public QuarkusExtensionTest assertException(Consumer assertException) { + if (this.assertException != null) { + throw new IllegalStateException("Don't set the asserted or excepted exception twice" + + " to avoid shadowing out the first call."); + } + this.assertException = assertException; + return this; + } + + public Supplier getArchiveProducer() { + return archiveProducer; + } + + /** + * + * @param archiveProducer + * @return self + * @see #withApplicationRoot(Consumer) + */ + public QuarkusExtensionTest setArchiveProducer(Supplier archiveProducer) { + this.archiveProducer = Objects.requireNonNull(archiveProducer); + return this; + } + + /** + * Customize the application root. + * + * @param applicationRootConsumer + * @return self + */ + public QuarkusExtensionTest withApplicationRoot(Consumer applicationRootConsumer) { + Objects.requireNonNull(applicationRootConsumer); + return setArchiveProducer(() -> { + JavaArchive jar = ShrinkWrap.create(JavaArchive.class); + applicationRootConsumer.accept(jar); + return jar; + }); + } + + /** + * Use an empty application for the test + * + * @return self + */ + public QuarkusExtensionTest withEmptyApplication() { + return withApplicationRoot(new Consumer() { + @Override + public void accept(JavaArchive javaArchive) { + + } + }); + } + + /** + * Add the java archive as an additional dependency. This dependency is always considered an application archive, even if it + * would not otherwise be one. + * + * @param archive + * @return self + */ + public QuarkusExtensionTest addAdditionalDependency(JavaArchive archive) { + this.additionalDependencies.add(Objects.requireNonNull(archive)); + return this; + } + + /** + * Add the java archive as an additional dependency. This dependency is always considered an application archive, even if it + * would not otherwise be one. + * + * @param dependencyConsumer + * @return self + */ + public QuarkusExtensionTest withAdditionalDependency(Consumer dependencyConsumer) { + JavaArchive dependency = ShrinkWrap.create(JavaArchive.class); + Objects.requireNonNull(dependencyConsumer).accept(dependency); + return addAdditionalDependency(dependency); + } + + public QuarkusExtensionTest addBuildChainCustomizer(Consumer customizer) { + this.buildChainCustomizers.add(customizer); + return this; + } + + public QuarkusExtensionTest addClassLoaderEventListener(ClassLoaderEventListener listener) { + this.classLoadListeners.add(listener); + return this; + } + + public QuarkusExtensionTest setLogRecordPredicate(Predicate predicate) { + this.inMemoryLogHandler = new InMemoryLogHandler(predicate); + return this; + } + + /** + * If this test should use a single ClassLoader to load all the classes. + * + * This is sometimes necessary when testing Quarkus itself, and we want the test classes + * and Quarkus classes to be in the same CL. + * + */ + public QuarkusExtensionTest setFlatClassPath(boolean flatClassPath) { + this.flatClassPath = flatClassPath; + return this; + } + + public QuarkusExtensionTest assertLogRecords(Consumer> assertLogRecords) { + if (this.assertLogRecords != null) { + throw new IllegalStateException("Don't set the a log record assertion twice" + + " to avoid shadowing out the first call."); + } + this.assertLogRecords = assertLogRecords; + return this; + } + + // set a Runnable that will run before ANYTHING else is done + public QuarkusExtensionTest setBeforeAllCustomizer(Runnable beforeAllCustomizer) { + this.beforeAllCustomizer = beforeAllCustomizer; + return this; + } + + // set a Runnable that will run after EVERYTHING else is done + public QuarkusExtensionTest setAfterAllCustomizer(Runnable afterAllCustomizer) { + this.afterAllCustomizer = afterAllCustomizer; + return this; + } + + /** + * Provides a convenient way to either add additional dependencies to the application (if it doesn't already contain a + * dependency), or override a version (if the dependency already exists) + */ + public QuarkusExtensionTest setForcedDependencies(List forcedDependencies) { + this.forcedDependencies = forcedDependencies; + return this; + } + + /** + * Provides a convenient way to exclude dependencies from the application. + * Dependencies listed here will be removed from the test classpath. + */ + public QuarkusExtensionTest setExcludedDependencies(Set excludedDependencies) { + this.excludedDependencies = excludedDependencies; + return this; + } + + public String[] getCommandLineParameters() { + return commandLineParameters; + } + + public QuarkusExtensionTest setCommandLineParameters(String... commandLineParameters) { + this.commandLineParameters = commandLineParameters; + return this; + } + + /** + * Normally access to any test classes that are not packaged in the deployment will result + * in a ClassNotFoundException. If this is true then access is allowed, which can be useful + * when testing shutdown behaviour. + */ + public QuarkusExtensionTest setAllowTestClassOutsideDeployment(boolean allowTestClassOutsideDeployment) { + this.allowTestClassOutsideDeployment = allowTestClassOutsideDeployment; + return this; + } + + /** + * An advanced option that allows tests to customize the {@link QuarkusBootstrap.Builder} that will be used to create the + * {@link CuratedApplication} + */ + public QuarkusExtensionTest addBootstrapCustomizer(Consumer consumer) { + this.bootstrapCustomizers.add(consumer); + return this; + } + + private void exportArchives(Path deploymentDir, Class testClass) { + try { + JavaArchive archive = getArchiveProducerOrDefault(); + Class c = testClass; + archive.addClasses(c.getClasses()); + while (c != Object.class) { + archive.addClass(c); + c = c.getSuperclass(); + } + + if (configResourceName != null) { + if (archive.get(APPLICATION_PROPERTIES) != null) { + // Asset added explicitly to the archive must be completely replaced with custom config resource + ExportUtil.deleteApplicationProperties(archive); + } + archive.addAsResource(configResourceName, APPLICATION_PROPERTIES); + } + if (customApplicationProperties != null) { + ExportUtil.mergeCustomApplicationProperties(archive, customApplicationProperties); + } + + archive.as(ExplodedExporter.class).exportExplodedInto(deploymentDir.resolve(APP_ROOT).toFile()); + + for (JavaArchive dependency : additionalDependencies) { + dependency.as(ExplodedExporter.class).exportExplodedInto(deploymentDir.resolve(dependency.getName()).toFile()); + } + + //debugging code + ExportUtil.exportToQuarkusDeploymentPath(archive); + } catch (Exception e) { + throw new RuntimeException("Unable to create the archive", e); + } + } + + private JavaArchive getArchiveProducerOrDefault() { + if (archiveProducer == null) { + return ShrinkWrap.create(JavaArchive.class); + } else { + return archiveProducer.get(); + } } - // For @ParameterizedClass tests, Quarkus startup is deferred to beforeEach - // so that @BeforeParameterizedClassInvocation can configure the scenario first. @Override - public void beforeAll(ExtensionContext extensionContext) throws Exception { - if (isParameterizedClass(extensionContext)) { - return; + public void interceptBeforeAllMethod(Invocation invocation, ReflectiveInvocationContext invocationContext, + ExtensionContext extensionContext) throws Throwable { + if (assertException == null) { + runExtensionMethod(invocationContext, extensionContext, false); + invocation.skip(); + } else { + invocation.proceed(); } - super.beforeAll(extensionContext); } - // Startup remains deferred to beforeEach for @ParameterizedClass invocations. @Override - public void beforeClassTemplateInvocation(ExtensionContext extensionContext) throws Exception { + public void interceptBeforeEachMethod(Invocation invocation, ReflectiveInvocationContext invocationContext, + ExtensionContext extensionContext) throws Throwable { + if (assertException == null) { + runExtensionMethod(invocationContext, extensionContext, true); + invocation.skip(); + } else { + invocation.proceed(); + } } - // Lazily starts Quarkus before the first test method of each @ParameterizedClass invocation. @Override - public void beforeEach(ExtensionContext context) throws Exception { - if (isParameterizedClass(context)) { - if (!started) { - doBeforeAll(context); + public T interceptTestFactoryMethod(Invocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) throws Throwable { + if (assertException == null) { + @SuppressWarnings("unchecked") + T result = (T) runExtensionMethod(invocationContext, extensionContext, false); + invocation.skip(); + return result; + } else { + return invocation.proceed(); + } + } + + @Override + public void interceptAfterEachMethod(Invocation invocation, ReflectiveInvocationContext invocationContext, + ExtensionContext extensionContext) throws Throwable { + if (assertException == null) { + runExtensionMethod(invocationContext, extensionContext, true); + invocation.skip(); + } else { + invocation.proceed(); + } + } + + @Override + public void interceptAfterAllMethod(Invocation invocation, ReflectiveInvocationContext invocationContext, + ExtensionContext extensionContext) throws Throwable { + if (assertException == null) { + runExtensionMethod(invocationContext, extensionContext, false); + } + invocation.skip(); + } + + @Override + public void interceptTestMethod(Invocation invocation, ReflectiveInvocationContext invocationContext, + ExtensionContext extensionContext) throws Throwable { + if (assertException == null) { + runExtensionMethod(invocationContext, extensionContext, true); + } + invocation.skip(); + } + + @Override + public void interceptTestTemplateMethod(Invocation invocation, ReflectiveInvocationContext invocationContext, + ExtensionContext extensionContext) throws Throwable { + if (assertException == null) { + runExtensionMethod(invocationContext, extensionContext, false); + } + invocation.skip(); + } + + /** + * Called immediately before each test method is invoked on the Quarkus test instance. + * Copies @Parameter fields from JUnit's outer test instance across the ClassLoader boundary + * into Quarkus's actual test instance before each test method runs. + */ + protected void onBeforeMethodInvocation(ExtensionContext extensionContext) { + if (isParameterizedClass(extensionContext)) { + + if (actualTestInstance == null || !extensionContext.getTestInstance().isPresent()) { + return; + } + // Enum values are re-resolved by name to safely cross the ClassLoader boundary. + Object outerInstance = extensionContext.getTestInstance().get(); + Class outerClass = outerInstance.getClass(); + Class innerClass = actualTestInstance.getClass(); + while (outerClass != null && outerClass != Object.class) { + for (Field outerField : outerClass.getDeclaredFields()) { + if (Modifier.isStatic(outerField.getModifiers()) || Modifier.isFinal(outerField.getModifiers())) { + continue; + } + if (!outerField.isAnnotationPresent(Parameter.class)) { + continue; + } + try { + Field innerField = getDeclaredField(innerClass, outerField.getName()); + if (innerField != null) { + outerField.setAccessible(true); + innerField.setAccessible(true); + Object val = outerField.get(outerInstance); + if (val == null) { + innerField.set(actualTestInstance, null); + } else if (val instanceof Enum enumVal && innerField.getType().isEnum()) { + @SuppressWarnings({ "unchecked", "rawtypes" }) + Object resolvedEnum = Enum.valueOf((Class) innerField.getType(), enumVal.name()); + innerField.set(actualTestInstance, resolvedEnum); + } else { + innerField.set(actualTestInstance, val); + } + } + } catch (Exception e) { + throw new RuntimeException( + "Failed to copy @Parameter field '" + outerField.getName() + "' to Quarkus test instance", e); + } + } + outerClass = outerClass.getSuperclass(); } } - super.beforeEach(context); } - // Shuts down Quarkus after each @ParameterizedClass invocation finishes so the next starts fresh. + private Object runExtensionMethod(ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext, + boolean testMethodInvokersAllowed) throws Throwable { + Method newMethod = null; + Class c = actualTestClass; + while ((c != Object.class) && newMethod == null) { + Method[] declaredMethods = c.getDeclaredMethods(); + for (Method declaredMethod : declaredMethods) { + if (!declaredMethod.getName().equals(invocationContext.getExecutable().getName())) { + continue; + } + boolean parametersMatch = true; + for (Class declaredMethodParameterType : declaredMethod.getParameterTypes()) { + boolean parameterTypeFound = false; + for (Class executionContextParameterType : invocationContext.getExecutable().getParameterTypes()) { + if (declaredMethodParameterType.getName().equals(executionContextParameterType.getName())) { + parameterTypeFound = true; + break; + } + } + if (!parameterTypeFound) { + parametersMatch = false; + break; + } + } + if (parametersMatch) { + newMethod = declaredMethod; + break; + } + } + c = c.getSuperclass(); + } + if (newMethod == null) { + throw new RuntimeException("Could not find method " + invocationContext.getExecutable() + " on test class"); + } + + Object testMethodInvokerToUse = null; + if (testMethodInvokersAllowed) { + for (Object testMethodInvoker : testMethodInvokers) { + boolean supportsMethod = (boolean) testMethodInvoker.getClass() + .getMethod("supportsMethod", Class.class, Method.class).invoke(testMethodInvoker, + extensionContext.getRequiredTestClass(), invocationContext.getExecutable()); + if (supportsMethod) { + testMethodInvokerToUse = testMethodInvoker; + break; + } + } + } + + onBeforeMethodInvocation(extensionContext); + try { + newMethod.setAccessible(true); + if (testMethodInvokerToUse != null) { + List effectiveArguments = new ArrayList<>(invocationContext.getArguments().size()); + Class[] parameterTypes = newMethod.getParameterTypes(); + if (parameterTypes.length != invocationContext.getArguments().size()) { + throw new IllegalStateException( + "Improper integration of '" + testMethodInvokerToUse.getClass() + "' detected"); + } + for (int i = 0; i < invocationContext.getArguments().size(); i++) { + Object originalValue = invocationContext.getArguments().get(i); + if ((originalValue == null) + && (testMethodInvokerHandlesParamType(testMethodInvokerToUse, parameterTypes[i].getName()))) { + effectiveArguments + .add(testMethodInvokerParamInstance(testMethodInvokerToUse, parameterTypes[i].getName())); + } else { + effectiveArguments.add(originalValue); + } + } + return testMethodInvokerToUse.getClass() + .getMethod("invoke", Object.class, Method.class, List.class, String.class) + .invoke(testMethodInvokerToUse, actualTestInstance, newMethod, effectiveArguments, + extensionContext.getRequiredTestClass().getName()); + } else { + return newMethod.invoke(actualTestInstance, invocationContext.getArguments().toArray()); + } + } catch (InvocationTargetException e) { + throw e.getCause(); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + } + @Override - public void afterClassTemplateInvocation(ExtensionContext extensionContext) throws Exception { + public void beforeAll(ExtensionContext extensionContext) throws Exception { + // For @ParameterizedClass tests, Quarkus startup is deferred to beforeEach + // so that @BeforeParameterizedClassInvocation can configure the scenario first. if (isParameterizedClass(extensionContext)) { - doAfterAll(extensionContext); + return; + } + + doBeforeAll(extensionContext); + } + + protected void doBeforeAll(ExtensionContext extensionContext) throws Exception { + TestConfigUtil.cleanUp(); + GroovyClassValue.disable(); + //set the right launch mode in the outer CL, used by the HTTP host config source + LaunchMode.set(LaunchMode.TEST); + if (beforeAllCustomizer != null) { + beforeAllCustomizer.run(); + } + String reproducibilityProp = System.getProperty(REPRODUCIBILITY_CHECK_PROPERTY_NAME); + boolean reproducibilityCheck = reproducibilityProp != null; + int reproducibilityRuns = 0; + if (reproducibilityCheck) { + if (reproducibilityProp.isEmpty() || Boolean.parseBoolean(reproducibilityProp)) { + reproducibilityRuns = 3; + } else { + reproducibilityRuns = Integer.parseInt(reproducibilityProp); + if (reproducibilityRuns < 2) { + throw new IllegalArgumentException( + "%s must be >= 2, got: %s".formatted(REPRODUCIBILITY_CHECK_PROPERTY_NAME, reproducibilityRuns)); + } + } + } + if (reproducibilityCheck) { + reproducibilityCheckActive = true; + // pin the output timestamp so that build time values are stable across runs + overrideConfigKey("quarkus.package.output-timestamp", "1970-01-02T00:00:00Z"); + } + if (debugBytecode) { + // Use a unique ID to avoid overriding dumps between test classes (and re-execution of flaky tests). + var testRunId = extensionContext.getRequiredTestClass().getName() + "/" + UUID.randomUUID(); + System.out.println("[QuarkusExtensionTest] Debug dumps enabled. Test run ID: " + testRunId); + // This needs to be set as system properties; see BootstrapDebug.java. + // Note these paths are considered standard and may be taken advantage of in Quarkus CI (to collect dumps). + overrideSystemProperty("quarkus.debug.transformed-classes-dir", + "target/debug/" + testRunId + "/transformed-classes"); + overrideSystemProperty("quarkus.debug.generated-classes-dir", "target/debug/" + testRunId + "/generated-classes"); + overrideSystemProperty("quarkus.debug.generated-sources-dir", "target/debug/" + testRunId + "/generated-sources"); + } + if (!traceCategories.isEmpty()) { + // This needs to be set very early (e.g. as system properties) in order to affect the build; + // needs to be set programmatically in order to not leak to other tests (for some reason?). + // See https://quarkusio.zulipchat.com/#narrow/stream/187038-dev/topic/Build.20logs + for (String category : traceCategories) { + overrideLoggerLevel(category, Level.TRACE); + } + } + originalClassLoader = Thread.currentThread().getContextClassLoader(); + originalHandlers = rootLogger.getHandlers(); + rootLogger.addHandler(inMemoryLogHandler); + + timeoutTask = new TimerTask() { + @Override + public void run() { + System.err.println("Test has been running for more than 5 minutes, thread dump is:"); + for (Map.Entry i : Thread.getAllStackTraces().entrySet()) { + System.err.println("\n"); + System.err.println(i.toString()); + System.err.println("\n"); + for (StackTraceElement j : i.getValue()) { + System.err.println(j); + } + } + } + }; + timeoutTimer = new Timer("Test thread dump timer"); + timeoutTimer.schedule(timeoutTask, 1000 * 60 * 5); + ExtensionContext.Store store = extensionContext.getRoot().getStore(ExtensionContext.Namespace.GLOBAL); + + ExclusivityChecker.checkTestType(extensionContext, QuarkusExtensionTest.class); + + TestResourceManager testResourceManager = (TestResourceManager) store.get(TestResourceManager.class.getName()); + if (testResourceManager == null) { + testResourceManager = new TestResourceManager(extensionContext.getRequiredTestClass()); + testResourceManager.init(null); + testResourceManager.start(); + TestResourceManager tm = testResourceManager; + store.put(TestResourceManager.class.getName(), testResourceManager); + store.put(TestResourceManager.CLOSEABLE_NAME, new AutoCloseable() { + + @Override + public void close() throws Exception { + tm.close(); + } + }); + } + + Class testClass = extensionContext.getRequiredTestClass(); + + try { + deploymentDir = Files.createTempDirectory("quarkus-unit-test"); + + exportArchives(deploymentDir, testClass); + + List> customizers = new ArrayList<>(buildChainCustomizers); + + try { + //this is a bit of a hack to avoid requiring a dep on the arc extension, + //as this would mean we cannot use this to test the extension + Class buildItem = Class + .forName("io.quarkus.arc.deployment.AdditionalBeanBuildItem").asSubclass(BuildItem.class); + customizers.add(new Consumer() { + @Override + public void accept(BuildChainBuilder buildChainBuilder) { + buildChainBuilder.addBuildStep(new BuildStep() { + @Override + public void execute(BuildContext context) { + try { + Method factoryMethod = buildItem.getMethod("unremovableOf", Class.class); + context.produce((BuildItem) factoryMethod.invoke(null, testClass)); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + }).produces(buildItem) + .build(); + + buildChainBuilder.addBuildStep(new BuildStep() { + @Override + public void execute(BuildContext context) { + //we need to make sure all hot reloadable classes are application classes + context.produce(new ApplicationClassPredicateBuildItem(new Predicate() { + @Override + public boolean test(String className) { + return QuarkusClassLoader.isApplicationClass(className); + } + })); + } + }).produces(ApplicationClassPredicateBuildItem.class).build(); + } + }); + } catch (ClassNotFoundException e) { + System.err.println("Couldn't make the test class " + testClass.getSimpleName() + " an unremovable bean" + + " (probably because a dependency on io.quarkus:quarkus-arc-deployment is missing);" + + " other beans may also be removed and injection may not work as expected"); + } + + try { + final Path testLocation = PathTestHelper.getTestClassesLocation(testClass); + final Path projectDir = Path.of("").normalize().toAbsolutePath(); + if (!allowTestClassOutsideDeployment) { + quarkusUnitTestClassLoader = QuarkusClassLoader + .builder("QuarkusExtensionTest ClassLoader for " + extensionContext.getDisplayName(), + getClass().getClassLoader(), false) + .addClassLoaderEventListeners(this.classLoadListeners) + .addBannedElement(ClassPathElement.fromPath(testLocation, true)).build(); + } + + String reproducibilityDumpDir = System.getProperty(REPRODUCIBILITY_DUMP_DIR_PROPERTY_NAME); + if (reproducibilityDumpDir != null) { + runReproducibilityDump(extensionContext, testLocation, projectDir, customizers, + Path.of(reproducibilityDumpDir)); + } + + if (reproducibilityCheck) { + runReproducibilityCheck(extensionContext, testLocation, projectDir, customizers, reproducibilityRuns); + } + + // Normal path: single augmentation, start app, run tests + // Reuse the cached ApplicationModel to skip dependency resolution + ApplicationModel cachedModel = null; + if (isApplicationModelCacheable()) { + cachedModel = (ApplicationModel) store.get(CACHED_APPLICATION_MODEL_KEY); + } + curatedApplication = createCuratedApplication(extensionContext, testLocation, projectDir, cachedModel); + if (cachedModel == null && isApplicationModelCacheable()) { + store.put(CACHED_APPLICATION_MODEL_KEY, curatedApplication.getApplicationModel()); + } + + StartupActionImpl startupAction = new AugmentActionImpl(curatedApplication, customizers, classLoadListeners) + .createInitialRuntimeApplication(); + + Map overriddenConfig = new HashMap<>(testResourceManager.getConfigProperties()); + if (customRuntimeApplicationProperties != null) { + overriddenConfig.putAll(customRuntimeApplicationProperties); + } + startupAction.overrideConfig(overriddenConfig); + runningQuarkusApplication = startupAction.run(commandLineParameters); + //we restore the CL at the end of the test + Thread.currentThread().setContextClassLoader(runningQuarkusApplication.getClassLoader()); + if (assertException != null) { + fail(THE_BUILD_WAS_EXPECTED_TO_FAIL); + } + started = true; + ValueRegistry valueRegistry = runningQuarkusApplication.valueRegistry(); + try { + actualTestClass = Class.forName(testClass.getName(), true, Thread.currentThread().getContextClassLoader()); + actualTestInstance = runningQuarkusApplication.instance(actualTestClass); + Class resM = runningQuarkusApplication.getClassLoader() + .loadClass(TestHTTPResourceManager.class.getName()); + resM.getDeclaredMethod("inject", Object.class, ValueRegistry.class).invoke(null, actualTestInstance, + valueRegistry); + populateTestMethodInvokers(startupAction.getClassLoader()); + } catch (Exception e) { + throw new TestInstantiationException("Failed to create test instance", e); + } + + extensionContext.getStore(ExtensionContext.Namespace.GLOBAL).put(testClass.getName(), actualTestInstance); + } catch (Throwable e) { + started = false; + if (assertException != null) { + if (e instanceof AssertionError && e.getMessage().equals(THE_BUILD_WAS_EXPECTED_TO_FAIL)) { + //don't pass the 'no failure' assertion into the assert exception function + throw e; + } + if (e instanceof TestAbortedException) { + // reproducibility check passed; don't confuse it with an expected build failure + throw e; + } + if (e instanceof RuntimeException) { + Throwable cause = e.getCause(); + if (cause != null && cause instanceof BuildException) { + assertException.accept(unwrapException(cause.getCause())); + } else if (cause != null) { + assertException.accept(unwrapException(cause)); + } else { + assertException.accept(e); + } + } else { + assertException.accept(e); + } + } else { + throw e; + } + } + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private Throwable unwrapException(Throwable cause) { + //TODO: huge hack + try { + Class localVer = QuarkusExtensionTest.class.getClassLoader().loadClass(cause.getClass().getName()); + if (localVer != cause.getClass()) { + Constructor ctor = localVer.getConstructor(String.class, Throwable.class); + return (Throwable) ctor.newInstance(cause.getMessage(), cause.getCause()); + } + } catch (Exception e) { + //failed to unwrap } + return cause; + } + + private void overrideSystemProperty(String key, String value) { + // IMPORTANT: Not logging the value in case it's a secret. + System.out.printf("[QuarkusExtensionTest] Overriding system property '%s'%n", key); + systemPropertiesToRestore.putIfAbsent(key, System.getProperty(key)); + System.setProperty(key, value); + } + + private void overrideLoggerLevel(String category, Level level) { + System.out.printf("[QuarkusExtensionTest] Overriding logger category '%s'; setting level '%s'%n", category, + level); + var logger = LogManager.getLogManager().getLogger(category); + loggerLevelsToRestore.putIfAbsent(category, logger.getLevel()); + logger.setLevel(level); } - // Teardown fallback for @ParameterizedClass tests if startup failed or an invocation aborted. @Override public void afterAll(ExtensionContext extensionContext) throws Exception { + // Teardown fallback for @ParameterizedClass tests if startup failed or an invocation aborted. if (isParameterizedClass(extensionContext)) { if (started) { doAfterAll(extensionContext); } + } else { + doAfterAll(extensionContext); + } + } + + protected void doAfterAll(ExtensionContext extensionContext) throws Exception { + actualTestClass = null; + actualTestInstance = null; + started = false; + List records = null; + if (assertLogRecords != null) { + records = new ArrayList<>(inMemoryLogHandler.records); + } + if (originalHandlers != null) { + rootLogger.setHandlers(originalHandlers); + } + inMemoryLogHandler.clearRecords(); + inMemoryLogHandler.setFilter(null); + if (testMethodInvokers != null) { + testMethodInvokers.clear(); + } + + try { + if (runningQuarkusApplication != null) { + runningQuarkusApplication.close(); + runningQuarkusApplication = null; + } + if (afterUndeployListener != null && !reproducibilityCheckActive) { + afterUndeployListener.run(); + } + if (curatedApplication != null) { + curatedApplication.close(); + curatedApplication = null; + } + } finally { + Thread.currentThread().setContextClassLoader(originalClassLoader); + originalClassLoader = null; + if (quarkusUnitTestClassLoader != null) { + quarkusUnitTestClassLoader.close(); + quarkusUnitTestClassLoader = null; + } + if (timeoutTask != null) { + timeoutTask.cancel(); + timeoutTask = null; + } + if (timeoutTimer != null) { + timeoutTimer.cancel(); + timeoutTimer = null; + } + if (deploymentDir != null) { + FileUtil.deleteDirectory(deploymentDir); + } + + if (afterAllCustomizer != null) { + afterAllCustomizer.run(); + } + systemPropertiesToRestore.forEach((key, previousValue) -> { + if (previousValue == null) { + System.clearProperty(key); + } else { + System.setProperty(key, previousValue); + } + }); + systemPropertiesToRestore.clear(); + loggerLevelsToRestore.forEach((category, previousLevel) -> Logger.getLogger(category).setLevel(previousLevel)); + loggerLevelsToRestore.clear(); + ClearCache.clearCaches(); + TestConfigUtil.cleanUp(); + } + // skip log assertions during reproducibility checks — the app was never started + if (records != null && !reproducibilityCheckActive) { + assertLogRecords.accept(records); + } + } + + private void runReproducibilityCheck(ExtensionContext extensionContext, Path testLocation, Path projectDir, + List> customizers, int runs) throws Exception { + if (System.getProperty(REPRODUCIBILITY_CROSS_JVM_PROPERTY_NAME) != null) { + runReproducibilityCheckCrossJvm(extensionContext, runs); + } else { + runReproducibilityCheckInsideJvm(extensionContext, testLocation, projectDir, customizers, runs); + } + } + + private void runReproducibilityCheckInsideJvm(ExtensionContext extensionContext, Path testLocation, Path projectDir, + List> customizers, int runs) throws Exception { + String testName = extensionContext.getRequiredTestClass().getSimpleName(); + System.out.printf("[QuarkusExtensionTest] Reproducibility check (%d runs) for %s%n", runs, testName); + List> reproducibilityCustomizers = new ArrayList<>(customizers); + reproducibilityCustomizers.add(buildChainBuilder -> buildChainBuilder + .addBuildStep(context -> context.produce(new ReproducibilityCheckBuildItem())) + .produces(ReproducibilityCheckBuildItem.class) + .build()); + Map referenceInMemoryClasses = null; + ApplicationModel appModel = null; + for (int run = 1; run <= runs; run++) { + CuratedApplication currentCuratedApplication = null; + StartupActionImpl currentStartupAction = null; + try { + resetLegacyGizmoFunctionCounters(); + currentCuratedApplication = createCuratedApplication(extensionContext, testLocation, projectDir, appModel); + if (appModel == null) { + // Reuse the resolved model so this check focuses on repeated augmentation bytecode. + appModel = currentCuratedApplication.getApplicationModel(); + } + currentStartupAction = new AugmentActionImpl(currentCuratedApplication, reproducibilityCustomizers, + classLoadListeners).createInitialRuntimeApplication(); + + Map currentInMemoryClasses = currentStartupAction.getClassLoader().getInMemoryClassBytes(); + if (referenceInMemoryClasses == null) { + referenceInMemoryClasses = currentInMemoryClasses; + } else { + var diff = BytecodeTools.diff(referenceInMemoryClasses, currentInMemoryClasses); + if (!diff.isEmpty()) { + String testRunId = extensionContext.getRequiredTestClass().getName() + + "/" + UUID.randomUUID(); + Path dumpDir = Path.of("target", "debug").resolve(testRunId) + .resolve("reproducibility-mismatch"); + Path mismatchDumpPath = BytecodeTools.dumpReproducibilityMismatch(diff, + referenceInMemoryClasses, currentInMemoryClasses, run, dumpDir); + throw new AssertionError("Build reproducibility check failed on run " + run + "/" + + runs + ": " + diff + ". Dumped to " + mismatchDumpPath); + } + } + } finally { + if (currentStartupAction != null) { + currentStartupAction.getClassLoader().close(); + } + if (currentCuratedApplication != null) { + currentCuratedApplication.close(); + } + inMemoryLogHandler.clearRecords(); + } + } + System.out.printf("[QuarkusExtensionTest] Reproducibility check passed for %s%n", testName); + throw new TestAbortedException("Reproducibility check passed (" + runs + " runs). Test execution skipped."); + } + + /** + * Legacy Gizmo {@code createFunction()} uses a {@code static} counter keyed by generated class name, + * which leaks between reproducibility runs in the same JVM. This affects generators that emit {@code $$function$$} + * helper classes, such as: + *
    + *
  • {@code io.quarkus.rest.data.panache.deployment.JaxRsResourceImplementor#implement}
  • + *
  • {@code io.quarkus.hibernate.reactive.rest.data.panache.deployment.ResourceImplementor#implement}
  • + *
  • {@code io.quarkus.security.jpa.reactive.deployment.QuarkusSecurityJpaReactiveProcessor#generateIdentityProvider}
  • + *
  • {@code io.quarkus.security.jpa.reactive.deployment.QuarkusSecurityJpaReactiveProcessor#generateTrustedIdentityProvider}
  • + *
+ */ + private static void resetLegacyGizmoFunctionCounters() { + try { + Class bytecodeCreatorImpl = Class.forName("io.quarkus.gizmo.BytecodeCreatorImpl"); + Field countersField = bytecodeCreatorImpl.getDeclaredField("functionCountersByClass"); + countersField.setAccessible(true); + ((Map) countersField.get(null)).clear(); + } catch (ReflectiveOperationException e) { + throw new RuntimeException("Unable to reset legacy Gizmo function counters", e); + } + } + + private void runReproducibilityCheckCrossJvm(ExtensionContext extensionContext, int runs) throws Exception { + String testClassName = extensionContext.getRequiredTestClass().getName(); + String testName = extensionContext.getRequiredTestClass().getSimpleName(); + System.out.printf("[QuarkusExtensionTest] Reproducibility check (%d runs) for %s%n", runs, testName); + + Path baseDir = Path.of("target", "reproducibility", testName); + IoUtils.recursiveDelete(baseDir); + Files.createDirectories(baseDir); + + List baseArgs = new ArrayList<>(); + baseArgs.add("-cp"); + baseArgs.add(System.getProperty("java.class.path")); + for (String key : System.getProperties().stringPropertyNames()) { + if (key.equals(REPRODUCIBILITY_DUMP_DIR_PROPERTY_NAME)) { + continue; + } + baseArgs.add("-D" + key + "=" + System.getProperty(key)); + } + + Map referenceClasses = null; + for (int run = 1; run <= runs; run++) { + Path runDumpDir = baseDir.resolve("run-" + run); + + List args = new ArrayList<>(baseArgs); + args.add("-D" + REPRODUCIBILITY_DUMP_DIR_PROPERTY_NAME + "=" + runDumpDir); + args.add(ReproducibilityTestLauncher.class.getName()); + args.add(testClassName); + + System.out.printf("[QuarkusExtensionTest] Starting run %d/%d for %s%n", run, runs, testName); + ProcessBuilder.newBuilder(ProcessUtil.pathOfJava()) + .arguments(args) + .output().inherited() + .error().inherited() + .run(); + + if (!Files.isDirectory(runDumpDir)) { + // this should never happen, the dump process creates the directory before running augmentation + throw new AssertionError("Reproducibility run " + run + "/" + runs + + " did not produce dump directory " + runDumpDir); + } + + Map runClasses = BytecodeTools.loadClassBytes(runDumpDir); + if (referenceClasses == null) { + referenceClasses = runClasses; + } else { + var diff = BytecodeTools.diff(referenceClasses, runClasses); + if (!diff.isEmpty()) { + Path mismatchDir = baseDir.resolve("mismatch-run-" + run); + BytecodeTools.dumpReproducibilityMismatch(diff, referenceClasses, runClasses, run, mismatchDir); + throw new AssertionError("Reproducibility check failed on run " + run + "/" + + runs + ": " + diff + ". Dumped to " + mismatchDir); + } + } + } + System.out.printf("[QuarkusExtensionTest] Reproducibility check passed for %s%n", testName); + throw new TestAbortedException("Reproducibility check passed (" + runs + " runs). Test execution skipped."); + } + + private void runReproducibilityDump(ExtensionContext extensionContext, Path testLocation, Path projectDir, + List> customizers, Path dumpDir) throws Exception { + + IoUtils.recursiveDelete(dumpDir); + Files.createDirectories(dumpDir); + + List> dumpCustomizers = new ArrayList<>(customizers); + dumpCustomizers.add( + buildChainBuilder -> buildChainBuilder + .addBuildStep(context -> context.produce(new ReproducibilityCheckBuildItem())) + .produces(ReproducibilityCheckBuildItem.class).build()); + + CuratedApplication curatedApplication = null; + StartupActionImpl startupAction = null; + try { + curatedApplication = createCuratedApplication(extensionContext, testLocation, projectDir, null); + startupAction = new AugmentActionImpl(curatedApplication, dumpCustomizers, classLoadListeners) + .createInitialRuntimeApplication(); + BytecodeTools.dumpClassBytes(startupAction.getClassLoader().getInMemoryClassBytes(), dumpDir); + } finally { + if (startupAction != null) { + startupAction.getClassLoader().close(); + } + if (curatedApplication != null) { + curatedApplication.close(); + } + } + throw new TestAbortedException("Reproducibility dump complete (" + dumpDir + "). Test execution skipped."); + } + + private CuratedApplication createCuratedApplication(ExtensionContext extensionContext, Path testLocation, Path projectDir, + ApplicationModel existingModel) throws Exception { + QuarkusBootstrap.Builder builder = QuarkusBootstrap.builder() + .setBaseName(extensionContext.getDisplayName() + " (QuarkusExtensionTest)") + .setApplicationRoot(deploymentDir.resolve(APP_ROOT)) + .setMode(QuarkusBootstrap.Mode.TEST) + .setTest(true) + .addExcludedPath(testLocation) + .setProjectRoot(projectDir) + .setTargetDirectory(PathTestHelper.getProjectBuildDir(projectDir, testLocation)) + .setFlatClassPath(flatClassPath) + .setForcedDependencies(forcedDependencies) + .setExcludedDependencies(excludedDependencies); + for (JavaArchive dependency : additionalDependencies) { + builder.addAdditionalApplicationArchive( + new AdditionalDependency(deploymentDir.resolve(dependency.getName()), false, true)); + } + if (hasDependencyCustomizations()) { + //if we have forced/excluded dependencies we can't use the cache + //as it can screw everything up + builder.setDisableClasspathCache(true); + } + if (quarkusUnitTestClassLoader != null) { + builder.setBaseClassLoader(quarkusUnitTestClassLoader); + } + builder.addClassLoaderEventListeners(this.classLoadListeners); + for (Consumer bootstrapCustomizer : bootstrapCustomizers) { + bootstrapCustomizer.accept(builder); + } + if (existingModel != null) { + builder.setExistingModel(existingModel); + } + return builder.build().bootstrap(); + } + + /** + * The resolved {@link ApplicationModel} can only be cached and reused if there are no forced or excluded dependencies + * and no bootstrap customizers that could alter model resolution (e.g. localProjectDiscovery, targetDirectory). + */ + protected boolean isApplicationModelCacheable() { + return !hasDependencyCustomizations() && bootstrapCustomizers.isEmpty(); + } + + /** + * @return {@code true} if there are forced or excluded dependencies, {@code false} otherwise + */ + protected boolean hasDependencyCustomizations() { + return !forcedDependencies.isEmpty() || !excludedDependencies.isEmpty(); + } + + @Override + public void afterEach(ExtensionContext context) throws Exception { + if (runningQuarkusApplication != null) { + //this kinda sucks, but everything is isolated, so we need to hook into everything via reflection + runningQuarkusApplication.getClassLoader().loadClass(RestAssuredStateManager.class.getName()) + .getDeclaredMethod("clearState") + .invoke(null); + } + } + + @Override + public void beforeEach(ExtensionContext context) throws Exception { + // Lazily starts Quarkus before the first test method of each @ParameterizedClass invocation. + if (isParameterizedClass(context)) { + if (!started) { + doBeforeAll(context); + } + } + + if (assertException != null) { + // Build failed as expected - test methods are not invoked return; } - super.afterAll(extensionContext); + if (runningQuarkusApplication != null) { + runningQuarkusApplication.getClassLoader().loadClass(RestAssuredStateManager.class.getName()) + .getDeclaredMethod("setURL", boolean.class).invoke(null, useSecureConnection); + } else { + Optional> testClass = context.getTestClass(); + if (testClass.isPresent()) { + Field extensionField = Arrays.stream(testClass.get().getDeclaredFields()).filter( + f -> f.isAnnotationPresent(RegisterExtension.class) + && QuarkusExtensionTest.class.equals(f.getType())) + .findAny().orElse(null); + if (extensionField != null && !Modifier.isStatic(extensionField.getModifiers())) { + throw new IllegalStateException( + "Test application not started - QuarkusExtensionTest must be used with a static field: " + + extensionField); + } + } + throw new IllegalStateException("Test application not started for an unknown reason"); + } + } + + public Runnable getAfterUndeployListener() { + return afterUndeployListener; + } + + public QuarkusExtensionTest setAfterUndeployListener(Runnable afterUndeployListener) { + this.afterUndeployListener = afterUndeployListener; + return this; + } + + /** + * Add an {@code application.properties} asset loaded from the specified resource file in the test {@link JavaArchive}. + *

+ * If an {@code application.properties} asset was already added explicitly to the archive (for instance through + * {@link JavaArchive#addAsResource(String)}), this formet asset is removed and completely replaced by the one given here. + *

+ * Configuration properties added with {@link #overrideConfigKey(String, String)} take precedence over the properties from + * the specified resource file. + * + * @param resourceName + * @return the test configuration + */ + public QuarkusExtensionTest withConfigurationResource(String resourceName) { + this.configResourceName = Objects.requireNonNull(resourceName); + return this; + } + + /** + * Adds build-time configuration properties using a text block. + * + * @param configBlock text block containing {@code key=value} configuration entries + */ + public QuarkusExtensionTest withConfiguration(String configBlock) { + applyConfigurationBlock(configBlock, this::overrideConfigKey); + return this; + } + + /** + * Adds runtime configuration properties using a text block. + * + * @param configBlock text block containing {@code key=value} configuration entries + */ + public QuarkusExtensionTest withRuntimeConfiguration(String configBlock) { + applyConfigurationBlock(configBlock, this::overrideRuntimeConfigKey); + return this; + } + + /** + * Parses a configuration text block and applies the given action for each {@code key=value} entry. + * + * @param configBlock the configuration text block to parse + * @param keyValueConsumer action applied to each parsed key/value pair + * @return this instance for fluent chaining + */ + private QuarkusExtensionTest applyConfigurationBlock(String configBlock, + BiConsumer keyValueConsumer) { + Objects.requireNonNull(configBlock, "Configuration text block must not be null"); + Objects.requireNonNull(keyValueConsumer, "Key/value consumer must not be null"); + + configBlock.lines() + .map(String::trim) + .filter(line -> !line.isEmpty() && !line.startsWith("#")) + .forEach(line -> { + int idx = line.indexOf('='); + if (idx <= 0) { + throw new IllegalArgumentException( + "Invalid configuration line: '" + line + "'. Expected format 'key=value'"); + } + String key = line.substring(0, idx).trim(); + String value = line.substring(idx + 1).trim(); + keyValueConsumer.accept(key, value); + }); + + return this; + } + + /** + * Overriden configuration properties take precedence over an {@code application.properties} asset added in the test + * {@link JavaArchive}. + * + * @param propertyKey + * @param propertyValue + * @return the test configuration + */ + public QuarkusExtensionTest overrideConfigKey(final String propertyKey, final String propertyValue) { + if (customApplicationProperties == null) { + customApplicationProperties = new Properties(); + } + customApplicationProperties.put(propertyKey, propertyValue); + return this; + } + + public QuarkusExtensionTest overrideRuntimeConfigKey(final String propertyKey, final String propertyValue) { + if (customRuntimeApplicationProperties == null) { + customRuntimeApplicationProperties = new HashMap<>(); + } + customRuntimeApplicationProperties.put(propertyKey, propertyValue); + return this; + } + + /** + * Resets mutable per-invocation state on this extension instance. + *

+ * This method is intended for use with JUnit 5's {@link org.junit.jupiter.params.ParameterizedClass}: + * call it from a {@link org.junit.jupiter.params.BeforeParameterizedClassInvocation} method to clear + * configuration and log-record state left by the previous parameterization, so each invocation + * starts with a clean slate. + * + * @return {@code this}, for method chaining. + */ + public QuarkusExtensionTest resetForParameterizedClass() { + this.customApplicationProperties = null; + this.customRuntimeApplicationProperties = null; + this.configResourceName = null; + this.assertLogRecords = null; + this.inMemoryLogHandler = new InMemoryLogHandler(r -> false); + this.forcedDependencies = Collections.emptyList(); + this.excludedDependencies = Collections.emptySet(); + this.additionalDependencies = new ArrayList<>(); + this.bootstrapCustomizers.clear(); + this.traceCategories.clear(); + this.assertException = null; + this.beforeAllCustomizer = null; + this.afterAllCustomizer = null; + this.afterUndeployListener = null; + this.buildChainCustomizers = new ArrayList<>(); + this.commandLineParameters = new String[0]; + return this; + } + + /** + * Controls bytecode-related debug dumping. + *

+ * When enabled, each Quarkus startup will have configuration properties + * such as {@code quarkus.debug.generated-classes-dir} set + * so that generated code gets dumped in {@code target/debug}, + * within a unique subdirectory for each test execution. + *

+ * Look at the logs of a particular test to identify the corresponding dump directory. + * + * @param debugBytecode {@code true} if debug should be enabled + * @return {@code this}, for method chaining. + */ + public QuarkusExtensionTest debugBytecode(boolean debugBytecode) { + this.debugBytecode = debugBytecode; + return this; + } + + /** + * Enables trace logs for the given categories, + * during both build and runtime. + * + * @param categories The categories for which to enable trace logging. + * @return {@code this}, for method chaining. + */ + public QuarkusExtensionTest traceCategories(String... categories) { + Collections.addAll(this.traceCategories, categories); + return this; } // Delegates constructor parameter resolution to JUnit for @ParameterizedClass tests so that @@ -90,59 +1394,113 @@ public void afterAll(ExtensionContext extensionContext) throws Exception { @Override public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException { - if (isParameterizedClass(extensionContext)) { - if (parameterContext.getDeclaringExecutable() instanceof Constructor) { - return false; + boolean isConstructor = parameterContext.getDeclaringExecutable() instanceof Constructor; + if (isConstructor) { + return !isParameterizedClass(extensionContext); + } + if (!(parameterContext.getDeclaringExecutable() instanceof Method)) { + return false; + } + if (testMethodInvokers == null) { + return false; + } + for (Object testMethodInvoker : testMethodInvokers) { + boolean handlesMethodParamType = testMethodInvokerHandlesParamType(testMethodInvoker, + parameterContext.getParameter().getType().getName()); + if (handlesMethodParamType) { + return true; } } - return super.supportsParameter(parameterContext, extensionContext); + return false; } - // Copies @Parameter fields from JUnit's outer test instance across the ClassLoader boundary - // into Quarkus's actual test instance before each test method runs. + /** + * We don't actually have to resolve the parameter (thus the default values in the implementation) + * since the class instance that is passed to JUnit isn't really used. + * The actual test instance that is used is the one that is pulled from Arc, which of course will already have its + * constructor parameters properly resolved + */ @Override - protected void onBeforeMethodInvocation(ExtensionContext extensionContext) { - if (!isParameterizedClass(extensionContext)) { - super.onBeforeMethodInvocation(extensionContext); - return; + public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) + throws ParameterResolutionException { + if ((parameterContext.getDeclaringExecutable() instanceof Method) && (testMethodInvokers != null)) { + for (Object testMethodInvoker : testMethodInvokers) { + if (testMethodInvokerHandlesParamType(testMethodInvoker, + parameterContext.getParameter().getType().getName())) { + return null; // don't return the actual value since it leads to class loading issues + } + } } - if (actualTestInstance == null || !extensionContext.getTestInstance().isPresent()) { - return; + String className = parameterContext.getParameter().getType().getName(); + switch (className) { + case "boolean": + return false; + case "byte": + case "short": + case "int": + return 0; + case "long": + return 0L; + case "float": + return 0.0f; + case "double": + return 0.0d; + case "char": + return '\u0000'; + default: + return null; } - // Enum values are re-resolved by name to safely cross the ClassLoader boundary. - Object outerInstance = extensionContext.getTestInstance().get(); - Class outerClass = outerInstance.getClass(); - Class innerClass = actualTestInstance.getClass(); - while (outerClass != null && outerClass != Object.class) { - for (Field outerField : outerClass.getDeclaredFields()) { - if (Modifier.isStatic(outerField.getModifiers()) || Modifier.isFinal(outerField.getModifiers())) { - continue; - } - if (!outerField.isAnnotationPresent(Parameter.class)) { - continue; - } - try { - Field innerField = getDeclaredField(innerClass, outerField.getName()); - if (innerField != null) { - outerField.setAccessible(true); - innerField.setAccessible(true); - Object val = outerField.get(outerInstance); - if (val == null) { - innerField.set(actualTestInstance, null); - } else if (val instanceof Enum enumVal && innerField.getType().isEnum()) { - @SuppressWarnings({ "unchecked", "rawtypes" }) - Object resolvedEnum = Enum.valueOf((Class) innerField.getType(), enumVal.name()); - innerField.set(actualTestInstance, resolvedEnum); - } else { - innerField.set(actualTestInstance, val); - } - } - } catch (Exception e) { - throw new RuntimeException( - "Failed to copy @Parameter field '" + outerField.getName() + "' to Quarkus test instance", e); - } + } + + // we need to use reflection because the instances of TestMethodInvoker are loaded from the QuarkusClassLoader + private boolean testMethodInvokerHandlesParamType(Object testMethodInvoker, String parameterTypeName) { + try { + return (boolean) testMethodInvoker.getClass().getMethod("handlesMethodParamType", String.class) + .invoke(testMethodInvoker, parameterTypeName); + } catch (IllegalAccessException | InvocationTargetException | NoSuchMethodException e) { + throw new IllegalStateException("Unable to determine if TestMethodInvoker supports parameter", e); + } + } + + // we need to use reflection because the instances of TestMethodInvoker are loaded from the QuarkusClassLoader + private Object testMethodInvokerParamInstance(Object testMethodInvoker, String parameterTypeName) { + try { + return testMethodInvoker.getClass().getMethod("methodParamInstance", String.class) + .invoke(testMethodInvoker, parameterTypeName); + } catch (IllegalAccessException | InvocationTargetException | NoSuchMethodException e) { + throw new IllegalStateException("Unable to obtain instance of parameter using TestMethodInvoker", e); + } + } + + private void populateTestMethodInvokers(ClassLoader quarkusClassLoader) { + testMethodInvokers = new ArrayList<>(); + try { + ServiceLoader loader = ServiceLoader.load(quarkusClassLoader.loadClass(TestMethodInvoker.class.getName()), + quarkusClassLoader); + for (Object testMethodInvoker : loader) { + testMethodInvokers.add(testMethodInvoker); } - outerClass = outerClass.getSuperclass(); + } catch (ClassNotFoundException e) { + throw new RuntimeException(e); + } + } + + private static boolean isParameterizedClass(ExtensionContext context) { + return context.getTestClass() + .map(c -> AnnotationSupport.isAnnotated(c, ParameterizedClass.class)) + .orElse(false); + } + + // Startup remains deferred to beforeEach for @ParameterizedClass invocations. + @Override + public void beforeClassTemplateInvocation(ExtensionContext extensionContext) throws Exception { + } + + // Shuts down Quarkus after each @ParameterizedClass invocation finishes so the next starts fresh. + @Override + public void afterClassTemplateInvocation(ExtensionContext extensionContext) throws Exception { + if (isParameterizedClass(extensionContext)) { + doAfterAll(extensionContext); } } diff --git a/test-framework/junit-internal/src/main/java/io/quarkus/test/QuarkusUnitTest.java b/test-framework/junit-internal/src/main/java/io/quarkus/test/QuarkusUnitTest.java deleted file mode 100644 index 6a83c80eecf1f..0000000000000 --- a/test-framework/junit-internal/src/main/java/io/quarkus/test/QuarkusUnitTest.java +++ /dev/null @@ -1,173 +0,0 @@ -package io.quarkus.test; - -import java.util.List; -import java.util.function.Consumer; -import java.util.function.Predicate; -import java.util.function.Supplier; -import java.util.logging.LogRecord; - -import org.jboss.shrinkwrap.api.spec.JavaArchive; - -import io.quarkus.bootstrap.app.QuarkusBootstrap; -import io.quarkus.bootstrap.classloading.ClassLoaderEventListener; -import io.quarkus.builder.BuildChainBuilder; -import io.quarkus.maven.dependency.Dependency; - -/** - * @deprecated use {@link QuarkusExtensionTest} - */ -// When we delete this, also remove the superclass and consolidate function back into QuarkusExtensionTest -@Deprecated(since = "3.35", forRemoval = true) -public class QuarkusUnitTest extends AbstractQuarkusExtensionTest { - - public QuarkusUnitTest() { - super(); - } - - public QuarkusUnitTest(boolean useSecureConnection) { - super(useSecureConnection); - } - - public static QuarkusUnitTest withSecuredConnection() { - return new QuarkusUnitTest(true); - } - - // Bridge methods for binary compatibility. - // The superclass methods return the generic type S which erases to AbstractQuarkusExtensionTest. - // Code compiled against the old non-generic QuarkusUnitTest expects methods returning QuarkusUnitTest. - - @Override - public QuarkusUnitTest setArchiveProducer(Supplier archiveProducer) { - return super.setArchiveProducer(archiveProducer); - } - - @Override - public QuarkusUnitTest withApplicationRoot(Consumer applicationRootConsumer) { - return super.withApplicationRoot(applicationRootConsumer); - } - - @Override - public QuarkusUnitTest withEmptyApplication() { - return super.withEmptyApplication(); - } - - @Override - public QuarkusUnitTest overrideConfigKey(String propertyKey, String propertyValue) { - return super.overrideConfigKey(propertyKey, propertyValue); - } - - @Override - public QuarkusUnitTest overrideRuntimeConfigKey(String propertyKey, String propertyValue) { - return super.overrideRuntimeConfigKey(propertyKey, propertyValue); - } - - @Override - public QuarkusUnitTest withConfigurationResource(String resourceName) { - return super.withConfigurationResource(resourceName); - } - - @Override - public QuarkusUnitTest assertException(Consumer assertException) { - return super.assertException(assertException); - } - - @Override - public QuarkusUnitTest setExpectedException(Class expectedException) { - return super.setExpectedException(expectedException); - } - - @Override - public QuarkusUnitTest setExpectedException(Class expectedException, boolean logMessage) { - return super.setExpectedException(expectedException, logMessage); - } - - @Override - public QuarkusUnitTest setForcedDependencies(List forcedDependencies) { - return super.setForcedDependencies(forcedDependencies); - } - - @Override - public QuarkusUnitTest addBuildChainCustomizer(Consumer customizer) { - return super.addBuildChainCustomizer(customizer); - } - - @Override - public QuarkusUnitTest setLogRecordPredicate(Predicate predicate) { - return super.setLogRecordPredicate(predicate); - } - - @Override - public QuarkusUnitTest assertLogRecords(Consumer> assertLogRecords) { - return super.assertLogRecords(assertLogRecords); - } - - @Override - public QuarkusUnitTest setBeforeAllCustomizer(Runnable beforeAllCustomizer) { - return super.setBeforeAllCustomizer(beforeAllCustomizer); - } - - @Override - public QuarkusUnitTest setAfterAllCustomizer(Runnable afterAllCustomizer) { - return super.setAfterAllCustomizer(afterAllCustomizer); - } - - @Override - public QuarkusUnitTest setCommandLineParameters(String... commandLineParameters) { - return super.setCommandLineParameters(commandLineParameters); - } - - @Override - public QuarkusUnitTest withAdditionalDependency(Consumer dependencyConsumer) { - return super.withAdditionalDependency(dependencyConsumer); - } - - @Override - public QuarkusUnitTest addAdditionalDependency(JavaArchive archive) { - return super.addAdditionalDependency(archive); - } - - @Override - public QuarkusUnitTest withConfiguration(String configBlock) { - return super.withConfiguration(configBlock); - } - - @Override - public QuarkusUnitTest withRuntimeConfiguration(String configBlock) { - return super.withRuntimeConfiguration(configBlock); - } - - @Override - public QuarkusUnitTest setFlatClassPath(boolean flatClassPath) { - return super.setFlatClassPath(flatClassPath); - } - - @Override - public QuarkusUnitTest setAllowTestClassOutsideDeployment(boolean allowTestClassOutsideDeployment) { - return super.setAllowTestClassOutsideDeployment(allowTestClassOutsideDeployment); - } - - @Override - public QuarkusUnitTest setAfterUndeployListener(Runnable afterUndeployListener) { - return super.setAfterUndeployListener(afterUndeployListener); - } - - @Override - public QuarkusUnitTest addClassLoaderEventListener(ClassLoaderEventListener listener) { - return super.addClassLoaderEventListener(listener); - } - - @Override - public QuarkusUnitTest addBootstrapCustomizer(Consumer consumer) { - return super.addBootstrapCustomizer(consumer); - } - - @Override - public QuarkusUnitTest debugBytecode(boolean debugBytecode) { - return super.debugBytecode(debugBytecode); - } - - @Override - public QuarkusUnitTest traceCategories(String... categories) { - return super.traceCategories(categories); - } -} diff --git a/test-framework/junit-internal/src/main/java/io/quarkus/test/ReproducibilityTestLauncher.java b/test-framework/junit-internal/src/main/java/io/quarkus/test/ReproducibilityTestLauncher.java index 55726ab4c3bf1..38ffc9825f2ae 100644 --- a/test-framework/junit-internal/src/main/java/io/quarkus/test/ReproducibilityTestLauncher.java +++ b/test-framework/junit-internal/src/main/java/io/quarkus/test/ReproducibilityTestLauncher.java @@ -9,7 +9,7 @@ /** * Minimal JUnit launcher for running a single test class in a forked JVM. - * Used by the reproducibility check in {@link AbstractQuarkusExtensionTest}. + * Used by the reproducibility check in {@link QuarkusExtensionTest}. */ class ReproducibilityTestLauncher { public static void main(String[] args) { diff --git a/test-framework/junit-internal/src/test/java/io/quarkus/test/QuarkusExtensionTestParameterizedClassTest.java b/test-framework/junit-internal/src/test/java/io/quarkus/test/QuarkusExtensionTestParameterizedClassTest.java index b13a7698a2300..f97195ee81a8c 100644 --- a/test-framework/junit-internal/src/test/java/io/quarkus/test/QuarkusExtensionTestParameterizedClassTest.java +++ b/test-framework/junit-internal/src/test/java/io/quarkus/test/QuarkusExtensionTestParameterizedClassTest.java @@ -178,16 +178,16 @@ void resetForParameterizedClassClearsMutableState() throws Exception { extension.resetForParameterizedClass(); - Field customAppPropsField = AbstractQuarkusExtensionTest.class.getDeclaredField("customApplicationProperties"); + Field customAppPropsField = QuarkusExtensionTest.class.getDeclaredField("customApplicationProperties"); customAppPropsField.setAccessible(true); assertNull(customAppPropsField.get(extension)); - Field customRuntimeAppPropsField = AbstractQuarkusExtensionTest.class + Field customRuntimeAppPropsField = QuarkusExtensionTest.class .getDeclaredField("customRuntimeApplicationProperties"); customRuntimeAppPropsField.setAccessible(true); assertNull(customRuntimeAppPropsField.get(extension)); - Field configResourceNameField = AbstractQuarkusExtensionTest.class.getDeclaredField("configResourceName"); + Field configResourceNameField = QuarkusExtensionTest.class.getDeclaredField("configResourceName"); configResourceNameField.setAccessible(true); assertNull(configResourceNameField.get(extension)); } From 33b66810f048b5aeb4e381dd801ee475df5ae653 Mon Sep 17 00:00:00 2001 From: Matheus Cruz Date: Tue, 13 Oct 2026 11:03:21 -0300 Subject: [PATCH 179/254] Deprecate quarkus.hibernate-orm.log-session-metrics property Signed-off-by: Matheus Cruz --- docs/src/main/asciidoc/hibernate-orm.adoc | 3 +++ .../hibernate/orm/deployment/HibernateOrmConfig.java | 12 ++++++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/src/main/asciidoc/hibernate-orm.adoc b/docs/src/main/asciidoc/hibernate-orm.adoc index ffdb8e7407201..1839ce585d454 100644 --- a/docs/src/main/asciidoc/hibernate-orm.adoc +++ b/docs/src/main/asciidoc/hibernate-orm.adoc @@ -1297,6 +1297,9 @@ capable of exposing metrics that Hibernate ORM collects at runtime. To enable ex on the `/q/metrics` endpoint, make sure your project depends on a metrics extension and set the configuration property `quarkus.hibernate-orm.metrics.enabled` to `true`. +To log session-level metrics for each Hibernate session, enable the `org.hibernate.session.metrics` log +category; this only has an effect if statistics are enabled (`quarkus.hibernate-orm.statistics`). + == Limitations and other things you should know Quarkus does not modify the libraries it uses; this rule applies to Hibernate ORM as well: when using diff --git a/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/HibernateOrmConfig.java b/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/HibernateOrmConfig.java index a069efdb8a13c..0cf89ee89523f 100644 --- a/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/HibernateOrmConfig.java +++ b/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/HibernateOrmConfig.java @@ -102,11 +102,15 @@ default Map namedPersistenceUnits() { Optional statistics(); /** - * Whether session metrics should be appended into the server log for each Hibernate session. This - * only has effect if statistics are enabled (`quarkus.hibernate-orm.statistics`). The default is false - * (which means both `statistics` and `log-session-metrics` need to be enabled for the session metrics - * to appear in the log). + * Whether session metrics should be appended into the server log for each Hibernate session. + * + * @deprecated This property is no longer functional: Hibernate ORM 7+ logs session metrics + * based on whether the {@code org.hibernate.session.metrics} log category is + * enabled, not based on this setting. Enable that log category instead. This + * property will be removed in a future version. + * @asciidoclet */ + @Deprecated(since = "4.0", forRemoval = true) Optional logSessionMetrics(); /** From 16463fa5e02bbc86904e6eb0d546f184a3b28a9e Mon Sep 17 00:00:00 2001 From: James Netherton Date: Tue, 13 Oct 2026 15:08:21 +0100 Subject: [PATCH 180/254] Upgrade protoc & protobuf-java to 4.35.1 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 187a793c8c2a8..930dd67f5eccd 100644 --- a/pom.xml +++ b/pom.xml @@ -89,7 +89,7 @@ 1.2.2 - 4.35.0 + 4.35.1 ${protoc.version} ${protoc.version} 2.74.0 From 89a2a91ca954c9ab0c9726ec2b36c0f7c64e13aa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:18:43 +0000 Subject: [PATCH 181/254] Build(deps): Bump commons-codec:commons-codec from 1.22.1 to 1.23.0 Bumps [commons-codec:commons-codec](https://github.com/apache/commons-codec) from 1.22.1 to 1.23.0. - [Changelog](https://github.com/apache/commons-codec/blob/master/RELEASE-NOTES.txt) - [Commits](https://github.com/apache/commons-codec/compare/rel/commons-codec-1.22.1...rel/commons-codec-1.23.0) --- updated-dependencies: - dependency-name: commons-codec:commons-codec dependency-version: 1.23.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- bom/application/pom.xml | 2 +- independent-projects/bootstrap/pom.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bom/application/pom.xml b/bom/application/pom.xml index 0dd2e10b6ce03..3162877895d0a 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -95,7 +95,7 @@ 2.22.3 2.0.0.Final 3.21.0 - 1.22.1 + 1.23.0 1.7.1 diff --git a/independent-projects/bootstrap/pom.xml b/independent-projects/bootstrap/pom.xml index 9ac0d8378c891..bbad306ac77b7 100644 --- a/independent-projects/bootstrap/pom.xml +++ b/independent-projects/bootstrap/pom.xml @@ -52,7 +52,7 @@ 2.0.0.Final 3.0.0 2.0.1 - 1.22.1 + 1.23.0 2.22.0 3.21.0 33.7.2-jre From b294b684bd7e700c16a449e954dabefc9cfa1b51 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:20:54 +0000 Subject: [PATCH 182/254] Build(deps): Bump rest-assured.version from 6.0.1 to 6.1.0 Bumps `rest-assured.version` from 6.0.1 to 6.1.0. Updates `io.rest-assured:rest-assured` from 6.0.1 to 6.1.0 - [Changelog](https://github.com/rest-assured/rest-assured/blob/master/changelog.txt) - [Commits](https://github.com/rest-assured/rest-assured/compare/rest-assured-6.0.1...rest-assured-6.1.0) Updates `io.rest-assured:json-schema-validator` from 6.0.1 to 6.1.0 Updates `io.rest-assured:kotlin-extensions` from 6.0.1 to 6.1.0 --- updated-dependencies: - dependency-name: io.rest-assured:rest-assured dependency-version: 6.1.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.rest-assured:json-schema-validator dependency-version: 6.1.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: io.rest-assured:kotlin-extensions dependency-version: 6.1.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 187a793c8c2a8..f8960ddb0de2f 100644 --- a/pom.xml +++ b/pom.xml @@ -71,7 +71,7 @@ 2.5.252 0.8.15 8.0.0 - 6.0.1 + 6.1.0 8.0.0.Beta3 4.0.0-M7 1.1.0-M3 From 271047300b600bffe2961f75a415113bbe36efe1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:27:30 +0000 Subject: [PATCH 183/254] Build(deps): Bump de.thetaphi:forbiddenapis from 3.10 to 3.11 Bumps [de.thetaphi:forbiddenapis](https://github.com/policeman-tools/forbidden-apis) from 3.10 to 3.11. - [Commits](https://github.com/policeman-tools/forbidden-apis/compare/3.10...3.11) --- updated-dependencies: - dependency-name: de.thetaphi:forbiddenapis dependency-version: '3.11' dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- independent-projects/parent/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/independent-projects/parent/pom.xml b/independent-projects/parent/pom.xml index 44113ec0befae..d735f1ab71952 100644 --- a/independent-projects/parent/pom.xml +++ b/independent-projects/parent/pom.xml @@ -41,7 +41,7 @@ 2.22.0 1.1.3 - 3.10 + 3.11 verify From ca1e4db3e418058b9b93b9edc3f219e003982821 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:28:03 +0000 Subject: [PATCH 184/254] Build(deps): Bump org.mvnpm.at.mvnpm:vaadin-webcomponents Bumps [org.mvnpm.at.mvnpm:vaadin-webcomponents](https://github.com/vaadin/web-components) from 25.2.10 to 25.3.2. - [Release notes](https://github.com/vaadin/web-components/releases) - [Commits](https://github.com/vaadin/web-components/compare/v25.2.10...v25.3.2) --- updated-dependencies: - dependency-name: org.mvnpm.at.mvnpm:vaadin-webcomponents dependency-version: 25.3.2 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- bom/dev-ui/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bom/dev-ui/pom.xml b/bom/dev-ui/pom.xml index 49afe3a277706..3e22d96a9eee0 100644 --- a/bom/dev-ui/pom.xml +++ b/bom/dev-ui/pom.xml @@ -13,7 +13,7 @@ Dependency management for dev-ui. Importable by third party extension developers. - 25.2.10 + 25.3.2 3.3.3 4.2.2 3.3.3 From 0accbb7140d9cb91aa48b42ffdc15499b92ea00e Mon Sep 17 00:00:00 2001 From: Georgios Andrianakis Date: Tue, 13 Oct 2026 11:23:57 +0300 Subject: [PATCH 185/254] Introduce JFR support for the REST Client --- docs/src/main/asciidoc/jfr.adoc | 41 ++++- extensions/jfr/deployment/pom.xml | 20 +++ .../quarkus/jfr/deployment/JfrProcessor.java | 15 ++ .../jfr/deployment/JfrConfigurationTest.java | 2 + .../deployment/http/JfrRestClientTest.java | 167 ++++++++++++++++++ extensions/jfr/runtime/pom.xml | 5 + .../jfr/runtime/internal/JfrRecorder.java | 10 ++ .../internal/config/JfrRuntimeConfig.java | 10 ++ .../http/rest/client/RestClientFilter.java | 99 +++++++++++ .../rest/client/RestClientPeriodEvent.java | 52 ++++++ .../JaxrsClientReactiveProcessor.java | 1 + .../client/impl/ClientRequestContextImpl.java | 6 +- 12 files changed, 424 insertions(+), 4 deletions(-) create mode 100644 extensions/jfr/deployment/src/test/java/io/quarkus/jfr/deployment/http/JfrRestClientTest.java create mode 100644 extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/internal/http/rest/client/RestClientFilter.java create mode 100644 extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/internal/http/rest/client/RestClientPeriodEvent.java diff --git a/docs/src/main/asciidoc/jfr.adoc b/docs/src/main/asciidoc/jfr.adoc index f37c1dd099a2b..163cd6d370cb7 100644 --- a/docs/src/main/asciidoc/jfr.adoc +++ b/docs/src/main/asciidoc/jfr.adoc @@ -197,7 +197,7 @@ This means that after we identify the trace and span IDs of interest from the UI If we have not enabled the OpenTelemetry extension, this extension creates an ID for each request and links it to JFR events as a traceId. In this case, the span ID will be null. -For now, Quarkus only records REST events, but we plan to use this ID to link each event to each other as we add more events in the future. +For now, Quarkus only records REST server and REST Client events, but we plan to use this ID to link each event to each other as we add more events in the future. === Event Implementation Policy @@ -250,6 +250,45 @@ We can check if the Resource Method was executed as expected by the HTTP Method Client records information about the accessing client. +=== REST Client Event + +This event is recorded when the Quarkus REST Client extension is enabled. + +RestClient:: + + Records the time period from the moment the REST Client starts sending a request until the response has been received or the request has failed. + +This event has the following information. + +- HTTP Method +- URI +- Address +- Client Name +- Status +- Failure + +HTTP Method records the HTTP Method of the outgoing request. + +URI records the URI path template of the invoked REST Client method, for example `/hello/{name}`. +If no template is available, the URI path is recorded instead. +Query parameters are never recorded. + +Address records the host and port of the remote endpoint. + +Client Name records the config key of the REST Client that sent the request. + +Status records the HTTP status code of the response, or `0` if no response was received. + +Failure records the class name of the exception if the request failed, for example when the connection was refused. + +When the request is sent while a REST server request is being processed, the event shares the trace ID of the corresponding REST server event. +This makes it possible to see which remote calls were made by each REST server request. + +[NOTE] +==== +Requests whose response is streamed as a `Multi` are not recorded. +==== + === Runtime Event This event is recorded by default. The following three JFR events are recorded in JFR chunks. diff --git a/extensions/jfr/deployment/pom.xml b/extensions/jfr/deployment/pom.xml index 6a7abaaa01d6e..0394e9437e27b 100644 --- a/extensions/jfr/deployment/pom.xml +++ b/extensions/jfr/deployment/pom.xml @@ -38,6 +38,26 @@ quarkus-junit-internal test + + io.quarkus + quarkus-rest-deployment + test + + + io.quarkus + quarkus-rest-client-deployment + test + + + io.rest-assured + rest-assured + test + + + org.assertj + assertj-core + test + diff --git a/extensions/jfr/deployment/src/main/java/io/quarkus/jfr/deployment/JfrProcessor.java b/extensions/jfr/deployment/src/main/java/io/quarkus/jfr/deployment/JfrProcessor.java index 6f73cfae1f66a..4611dc3d4b2aa 100644 --- a/extensions/jfr/deployment/src/main/java/io/quarkus/jfr/deployment/JfrProcessor.java +++ b/extensions/jfr/deployment/src/main/java/io/quarkus/jfr/deployment/JfrProcessor.java @@ -15,6 +15,7 @@ import io.quarkus.deployment.annotations.BuildSteps; import io.quarkus.deployment.annotations.ExecutionTime; import io.quarkus.deployment.annotations.Record; +import io.quarkus.deployment.builditem.AdditionalIndexedClassesBuildItem; import io.quarkus.deployment.builditem.FeatureBuildItem; import io.quarkus.deployment.builditem.nativeimage.RuntimeInitializedClassBuildItem; import io.quarkus.jfr.runtime.internal.JfrRecorder; @@ -22,6 +23,7 @@ import io.quarkus.jfr.runtime.internal.QuarkusIdProducer; import io.quarkus.jfr.runtime.internal.http.rest.classic.ClassicServerEventRecorderProducer; import io.quarkus.jfr.runtime.internal.http.rest.classic.ClassicServerFilter; +import io.quarkus.jfr.runtime.internal.http.rest.client.RestClientFilter; import io.quarkus.jfr.runtime.internal.http.rest.reactive.ReactiveServerEventRecorderProducer; import io.quarkus.jfr.runtime.internal.http.rest.reactive.ReactiveServerFilters; import io.quarkus.jfr.runtime.internal.http.rest.reactive.ServerStartRecordingHandler; @@ -103,6 +105,19 @@ void registerRestIntegration(Capabilities capabilities, } } + @BuildStep + void registerRestClientIntegration(Capabilities capabilities, + BuildProducer additionalIndexed, + BuildProducer additionalBeans) { + if (capabilities.isPresent(Capability.REST_CLIENT_REACTIVE)) { + // the REST Client picks up the @Provider annotation from the index + additionalIndexed.produce(new AdditionalIndexedClassesBuildItem(RestClientFilter.class.getName())); + additionalBeans.produce(AdditionalBeanBuildItem.builder().setUnremovable() + .addBeanClasses(RestClientFilter.class) + .build()); + } + } + @BuildStep void registerResteasyClassicIntegration(Capabilities capabilities, BuildProducer resteasyJaxrsProviderBuildItemBuildProducer, diff --git a/extensions/jfr/deployment/src/test/java/io/quarkus/jfr/deployment/JfrConfigurationTest.java b/extensions/jfr/deployment/src/test/java/io/quarkus/jfr/deployment/JfrConfigurationTest.java index de0f9471733ad..b79389300a708 100644 --- a/extensions/jfr/deployment/src/test/java/io/quarkus/jfr/deployment/JfrConfigurationTest.java +++ b/extensions/jfr/deployment/src/test/java/io/quarkus/jfr/deployment/JfrConfigurationTest.java @@ -16,6 +16,7 @@ public class JfrConfigurationTest { static final QuarkusExtensionTest TEST = new QuarkusExtensionTest() .overrideConfigKey("quarkus.jfr.enabled", "false") .overrideConfigKey("quarkus.jfr.rest.enabled", "false") + .overrideConfigKey("quarkus.jfr.rest-client.enabled", "false") .overrideConfigKey("quarkus.jfr.runtime.enabled", "false"); @Inject @@ -25,6 +26,7 @@ public class JfrConfigurationTest { void config() { assertFalse(runtimeConfig.enabled()); assertFalse(runtimeConfig.restEnabled()); + assertFalse(runtimeConfig.restClientEnabled()); assertFalse(runtimeConfig.runtimeEnabled()); } } diff --git a/extensions/jfr/deployment/src/test/java/io/quarkus/jfr/deployment/http/JfrRestClientTest.java b/extensions/jfr/deployment/src/test/java/io/quarkus/jfr/deployment/http/JfrRestClientTest.java new file mode 100644 index 0000000000000..601aee6bce694 --- /dev/null +++ b/extensions/jfr/deployment/src/test/java/io/quarkus/jfr/deployment/http/JfrRestClientTest.java @@ -0,0 +1,167 @@ +package io.quarkus.jfr.deployment.http; + +import static io.restassured.RestAssured.when; +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.is; + +import java.io.IOException; +import java.nio.file.Files; +import java.time.Duration; +import java.util.List; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.ProcessingException; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; +import org.eclipse.microprofile.rest.client.inject.RestClient; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusExtensionTest; +import io.smallrye.mutiny.Multi; +import jdk.jfr.Recording; +import jdk.jfr.consumer.RecordedEvent; +import jdk.jfr.consumer.RecordingFile; + +public class JfrRestClientTest { + + @RegisterExtension + static final QuarkusExtensionTest TEST = new QuarkusExtensionTest() + .withApplicationRoot((jar) -> jar + .addClasses(Resource.class, HelloClient.class, UnreachableClient.class, StreamingClient.class)) + .overrideConfigKey("quarkus.rest-client.hello.url", "${test.url}") + .overrideConfigKey("quarkus.rest-client.unreachable.url", "http://localhost:1") + .overrideConfigKey("quarkus.rest-client.streaming.url", "${test.url}"); + + @Test + public void test() throws IOException { + final java.nio.file.Path dumpPath = java.nio.file.Path.of("./dump.jfr"); + try { + try (Recording r = new Recording()) { + r.start(); + + // 1) server resource /exercise calls HelloClient#hello("world") -> GET /hello/world on this server + when().get("/exercise").then().statusCode(200).body(is("Hello world")); + // 2) server resource /exercise-failure calls UnreachableClient#hello("world") -> GET http://localhost:1/hello/world, + // the connection is refused and the resource swallows the ProcessingException + when().get("/exercise-failure").then().statusCode(200).body(is("failed")); + // 3) server resource /exercise-stream calls StreamingClient#stream() -> GET /stream, consumed as an SSE Multi + when().get("/exercise-stream").then().statusCode(200).body(is("a,b,c")); + + r.stop(); + r.dump(dumpPath); + } + List recordedEvents = RecordingFile.readAllEvents(dumpPath); + + List clientEvents = recordedEvents.stream() + .filter(e -> e.getEventType().getName().equals("quarkus.RestClient")).toList(); + // only 1) and 2) produce an event, the streaming invocation 3) is not recorded + assertThat(clientEvents).hasSize(2); + + // 1) the successful call: GET /hello/world made by the "hello" client + RecordedEvent success = clientEvents.stream().filter(e -> e.getInt("status") == 200).findFirst().orElseThrow(); + assertThat(success.getString("httpMethod")).isEqualTo("GET"); + // the URI is the @Path template of HelloClient#hello, not the concrete /hello/world path + assertThat(success.getString("uri")).isEqualTo("/hello/{name}"); + assertThat(success.getString("address")).startsWith("localhost:"); + assertThat(success.getString("clientName")).isEqualTo("hello"); + assertThat(success.getString("failure")).isNull(); + assertThat(success.getDuration()).isPositive(); + + // the client event shares the trace ID of the quarkus.Rest server event for GET /exercise that triggered it + RecordedEvent server = recordedEvents.stream() + .filter(e -> e.getEventType().getName().equals("quarkus.Rest")) + .filter(e -> e.getString("uri").equals("/exercise")) + .findFirst().orElseThrow(); + assertThat(success.getString("traceId")).isNotNull().isEqualTo(server.getString("traceId")); + + // 2) the failed call: GET http://localhost:1/hello/world made by the "unreachable" client, no response received + RecordedEvent failure = clientEvents.stream().filter(e -> e.getInt("status") == 0).findFirst().orElseThrow(); + assertThat(failure.getString("httpMethod")).isEqualTo("GET"); + // the @Path template is recorded even though the request never reached a server + assertThat(failure.getString("uri")).isEqualTo("/hello/{name}"); + assertThat(failure.getString("address")).isEqualTo("localhost:1"); + assertThat(failure.getString("clientName")).isEqualTo("unreachable"); + assertThat(failure.getString("failure")).contains("ConnectException"); + } finally { + Files.deleteIfExists(dumpPath); + } + } + + @RegisterRestClient(configKey = "hello") + public interface HelloClient { + + @GET + @Path("/hello/{name}") + String hello(@PathParam("name") String name); + } + + @RegisterRestClient(configKey = "unreachable") + public interface UnreachableClient { + + @GET + @Path("/hello/{name}") + String hello(@PathParam("name") String name); + } + + @RegisterRestClient(configKey = "streaming") + public interface StreamingClient { + + @GET + @Path("/stream") + @Produces(MediaType.SERVER_SENT_EVENTS) + Multi stream(); + } + + @Path("/") + public static class Resource { + + @RestClient + HelloClient helloClient; + + @RestClient + UnreachableClient unreachableClient; + + @RestClient + StreamingClient streamingClient; + + @GET + @Path("/hello/{name}") + public String hello(@PathParam("name") String name) { + return "Hello " + name; + } + + @GET + @Path("/stream") + @Produces(MediaType.SERVER_SENT_EVENTS) + public Multi stream() { + return Multi.createFrom().items("a", "b", "c"); + } + + @GET + @Path("/exercise") + public String exercise() { + return helloClient.hello("world"); + } + + @GET + @Path("/exercise-failure") + public String exerciseFailure() { + try { + return unreachableClient.hello("world"); + } catch (ProcessingException e) { + return "failed"; + } + } + + @GET + @Path("/exercise-stream") + public String exerciseStream() { + return String.join(",", streamingClient.stream().collect().asList().await().atMost(Duration.ofSeconds(5))); + } + } +} diff --git a/extensions/jfr/runtime/pom.xml b/extensions/jfr/runtime/pom.xml index 6700d95b33918..50f9ed9b91b5f 100644 --- a/extensions/jfr/runtime/pom.xml +++ b/extensions/jfr/runtime/pom.xml @@ -25,6 +25,11 @@ quarkus-rest provided + + io.quarkus + quarkus-rest-client + provided + diff --git a/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/internal/JfrRecorder.java b/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/internal/JfrRecorder.java index c815042914dbd..7d32e5471cedd 100644 --- a/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/internal/JfrRecorder.java +++ b/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/internal/JfrRecorder.java @@ -9,6 +9,7 @@ import io.quarkus.jfr.runtime.internal.http.rest.RestEndEvent; import io.quarkus.jfr.runtime.internal.http.rest.RestPeriodEvent; import io.quarkus.jfr.runtime.internal.http.rest.RestStartEvent; +import io.quarkus.jfr.runtime.internal.http.rest.client.RestClientPeriodEvent; import io.quarkus.jfr.runtime.internal.runtime.QuarkusRuntimeInfo; import io.quarkus.runtime.ImageMode; import io.quarkus.runtime.RuntimeValue; @@ -36,6 +37,10 @@ public void runtimeInit() { logger.info("quarkus-jfr for REST server is disabled at runtime"); this.disabledRestJfr(); } + if (!runtimeConfig.getValue().restClientEnabled()) { + logger.info("quarkus-jfr for REST Client is disabled at runtime"); + this.disabledRestClientJfr(); + } } } @@ -45,8 +50,13 @@ public void disabledRestJfr() { FlightRecorder.unregister(RestPeriodEvent.class); } + public void disabledRestClientJfr() { + FlightRecorder.unregister(RestClientPeriodEvent.class); + } + public void disabledQuarkusJfr() { this.disabledRestJfr(); + this.disabledRestClientJfr(); } public Supplier quarkusInfoSupplier(String version, List features) { diff --git a/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/internal/config/JfrRuntimeConfig.java b/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/internal/config/JfrRuntimeConfig.java index 06f9b3ad440b3..971d138da56c3 100644 --- a/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/internal/config/JfrRuntimeConfig.java +++ b/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/internal/config/JfrRuntimeConfig.java @@ -28,6 +28,16 @@ public interface JfrRuntimeConfig { @WithDefault("true") boolean restEnabled(); + /** + * If false, only REST Client events in quarkus-jfr are not recorded even if JFR is enabled. + * In this case, other quarkus-jfr, Java standard API and virtual machine information will be recorded according to the + * setting. + * Default value is true + */ + @WithName("rest-client.enabled") + @WithDefault("true") + boolean restClientEnabled(); + /** * If false, only Runtime events in quarkus-jfr are not recorded even if JFR is enabled. * In this case, other quarkus-jfr, Java standard API and virtual machine information will be recorded according to the diff --git a/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/internal/http/rest/client/RestClientFilter.java b/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/internal/http/rest/client/RestClientFilter.java new file mode 100644 index 0000000000000..44d4dd96c867c --- /dev/null +++ b/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/internal/http/rest/client/RestClientFilter.java @@ -0,0 +1,99 @@ +package io.quarkus.jfr.runtime.internal.http.rest.client; + +import java.net.URI; + +import jakarta.ws.rs.ProcessingException; +import jakarta.ws.rs.client.ClientResponseContext; +import jakarta.ws.rs.ext.Provider; + +import org.jboss.resteasy.reactive.client.impl.RestClientRequestContext; +import org.jboss.resteasy.reactive.client.spi.ResteasyReactiveClientRequestContext; +import org.jboss.resteasy.reactive.client.spi.ResteasyReactiveClientRequestFilter; +import org.jboss.resteasy.reactive.client.spi.ResteasyReactiveClientResponseFilter; + +import io.quarkus.arc.Arc; +import io.quarkus.jfr.api.IdProducer; +import io.vertx.core.http.HttpClientOptions; + +/** + * Records a {@link RestClientPeriodEvent} for every REST Client invocation. + *

+ * The event is started before the request is sent and committed once the response has been received + * or the request has failed. Streaming responses (Multi, SSE) are not recorded as their duration is + * not well-defined. + */ +@Provider +public class RestClientFilter implements ResteasyReactiveClientRequestFilter, ResteasyReactiveClientResponseFilter { + + private static final String JFR_EVENT_PROPERTY = "quarkus.jfr.restClientEvent"; + private static final String URL_PATH_TEMPLATE_PROPERTY = "UrlPathTemplate"; + private static final String CLIENT_NAME_SEPARATOR = "|"; + + private final IdProducer idProducer; + + public RestClientFilter(IdProducer idProducer) { + this.idProducer = idProducer; + } + + @Override + public void filter(ResteasyReactiveClientRequestContext requestContext) { + // streaming invocations (Multi, SSE) do not register a body handler as the body is consumed as it arrives + if (!requestContext.unwrap(RestClientRequestContext.class).isRegisterBodyHandler()) { + return; + } + RestClientPeriodEvent event = new RestClientPeriodEvent(); + // the IDs are captured now because the request scope is not active anymore when the response arrives + if (Arc.container().requestContext().isActive()) { + event.traceId = idProducer.getTraceId(); + event.spanId = idProducer.getSpanId(); + } + event.begin(); + requestContext.setProperty(JFR_EVENT_PROPERTY, event); + } + + @Override + public void filter(ResteasyReactiveClientRequestContext requestContext, ClientResponseContext responseContext) { + RestClientPeriodEvent event = (RestClientPeriodEvent) requestContext.getProperty(JFR_EVENT_PROPERTY); + if (event == null) { + return; + } + event.end(); + if (!event.shouldCommit()) { + return; + } + event.httpMethod = requestContext.getMethod(); + URI uri = requestContext.getUri(); + String pathTemplate = (String) requestContext.getProperty(URL_PATH_TEMPLATE_PROPERTY); + event.uri = pathTemplate != null ? pathTemplate : uri.getPath(); + event.address = uri.getPort() > 0 ? uri.getHost() + ":" + uri.getPort() : uri.getHost(); + event.clientName = clientName(requestContext); + event.status = responseContext.getStatus(); + event.failure = effectiveFailureClassName(requestContext); + event.commit(); + } + + private static String clientName(ResteasyReactiveClientRequestContext requestContext) { + HttpClientOptions httpClientOptions = requestContext.unwrap(HttpClientOptions.class); + if (httpClientOptions == null) { + return null; + } + // the REST Client sets the metrics name to "rest-client|" + String metricsName = httpClientOptions.getMetricsName(); + if (metricsName == null || !metricsName.contains(CLIENT_NAME_SEPARATOR)) { + return null; + } + return metricsName.substring(metricsName.indexOf(CLIENT_NAME_SEPARATOR) + 1); + } + + private static String effectiveFailureClassName(ResteasyReactiveClientRequestContext requestContext) { + Throwable throwable = requestContext.unwrap(RestClientRequestContext.class).getThrowable(); + if (throwable == null) { + return null; + } + // the actual I/O failure is more informative than the ProcessingException wrapping it + if (throwable instanceof ProcessingException && throwable.getCause() != null) { + throwable = throwable.getCause(); + } + return throwable.getClass().getName(); + } +} diff --git a/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/internal/http/rest/client/RestClientPeriodEvent.java b/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/internal/http/rest/client/RestClientPeriodEvent.java new file mode 100644 index 0000000000000..8ed60dbfddcc5 --- /dev/null +++ b/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/internal/http/rest/client/RestClientPeriodEvent.java @@ -0,0 +1,52 @@ +package io.quarkus.jfr.runtime.internal.http.rest.client; + +import io.quarkus.jfr.api.SpanIdRelational; +import io.quarkus.jfr.api.TraceIdRelational; +import jdk.jfr.Category; +import jdk.jfr.Description; +import jdk.jfr.Event; +import jdk.jfr.Label; +import jdk.jfr.Name; +import jdk.jfr.StackTrace; + +@Label("REST Client") +@Category({ "Quarkus", "HTTP" }) +@Name("quarkus.RestClient") +@Description("REST Client has been invoking a remote endpoint during this period") +@StackTrace(false) +public class RestClientPeriodEvent extends Event { + + @Label("Trace ID") + @Description("Trace ID to identify the request") + @TraceIdRelational + String traceId; + + @Label("Span ID") + @Description("Span ID to identify the request if necessary") + @SpanIdRelational + String spanId; + + @Label("HTTP Method") + @Description("HTTP Method of the outgoing request") + String httpMethod; + + @Label("URI") + @Description("URI path template of the invoked REST Client method, or the URI path if no template is available") + String uri; + + @Label("Address") + @Description("Host and port of the remote endpoint") + String address; + + @Label("Client Name") + @Description("Config key of the REST Client that sent the request") + String clientName; + + @Label("Status") + @Description("HTTP status code of the response, or 0 if no response was received") + int status; + + @Label("Failure") + @Description("Class name of the exception if the request failed") + String failure; +} diff --git a/extensions/resteasy-reactive/rest-client-jaxrs/deployment/src/main/java/io/quarkus/jaxrs/client/reactive/deployment/JaxrsClientReactiveProcessor.java b/extensions/resteasy-reactive/rest-client-jaxrs/deployment/src/main/java/io/quarkus/jaxrs/client/reactive/deployment/JaxrsClientReactiveProcessor.java index e52b5c894be9a..d738d4fbc7378 100644 --- a/extensions/resteasy-reactive/rest-client-jaxrs/deployment/src/main/java/io/quarkus/jaxrs/client/reactive/deployment/JaxrsClientReactiveProcessor.java +++ b/extensions/resteasy-reactive/rest-client-jaxrs/deployment/src/main/java/io/quarkus/jaxrs/client/reactive/deployment/JaxrsClientReactiveProcessor.java @@ -424,6 +424,7 @@ public void accept(EndpointIndexer.ResourceMethodCallbackEntry entry) { .build(); boolean observabilityIntegrationNeeded = (capabilities.isPresent(Capability.OPENTELEMETRY_TRACER) || + capabilities.isPresent(Capability.JFR) || (metricsCapability.isPresent() && metricsCapability.get().metricsSupported(MetricsFactory.MICROMETER))); diff --git a/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/ClientRequestContextImpl.java b/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/ClientRequestContextImpl.java index b726683883f50..849a0feb00ba0 100644 --- a/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/ClientRequestContextImpl.java +++ b/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/ClientRequestContextImpl.java @@ -106,11 +106,11 @@ public void setCallStatsCollector(ServiceInstance statCollectingServiceInstance) @Override public T unwrap(Class theType) { if (theType == RestClientRequestContext.class) { - return (T) restClientRequestContext; + return theType.cast(restClientRequestContext); } else if (theType == HttpClientRequest.class) { - return (T) restClientRequestContext.getHttpClientRequest(); + return theType.cast(restClientRequestContext.getHttpClientRequest()); } else if (theType == HttpClientOptions.class) { - return (T) restClientRequestContext.getHttpClientOptions(); + return theType.cast(restClientRequestContext.getHttpClientOptions()); } return null; } From 6eb4df04e63d8c1e98f44426e321a3f5c8647487 Mon Sep 17 00:00:00 2001 From: Georgios Andrianakis Date: Tue, 13 Oct 2026 11:55:27 +0300 Subject: [PATCH 186/254] Remove bytecode recorder from JFR --- .../quarkus/jfr/deployment/JfrProcessor.java | 34 ++++--- .../jfr/runtime/internal/JfrRecorder.java | 90 ------------------- .../jfr/runtime/internal/JfrRuntimeSetup.java | 49 ++++++++++ .../internal/runtime/QuarkusRuntimeInfo.java | 27 ++++++ 4 files changed, 99 insertions(+), 101 deletions(-) delete mode 100644 extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/internal/JfrRecorder.java create mode 100644 extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/internal/JfrRuntimeSetup.java diff --git a/extensions/jfr/deployment/src/main/java/io/quarkus/jfr/deployment/JfrProcessor.java b/extensions/jfr/deployment/src/main/java/io/quarkus/jfr/deployment/JfrProcessor.java index 4611dc3d4b2aa..d512936c840df 100644 --- a/extensions/jfr/deployment/src/main/java/io/quarkus/jfr/deployment/JfrProcessor.java +++ b/extensions/jfr/deployment/src/main/java/io/quarkus/jfr/deployment/JfrProcessor.java @@ -7,20 +7,21 @@ import io.quarkus.arc.deployment.AdditionalBeanBuildItem; import io.quarkus.arc.deployment.SyntheticBeanBuildItem; import io.quarkus.builder.Version; +import io.quarkus.core.deployment.service.ServiceRegistrar; import io.quarkus.deployment.Capabilities; import io.quarkus.deployment.Capability; import io.quarkus.deployment.Feature; import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; import io.quarkus.deployment.annotations.BuildSteps; -import io.quarkus.deployment.annotations.ExecutionTime; -import io.quarkus.deployment.annotations.Record; import io.quarkus.deployment.builditem.AdditionalIndexedClassesBuildItem; import io.quarkus.deployment.builditem.FeatureBuildItem; +import io.quarkus.deployment.builditem.ServiceStartBuildItem; import io.quarkus.deployment.builditem.nativeimage.RuntimeInitializedClassBuildItem; -import io.quarkus.jfr.runtime.internal.JfrRecorder; +import io.quarkus.jfr.runtime.internal.JfrRuntimeSetup; import io.quarkus.jfr.runtime.internal.OTelIdProducer; import io.quarkus.jfr.runtime.internal.QuarkusIdProducer; +import io.quarkus.jfr.runtime.internal.config.JfrRuntimeConfig; import io.quarkus.jfr.runtime.internal.http.rest.classic.ClassicServerEventRecorderProducer; import io.quarkus.jfr.runtime.internal.http.rest.classic.ClassicServerFilter; import io.quarkus.jfr.runtime.internal.http.rest.client.RestClientFilter; @@ -43,18 +44,26 @@ FeatureBuildItem feature() { } @BuildStep - @Record(ExecutionTime.RUNTIME_INIT) - void registerVersion(List features, BuildProducer additionalBeans, - BuildProducer beanProduce, JfrRecorder recorder) { + void registerVersion(List features, ServiceRegistrar serviceRegistrar, + BuildProducer additionalBeans, + BuildProducer beanProduce, + BuildProducer serviceStart) { - List featureNames = features.stream().map(f -> f.getName()).toList(); + List featureNames = features.stream().map(FeatureBuildItem::getName).toList(); String quarkusVersion = Version.getVersion(); + serviceRegistrar + .forService(QuarkusRuntimeInfo.class) + .onStart(ctx -> QuarkusRuntimeInfo.create(quarkusVersion, featureNames)); + beanProduce.produce(SyntheticBeanBuildItem.configure(QuarkusRuntimeInfo.class) - .supplier(recorder.quarkusInfoSupplier(quarkusVersion, featureNames)) + .serviceValue(QuarkusRuntimeInfo.class) .scope(ApplicationScoped.class) .setRuntimeInit().done()); + // makes sure the service has run before JfrRuntimeBean reads the bean in its StartupEvent observer + serviceStart.produce(new ServiceStartBuildItem("jfr-runtime-info")); + additionalBeans.produce(AdditionalBeanBuildItem.builder().setUnremovable() .addBeanClasses(JfrRuntimeBean.class) .build()); @@ -134,8 +143,11 @@ void registerResteasyClassicIntegration(Capabilities capabilities, } @BuildStep - @Record(ExecutionTime.RUNTIME_INIT) - public void runtimeInit(JfrRecorder recorder) { - recorder.runtimeInit(); + ServiceStartBuildItem runtimeInit(ServiceRegistrar serviceRegistrar) { + serviceRegistrar + .forService("io.quarkus.jfr.disable-events") + .require(JfrRuntimeConfig.class) + .onStart((ctx, runtimeConfig) -> JfrRuntimeSetup.disableEvents(runtimeConfig)); + return new ServiceStartBuildItem(Feature.JFR); } } diff --git a/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/internal/JfrRecorder.java b/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/internal/JfrRecorder.java deleted file mode 100644 index 7d32e5471cedd..0000000000000 --- a/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/internal/JfrRecorder.java +++ /dev/null @@ -1,90 +0,0 @@ -package io.quarkus.jfr.runtime.internal; - -import java.util.List; -import java.util.function.Supplier; - -import org.jboss.logging.Logger; - -import io.quarkus.jfr.runtime.internal.config.JfrRuntimeConfig; -import io.quarkus.jfr.runtime.internal.http.rest.RestEndEvent; -import io.quarkus.jfr.runtime.internal.http.rest.RestPeriodEvent; -import io.quarkus.jfr.runtime.internal.http.rest.RestStartEvent; -import io.quarkus.jfr.runtime.internal.http.rest.client.RestClientPeriodEvent; -import io.quarkus.jfr.runtime.internal.runtime.QuarkusRuntimeInfo; -import io.quarkus.runtime.ImageMode; -import io.quarkus.runtime.RuntimeValue; -import io.quarkus.runtime.annotations.Recorder; -import io.quarkus.runtime.configuration.ConfigUtils; -import jdk.jfr.FlightRecorder; - -@Recorder -public class JfrRecorder { - private final RuntimeValue runtimeConfig; - - public JfrRecorder(final RuntimeValue runtimeConfig) { - this.runtimeConfig = runtimeConfig; - } - - public void runtimeInit() { - - Logger logger = Logger.getLogger(JfrRecorder.class); - - if (!runtimeConfig.getValue().enabled()) { - logger.info("quarkus-jfr is disabled at runtime"); - this.disabledQuarkusJfr(); - } else { - if (!runtimeConfig.getValue().restEnabled()) { - logger.info("quarkus-jfr for REST server is disabled at runtime"); - this.disabledRestJfr(); - } - if (!runtimeConfig.getValue().restClientEnabled()) { - logger.info("quarkus-jfr for REST Client is disabled at runtime"); - this.disabledRestClientJfr(); - } - } - } - - public void disabledRestJfr() { - FlightRecorder.unregister(RestStartEvent.class); - FlightRecorder.unregister(RestEndEvent.class); - FlightRecorder.unregister(RestPeriodEvent.class); - } - - public void disabledRestClientJfr() { - FlightRecorder.unregister(RestClientPeriodEvent.class); - } - - public void disabledQuarkusJfr() { - this.disabledRestJfr(); - this.disabledRestClientJfr(); - } - - public Supplier quarkusInfoSupplier(String version, List features) { - return new Supplier<>() { - @Override - public QuarkusRuntimeInfo get() { - return new QuarkusRuntimeInfo() { - @Override - public String imageMode() { - return ImageMode.current().name(); - } - - @Override - public String profiles() { - return String.join(",", ConfigUtils.getProfiles()); - } - - @Override - public String version() { - return version; - } - - @Override - public List features() { - return features; - } - }; - } - }; - } -} diff --git a/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/internal/JfrRuntimeSetup.java b/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/internal/JfrRuntimeSetup.java new file mode 100644 index 0000000000000..4307d5afd0a28 --- /dev/null +++ b/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/internal/JfrRuntimeSetup.java @@ -0,0 +1,49 @@ +package io.quarkus.jfr.runtime.internal; + +import org.jboss.logging.Logger; + +import io.quarkus.jfr.runtime.internal.config.JfrRuntimeConfig; +import io.quarkus.jfr.runtime.internal.http.rest.RestEndEvent; +import io.quarkus.jfr.runtime.internal.http.rest.RestPeriodEvent; +import io.quarkus.jfr.runtime.internal.http.rest.RestStartEvent; +import io.quarkus.jfr.runtime.internal.http.rest.client.RestClientPeriodEvent; +import jdk.jfr.FlightRecorder; + +public final class JfrRuntimeSetup { + + private static final Logger LOG = Logger.getLogger(JfrRuntimeSetup.class); + + private JfrRuntimeSetup() { + } + + public static void disableEvents(JfrRuntimeConfig runtimeConfig) { + if (!runtimeConfig.enabled()) { + LOG.info("quarkus-jfr is disabled at runtime"); + disableQuarkusJfr(); + } else { + if (!runtimeConfig.restEnabled()) { + LOG.info("quarkus-jfr for REST server is disabled at runtime"); + disableRestJfr(); + } + if (!runtimeConfig.restClientEnabled()) { + LOG.info("quarkus-jfr for REST Client is disabled at runtime"); + disableRestClientJfr(); + } + } + } + + private static void disableRestJfr() { + FlightRecorder.unregister(RestStartEvent.class); + FlightRecorder.unregister(RestEndEvent.class); + FlightRecorder.unregister(RestPeriodEvent.class); + } + + private static void disableRestClientJfr() { + FlightRecorder.unregister(RestClientPeriodEvent.class); + } + + private static void disableQuarkusJfr() { + disableRestJfr(); + disableRestClientJfr(); + } +} diff --git a/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/internal/runtime/QuarkusRuntimeInfo.java b/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/internal/runtime/QuarkusRuntimeInfo.java index a18ec278c0e9e..091e534ade5ae 100644 --- a/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/internal/runtime/QuarkusRuntimeInfo.java +++ b/extensions/jfr/runtime/src/main/java/io/quarkus/jfr/runtime/internal/runtime/QuarkusRuntimeInfo.java @@ -2,6 +2,9 @@ import java.util.List; +import io.quarkus.runtime.ImageMode; +import io.quarkus.runtime.configuration.ConfigUtils; + public interface QuarkusRuntimeInfo { String imageMode(); @@ -11,4 +14,28 @@ public interface QuarkusRuntimeInfo { String version(); List features(); + + static QuarkusRuntimeInfo create(String version, List features) { + return new QuarkusRuntimeInfo() { + @Override + public String imageMode() { + return ImageMode.current().name(); + } + + @Override + public String profiles() { + return String.join(",", ConfigUtils.getProfiles()); + } + + @Override + public String version() { + return version; + } + + @Override + public List features() { + return features; + } + }; + } } From bdca0eed0f6ebcfab63c83efda6f60174170f00b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:35:52 +0000 Subject: [PATCH 187/254] Build(deps): Bump org.bouncycastle:bc-jdk18on-bom from 1.86.1 to 1.86.2 Bumps [org.bouncycastle:bc-jdk18on-bom](https://github.com/bcgit/bc-java) from 1.86.1 to 1.86.2. - [Changelog](https://github.com/bcgit/bc-java/blob/main/docs/releasenotes.md) - [Commits](https://github.com/bcgit/bc-java/commits) --- updated-dependencies: - dependency-name: org.bouncycastle:bc-jdk18on-bom dependency-version: 1.86.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- bom/application/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bom/application/pom.xml b/bom/application/pom.xml index 0dd2e10b6ce03..d119e6e85af9d 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -16,7 +16,7 @@ 2.0.3 2.0.5 - 1.86.1 + 1.86.2 2.1.3 2.1.25 13.2.0 From dc6c6ada1c30e093e68a070a093b71768524aa4a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:36:20 +0000 Subject: [PATCH 188/254] Build(deps): Bump org.commonmark:commonmark from 0.30.0 to 0.30.1 Bumps [org.commonmark:commonmark](https://github.com/commonmark/commonmark-java) from 0.30.0 to 0.30.1. - [Release notes](https://github.com/commonmark/commonmark-java/releases) - [Changelog](https://github.com/commonmark/commonmark-java/blob/main/CHANGELOG.md) - [Commits](https://github.com/commonmark/commonmark-java/compare/commonmark-parent-0.30.0...commonmark-parent-0.30.1) --- updated-dependencies: - dependency-name: org.commonmark:commonmark dependency-version: 0.30.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- build-parent/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build-parent/pom.xml b/build-parent/pom.xml index 04d33489287b5..0cace1906dafc 100644 --- a/build-parent/pom.xml +++ b/build-parent/pom.xml @@ -39,7 +39,7 @@ 2.3.35 - 0.30.0 + 0.30.1 7.8.0.202609011348-r From 0cdc56b67f8f203598a67a3e3208898285af71a9 Mon Sep 17 00:00:00 2001 From: jnbdz <237008+jnbdz@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:07:20 -0400 Subject: [PATCH 189/254] Register the Kubernetes Config secrets warning as a service The warning about secrets being read while secrets access is disabled now runs as a CONFIG phase service instead of a recorder call. A test checks that the warning is still logged. --- .../kubernetes-config/deployment/pom.xml | 5 ++++ .../deployment/KubernetesConfigProcessor.java | 15 ++++++---- .../KubernetesConfigSecretsWarningTest.java | 28 +++++++++++++++++++ .../runtime/KubernetesConfigRecorder.java | 24 ++++------------ 4 files changed, 48 insertions(+), 24 deletions(-) create mode 100644 extensions/kubernetes-config/deployment/src/test/java/io/quarkus/kubernetes/config/deployment/KubernetesConfigSecretsWarningTest.java diff --git a/extensions/kubernetes-config/deployment/pom.xml b/extensions/kubernetes-config/deployment/pom.xml index e97def48c326f..1f6bb7643b9e2 100644 --- a/extensions/kubernetes-config/deployment/pom.xml +++ b/extensions/kubernetes-config/deployment/pom.xml @@ -22,6 +22,11 @@ io.quarkus quarkus-kubernetes-config + + io.quarkus + quarkus-junit-internal + test + diff --git a/extensions/kubernetes-config/deployment/src/main/java/io/quarkus/kubernetes/config/deployment/KubernetesConfigProcessor.java b/extensions/kubernetes-config/deployment/src/main/java/io/quarkus/kubernetes/config/deployment/KubernetesConfigProcessor.java index 22b936f8bdb2d..82bd8824f3efe 100644 --- a/extensions/kubernetes-config/deployment/src/main/java/io/quarkus/kubernetes/config/deployment/KubernetesConfigProcessor.java +++ b/extensions/kubernetes-config/deployment/src/main/java/io/quarkus/kubernetes/config/deployment/KubernetesConfigProcessor.java @@ -6,14 +6,15 @@ import org.eclipse.microprofile.config.ConfigProvider; import org.jboss.logging.Logger; +import io.quarkus.core.Phase; +import io.quarkus.core.deployment.service.ServiceRegistrar; import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; -import io.quarkus.deployment.annotations.ExecutionTime; -import io.quarkus.deployment.annotations.Record; import io.quarkus.deployment.builditem.ApplicationInfoBuildItem; import io.quarkus.deployment.builditem.RunTimeConfigBuilderBuildItem; import io.quarkus.kubernetes.config.runtime.KubernetesConfigBuildTimeConfig; import io.quarkus.kubernetes.config.runtime.KubernetesConfigRecorder; +import io.quarkus.kubernetes.config.runtime.KubernetesConfigSourceConfig; import io.quarkus.kubernetes.config.runtime.KubernetesConfigSourceFactoryBuilder; import io.quarkus.kubernetes.config.runtime.SecretsRoleConfig; import io.quarkus.kubernetes.spi.KubernetesClusterRoleBuildItem; @@ -40,7 +41,6 @@ void configFactory(BuildProducer runTimeConfigBui } @BuildStep - @Record(ExecutionTime.RUNTIME_INIT) public void handleAccessToSecrets( KubernetesConfigBuildTimeConfig buildTimeConfig, ApplicationInfoBuildItem applicationInfo, @@ -48,7 +48,7 @@ public void handleAccessToSecrets( BuildProducer clusterRoleProducer, BuildProducer serviceAccountProducer, BuildProducer roleBindingProducer, - KubernetesConfigRecorder recorder) { + ServiceRegistrar serviceRegistrar) { if (buildTimeConfig.secretsEnabled()) { SecretsRoleConfig roleConfig = buildTimeConfig.secretsRoleConfig(); @@ -91,6 +91,11 @@ public void handleAccessToSecrets( new Subject("", "ServiceAccount", null, null))); } - recorder.warnAboutSecrets(); + boolean secretsEnabled = buildTimeConfig.secretsEnabled(); + serviceRegistrar + .forService("io.quarkus.kubernetes-config.warn-about-secrets") + .atPhase(Phase.CONFIG) + .require(KubernetesConfigSourceConfig.class) + .onStart((ctx, runtimeConfig) -> KubernetesConfigRecorder.warnAboutSecrets(secretsEnabled, runtimeConfig)); } } diff --git a/extensions/kubernetes-config/deployment/src/test/java/io/quarkus/kubernetes/config/deployment/KubernetesConfigSecretsWarningTest.java b/extensions/kubernetes-config/deployment/src/test/java/io/quarkus/kubernetes/config/deployment/KubernetesConfigSecretsWarningTest.java new file mode 100644 index 0000000000000..dfc3beed10788 --- /dev/null +++ b/extensions/kubernetes-config/deployment/src/test/java/io/quarkus/kubernetes/config/deployment/KubernetesConfigSecretsWarningTest.java @@ -0,0 +1,28 @@ +package io.quarkus.kubernetes.config.deployment; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.logging.Level; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.kubernetes.config.runtime.KubernetesConfigRecorder; +import io.quarkus.test.QuarkusExtensionTest; + +public class KubernetesConfigSecretsWarningTest { + + @RegisterExtension + static final QuarkusExtensionTest config = new QuarkusExtensionTest() + .overrideConfigKey("quarkus.kubernetes-config.secrets", "my-secret") + .overrideConfigKey("quarkus.kubernetes-client.devservices.enabled", "false") + .setLogRecordPredicate(record -> KubernetesConfigRecorder.class.getName().equals(record.getLoggerName())) + .assertLogRecords(records -> assertTrue(records.stream().anyMatch( + record -> Level.WARNING.equals(record.getLevel()) + && record.getMessage().contains("quarkus.kubernetes-config.secrets.enabled is false")), + () -> "Expected the secrets warning, got " + records)); + + @Test + public void testWarningIsLogged() { + } +} diff --git a/extensions/kubernetes-config/runtime/src/main/java/io/quarkus/kubernetes/config/runtime/KubernetesConfigRecorder.java b/extensions/kubernetes-config/runtime/src/main/java/io/quarkus/kubernetes/config/runtime/KubernetesConfigRecorder.java index 23a76afcd811f..a15f103255606 100644 --- a/extensions/kubernetes-config/runtime/src/main/java/io/quarkus/kubernetes/config/runtime/KubernetesConfigRecorder.java +++ b/extensions/kubernetes-config/runtime/src/main/java/io/quarkus/kubernetes/config/runtime/KubernetesConfigRecorder.java @@ -2,28 +2,14 @@ import org.jboss.logging.Logger; -import io.quarkus.runtime.RuntimeValue; -import io.quarkus.runtime.annotations.Recorder; - -@Recorder public class KubernetesConfigRecorder { private static final Logger log = Logger.getLogger(KubernetesConfigRecorder.class); - private final KubernetesConfigBuildTimeConfig buildTimeConfig; - private final RuntimeValue runtimeConfig; - - public KubernetesConfigRecorder( - final KubernetesConfigBuildTimeConfig buildTimeConfig, - final RuntimeValue runtimeConfig) { - this.buildTimeConfig = buildTimeConfig; - this.runtimeConfig = runtimeConfig; - } - - public void warnAboutSecrets() { - if (runtimeConfig.getValue().secrets().isPresent() - && !runtimeConfig.getValue().secrets().get().isEmpty() - && !buildTimeConfig.secretsEnabled()) { - log.warn("Configuration is read from Secrets " + runtimeConfig.getValue().secrets().get() + public static void warnAboutSecrets(boolean secretsEnabled, KubernetesConfigSourceConfig runtimeConfig) { + if (runtimeConfig.secrets().isPresent() + && !runtimeConfig.secrets().get().isEmpty() + && !secretsEnabled) { + log.warn("Configuration is read from Secrets " + runtimeConfig.secrets().get() + ", but quarkus.kubernetes-config.secrets.enabled is false." + " Check if your application's service account has enough permissions to read secrets."); } From 38f90da1512ffea99255ce4c63cbcc96e46ad1bf Mon Sep 17 00:00:00 2001 From: Foivos Zakkak Date: Mon, 12 Oct 2026 17:16:38 +0000 Subject: [PATCH 190/254] Use the public GraalVM bootstrap registration API in GraalVM 25.4 GraalVM 25.4 exposes bootstrap registration through `DuringSetupAccess` and removes the internal `IncludeList` methods. Generate public interface calls for newer versions while retaining the legacy API for earlier versions. Document registration for extension authors. Fixes #57371 --- .../steps/NativeImageFeatureStep.java | 31 +++++++++++++++---- .../src/main/asciidoc/writing-extensions.adoc | 5 +++ 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/core/deployment/src/main/java/io/quarkus/deployment/steps/NativeImageFeatureStep.java b/core/deployment/src/main/java/io/quarkus/deployment/steps/NativeImageFeatureStep.java index d75534483d6c6..ff664425127c9 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/steps/NativeImageFeatureStep.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/steps/NativeImageFeatureStep.java @@ -41,6 +41,7 @@ import io.quarkus.gizmo2.LocalVar; import io.quarkus.gizmo2.ParamVar; import io.quarkus.gizmo2.desc.ClassMethodDesc; +import io.quarkus.gizmo2.desc.InterfaceMethodDesc; import io.quarkus.gizmo2.desc.MethodDesc; import io.quarkus.modular.spi.items.AddDependencyBuildItem; import io.quarkus.runtime.LocalesBuildTimeConfig; @@ -87,6 +88,13 @@ public class NativeImageFeatureStep { private static final ClassMethodDesc MD_FeatureImpl_DuringSetupAccessImpl_registerBuildTimeIndyIncludeList = ClassMethodDesc .of(CD_FeatureImpl_DuringSetupAccessImpl, "registerBuildTimeIndyIncludeList", ConstantDescs.CD_void, CD_Executable); + private static final MethodDesc MD_DuringSetupAccess_registerBuildTimeBootstrapCondy = InterfaceMethodDesc.of( + ClassDesc.of(Feature.DuringSetupAccess.class.getName()), "registerBuildTimeBootstrapCondy", + ConstantDescs.CD_void, CD_Executable); + private static final MethodDesc MD_DuringSetupAccess_registerBuildTimeBootstrapIndy = InterfaceMethodDesc.of( + ClassDesc.of(Feature.DuringSetupAccess.class.getName()), "registerBuildTimeBootstrapIndy", + ConstantDescs.CD_void, CD_Executable); + /** * Exports the {@code com.oracle.svm.hosted} package from the GraalVM native image builder module * so that the generated feature class can access {@code FeatureImpl.DuringSetupAccessImpl} for @@ -203,14 +211,25 @@ void generateFeature(BuildProducer nativeIma b1.newArray(Class.class, mtd.parameterList().stream().map(Const::of).toList()))); if (condy) { - b1.invokeVirtual( - MD_FeatureImpl_DuringSetupAccessImpl_registerBuildTimeCondyIncludeList, - accessImpl, declaredMethod); + // GraalVM 25.4 moved bootstrap registration to the public Feature API. + b1.ifElse(b1.ge(b1.invokeVirtual(GRAALVM_VERSION_COMPARE_TO, graalVMVersion, + b1.newArray(CD_int, Const.of(25), Const.of(4))), 0), + b2 -> b2.invokeInterface( + MD_DuringSetupAccess_registerBuildTimeBootstrapCondy, + access, declaredMethod), + b2 -> b2.invokeVirtual( + MD_FeatureImpl_DuringSetupAccessImpl_registerBuildTimeCondyIncludeList, + accessImpl, declaredMethod)); } if (indy) { - b1.invokeVirtual( - MD_FeatureImpl_DuringSetupAccessImpl_registerBuildTimeIndyIncludeList, - accessImpl, declaredMethod); + b1.ifElse(b1.ge(b1.invokeVirtual(GRAALVM_VERSION_COMPARE_TO, graalVMVersion, + b1.newArray(CD_int, Const.of(25), Const.of(4))), 0), + b2 -> b2.invokeInterface( + MD_DuringSetupAccess_registerBuildTimeBootstrapIndy, + access, declaredMethod), + b2 -> b2.invokeVirtual( + MD_FeatureImpl_DuringSetupAccessImpl_registerBuildTimeIndyIncludeList, + accessImpl, declaredMethod)); } } }); diff --git a/docs/src/main/asciidoc/writing-extensions.adoc b/docs/src/main/asciidoc/writing-extensions.adoc index 92e3550821740..c678053553fbc 100644 --- a/docs/src/main/asciidoc/writing-extensions.adoc +++ b/docs/src/main/asciidoc/writing-extensions.adoc @@ -2670,6 +2670,11 @@ Includes a resource bundle in the native executable. `io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem`:: Registers a class for reflection in Substrate. Constructors are always registered, while methods and fields are optional. +`io.quarkus.deployment.builditem.nativeimage.ConstantBootstrapBuildItem`:: +Registers a bootstrap method for execution at native image build time, allowing GraalVM to constant-fold dynamic constants and `invokedynamic` call sites. +The third bootstrap parameter must be `Class` for dynamic constants, `MethodType` for `invokedynamic`, or `TypeDescriptor` for a method supporting both. +Quarkus uses the public bootstrap registration API on GraalVM 25.4 and later and the internal API on earlier versions that support registration (25.0.4 and later). + `io.quarkus.deployment.builditem.nativeimage.RuntimeInitializedClassBuildItem`:: A class that will be initialized at runtime rather than build time. This will cause the build to fail if the class is initialized as part of the native executable build process, so care must be taken. From 05375682ddc9d60e7bca1df350f02e5b5142404e Mon Sep 17 00:00:00 2001 From: George Gastaldi Date: Tue, 13 Oct 2026 20:30:09 -0300 Subject: [PATCH 191/254] Security JPA Reactive: restore the anonymous class lambda strategy The identity providers generated by `QuarkusSecurityJpaReactiveProcessor` are written through `GeneratedBeanGizmo2Adaptor`, which does not implement `ClassOutput.registerBootstrapMethod`. With Gizmo's default `OPTIMIZED` lambda strategy, the constant bootstraps of the generated lambdas are therefore never registered with GraalVM, and the `invokedynamic` call site tries to define the `$lambda` hidden class at runtime: java.lang.BootstrapMethodError: com.oracle.svm.core.jdk.UnsupportedFeatureError: Classes cannot be defined at runtime by default when using ahead-of-time Native Image compilation. Tried to define class 'org/acme/elytron/security/jpa/reactive/User__JpaReactiveIdentityProviderImpl$lambda' Restore `LambdaStrategy.ANONYMOUS_CLASS` here, as is already done for the other generators whose output goes through a bean adaptor. Fixes #57368 --- .../deployment/QuarkusSecurityJpaReactiveProcessor.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/extensions/security-jpa-reactive/deployment/src/main/java/io/quarkus/security/jpa/reactive/deployment/QuarkusSecurityJpaReactiveProcessor.java b/extensions/security-jpa-reactive/deployment/src/main/java/io/quarkus/security/jpa/reactive/deployment/QuarkusSecurityJpaReactiveProcessor.java index 002185f3ddd62..5a4bc46f5ceb3 100644 --- a/extensions/security-jpa-reactive/deployment/src/main/java/io/quarkus/security/jpa/reactive/deployment/QuarkusSecurityJpaReactiveProcessor.java +++ b/extensions/security-jpa-reactive/deployment/src/main/java/io/quarkus/security/jpa/reactive/deployment/QuarkusSecurityJpaReactiveProcessor.java @@ -33,6 +33,7 @@ import io.quarkus.gizmo2.Const; import io.quarkus.gizmo2.Expr; import io.quarkus.gizmo2.Gizmo; +import io.quarkus.gizmo2.LambdaStrategy; import io.quarkus.gizmo2.LocalVar; import io.quarkus.gizmo2.Var; import io.quarkus.gizmo2.creator.BlockCreator; @@ -94,7 +95,8 @@ private static void generateIdentityProvider(Index index, JpaSecurityDefinition GeneratedBeanGizmo2Adaptor gizmoAdaptor = new GeneratedBeanGizmo2Adaptor(beanProducer); String name = jpaSecurityDefinition.annotatedClass.name() + "__JpaReactiveIdentityProviderImpl"; - Gizmo.create(gizmoAdaptor).class_(name, cc -> { + // todo: the gizmo-to-bean adapter doesn't know about constant bootstraps; use anon classes for lambdas for now + Gizmo.create(gizmoAdaptor).withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS).class_(name, cc -> { cc.extends_(JpaReactiveIdentityProvider.class); cc.addAnnotation(Singleton.class); cc.defaultConstructor(); @@ -141,7 +143,8 @@ private static void generateTrustedIdentityProvider(Index index, JpaSecurityDefi GeneratedBeanGizmo2Adaptor gizmoAdaptor = new GeneratedBeanGizmo2Adaptor(beanProducer); String name = jpaSecurityDefinition.annotatedClass.name() + "__JpaReactiveTrustedIdentityProviderImpl"; - Gizmo.create(gizmoAdaptor).class_(name, cc -> { + // todo: the gizmo-to-bean adapter doesn't know about constant bootstraps; use anon classes for lambdas for now + Gizmo.create(gizmoAdaptor).withLambdaStrategy(LambdaStrategy.ANONYMOUS_CLASS).class_(name, cc -> { cc.extends_(JpaReactiveTrustedIdentityProvider.class); cc.addAnnotation(Singleton.class); cc.defaultConstructor(); From c55fea831e881c76941dcb45ff59986474a1da63 Mon Sep 17 00:00:00 2001 From: jnbdz <237008+jnbdz@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:32:04 -0400 Subject: [PATCH 192/254] Let WebSockets Next use Mutiny and Vert.x internals in a jlink image A WebSockets Next application packaged with quarkus-jlink accepted the upgrade but failed to create the endpoint instance with NoClassDefFoundError: io/smallrye/mutiny/helpers/queues/Queues. The extension uses that Mutiny helper package and io.vertx.core.buffer.impl, which the Mutiny and Vert.x module descriptors do not export to it. --- .../websockets/next/deployment/WebSocketProcessor.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/extensions/websockets-next/deployment/src/main/java/io/quarkus/websockets/next/deployment/WebSocketProcessor.java b/extensions/websockets-next/deployment/src/main/java/io/quarkus/websockets/next/deployment/WebSocketProcessor.java index f87ee8d4d928f..95be50d4a6bf9 100644 --- a/extensions/websockets-next/deployment/src/main/java/io/quarkus/websockets/next/deployment/WebSocketProcessor.java +++ b/extensions/websockets-next/deployment/src/main/java/io/quarkus/websockets/next/deployment/WebSocketProcessor.java @@ -94,6 +94,7 @@ import io.quarkus.deployment.builditem.GeneratedClassBuildItem; import io.quarkus.deployment.builditem.GeneratedResourceBuildItem; import io.quarkus.deployment.builditem.GeneratedServiceProviderBuildItem; +import io.quarkus.deployment.builditem.ModuleExportBuildItem; import io.quarkus.deployment.builditem.SystemPropertyBuildItem; import io.quarkus.deployment.builditem.nativeimage.ConstantBootstrapBuildItem; import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; @@ -195,6 +196,14 @@ public class WebSocketProcessor { private static final Pattern PATH_PARAM_PATTERN = Pattern.compile("\\{[a-zA-Z0-9_]+\\}"); public static final Pattern TRANSLATED_PATH_PARAM_PATTERN = Pattern.compile(":[a-zA-Z0-9_]+"); + @BuildStep + void exportInternals(BuildProducer exports) { + exports.produce(new ModuleExportBuildItem("io.smallrye.mutiny", "io.quarkus.websockets.next", + "io.smallrye.mutiny.helpers.queues")); + exports.produce(new ModuleExportBuildItem("io.vertx.core", "io.quarkus.websockets.next", + "io.vertx.core.buffer.impl")); + } + @BuildStep FeatureBuildItem feature() { return new FeatureBuildItem("websockets-next"); From 5699351acb96868a8d77c368aa1151815dd1d7a7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:33:29 +0000 Subject: [PATCH 193/254] Build(deps): Bump org.mvnpm:type-fest from 5.8.0 to 5.10.0 Bumps [org.mvnpm:type-fest](https://github.com/sindresorhus/type-fest) from 5.8.0 to 5.10.0. - [Release notes](https://github.com/sindresorhus/type-fest/releases) - [Commits](https://github.com/sindresorhus/type-fest/compare/v5.8.0...v5.10.0) --- updated-dependencies: - dependency-name: org.mvnpm:type-fest dependency-version: 5.10.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- bom/dev-ui/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bom/dev-ui/pom.xml b/bom/dev-ui/pom.xml index 49afe3a277706..4804219da0f87 100644 --- a/bom/dev-ui/pom.xml +++ b/bom/dev-ui/pom.xml @@ -35,7 +35,7 @@ 6.0.0 2.8.4 6.3.0 - 5.8.0 + 5.10.0 1.0.0 1.1.2 1.0.6 From 652135e3ac8d55e6407f7167429893aa83fe5a23 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 07:25:41 +0000 Subject: [PATCH 194/254] Build(deps): Bump runs-on/action from 2.3.1 to 2.4.0 Bumps [runs-on/action](https://github.com/runs-on/action) from 2.3.1 to 2.4.0. - [Release notes](https://github.com/runs-on/action/releases) - [Commits](https://github.com/runs-on/action/compare/efac073ea2507ec18797de3a81704201ade11d9d...dfae4d98c5537a0dc7bbb7e6b9211f30ba240f6d) --- updated-dependencies: - dependency-name: runs-on/action dependency-version: 2.4.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/ci-actions-incremental.yml | 28 ++++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci-actions-incremental.yml b/.github/workflows/ci-actions-incremental.yml index d22aa5fa9c437..4ea8bf560823e 100644 --- a/.github/workflows/ci-actions-incremental.yml +++ b/.github/workflows/ci-actions-incremental.yml @@ -342,7 +342,7 @@ jobs: scalpel_impacted: ${{ steps.get-scalpel-config.outputs.impacted_modules }} scalpel_impacted_gav: ${{ steps.get-scalpel-config.outputs.impacted_modules_gav }} steps: - - uses: runs-on/action@efac073ea2507ec18797de3a81704201ade11d9d # v2.3.1 + - uses: runs-on/action@dfae4d98c5537a0dc7bbb7e6b9211f30ba240f6d # v2.4.0 - name: Gradle Enterprise environment run: | echo "GE_TAGS=jdk-${{ needs.configure.outputs.base-jdk-version }}" >> "$GITHUB_ENV" @@ -534,7 +534,7 @@ jobs: run_quickstarts: ${{ steps.calc-run-flags.outputs.run_quickstarts }} run_tcks: ${{ steps.calc-run-flags.outputs.run_tcks }} steps: - - uses: runs-on/action@efac073ea2507ec18797de3a81704201ade11d9d # v2.3.1 + - uses: runs-on/action@dfae4d98c5537a0dc7bbb7e6b9211f30ba240f6d # v2.4.0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Calculate matrix from native-tests.json id: calc-native-matrix @@ -612,7 +612,7 @@ jobs: matrix: ${{ fromJson(needs.calculate-test-jobs.outputs.jvm_matrix) }} steps: - - uses: runs-on/action@efac073ea2507ec18797de3a81704201ade11d9d # v2.3.1 + - uses: runs-on/action@dfae4d98c5537a0dc7bbb7e6b9211f30ba240f6d # v2.4.0 - name: Gradle Enterprise environment run: | echo "GE_TAGS=jdk-${{matrix.java.java-version}},distribution-${{matrix.java.java-distribution}},${{matrix.java.tag}}" >> "$GITHUB_ENV" @@ -792,7 +792,7 @@ jobs: runs-on: false } steps: - - uses: runs-on/action@efac073ea2507ec18797de3a81704201ade11d9d # v2.3.1 + - uses: runs-on/action@dfae4d98c5537a0dc7bbb7e6b9211f30ba240f6d # v2.4.0 - name: Gradle Enterprise environment run: | echo "GE_TAGS=jdk-${{matrix.java.name}},distribution-${{matrix.java.java-distribution}}" >> "$GITHUB_ENV" @@ -906,7 +906,7 @@ jobs: runs-on: false } steps: - - uses: runs-on/action@efac073ea2507ec18797de3a81704201ade11d9d # v2.3.1 + - uses: runs-on/action@dfae4d98c5537a0dc7bbb7e6b9211f30ba240f6d # v2.4.0 - name: Gradle Enterprise environment run: | echo "GE_TAGS=jdk-${{matrix.java.name}}" >> "$GITHUB_ENV" @@ -1003,7 +1003,7 @@ jobs: runs-on: false } steps: - - uses: runs-on/action@efac073ea2507ec18797de3a81704201ade11d9d # v2.3.1 + - uses: runs-on/action@dfae4d98c5537a0dc7bbb7e6b9211f30ba240f6d # v2.4.0 - name: Gradle Enterprise environment run: | echo "GE_TAGS=jdk-${{matrix.java.name}},distribution-${{matrix.java.java-distribution}}" >> "$GITHUB_ENV" @@ -1096,7 +1096,7 @@ jobs: runs-on: true } steps: - - uses: runs-on/action@efac073ea2507ec18797de3a81704201ade11d9d # v2.3.1 + - uses: runs-on/action@dfae4d98c5537a0dc7bbb7e6b9211f30ba240f6d # v2.4.0 - name: Gradle Enterprise environment run: | echo "GE_TAGS=jdk-${{matrix.java.name}},distribution-${{matrix.java.java-distribution}}" >> "$GITHUB_ENV" @@ -1188,7 +1188,7 @@ jobs: runs-on: true } steps: - - uses: runs-on/action@efac073ea2507ec18797de3a81704201ade11d9d # v2.3.1 + - uses: runs-on/action@dfae4d98c5537a0dc7bbb7e6b9211f30ba240f6d # v2.4.0 - name: Gradle Enterprise environment run: | echo "GE_TAGS=jdk-${{matrix.java.name}}" >> "$GITHUB_ENV" @@ -1304,7 +1304,7 @@ jobs: runs-on: true } steps: - - uses: runs-on/action@efac073ea2507ec18797de3a81704201ade11d9d # v2.3.1 + - uses: runs-on/action@dfae4d98c5537a0dc7bbb7e6b9211f30ba240f6d # v2.4.0 - name: Gradle Enterprise environment run: | echo "GE_TAGS=jdk-${{matrix.java.name}}" >> "$GITHUB_ENV" @@ -1396,7 +1396,7 @@ jobs: fail-fast: false matrix: ${{ fromJson(needs.calculate-test-jobs.outputs.virtual_threads_matrix) }} steps: - - uses: runs-on/action@efac073ea2507ec18797de3a81704201ade11d9d # v2.3.1 + - uses: runs-on/action@dfae4d98c5537a0dc7bbb7e6b9211f30ba240f6d # v2.4.0 - name: Gradle Enterprise environment run: | category=$(echo -n '${{matrix.category}}' | tr '[:upper:]' '[:lower:]' | tr -c '[:alnum:]-' '-' | sed -E 's/-+/-/g') @@ -1473,7 +1473,7 @@ jobs: RUNS_ON_ENABLED: ${{ fromJson(needs.configure.outputs.config).runners['ubuntu-latest'].runsOn && 'true' || 'false' }} timeout-minutes: 150 steps: - - uses: runs-on/action@efac073ea2507ec18797de3a81704201ade11d9d # v2.3.1 + - uses: runs-on/action@dfae4d98c5537a0dc7bbb7e6b9211f30ba240f6d # v2.4.0 - name: Gradle Enterprise environment run: | echo "GE_TAGS=jdk-${{ needs.configure.outputs.base-jdk-version }}" >> "$GITHUB_ENV" @@ -1571,7 +1571,7 @@ jobs: fail-fast: false matrix: ${{ fromJson(needs.calculate-test-jobs.outputs.native_matrix) }} steps: - - uses: runs-on/action@efac073ea2507ec18797de3a81704201ade11d9d # v2.3.1 + - uses: runs-on/action@dfae4d98c5537a0dc7bbb7e6b9211f30ba240f6d # v2.4.0 - name: Gradle Enterprise environment run: | category=$(echo -n '${{matrix.category}}' | tr '[:upper:]' '[:lower:]' | tr -c '[:alnum:]-' '-' | sed -E 's/-+/-/g') @@ -1702,7 +1702,7 @@ jobs: - native-tests runs-on: ubuntu-slim steps: - - uses: runs-on/action@efac073ea2507ec18797de3a81704201ade11d9d # v2.3.1 + - uses: runs-on/action@dfae4d98c5537a0dc7bbb7e6b9211f30ba240f6d # v2.4.0 - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: pattern: build-stats-* @@ -2064,7 +2064,7 @@ jobs: needs: [configure,initial-build,jvm-tests,maven-tests,gradle-tests,devtools-tests,kubernetes-tests,platform-tests,tcks-test,native-tests,virtual-thread-native-tests,oracle-test-pilot-jvm-tests,oracle-test-pilot-native-tests,pqc-tests] if: always() && needs.configure.outputs.run-ci-build == 'true' steps: - - uses: runs-on/action@efac073ea2507ec18797de3a81704201ade11d9d # v2.3.1 + - uses: runs-on/action@dfae4d98c5537a0dc7bbb7e6b9211f30ba240f6d # v2.4.0 - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: path: build-reports-artifacts From d68ee31bf9cd1b1e5eccf4b6ec7e202908d77e66 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 07:26:15 +0000 Subject: [PATCH 195/254] Build(deps): Bump actions/setup-java from 5.7.0 to 6.0.1 Bumps [actions/setup-java](https://github.com/actions/setup-java) from 5.7.0 to 6.0.1. - [Release notes](https://github.com/actions/setup-java/releases) - [Commits](https://github.com/actions/setup-java/compare/v5.7.0...de7274f081f381c8f8158605e0321c36c376e2e6) --- updated-dependencies: - dependency-name: actions/setup-java dependency-version: 6.0.1 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/develocity-publish-build-scans.yml | 2 +- .github/workflows/doc-build.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/develocity-publish-build-scans.yml b/.github/workflows/develocity-publish-build-scans.yml index e6f1da21ea89e..1a89d31a22bb8 100644 --- a/.github/workflows/develocity-publish-build-scans.yml +++ b/.github/workflows/develocity-publish-build-scans.yml @@ -26,7 +26,7 @@ jobs: add-pr-comment: false add-job-summary: true - name: Set up JDK - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: 25 diff --git a/.github/workflows/doc-build.yml b/.github/workflows/doc-build.yml index c0472bf031bd9..ce7c090c235d6 100644 --- a/.github/workflows/doc-build.yml +++ b/.github/workflows/doc-build.yml @@ -169,7 +169,7 @@ jobs: rm -rf documentation-temp quarkus-main - name: Set up JDK - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: java-version: '25' distribution: temurin From f4e1e7b1b7e992febdcbffa1aa800b9550a6133f Mon Sep 17 00:00:00 2001 From: Guillaume Smet Date: Tue, 13 Oct 2026 11:21:44 +0200 Subject: [PATCH 196/254] Update to Flyway 13 This is important for our Jackson 3 story. The MongoDB artifact has been renamed as it's a native connector now. --- bom/application/pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bom/application/pom.xml b/bom/application/pom.xml index 0dd2e10b6ce03..35ece83323574 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -163,7 +163,7 @@ 3.2.0 4.3.0 3.2.2.Final - 12.0.0 + 13.10.0 3.0.5 4.33.0 @@ -6455,7 +6455,7 @@ org.flywaydb - flyway-database-mongodb + flyway-database-nc-mongodb ${flyway.version} From 7d163c9824555e763ca8bcde2a04e05cb8c063a7 Mon Sep 17 00:00:00 2001 From: Severin Gehwolf Date: Tue, 13 Oct 2026 21:47:03 +0200 Subject: [PATCH 197/254] Add section about native-image-configure tool Mandrel 25.0.4.0-Final release includes a CLI tool for extracting an embedded SBOM from a native-image. Provide brief instructions on how to use it. --- docs/src/main/asciidoc/cyclonedx.adoc | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/src/main/asciidoc/cyclonedx.adoc b/docs/src/main/asciidoc/cyclonedx.adoc index 333f9f5f757e4..cfb01945c9f00 100644 --- a/docs/src/main/asciidoc/cyclonedx.adoc +++ b/docs/src/main/asciidoc/cyclonedx.adoc @@ -335,6 +335,15 @@ podman run --rm -v $(pwd):/tmp:z anchore/syft scan myapp -o cyclonedx-json NOTE: The SBOM is always GZIP-compressed before being embedded as a global symbol, as required by the GraalVM SBOM specification and regardless of the `quarkus.cyclonedx.embedded.compress` setting. Since the classpath resource is always stored uncompressed, Quarkus compresses it at native image build time. +==== Extracting the embedded SBOM using Mandrel + +In addition to `syft`, the embedded SBOM can also be extracted using the `native-image-configure` tool included in Mandrel version `25.0.4` or later. See also the https://github.com/graalvm/mandrel/releases/tag/mandrel-25.0.4.0-Final[Mandrel 25.0.4.0-Final release notes]: + +[source,bash] +---- +$GRAALVM_HOME/bin/native-image-configure extract-sbom --image-path=/path/to/quarkus-app --output-file=quarkus-app.sbom.json +---- + === Mutable JAR Mutable JAR distribution is similar to the Fast JAR one except it also includes build time dependencies to support re-augmentation (re-building) of an application. From 26642bab69bd721d0f5e3838ac319fb4b16feca3 Mon Sep 17 00:00:00 2001 From: marko-bekhta Date: Thu, 1 Oct 2026 16:57:41 +0200 Subject: [PATCH 198/254] Do not try running validation on unconstrained configs those aren't registered for refection and have nothing to check anyway --- .../HibernateValidatorProcessor.java | 11 +++- .../test/config/ConfigMappingInvalidTest.java | 50 +++++++++++++++++++ ...application-mappings-validation.properties | 9 ++++ ...ibernateBeanValidationConfigValidator.java | 21 +++++++- .../ConfigMappingValidationResource.java | 46 +++++++++++++++++ .../config/NestedValidatedConfig.java | 31 ++++++++++++ .../validator/config/NotValidatedConfig.java | 15 ++++++ .../validator/config/RootValidatedConfig.java | 15 ++++++ .../src/main/resources/application.properties | 6 +++ .../ConfigMappingValidationInGraalITCase.java | 10 ++++ .../config/ConfigMappingValidationTest.java | 43 ++++++++++++++++ 11 files changed, 254 insertions(+), 3 deletions(-) create mode 100644 integration-tests/hibernate-validator/src/main/java/io/quarkus/it/hibernate/validator/config/ConfigMappingValidationResource.java create mode 100644 integration-tests/hibernate-validator/src/main/java/io/quarkus/it/hibernate/validator/config/NestedValidatedConfig.java create mode 100644 integration-tests/hibernate-validator/src/main/java/io/quarkus/it/hibernate/validator/config/NotValidatedConfig.java create mode 100644 integration-tests/hibernate-validator/src/main/java/io/quarkus/it/hibernate/validator/config/RootValidatedConfig.java create mode 100644 integration-tests/hibernate-validator/src/test/java/io/quarkus/it/hibernate/validator/config/ConfigMappingValidationInGraalITCase.java create mode 100644 integration-tests/hibernate-validator/src/test/java/io/quarkus/it/hibernate/validator/config/ConfigMappingValidationTest.java diff --git a/extensions/hibernate-validator/deployment/src/main/java/io/quarkus/hibernate/validator/deployment/HibernateValidatorProcessor.java b/extensions/hibernate-validator/deployment/src/main/java/io/quarkus/hibernate/validator/deployment/HibernateValidatorProcessor.java index 5f777f2c8242b..417e61237f5fb 100644 --- a/extensions/hibernate-validator/deployment/src/main/java/io/quarkus/hibernate/validator/deployment/HibernateValidatorProcessor.java +++ b/extensions/hibernate-validator/deployment/src/main/java/io/quarkus/hibernate/validator/deployment/HibernateValidatorProcessor.java @@ -326,6 +326,7 @@ void configValidator( // we might be able to do some more advanced surgery with Jandex evolution but for now // that's the best we can do Set configComponentsInterfacesToRegisterForReflection = new HashSet<>(); + Set mappingsRequiringValidation = new HashSet<>(); for (DotName constrainedConfigMapping : constrainedConfigMappings) { if (!embeddingMap.containsKey(constrainedConfigMapping)) { // should never happen but let's be safe @@ -335,6 +336,7 @@ void configValidator( for (GeneratedConfigClassBuildItem configClass : embeddingMap.get(constrainedConfigMapping).values()) { unremovableBeans.produce(UnremovableBeanBuildItem.beanTypes(configClass.getConfigClass())); configComponentsInterfacesToRegisterForReflection.addAll(configClass.getInterfaces()); + mappingsRequiringValidation.add(DotName.createSimple(configClass.getConfigClass())); } } reflectiveClass.produce(ReflectiveClassBuildItem @@ -365,8 +367,13 @@ void configValidator( configClassesToValidate.stream().sorted().toList(), c -> Const.of(classDescOf(c)))); - bc.yield(bc.new_(ConstructorDesc.of(HibernateBeanValidationConfigValidator.class, Set.class, Set.class), - constraints, classes)); + LocalVar rootMappings = bc.localVar("rootMappings", bc.setOf( + mappingsRequiringValidation.stream().sorted().toList(), + c -> Const.of(classDescOf(c)))); + + bc.yield(bc.new_( + ConstructorDesc.of(HibernateBeanValidationConfigValidator.class, Set.class, Set.class, Set.class), + constraints, classes, rootMappings)); }); }); diff --git a/extensions/hibernate-validator/deployment/src/test/java/io/quarkus/hibernate/validator/test/config/ConfigMappingInvalidTest.java b/extensions/hibernate-validator/deployment/src/test/java/io/quarkus/hibernate/validator/test/config/ConfigMappingInvalidTest.java index 167f98263c6c7..3f612e3783768 100644 --- a/extensions/hibernate-validator/deployment/src/test/java/io/quarkus/hibernate/validator/test/config/ConfigMappingInvalidTest.java +++ b/extensions/hibernate-validator/deployment/src/test/java/io/quarkus/hibernate/validator/test/config/ConfigMappingInvalidTest.java @@ -53,6 +53,9 @@ public void accept(final Throwable throwable) { assertTrue(message.contains("validator.server.host must be less than or equal to 3")); assertTrue(message.contains("validator.hierarchy.number must be greater than or equal to 10")); assertTrue(message.contains("validator.repeatable.name size must be between 2")); + assertTrue(message.contains("validator.onlynested.level1.level2.name size must be between 5 and 10")); + assertTrue(message.contains("validator.shareda.shared.value size must be between 5 and 10")); + assertTrue(message.contains("validator.sharedb.shared.value size must be between 5 and 10")); assertTrue(message.contains("cloud.port must be greater than or equal to 8000")); assertTrue(message.contains("cloud.log.days must be less than or equal to 15")); @@ -97,6 +100,53 @@ public interface Repeatable { String name(); } + @SuppressWarnings("unused") + // No Bean Validation constraints anywhere in this mapping's tree. It must still be constructed + // successfully alongside the constrained mappings above, without the validator touching it. + @ConfigMapping(prefix = "unconstrained") + public interface Unconstrained { + String name(); + } + + // Neither the root nor the first level of nesting carries a constraint; the only constraint is two + // levels deep. The validator must still find and report it, i.e. the root must not be mistaken for + // an unconstrained mapping just because it has no constraint of its own. + @ConfigMapping(prefix = "validator.onlynested") + public interface OnlyNestedConstrained { + String name(); + + Level1 level1(); + + interface Level1 { + String name(); + + Level2 level2(); + + interface Level2 { + @Size(min = 5, max = 10) + String name(); + } + } + } + + // A plain, top-level interface that is not itself a @ConfigMapping root, but is reused as a nested + // group by two independent root mappings. Both owning roots must end up registered for validation, + // not just whichever one happens to be discovered first. + public interface SharedGroup { + @Size(min = 5, max = 10) + String value(); + } + + @ConfigMapping(prefix = "validator.shareda") + public interface SharedGroupRootA { + SharedGroup shared(); + } + + @ConfigMapping(prefix = "validator.sharedb") + public interface SharedGroupRootB { + SharedGroup shared(); + } + @ConfigMapping(prefix = "cloud") @Prod public interface Cloud { diff --git a/extensions/hibernate-validator/deployment/src/test/resources/application-mappings-validation.properties b/extensions/hibernate-validator/deployment/src/test/resources/application-mappings-validation.properties index a94e71916cc37..d7493ce638e25 100644 --- a/extensions/hibernate-validator/deployment/src/test/resources/application-mappings-validation.properties +++ b/extensions/hibernate-validator/deployment/src/test/resources/application-mappings-validation.properties @@ -3,6 +3,15 @@ validator.server.services=redis,postgresql validator.hierarchy.number=1 validator.repeatable.name=a +unconstrained.name=whatever + +validator.onlynested.name=foo +validator.onlynested.level1.name=foo +validator.onlynested.level1.level2.name=x + +validator.shareda.shared.value=a +validator.sharedb.shared.value=b + cloud.host=localhost cloud.port=5000 cloud.io-threads=200 diff --git a/extensions/hibernate-validator/runtime/src/main/java/io/quarkus/hibernate/validator/runtime/HibernateBeanValidationConfigValidator.java b/extensions/hibernate-validator/runtime/src/main/java/io/quarkus/hibernate/validator/runtime/HibernateBeanValidationConfigValidator.java index 769f236430a53..10f70cf046e31 100644 --- a/extensions/hibernate-validator/runtime/src/main/java/io/quarkus/hibernate/validator/runtime/HibernateBeanValidationConfigValidator.java +++ b/extensions/hibernate-validator/runtime/src/main/java/io/quarkus/hibernate/validator/runtime/HibernateBeanValidationConfigValidator.java @@ -10,11 +10,18 @@ import org.hibernate.validator.PredefinedScopeHibernateValidatorConfiguration; import org.hibernate.validator.constraintvalidation.spi.DefaultConstraintValidatorFactory; +import io.smallrye.config.ConfigMappingLoader.GeneratedConfigClass; +import io.smallrye.config.ConfigValidationException; import io.smallrye.config.validator.BeanValidationConfigValidator; public class HibernateBeanValidationConfigValidator implements BeanValidationConfigValidator { - public HibernateBeanValidationConfigValidator(Set constraints, Set> classesToBeValidated) { + private final Set> mappingsRequiringValidation; + + public HibernateBeanValidationConfigValidator(Set constraints, Set> classesToBeValidated, + Set> mappingsRequiringValidation) { + this.mappingsRequiringValidation = mappingsRequiringValidation; + PredefinedScopeHibernateValidatorConfiguration configuration = Validation .byProvider(PredefinedScopeHibernateValidator.class) .configure(); @@ -30,6 +37,18 @@ public HibernateBeanValidationConfigValidator(Set constraints, Set Date: Tue, 13 Oct 2026 15:00:49 +0300 Subject: [PATCH 199/254] Remove Jackson Databind from Vertx This brings Quarkus 4 in line with what we did in Quarkus 3 --- bom/application/pom.xml | 4 ++++ extensions/hal/deployment/pom.xml | 2 +- extensions/hal/runtime/pom.xml | 1 - extensions/web-dependency-locator/deployment/pom.xml | 2 +- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/bom/application/pom.xml b/bom/application/pom.xml index 0dd2e10b6ce03..42580435bf334 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -447,6 +447,10 @@ com.fasterxml.jackson.core jackson-databind + + tools.jackson.core + jackson-databind + diff --git a/extensions/hal/deployment/pom.xml b/extensions/hal/deployment/pom.xml index f151d124ec732..c7e6c629cb01e 100644 --- a/extensions/hal/deployment/pom.xml +++ b/extensions/hal/deployment/pom.xml @@ -27,7 +27,7 @@ io.quarkus - quarkus-jackson-spi + quarkus-jackson-deployment io.quarkus diff --git a/extensions/hal/runtime/pom.xml b/extensions/hal/runtime/pom.xml index 45ddf474d13d1..2f88726390163 100644 --- a/extensions/hal/runtime/pom.xml +++ b/extensions/hal/runtime/pom.xml @@ -28,7 +28,6 @@ io.quarkus quarkus-jackson - true io.quarkus diff --git a/extensions/web-dependency-locator/deployment/pom.xml b/extensions/web-dependency-locator/deployment/pom.xml index db678a9fd9389..886bce5363960 100644 --- a/extensions/web-dependency-locator/deployment/pom.xml +++ b/extensions/web-dependency-locator/deployment/pom.xml @@ -38,7 +38,7 @@ - com.fasterxml.jackson.core + tools.jackson.core jackson-databind From d19444c9c99038d32e0fba03148ddce0384d7f7d Mon Sep 17 00:00:00 2001 From: Matheus Cruz Date: Wed, 30 Sep 2026 13:44:23 -0300 Subject: [PATCH 200/254] Add IBM Bob MCP configuration for Dev MCP direct connection --- .../dev-ui/qwc/qwc-dev-mcp-setting.js | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/extensions/devui/resources/src/main/resources/dev-ui/qwc/qwc-dev-mcp-setting.js b/extensions/devui/resources/src/main/resources/dev-ui/qwc/qwc-dev-mcp-setting.js index a379bd448255f..0eebe5877cf85 100644 --- a/extensions/devui/resources/src/main/resources/dev-ui/qwc/qwc-dev-mcp-setting.js +++ b/extensions/devui/resources/src/main/resources/dev-ui/qwc/qwc-dev-mcp-setting.js @@ -351,6 +351,26 @@ export class QwcDevMCPSetting extends QwcHotReloadElement { _getAgentMcpConfigurations() { return [ + { + name: 'IBM Bob', + icon: 'font-awesome-solid:code', + file: '.bob/mcp.json', + docsUrl: 'https://bob.ibm.com/docs/ide/configuration/mcp/mcp-in-bob', + config: { + "mcpServers": { + "quarkus-agent": { + "command": "${env:JBANG_HOME}/bin/jbang", + "args": [ + "quarkus-agent-mcp@quarkusio" + ], + "env": { + "JAVA_HOME": "${env:JAVA_HOME}" + }, + "disabled": false + } + } + } + }, { name: 'Claude Code', icon: 'font-awesome-solid:terminal', @@ -494,6 +514,20 @@ export class QwcDevMCPSetting extends QwcHotReloadElement { _getDirectMcpConfigurations() { return [ + { + name: 'IBM Bob', + icon: 'font-awesome-solid:code', + file: '.bob/mcp.json', + docsUrl: 'https://bob.ibm.com/docs/ide/configuration/mcp/mcp-in-bob', + config: { + mcpServers: { + 'quarkus-mcp': { + type: 'streamable-http', + url: this._mcpPath + } + } + } + }, { name: 'OpenCode', icon: 'font-awesome-solid:terminal', From a8c1c02859525ec7dd1a774102283d387ac7e510 Mon Sep 17 00:00:00 2001 From: Georgios Andrianakis Date: Wed, 14 Oct 2026 17:12:12 +0300 Subject: [PATCH 201/254] Replace recorders in Jackson with service registration Closes: #57451 --- .../jackson/deployment/JacksonProcessor.java | 29 +++++--- .../runtime/ConfigurationCustomizer.java | 12 ++-- .../jackson/runtime/JacksonBeanCreator.java | 52 +++++++++++++++ .../jackson/runtime/JacksonRecorder.java | 66 ------------------- 4 files changed, 79 insertions(+), 80 deletions(-) create mode 100644 extensions/jackson/runtime/src/main/java/io/quarkus/jackson/runtime/JacksonBeanCreator.java delete mode 100644 extensions/jackson/runtime/src/main/java/io/quarkus/jackson/runtime/JacksonRecorder.java diff --git a/extensions/jackson/deployment/src/main/java/io/quarkus/jackson/deployment/JacksonProcessor.java b/extensions/jackson/deployment/src/main/java/io/quarkus/jackson/deployment/JacksonProcessor.java index 98f89dfa77b14..94a5ac6409283 100644 --- a/extensions/jackson/deployment/src/main/java/io/quarkus/jackson/deployment/JacksonProcessor.java +++ b/extensions/jackson/deployment/src/main/java/io/quarkus/jackson/deployment/JacksonProcessor.java @@ -36,12 +36,12 @@ import io.quarkus.arc.deployment.SyntheticBeanBuildItem; import io.quarkus.arc.deployment.UnremovableBeanBuildItem; import io.quarkus.arc.impl.Reflections; +import io.quarkus.core.Phase; +import io.quarkus.core.deployment.service.ServiceRegistrar; import io.quarkus.deployment.Capabilities; import io.quarkus.deployment.Capability; import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; -import io.quarkus.deployment.annotations.ExecutionTime; -import io.quarkus.deployment.annotations.Record; import io.quarkus.deployment.builditem.CombinedIndexBuildItem; import io.quarkus.deployment.builditem.NativeImageFeatureBuildItem; import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; @@ -60,8 +60,8 @@ import io.quarkus.jackson.JacksonMixin; import io.quarkus.jackson.JsonMapperBuilderCustomizer; import io.quarkus.jackson.runtime.ConfigurationCustomizer; +import io.quarkus.jackson.runtime.JacksonBeanCreator; import io.quarkus.jackson.runtime.JacksonBuildTimeConfig; -import io.quarkus.jackson.runtime.JacksonRecorder; import io.quarkus.jackson.runtime.JacksonSupport; import io.quarkus.jackson.runtime.JsonMapperProducer; import io.quarkus.jackson.runtime.VertxHybridPoolJsonFactoryBuilderCustomizer; @@ -441,9 +441,8 @@ void generateCustomizer(BuildProducer generatedBeans, }); } - @Record(ExecutionTime.STATIC_INIT) @BuildStep - public void supportMixins(JacksonRecorder recorder, + public void supportMixins(ServiceRegistrar serviceRegistrar, CombinedIndexBuildItem combinedIndexBuildItem, BuildProducer syntheticBeans, BuildProducer reflectiveClass) { @@ -484,20 +483,32 @@ public void supportMixins(JacksonRecorder recorder, if (mixinsMap.isEmpty()) { return; } + Map, Class> mixins = Map.copyOf(mixinsMap); + serviceRegistrar + .forService(JsonMapperBuilderCustomizer.class, "mixins") + .atPhase(Phase.STATIC_INIT) + .onStart(ctx -> JacksonBeanCreator.mixinsCustomizer(mixins)); + syntheticBeans.produce(SyntheticBeanBuildItem.configure(JsonMapperBuilderCustomizer.class) .scope(Singleton.class) - .supplier(recorder.customizerSupplier(mixinsMap)) + .serviceValue(JsonMapperBuilderCustomizer.class, "mixins") .done()); } - @Record(ExecutionTime.STATIC_INIT) @BuildStep - public SyntheticBeanBuildItem jacksonSupport(JacksonRecorder recorder, + public SyntheticBeanBuildItem jacksonSupport(ServiceRegistrar serviceRegistrar, JacksonBuildTimeConfig jacksonBuildTimeConfig) { + String propertyNamingStrategyClassName = determinePropertyNamingStrategyClassName(jacksonBuildTimeConfig) + .orElse(null); + serviceRegistrar + .forService(JacksonSupport.class) + .atPhase(Phase.STATIC_INIT) + .onStart(ctx -> JacksonBeanCreator.jacksonSupport(propertyNamingStrategyClassName)); + return SyntheticBeanBuildItem .configure(JacksonSupport.class) .scope(Singleton.class) - .supplier(recorder.supplier(determinePropertyNamingStrategyClassName(jacksonBuildTimeConfig))) + .serviceValue(JacksonSupport.class) .done(); } diff --git a/extensions/jackson/runtime/src/main/java/io/quarkus/jackson/runtime/ConfigurationCustomizer.java b/extensions/jackson/runtime/src/main/java/io/quarkus/jackson/runtime/ConfigurationCustomizer.java index 9d703f5574610..a5d92a579f6e5 100644 --- a/extensions/jackson/runtime/src/main/java/io/quarkus/jackson/runtime/ConfigurationCustomizer.java +++ b/extensions/jackson/runtime/src/main/java/io/quarkus/jackson/runtime/ConfigurationCustomizer.java @@ -4,7 +4,6 @@ import java.util.TimeZone; import java.util.function.UnaryOperator; -import jakarta.inject.Inject; import jakarta.inject.Singleton; import com.fasterxml.jackson.annotation.JsonInclude; @@ -18,11 +17,14 @@ @Singleton public class ConfigurationCustomizer implements JsonMapperBuilderCustomizer { - @Inject - JacksonBuildTimeConfig jacksonBuildTimeConfig; - @Inject - JacksonSupport jacksonSupport; + private final JacksonBuildTimeConfig jacksonBuildTimeConfig; + private final JacksonSupport jacksonSupport; + + public ConfigurationCustomizer(JacksonBuildTimeConfig jacksonBuildTimeConfig, JacksonSupport jacksonSupport) { + this.jacksonBuildTimeConfig = jacksonBuildTimeConfig; + this.jacksonSupport = jacksonSupport; + } @Override public void customize(JsonMapper.Builder builder) { diff --git a/extensions/jackson/runtime/src/main/java/io/quarkus/jackson/runtime/JacksonBeanCreator.java b/extensions/jackson/runtime/src/main/java/io/quarkus/jackson/runtime/JacksonBeanCreator.java new file mode 100644 index 0000000000000..e39a6ee744920 --- /dev/null +++ b/extensions/jackson/runtime/src/main/java/io/quarkus/jackson/runtime/JacksonBeanCreator.java @@ -0,0 +1,52 @@ +package io.quarkus.jackson.runtime; + +import java.util.Map; +import java.util.Optional; + +import io.quarkus.jackson.JsonMapperBuilderCustomizer; +import tools.jackson.databind.PropertyNamingStrategies; +import tools.jackson.databind.json.JsonMapper; + +public final class JacksonBeanCreator { + + private JacksonBeanCreator() { + } + + public static JacksonSupport jacksonSupport(String propertyNamingStrategyClassName) { + return new JacksonSupport() { + @Override + public Optional configuredNamingStrategy() { + if (propertyNamingStrategyClassName != null) { + try { + var value = (PropertyNamingStrategies.NamingBase) Class + .forName(propertyNamingStrategyClassName, true, + Thread.currentThread() + .getContextClassLoader()) + .getDeclaredConstructor().newInstance(); + return Optional.of(value); + } catch (Exception e) { + // shouldn't happen as propertyNamingStrategyClassName is validated at build time + throw new RuntimeException(e); + } + } + return Optional.empty(); + } + }; + } + + public static JsonMapperBuilderCustomizer mixinsCustomizer(Map, Class> mixinsMap) { + return new JsonMapperBuilderCustomizer() { + @Override + public void customize(JsonMapper.Builder objectMapper) { + for (var entry : mixinsMap.entrySet()) { + objectMapper.addMixIn(entry.getKey(), entry.getValue()); + } + } + + @Override + public int priority() { + return DEFAULT_PRIORITY + 1; + } + }; + } +} diff --git a/extensions/jackson/runtime/src/main/java/io/quarkus/jackson/runtime/JacksonRecorder.java b/extensions/jackson/runtime/src/main/java/io/quarkus/jackson/runtime/JacksonRecorder.java deleted file mode 100644 index 7a3b4bb8d30e4..0000000000000 --- a/extensions/jackson/runtime/src/main/java/io/quarkus/jackson/runtime/JacksonRecorder.java +++ /dev/null @@ -1,66 +0,0 @@ -package io.quarkus.jackson.runtime; - -import java.util.Map; -import java.util.Optional; -import java.util.function.Supplier; - -import io.quarkus.jackson.JsonMapperBuilderCustomizer; -import io.quarkus.runtime.annotations.Recorder; -import io.quarkus.runtime.annotations.StaticInit; -import tools.jackson.databind.PropertyNamingStrategies; -import tools.jackson.databind.json.JsonMapper; - -@Recorder -public class JacksonRecorder { - - @StaticInit - public Supplier supplier(Optional propertyNamingStrategyClassName) { - return new Supplier<>() { - @Override - public JacksonSupport get() { - return new JacksonSupport() { - @Override - public Optional configuredNamingStrategy() { - if (propertyNamingStrategyClassName.isPresent()) { - try { - var value = (PropertyNamingStrategies.NamingBase) Class - .forName(propertyNamingStrategyClassName.get(), true, - Thread.currentThread() - .getContextClassLoader()) - .getDeclaredConstructor().newInstance(); - return Optional.of(value); - } catch (Exception e) { - // shouldn't happen as propertyNamingStrategyClassName is validated at build time - throw new RuntimeException(e); - } - } - return Optional.empty(); - } - }; - } - }; - } - - @StaticInit - public Supplier customizerSupplier(Map, Class> mixinsMap) { - return new Supplier<>() { - @Override - public JsonMapperBuilderCustomizer get() { - return new JsonMapperBuilderCustomizer() { - @Override - public void customize(JsonMapper.Builder objectMapper) { - for (var entry : mixinsMap.entrySet()) { - objectMapper.addMixIn(entry.getKey(), entry.getValue()); - } - } - - @Override - public int priority() { - return DEFAULT_PRIORITY + 1; - } - }; - } - }; - } - -} From 161175ba0d8830ec84204e61256be27ac35252e3 Mon Sep 17 00:00:00 2001 From: "David M. Lloyd" Date: Thu, 8 Oct 2026 17:34:51 -0500 Subject: [PATCH 202/254] Update recorder-to-services skills to be more succinct and complete --- .../converting-recorders-to-services/SKILL.md | 217 +++++++++++------- 1 file changed, 132 insertions(+), 85 deletions(-) diff --git a/.agents/skills/converting-recorders-to-services/SKILL.md b/.agents/skills/converting-recorders-to-services/SKILL.md index db22949e5be8c..d891adb8a335d 100644 --- a/.agents/skills/converting-recorders-to-services/SKILL.md +++ b/.agents/skills/converting-recorders-to-services/SKILL.md @@ -9,9 +9,10 @@ description: > ## Architecture -The `ServiceRegistrar` service system replaces `@Recorder` with typed service. Services declare dependencies explicitly and execute in dependency -order, not build step order. The system is designed for incremental -migration — converted and unconverted extensions may coexist. +The `ServiceRegistrar` service system replaces `@Recorder` with typed +services. Services declare dependencies explicitly and execute in +dependency order, not build step order. Converted and unconverted +extensions may coexist. ### Key principles @@ -19,12 +20,13 @@ migration — converted and unconverted extensions may coexist. the build step graph. All ordering comes from `require()`, `after()`, `before()`, or `afterBuildItem()`. 2. **Services are independent from recorders in the same step.** Even if - a build step method contains both a `@Record` call and an `ServiceRegistrar` - call, the service and recorder are separate nodes with no implicit - ordering between them. -3. **Values flow through the dependency graph.** Service values are passed - from producer to consumer via indexed dependency access on `ServiceNode`, - not through the `StartupContext` maps (except for recorder proxy bridges). + a build step method contains both a `@Record` call and a + `ServiceRegistrar` call, the service and recorder are separate nodes + with no implicit ordering between them. +3. **Values flow through the dependency graph.** Service values are + passed from producer to consumer via indexed dependency access on + `ServiceNode`, not through the `StartupContext` maps (except for + recorder proxy bridges). ### Node types in the service graph @@ -143,9 +145,9 @@ replace with `require()` or `after()`, or drop it, as appropriate. #### Cross-phase dependencies Runtime services can `require()` static-init services. The framework -automatically creates a `CROSS_PHASE_PROXY` node that reads the value -from the `serviceValues` map (populated during static-init and retained -via `retainServiceValues()`). +creates a `CROSS_PHASE_PROXY` node that reads the value from the +`serviceValues` map (populated during static-init and retained via +`retainServiceValues()`). ```java // static-init service @@ -217,6 +219,54 @@ SyntheticBeanBuildItem.configure(MyBean.class) .done(); ``` +**Key rules for `serviceValue()`:** + +- **Type must match exactly.** The `serviceValue(type, name)` type must + equal the `forService(type, name)` type. If the service produces an + impl class but the bean type is the interface, register the service + under the interface type. +- **Works with `optional()` services.** When the optional service + produces a value, it is stored for the bean. When it produces + `Optional.empty()`, no value is stored; pair with `checkActive()` on + the bean to prevent injection of absent services. +- **`checkActive()` still requires `@Record`.** There is no + service-native way to produce a `Supplier` for + `SyntheticBeanBuildItem.checkActive()`. If your converted extension + needs activation checks, keep a minimal `@Recorder` with a + `checkActive()` method. + +#### Activation via optional services + +The `optional()` mechanism replaces `checkActive` for runtime-gated +services. Return `Optional.empty()` when inactive; downstream services +using `request()` receive the empty optional and can propagate it: + +```java +reg.forService(RedisClientConnection.class, name) + .optional() + .require(RedisConfig.class) + .onStart((ctx, config) -> config.clients().get(name).active() + ? Optional.of(createConnection(config)) + : Optional.empty()); + +reg.forService(RedisDataSource.class, name) + .optional() + .request(RedisClientConnection.class, name) + .onStart((ctx, connOpt) -> connOpt.map(conn -> ...)); +``` + +#### Services vs. CDI beans + +Use whichever is the cleanest fit: + +- **Inter-extension wiring** (one extension producing a value another + extension consumes): prefer services — `require()`/`request()` gives + explicit ordering and type safety with no CDI proxying overhead. +- **Application-facing injection** (`@Inject` in user code): keep CDI + synthetic beans for the user-facing surface. +- **Both**: register a service *and* a `serviceValue()` synthetic bean. + Drop the synthetic bean only if no application code injects it. + ### 8. Shutdown handlers Register cleanup via `ctx.onStop(Runnable)` or @@ -230,13 +280,10 @@ stops after the services it cleans up for. ### 9. Runtime-optional services A value-producing service must return a non-`null` value — the -generated code throws `"...returned null; use a void service if no value is -produced"` otherwise. For a service whose *identity* is known at build time -but which may be **absent at runtime** (e.g. a handler gated by -`quarkus.log.file.enabled`), declare `optional()` before `onStart()`. This -flips the service's return type from `T` to `Optional`; returning -`Optional.empty()` marks the service absent (nothing constructed, no side -effects) instead of failing. +generated code throws otherwise. For a service whose identity is known +at build time but which may be **absent at runtime**, declare +`optional()` before `onStart()`. This flips the return type from `T` to +`Optional`; returning `Optional.empty()` marks the service absent. ```java serviceRegistrar.forService(Handler.class, "file") @@ -251,14 +298,11 @@ serviceRegistrar.forService(Handler.class, "file") Consuming an optional service: - `consumeAll(T.class)` skips absent producers (the map omits them). - `request(T.class)` yields `Optional.empty()` when absent. -- `require(T.class)` on an optional service is a **build error** — a - mandatory dependency cannot depend on a maybe-absent value. Use +- `require(T.class)` on an optional service is a **build error** — use `request()` or `consumeAll()` instead. -Do not return `null` from a normal (non-`optional`) service, and do not use a -sentinel value — `optional()` is the supported mechanism. (The `null` -tolerance in `consumeAll` applies only to aliased recorder values that -resolve to `null`, not to direct services.) +Do not return `null` from a non-`optional` service. The `null` +tolerance in `consumeAll` applies only to aliased recorder values. ### 10. Lambda capture rules @@ -270,26 +314,43 @@ Supported captures: `Map.copyOf()` - `BUILD_AND_RUN_TIME_FIXED` config mappings (via `ConfigCaptureInterceptor`) - `Constable` values — any type implementing `java.lang.constant.Constable` - is captured via its `describeConstable()`. To make a custom value type - capturable, implement `Constable` with a `describeConstable()` that - reconstructs it (e.g. a `DynamicConstantDesc` invoking a static factory), - rather than registering a recorder `ObjectSubstitution`. Immutable - collections of `Constable` values (via `Map.copyOf`/`List.copyOf`) are - themselves capturable. + is captured via its `describeConstable()`. Immutable collections of + `Constable` values (via `Map.copyOf`/`List.copyOf`) are also capturable. - `record` values — captured by reconstructing via the canonical constructor, as long as every component is itself capturable (recursively). - Note this is the **capture** path; it is unrelated to the *proxy* path — a - `record` still cannot be used as an aliased recorder return type (see - "Aliased recorder return types must be proxyable" below). + This is the **capture** path; a `record` still cannot be used as an + aliased recorder return type (see "Aliased recorder return types must + be proxyable" below). **Not capturable:** - Mutable collections — use `Set.copyOf()` / `List.copyOf()` first + (`Stream#toList()` is already unmodifiable — don't wrap it again) - `BUILD_TIME` config objects — extract values into locals - `RUN_TIME` config objects — use `require()` instead - Arbitrary runtime objects — use `require()` instead ## Common pitfalls +### Global mutable state must be eliminated or made thread-safe + +Recorders ran sequentially, so global mutable state (static maps, +shared lists, singletons populated during startup) was incidentally +safe. Services run concurrently — any global state that survives +conversion must be thread-safe. + +Prefer eliminating the global state entirely: the dependency graph +carries values directly, so each entry becomes a named service and +each lookup becomes `require()` or `request()`. Reserve global state +for cases where it is genuinely shared across unrelated consumers +with no dependency relationship. + +### Choose the right phase for data-access services + +Services that create connections to external data systems (database +pools, Redis clients, directory services) belong at `Phase.DATA`, +ensuring `serviceValue()` beans can read values before any bean is +accessed. + ### No implicit ordering from build steps Services get NO ordering from `@Consume`/`@Produce` annotations or @@ -308,33 +369,29 @@ proxyable. ### Aliased recorder return types must be proxyable -A value published via `aliasRecorderValue(T.class, recorderValue)` — or any -`@Recorder` method whose return value is used as a recorder proxy — must have -a **proxyable** type: an interface or a non-`final` class. A `record` is -`final`, so it fails with *"the return type cannot be proxied; use -RuntimeValue to wrap the return value instead"*. When bridging a bundle of -recorder values into a single service value, make the holder an **interface** -(with a nested `record` impl for the runtime value), or wrap it in -`RuntimeValue`. Abstract JDK types such as `java.util.logging.Formatter` and -`java.util.logging.Handler` are already proxyable. +A value published via `aliasRecorderValue(T.class, recorderValue)` must +have a **proxyable** type: an interface or a non-`final` class. Records +are `final` and will fail. When bridging recorder values, make the +holder an **interface** (with a nested `record` impl), or wrap it in +`RuntimeValue`. ### Package-private visibility Lambda bytecode runs at runtime but references runtime classes directly. Package-private classes become inaccessible from the generated -consolidated class. - -Solutions: +consolidated class. Solutions: 1. Add a public static factory method on a public runtime class. 2. Move the class to a `.impl` subpackage and make it public. -### Never reference deployment classes from within service lambdas - -Action lambda bytecode (defined inside `.onStart(ctx -> { ... })`) is executed at **runtime**. Therefore, the lambda body must **never** reference, instantiate, or invoke methods on any deployment classes (such as your `@BuildStep` processor classes). Doing so will compile successfully but will throw a `ClassNotFoundException` / `NoClassDefFoundError` at runtime because deployment modules are absent from the runtime classpath. +### Never reference deployment classes from service lambdas -* **The Anonymous Inner Class Trap**: Writing an anonymous inner class (e.g. `new MyInterface() {}`) inside an instance-method lambda forces `javac` to generate a constructor that captures `EnclosingProcessor.this` (via a synthetic `this$0` parameter) to bind the inner class to its enclosing processor scope. This turns your lambda into an instance-capturing lambda (throwing a `TransliterationException` at build time). -* **The Static Helper Method Trap**: Moving the anonymous class instantiation to a `private static` helper method inside your processor class solves the `EnclosingProcessor.this` capture, but because the compiled lambda still references the static helper method on your deployment processor, it causes a `NoClassDefFoundError` at runtime. -* **Solution**: Always place helper methods, custom interfaces, or static factory methods inside your **runtime** module classes (which are safely present at both build time and runtime). +Service lambda bodies execute at **runtime** — deployment classes are +absent from the runtime classpath. Anonymous inner classes inside +instance-method lambdas silently capture `this` (the processor), +causing `TransliterationException`. Moving the instantiation to a +`private static` helper in the processor avoids the capture but still +references a deployment class, causing `NoClassDefFoundError`. Place +all helper methods and factories in **runtime** module classes. ### Use `require()` instead of `Arc.container()` @@ -350,48 +407,36 @@ Set captured = Set.copyOf(endpoints); // before the lambda ## Behavioral parity with recorders -Conversion must preserve behavior the recorder model provided *implicitly*. -These are recurring, high-cost regressions — check each when converting a -step that touches classloaders, threads, cleanup, or failure handling. +Conversion must preserve behavior the recorder model provided +*implicitly*. Check each of these when converting a step that touches +classloaders, threads, cleanup, or failure handling. -### TCCL parity (thread context classloader) +### TCCL parity -Recorder bytecode always ran with the **runtime classloader as the TCCL** -(the generated `doStart` sets it). Any recorder code that captured -`Thread.currentThread().getContextClassLoader()` for later use — request -handlers, thread factories, config/`ServiceLoader` lookups — relied on that. +A wrong TCCL surfaces as `ServiceLoader` "not a subtype" errors or +dev-mode failures. -- After converting, ensure the service still runs with the runtime CL as - TCCL, and that captured/inherited CLs are the runtime CL (not the system - `AppClassLoader`). A wrong TCCL surfaces as `ServiceLoader` "not a subtype" - errors or dev-mode failures, not as an obvious classloader bug. -- **Threads created during startup inherit the creating thread's TCCL.** - A long-lived or library thread that captures the per-app runtime CL and - outlives the app **leaks the `QuarkusClassLoader`** (metaspace growth - across test/dev restarts). Internal/scheduler threads that run no user - code should not hold an app CL. Clean up threads/`ThreadLocal`s your - service owns in `onStop`; never rely on a global "reset all TCCLs" sweep. +**Threads created during startup inherit the creating thread's TCCL.** +A long-lived thread that captures the per-app runtime CL leaks the +`QuarkusClassLoader` across restarts. Clean up threads/`ThreadLocal`s +in `onStop`; never rely on a global "reset all TCCLs" sweep. ### Teardown-ordering parity -Start ordering is explicit (`require`/`after`/`before`) — but so is **stop -ordering**, and that is the easy thing to lose. The recorder model gave -cleanup incidental ordering from build-step order; a converted service's -`onStop` runs only in reverse *dependency* order. If a cleanup implicitly -depended on running before/after another (e.g. bean destruction before a -"last" task, or a listener firing before container teardown), declare it -with `before()`/`after()`. Lost teardown ordering surfaces as unrelated -downstream symptoms, so verify shutdown explicitly. +Start ordering is explicit — but so is **stop ordering**, and that is +the easy thing to lose. The recorder model gave cleanup incidental +ordering from build-step order; a converted service's `onStop` runs +only in reverse *dependency* order. If a cleanup implicitly depended on +running before/after another, declare it with `before()`/`after()`. +Lost teardown ordering surfaces as unrelated downstream symptoms — verify +shutdown explicitly. ### Failure-path parity -Recorders logged **nothing in-band** on failure — `doStart` caught, wrapped -in `RuntimeException("Failed to start quarkus", cause)`, and rethrew. The -service graph rethrows the first failure (concurrent ones attach as -suppressed), so nothing is lost by staying quiet. **Do not add ERROR/WARN -logging in service bodies for expected failures** — let them propagate. Some -tests assert clean propagation (no WARNING+ log records, no suppressed -exceptions) for a static-init failure, e.g. `StaticInitFailureTest`. +Recorders logged nothing on failure — `doStart` caught, wrapped in +`RuntimeException`, and rethrew. The service graph rethrows the first +failure (concurrent ones attach as suppressed). **Do not add ERROR/WARN +logging in service bodies for expected failures** — let them propagate. ### `consumeAll` uses indexed access @@ -411,5 +456,7 @@ index alignment. - [ ] Do NOT rely on build step ordering for service execution order - [ ] Bridge values to legacy build items where needed - [ ] Convert synthetic beans from `supplier()`/`runtimeValue()` to `serviceValue()` +- [ ] Verify `serviceValue(type, name)` types match `forService(type, name)` exactly +- [ ] Choose the right phase (`Phase.DATA` for data connections, `Phase.INIT` for migrations, etc.) - [ ] Check for package-private visibility issues - [ ] `./mvnw install` the modified modules before running integration tests From 706ffe53e61b8290b9d917e9bff7d03c18b8d40c Mon Sep 17 00:00:00 2001 From: Rolfe Dlugy-Hegwer Date: Mon, 12 Oct 2026 21:53:36 -0400 Subject: [PATCH 203/254] Update the guides for the Java 21 minimum Since #55147, main requires Java 21, but several guides still assumed Java 17: the prerequisites, the JDK installation commands, the generated POMs, the default container images, and the alternative Java runtimes on Google Cloud and Heroku. The Javadoc of quarkus.jib.base-jvm-image also still described a fallback to a Java 17 image that is no longer a default. Where a guide names two Java versions, Java 25 comes first, because it is the recommended version, and the deploy examples for Google Cloud, Heroku and OpenShift use Java 25. The jdk-version-* attributes and the OpenShift guides that use them need a structural change and are left for a separate change. --- docs/src/main/asciidoc/_includes/prerequisites.adoc | 2 +- .../src/main/asciidoc/building-my-first-extension.adoc | 2 +- docs/src/main/asciidoc/cli-tooling.adoc | 6 +++--- docs/src/main/asciidoc/container-image.adoc | 6 +++--- docs/src/main/asciidoc/deploying-to-google-cloud.adoc | 4 ++-- docs/src/main/asciidoc/deploying-to-heroku.adoc | 4 ++-- docs/src/main/asciidoc/deploying-to-openshift.adoc | 2 +- docs/src/main/asciidoc/drools.adoc | 2 +- docs/src/main/asciidoc/funqy-gcp-functions.adoc | 8 ++++---- docs/src/main/asciidoc/gcp-functions-http.adoc | 4 ++-- docs/src/main/asciidoc/gcp-functions.adoc | 10 +++++----- docs/src/main/asciidoc/native-reference.adoc | 2 +- docs/src/main/asciidoc/rest-virtual-threads.adoc | 2 +- .../image/jib/deployment/ContainerImageJibConfig.java | 5 +---- 14 files changed, 28 insertions(+), 31 deletions(-) diff --git a/docs/src/main/asciidoc/_includes/prerequisites.adoc b/docs/src/main/asciidoc/_includes/prerequisites.adoc index 2a2d0636e2b03..97538b95eeb26 100644 --- a/docs/src/main/asciidoc/_includes/prerequisites.adoc +++ b/docs/src/main/asciidoc/_includes/prerequisites.adoc @@ -8,7 +8,7 @@ ifndef::prerequisites-time[] endif::[] * An IDE ifdef::prerequisites-ide[{prerequisites-ide}] -* JDK 17+ installed with `JAVA_HOME` configured appropriately +* JDK 21+ installed with `JAVA_HOME` configured appropriately ifndef::prerequisites-no-maven[] * Apache Maven {maven-version} endif::[] diff --git a/docs/src/main/asciidoc/building-my-first-extension.adoc b/docs/src/main/asciidoc/building-my-first-extension.adoc index f3aa606ee762f..80e0ea9c2f758 100644 --- a/docs/src/main/asciidoc/building-my-first-extension.adoc +++ b/docs/src/main/asciidoc/building-my-first-extension.adoc @@ -173,7 +173,7 @@ Your extension is a multi-module project. So let's start by checking out the par 3.15.0 ${surefire-plugin.version} - 17 + 21 UTF-8 UTF-8 {quarkus-version} diff --git a/docs/src/main/asciidoc/cli-tooling.adoc b/docs/src/main/asciidoc/cli-tooling.adoc index d10c7bac39d4b..1725e7af0dcc5 100644 --- a/docs/src/main/asciidoc/cli-tooling.adoc +++ b/docs/src/main/asciidoc/cli-tooling.adoc @@ -164,7 +164,7 @@ You can use Homebrew to install (and update) the Quarkus CLI. Make sure you have a JDK installed before installing the Quarkus CLI. We haven't added an explicit dependency as we wanted to make sure you could use your preferred JDK version. -You can install a JDK with `brew install openjdk` for the latest Java version, `brew install openjdk@17` for Java 17, or `brew install openjdk@21` for Java 21. +You can install a JDK with `brew install openjdk` for the latest Java version, `brew install openjdk@25` for Java 25, or `brew install openjdk@21` for Java 21. ==== To install the Quarkus CLI using Homebrew, run the following command: @@ -207,7 +207,7 @@ You can use Chocolatey to install (and update) the Quarkus CLI. ==== Make sure you have a JDK installed before installing the Quarkus CLI. -You can install a JDK with `choco install temurin17` for Java 17 or `choco install temurin21` for Java 21. +You can install a JDK with `choco install temurin25` for Java 25 or `choco install temurin21` for Java 21. ==== To install the Quarkus CLI using Chocolatey, run the following command: @@ -243,7 +243,7 @@ You can use Scoop to install (and update) the Quarkus CLI. [NOTE] ==== Make sure you have a JDK installed before installing the Quarkus CLI. -You can install a JDK with `scoop install openjdk17` for Java 17 or `scoop install openjdk21` for Java 21. +You can install a JDK with `scoop install openjdk25` for Java 25 or `scoop install openjdk21` for Java 21. ==== To install the Quarkus CLI using Scoop, run the following command: [source,shell] diff --git a/docs/src/main/asciidoc/container-image.adoc b/docs/src/main/asciidoc/container-image.adoc index cdf69935a3829..9aa034333509e 100644 --- a/docs/src/main/asciidoc/container-image.adoc +++ b/docs/src/main/asciidoc/container-image.adoc @@ -47,7 +47,7 @@ For example, the presence of `src/main/jib/foo/bar` would result in `/foo/bar` There are cases where the built container image may need to have Java debugging conditionally enabled at runtime. -When the base image has not been changed (and therefore `ubi9/openjdk-17-runtime`, or `ubi10/openjdk-21-runtime` is used), then the `quarkus.jib.jvm-additional-arguments` configuration property can be used in order to +When the base image has not been changed (and therefore `ubi10/openjdk-25-runtime` or `ubi10/openjdk-21-runtime` is used), then the `quarkus.jib.jvm-additional-arguments` configuration property can be used in order to make the JVM listen on the debug port at startup. The exact configuration is: @@ -63,7 +63,7 @@ Other base images might provide launch scripts that enable debugging when an env The `quarkus.jib.jvm-entrypoint` configuration property can be used to completely override the container entry point and can thus be used to either hard code the JVM debug configuration or point to a script that handles the details. -For example, if the base images `ubi9/openjdk-17-runtime` or `ubi10/openjdk-21-runtime` are used to build the container, the entry point can be hard-coded on the application properties file. +For example, if the base images `ubi10/openjdk-25-runtime` or `ubi10/openjdk-21-runtime` are used to build the container, the entry point can be hard-coded on the application properties file. .Example application.properties [source,properties] @@ -150,7 +150,7 @@ For example, building multi-platform images is implemented differently for Docke === OpenShift [[s2i]] The extension `quarkus-container-image-openshift` is using OpenShift binary builds in order to perform container builds inside the OpenShift cluster. -The idea behind the binary build is that you just upload the artifact and its dependencies to the cluster and during the build they will be merged to a builder image (defaults to `ubi9/openjdk-17` or `ubi10/openjdk-21`). +The idea behind the binary build is that you just upload the artifact and its dependencies to the cluster and during the build they will be merged to a builder image (defaults to `ubi10/openjdk-25` or `ubi10/openjdk-21`). The benefit of this approach, is that it can be combined with OpenShift's `DeploymentConfig` that makes it easy to roll out changes to the cluster. diff --git a/docs/src/main/asciidoc/deploying-to-google-cloud.adoc b/docs/src/main/asciidoc/deploying-to-google-cloud.adoc index 7e4ee6112f5f6..4eb7a93cd44a7 100644 --- a/docs/src/main/asciidoc/deploying-to-google-cloud.adoc +++ b/docs/src/main/asciidoc/deploying-to-google-cloud.adoc @@ -59,14 +59,14 @@ Then, you will need to create a `src/main/appengine/app.yaml` file, let's keep i [source, yaml] ---- -runtime: java21 +runtime: java25 ---- This will create a default service for your App Engine application. [NOTE] ==== -You can also use another Java runtime supported by App Engine, for example, for Java 17, use `runtime: java17` instead. +You can also use another Java runtime supported by App Engine, for example, for Java 21, use `runtime: java21` instead. ==== App Engine Standard does not support the default Quarkus' specific packaging layout, therefore, you must set up your application to be packaged as an uber-jar via your `application.properties` file: diff --git a/docs/src/main/asciidoc/deploying-to-heroku.adoc b/docs/src/main/asciidoc/deploying-to-heroku.adoc index 7a69c10cc00b5..846b04f53984b 100644 --- a/docs/src/main/asciidoc/deploying-to-heroku.adoc +++ b/docs/src/main/asciidoc/deploying-to-heroku.adoc @@ -101,11 +101,11 @@ For the first variant, two additional files are needed in your application's roo * `system.properties` to configure the Java version * `Procfile` to configure how Heroku starts your application -Quarkus needs JDK 17, so we specify that first: +Quarkus needs JDK 21 or later, so we specify the Java version first: [source,bash] ---- -echo "java.runtime.version=17" >> system.properties +echo "java.runtime.version=25" >> system.properties git add system.properties git commit -am "Configure the Java version for Heroku." ---- diff --git a/docs/src/main/asciidoc/deploying-to-openshift.adoc b/docs/src/main/asciidoc/deploying-to-openshift.adoc index 63e6c34f6cb4c..98a81b8123992 100644 --- a/docs/src/main/asciidoc/deploying-to-openshift.adoc +++ b/docs/src/main/asciidoc/deploying-to-openshift.adoc @@ -201,7 +201,7 @@ For example: [source,properties] ---- -quarkus.openshift.base-jvm-image=image-registry.openshift-image-registry.svc:5000/some-project/openjdk-17:17.1.16 +quarkus.openshift.base-jvm-image=image-registry.openshift-image-registry.svc:5000/some-project/openjdk-25:1.24 ---- [NOTE] diff --git a/docs/src/main/asciidoc/drools.adoc b/docs/src/main/asciidoc/drools.adoc index bd6c609c2db87..0a396e1d17617 100644 --- a/docs/src/main/asciidoc/drools.adoc +++ b/docs/src/main/asciidoc/drools.adoc @@ -306,7 +306,7 @@ To complete this guide, you need: * less than 15 minutes * an IDE -* JDK 17+ installed with `JAVA_HOME` configured appropriately +* JDK 21+ installed with `JAVA_HOME` configured appropriately * Apache Maven 3.9.3+ * Docker * link:{https://quarkus.io/guides/building-native-image}[GraalVM installed] if you want to run in native mode diff --git a/docs/src/main/asciidoc/funqy-gcp-functions.adoc b/docs/src/main/asciidoc/funqy-gcp-functions.adoc index eb5031037af24..7b0f2360273e1 100644 --- a/docs/src/main/asciidoc/funqy-gcp-functions.adoc +++ b/docs/src/main/asciidoc/funqy-gcp-functions.adoc @@ -143,7 +143,7 @@ You will have a single JAR inside the `target/deployment` repository that contai Then you will be able to use `gcloud` to deploy your function to Google Cloud. The `gcloud` command will be different depending on which event triggers your function. -NOTE: We will use the Java 21 runtime, but you can switch to the Java 17 runtime by using `--runtime=java17` instead of `--runtime=java21` on the deploy commands. +NOTE: We will use the Java 25 runtime, but you can switch to the Java 21 runtime by using `--runtime=java21` instead of `--runtime=java25` on the deploy commands. [WARNING] ==== @@ -164,7 +164,7 @@ Use this command to deploy to Google Cloud Functions: ---- gcloud functions deploy quarkus-example-funky-pubsub \ --entry-point=io.quarkus.funqy.gcp.functions.FunqyBackgroundFunction \ - --runtime=java21 --trigger-resource hello_topic --trigger-event google.pubsub.topic.publish \ + --runtime=java25 --trigger-resource hello_topic --trigger-event google.pubsub.topic.publish \ --source=target/deployment ---- @@ -201,7 +201,7 @@ Then, use this command to deploy to Google Cloud Functions: ---- gcloud functions deploy quarkus-example-funky-storage \ --entry-point=io.quarkus.funqy.gcp.functions.FunqyBackgroundFunction \ - --runtime=java21 --trigger-resource quarkus-hello --trigger-event google.storage.object.finalize \ + --runtime=java25 --trigger-resource quarkus-hello --trigger-event google.storage.object.finalize \ --source=target/deployment ---- @@ -240,7 +240,7 @@ Then, use this command to deploy to Google Cloud Functions: ---- gcloud functions deploy quarkus-example-cloud-event \ --entry-point=io.quarkus.funqy.gcp.functions.FunqyCloudEventsFunction \ - --runtime=java21 --trigger-bucket=example-cloud-event --source=target/deployment + --runtime=java25 --trigger-bucket=example-cloud-event --source=target/deployment ---- [IMPORTANT] diff --git a/docs/src/main/asciidoc/gcp-functions-http.adoc b/docs/src/main/asciidoc/gcp-functions-http.adoc index 1131646ba48b3..ef897b2b66908 100644 --- a/docs/src/main/asciidoc/gcp-functions-http.adoc +++ b/docs/src/main/asciidoc/gcp-functions-http.adoc @@ -159,13 +159,13 @@ The result of the previous command is a single JAR file inside the `target/deplo Then you will be able to use `gcloud` to deploy your function to Google Cloud. -NOTE: We will use the Java 21 runtime, but you can switch to the Java 17 runtime by using `--runtime=java17` instead of `--runtime=java21` on the deploy commands. +NOTE: We will use the Java 25 runtime, but you can switch to the Java 21 runtime by using `--runtime=java21` instead of `--runtime=java25` on the deploy commands. [source,bash] ---- gcloud functions deploy quarkus-example-http \ --entry-point=io.quarkus.gcp.functions.http.QuarkusHttpFunction \ - --runtime=java21 --trigger-http --allow-unauthenticated --source=target/deployment + --runtime=java25 --trigger-http --allow-unauthenticated --source=target/deployment ---- [IMPORTANT] diff --git a/docs/src/main/asciidoc/gcp-functions.adoc b/docs/src/main/asciidoc/gcp-functions.adoc index fe0de7ccdc218..8279e607982d4 100644 --- a/docs/src/main/asciidoc/gcp-functions.adoc +++ b/docs/src/main/asciidoc/gcp-functions.adoc @@ -237,7 +237,7 @@ The result of the previous command is a single JAR file inside the `target/deplo Then you will be able to use `gcloud` to deploy your function to Google Cloud. The `gcloud` command will be different depending on which event triggers your function. -NOTE: We will use the Java 21 runtime but you can switch to the Java 17 runtime by using `--runtime=java17` instead of `--runtime=java21` on the deploy commands. +NOTE: We will use the Java 25 runtime but you can switch to the Java 21 runtime by using `--runtime=java21` instead of `--runtime=java25` on the deploy commands. [WARNING] ==== @@ -257,7 +257,7 @@ This is an example command to deploy your `HttpFunction` to Google Cloud: ---- gcloud functions deploy quarkus-example-http \ --entry-point=io.quarkus.gcp.functions.QuarkusHttpFunction \ - --runtime=java21 --trigger-http --allow-unauthenticated --source=target/deployment + --runtime=java25 --trigger-http --allow-unauthenticated --source=target/deployment ---- [IMPORTANT] @@ -284,7 +284,7 @@ it needs to use `--trigger-event google.storage.object.finalize` and the `--trig gcloud functions deploy quarkus-example-storage \ --entry-point=io.quarkus.gcp.functions.QuarkusBackgroundFunction \ --trigger-resource quarkus-hello --trigger-event google.storage.object.finalize \ - --runtime=java21 --source=target/deployment + --runtime=java25 --source=target/deployment ---- [IMPORTANT] @@ -310,7 +310,7 @@ it needs to use `--trigger-event google.pubsub.topic.publish` and the `--trigger ---- gcloud functions deploy quarkus-example-pubsub \ --entry-point=io.quarkus.gcp.functions.QuarkusBackgroundFunction \ - --runtime=java21 --trigger-resource hello_topic --trigger-event google.pubsub.topic.publish --source=target/deployment + --runtime=java25 --trigger-resource hello_topic --trigger-event google.pubsub.topic.publish --source=target/deployment ---- [IMPORTANT] @@ -334,7 +334,7 @@ it needs to use `--trigger-bucket` parameter with the name of a previously creat ---- gcloud functions deploy quarkus-example-cloud-event \ --entry-point=io.quarkus.gcp.functions.QuarkusCloudEventsFunction \ - --runtime=java21 --trigger-bucket=example-cloud-event --source=target/deployment + --runtime=java25 --trigger-bucket=example-cloud-event --source=target/deployment ---- [IMPORTANT] diff --git a/docs/src/main/asciidoc/native-reference.adoc b/docs/src/main/asciidoc/native-reference.adoc index ae1700baf9ae4..23f01126ab3e1 100644 --- a/docs/src/main/asciidoc/native-reference.adoc +++ b/docs/src/main/asciidoc/native-reference.adoc @@ -805,7 +805,7 @@ You can find instructions on how to quickly set up this application in this guid This debugging guide has the following requirements: -* JDK 17 installed with `JAVA_HOME` configured appropriately +* JDK 21+ installed with `JAVA_HOME` configured appropriately * Apache Maven {maven-version} * A working container runtime (Docker, podman) diff --git a/docs/src/main/asciidoc/rest-virtual-threads.adoc b/docs/src/main/asciidoc/rest-virtual-threads.adoc index 927682f03ce9f..e90919ffb0445 100644 --- a/docs/src/main/asciidoc/rest-virtual-threads.adoc +++ b/docs/src/main/asciidoc/rest-virtual-threads.adoc @@ -68,7 +68,7 @@ Check the xref:./virtual-threads.adoc#why-not[virtual thread reference guide] fo We need to customize the `pom.xml` file to use virtual threads. -1) Locate the line with `17`, and replace it with: +1) Check that the `maven.compiler.release` property is 21 or later: [source, xml] ---- diff --git a/extensions/container-image/container-image-jib/deployment/src/main/java/io/quarkus/container/image/jib/deployment/ContainerImageJibConfig.java b/extensions/container-image/container-image-jib/deployment/src/main/java/io/quarkus/container/image/jib/deployment/ContainerImageJibConfig.java index f6646fda6388d..2678102342f7f 100644 --- a/extensions/container-image/container-image-jib/deployment/src/main/java/io/quarkus/container/image/jib/deployment/ContainerImageJibConfig.java +++ b/extensions/container-image/container-image-jib/deployment/src/main/java/io/quarkus/container/image/jib/deployment/ContainerImageJibConfig.java @@ -23,10 +23,7 @@ public interface ContainerImageJibConfig { * * When the application is built against Java 25 or higher, {@code registry.access.redhat.com/ubi10/openjdk-25-runtime:1.24} * is used as the default. - * Otherwise, when the application is built against Java 21 to Java 24, - * {@code registry.access.redhat.com/ubi10/openjdk-21-runtime:1.24} - * is used as the default. - * Otherwise {@code registry.access.redhat.com/ubi9/openjdk-17-runtime:1.24} is used as the default. + * Otherwise {@code registry.access.redhat.com/ubi10/openjdk-21-runtime:1.24} is used as the default. */ Optional baseJvmImage(); From 10849f72e2f72da674cf8bd8c4c3a4bc6d847e45 Mon Sep 17 00:00:00 2001 From: Roberto Cortez Date: Wed, 14 Oct 2026 16:10:57 +0100 Subject: [PATCH 204/254] Honor custom test port for @TestHTTPResource with Amazon Lambda HTTP --- .../lambda/AmazonLambdaCustomPortTest.java | 59 +++++++++++++++++++ .../quarkus/test/common/ListeningAddress.java | 3 + .../test/common/RestAssuredStateManager.java | 7 +-- .../common/http/TestHTTPResourceManager.java | 5 +- 4 files changed, 69 insertions(+), 5 deletions(-) create mode 100644 integration-tests/amazon-lambda-http/src/test/java/io/quarkus/it/amazon/lambda/AmazonLambdaCustomPortTest.java diff --git a/integration-tests/amazon-lambda-http/src/test/java/io/quarkus/it/amazon/lambda/AmazonLambdaCustomPortTest.java b/integration-tests/amazon-lambda-http/src/test/java/io/quarkus/it/amazon/lambda/AmazonLambdaCustomPortTest.java new file mode 100644 index 0000000000000..07d400b38844e --- /dev/null +++ b/integration-tests/amazon-lambda-http/src/test/java/io/quarkus/it/amazon/lambda/AmazonLambdaCustomPortTest.java @@ -0,0 +1,59 @@ +package io.quarkus.it.amazon.lambda; + +import static io.restassured.RestAssured.given; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.net.URL; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import io.quarkus.test.common.http.TestHTTPEndpoint; +import io.quarkus.test.common.http.TestHTTPResource; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.QuarkusTestProfile; +import io.quarkus.test.junit.TestProfile; + +/** + * {@code @TestHTTPResource} and {@code @TestHTTPEndpoint} must honor a custom test port, instead of falling back to + * 8081, when requests are served by the Lambda mock event server. + */ +@QuarkusTest +@TestProfile(AmazonLambdaCustomPortTest.CustomPortProfile.class) +class AmazonLambdaCustomPortTest { + @TestHTTPResource + @TestHTTPEndpoint(GreetingResource.class) + URL endpoint; + + @Test + void injectedUrlUsesConfiguredPort() { + assertEquals("http://localhost:8085/hello", endpoint.toString()); + } + + @Test + void requestToInjectedUrl() { + given() + .header("Access-Control-Request-Headers", "foo, bar, yello") + .when().get(endpoint + "/comma") + .then() + .statusCode(200); + } + + @Test + void restAssuredUsesConfiguredPort() { + given() + .header("Access-Control-Request-Headers", "foo, bar, yello") + .when().get("/hello/comma") + .then() + .statusCode(200); + } + + public static class CustomPortProfile implements QuarkusTestProfile { + @Override + public Map getConfigOverrides() { + return Map.of( + "quarkus.http.test-port", "8085", + "quarkus.lambda.mock-event-server.test-port", "8085"); + } + } +} diff --git a/test-framework/common/src/main/java/io/quarkus/test/common/ListeningAddress.java b/test-framework/common/src/main/java/io/quarkus/test/common/ListeningAddress.java index 6194ede94a0e4..9d2e1e49513fb 100644 --- a/test-framework/common/src/main/java/io/quarkus/test/common/ListeningAddress.java +++ b/test-framework/common/src/main/java/io/quarkus/test/common/ListeningAddress.java @@ -41,4 +41,7 @@ public void registerManagement(ValueRegistry valueRegistry, Config config) { public static final RuntimeKey MANAGEMENT_PORT = RuntimeKey.intKey("quarkus.management.port"); public static final RuntimeKey MANAGEMENT_TEST_PORT = RuntimeKey.intKey("quarkus.management.test-port"); public static final RuntimeKey LOCAL_MANAGEMENT_BASE_URI = RuntimeKey.key("quarkus.management.local-base-uri"); + + // Registered by the Amazon Lambda extension when requests are served by the mock event server + public static final RuntimeKey LAMBDA_BASE_URI = RuntimeKey.key("quarkus.lambda.local-base-uri"); } diff --git a/test-framework/common/src/main/java/io/quarkus/test/common/RestAssuredStateManager.java b/test-framework/common/src/main/java/io/quarkus/test/common/RestAssuredStateManager.java index fc329e73344ec..3373a85014922 100644 --- a/test-framework/common/src/main/java/io/quarkus/test/common/RestAssuredStateManager.java +++ b/test-framework/common/src/main/java/io/quarkus/test/common/RestAssuredStateManager.java @@ -1,5 +1,8 @@ package io.quarkus.test.common; +import static io.quarkus.test.common.ListeningAddress.LAMBDA_BASE_URI; +import static io.quarkus.test.common.ListeningAddress.LOCAL_BASE_URI; + import java.net.URI; import java.time.Duration; import java.util.Optional; @@ -7,7 +10,6 @@ import org.eclipse.microprofile.config.ConfigProvider; import io.quarkus.value.registry.ValueRegistry; -import io.quarkus.value.registry.ValueRegistry.RuntimeKey; import io.restassured.RestAssured; import io.restassured.config.HttpClientConfig; import io.restassured.path.json.JsonPath; @@ -122,9 +124,6 @@ public static void setURL(boolean useSecureConnection, Integer port, String addi } } - private static final RuntimeKey LOCAL_BASE_URI = RuntimeKey.key("quarkus.http.local-base-uri"); - private static final RuntimeKey LAMBDA_BASE_URI = RuntimeKey.key("quarkus.lambda.local-base-uri"); - public static void setTestUri(ValueRegistry valueRegister) { setTestUri(valueRegister, null); } diff --git a/test-framework/common/src/main/java/io/quarkus/test/common/http/TestHTTPResourceManager.java b/test-framework/common/src/main/java/io/quarkus/test/common/http/TestHTTPResourceManager.java index 2f49b1dd8fde3..7b86c5f9b937c 100644 --- a/test-framework/common/src/main/java/io/quarkus/test/common/http/TestHTTPResourceManager.java +++ b/test-framework/common/src/main/java/io/quarkus/test/common/http/TestHTTPResourceManager.java @@ -123,7 +123,10 @@ private static String getEndpointPath(List, String>> endpointP public static String testUrl(ValueRegistry valueRegistry, Config config, String... paths) { String host = host(config, "quarkus.http.host"); - int port = valueRegistry.getOrDefault(ListeningAddress.HTTP_TEST_PORT, 8081); + // When served by the Lambda mock event server, there is no Vert.x HTTP server registering the test port + int port = valueRegistry.containsKey(ListeningAddress.LAMBDA_BASE_URI) + ? valueRegistry.get(ListeningAddress.LAMBDA_BASE_URI).getPort() + : valueRegistry.getOrDefault(ListeningAddress.HTTP_TEST_PORT, 8081); String rootPath = rootPath(config, paths); return "http://" + host + ":" + port + rootPath; } From 98e05e8ef51aa8f67b47b4a3f5c8d23106038353 Mon Sep 17 00:00:00 2001 From: Matheus Cruz Date: Wed, 14 Oct 2026 12:52:05 -0300 Subject: [PATCH 205/254] Apply pull request suggestions from @gsmet Signed-off-by: Matheus Cruz --- docs/src/main/asciidoc/hibernate-orm.adoc | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/src/main/asciidoc/hibernate-orm.adoc b/docs/src/main/asciidoc/hibernate-orm.adoc index 1839ce585d454..3888469bbcc37 100644 --- a/docs/src/main/asciidoc/hibernate-orm.adoc +++ b/docs/src/main/asciidoc/hibernate-orm.adoc @@ -1297,8 +1297,16 @@ capable of exposing metrics that Hibernate ORM collects at runtime. To enable ex on the `/q/metrics` endpoint, make sure your project depends on a metrics extension and set the configuration property `quarkus.hibernate-orm.metrics.enabled` to `true`. -To log session-level metrics for each Hibernate session, enable the `org.hibernate.session.metrics` log -category; this only has an effect if statistics are enabled (`quarkus.hibernate-orm.statistics`). +To log session-level metrics for each Hibernate session, enable `DEBUG` logging for the +`org.hibernate.session.metrics` category: + +[source,properties] +---- +quarkus.hibernate-orm.statistics=true +quarkus.log.category."org.hibernate.session.metrics".level=DEBUG +---- + +This only has an effect if statistics are enabled (`quarkus.hibernate-orm.statistics`). == Limitations and other things you should know From 8c9d6251b6e46678ec27706eed9b75ffb529ea3e Mon Sep 17 00:00:00 2001 From: Matheus Cruz Date: Wed, 14 Oct 2026 12:53:35 -0300 Subject: [PATCH 206/254] Fix AgroalDataSourceUtil.activeDataSourceNames() to include named datasources Arc.container().select(AgroalDataSource.class) without any qualifiers implicitly resolves beans qualified default (standard CDI Instance semantics), so the enumeration only ever returned the default datasource and silently dropped every named one. This made FlywayContainerUtil.getActiveFlywayContainers() (used by the Flyway Dev UI page and its datasource count badge) and the equivalent Liquibase lookup miss any named datasource, even when that datasource, its Flyway container, and its migrations were genuinely active - the migrations still ran correctly since consumers that inject Flyway/the datasource by qualifier are unaffected, only the by-name CDI enumeration path was broken. Pass Any.Literal.INSTANCE explicitly so the lookup matches beans regardless of qualifier, as intended for an "enumerate everything" query. Signed-off-by: Matheus Cruz --- .../agroal/runtime/AgroalDataSourceUtil.java | 3 ++- ...lywayExtensionConfigMultiDataSourcesTest.java | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/extensions/agroal/runtime/src/main/java/io/quarkus/agroal/runtime/AgroalDataSourceUtil.java b/extensions/agroal/runtime/src/main/java/io/quarkus/agroal/runtime/AgroalDataSourceUtil.java index f5c0af0522274..cee673eefc8da 100644 --- a/extensions/agroal/runtime/src/main/java/io/quarkus/agroal/runtime/AgroalDataSourceUtil.java +++ b/extensions/agroal/runtime/src/main/java/io/quarkus/agroal/runtime/AgroalDataSourceUtil.java @@ -5,6 +5,7 @@ import java.util.Optional; import java.util.Set; +import jakarta.enterprise.inject.Any; import jakarta.enterprise.inject.Default; import jakarta.enterprise.inject.spi.Bean; @@ -34,7 +35,7 @@ public static Optional dataSourceIfActive(String dataSourceNam public static Set activeDataSourceNames() { Set activeNames = new LinkedHashSet<>(); - for (var handle : Arc.container().select(AgroalDataSource.class).handles()) { + for (var handle : Arc.container().select(AgroalDataSource.class, Any.Literal.INSTANCE).handles()) { var bean = handle.getBean(); if (bean != null && bean.isActive()) { String name = dataSourceName(bean); diff --git a/extensions/flyway/deployment/src/test/java/io/quarkus/flyway/test/FlywayExtensionConfigMultiDataSourcesTest.java b/extensions/flyway/deployment/src/test/java/io/quarkus/flyway/test/FlywayExtensionConfigMultiDataSourcesTest.java index 508d39ffc917d..9d560620a293f 100644 --- a/extensions/flyway/deployment/src/test/java/io/quarkus/flyway/test/FlywayExtensionConfigMultiDataSourcesTest.java +++ b/extensions/flyway/deployment/src/test/java/io/quarkus/flyway/test/FlywayExtensionConfigMultiDataSourcesTest.java @@ -1,5 +1,6 @@ package io.quarkus.flyway.test; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertFalse; import jakarta.inject.Inject; @@ -10,7 +11,9 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; +import io.quarkus.agroal.runtime.AgroalDataSourceUtil; import io.quarkus.flyway.FlywayDataSource; +import io.quarkus.flyway.runtime.FlywayContainerUtil; import io.quarkus.test.QuarkusExtensionTest; /** @@ -69,4 +72,17 @@ public void testFlywayConfigDirectlyNamedInventoryInjection() { fixture.assertAllConfigurationSettings(flywayNamedInventory.getConfiguration(), "inventory"); assertFalse(fixture.migrateAtStart("")); } + + @Test + @DisplayName("Enumerates all active datasources, not just the default one") + public void testActiveDataSourceNamesEnumeratesAllDatasources() { + assertThat(AgroalDataSourceUtil.activeDataSourceNames()) + .containsExactlyInAnyOrder("", "users", "inventory"); + } + + @Test + @DisplayName("Enumerates all active Flyway containers, not just the default one") + public void testActiveFlywayContainersEnumeratesAllDatasources() { + assertThat(FlywayContainerUtil.getActiveFlywayContainers()).hasSize(3); + } } From d4ad1545a064a1f8680f1188e8eace7b3477d8d2 Mon Sep 17 00:00:00 2001 From: jnbdz <237008+jnbdz@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:02:51 -0400 Subject: [PATCH 207/254] Let Quarkus REST build responses in a jlink image In a jlink image, Response.status() and every other response built through the JAX-RS RuntimeDelegate failed with "Quarkus REST server side components are not installed", so a request rejected by HTTP security answered 500 instead of 401. RuntimeDelegateImpl in resteasy-reactive-common looks up the server ResponseBuilderFactory through ServiceLoader, and the common module had no service dependency on the server module. ResteasyReactiveCommonProcessor now adds that dependency. --- .../deployment/ResteasyReactiveCommonProcessor.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/extensions/resteasy-reactive/rest-common/deployment/src/main/java/io/quarkus/resteasy/reactive/common/deployment/ResteasyReactiveCommonProcessor.java b/extensions/resteasy-reactive/rest-common/deployment/src/main/java/io/quarkus/resteasy/reactive/common/deployment/ResteasyReactiveCommonProcessor.java index a6db38ff4d00a..5e24578130ca3 100644 --- a/extensions/resteasy-reactive/rest-common/deployment/src/main/java/io/quarkus/resteasy/reactive/common/deployment/ResteasyReactiveCommonProcessor.java +++ b/extensions/resteasy-reactive/rest-common/deployment/src/main/java/io/quarkus/resteasy/reactive/common/deployment/ResteasyReactiveCommonProcessor.java @@ -60,6 +60,7 @@ import io.quarkus.deployment.builditem.nativeimage.ServiceProviderBuildItem; import io.quarkus.deployment.pkg.builditem.CurateOutcomeBuildItem; import io.quarkus.maven.dependency.ResolvedDependency; +import io.quarkus.modular.spi.items.AddDependencyBuildItem; import io.quarkus.resteasy.reactive.common.runtime.ResteasyReactiveCommonRecorder; import io.quarkus.resteasy.reactive.common.runtime.ResteasyReactiveConfig; import io.quarkus.resteasy.reactive.spi.AbstractInterceptorBuildItem; @@ -75,6 +76,7 @@ import io.quarkus.resteasy.reactive.spi.ReaderInterceptorBuildItem; import io.quarkus.resteasy.reactive.spi.WriterInterceptorBuildItem; import io.quarkus.security.spi.DefaultSecurityCheckBuildItem; +import io.smallrye.modules.desc.Dependency.Modifier; public class ResteasyReactiveCommonProcessor { @@ -134,6 +136,12 @@ void searchForProviders(Capabilities capabilities, "META-INF/services/" + jakarta.ws.rs.container.DynamicFeature.class.getName())); } + @BuildStep + AddDependencyBuildItem responseBuilderServices() { + return new AddDependencyBuildItem("io.quarkus.resteasy.reactive.common", "io.quarkus.resteasy.reactive", + Modifier.Set.of(Modifier.SERVICES, Modifier.OPTIONAL)); + } + @BuildStep void setUpDenyAllJaxRs(JaxRsSecurityConfig securityConfig, BuildProducer defaultSecurityCheckProducer) { From 73428b71fdbc06cda5c4655c472b0ecfbbe7177e Mon Sep 17 00:00:00 2001 From: jnbdz <237008+jnbdz@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:46:38 -0400 Subject: [PATCH 208/254] Bind the JAXB classes through a service The classes to be bound to the JAXB context are now set by a static-init service instead of a recorder. --- .../jaxb/deployment/JaxbProcessor.java | 20 +++++++++---------- .../runtime/JaxbContextConfigRecorder.java | 14 +++++++------ 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/extensions/jaxb/deployment/src/main/java/io/quarkus/jaxb/deployment/JaxbProcessor.java b/extensions/jaxb/deployment/src/main/java/io/quarkus/jaxb/deployment/JaxbProcessor.java index 77b82491a8be1..8f51c2cb2be9b 100644 --- a/extensions/jaxb/deployment/src/main/java/io/quarkus/jaxb/deployment/JaxbProcessor.java +++ b/extensions/jaxb/deployment/src/main/java/io/quarkus/jaxb/deployment/JaxbProcessor.java @@ -64,11 +64,11 @@ import io.quarkus.arc.deployment.SynthesisFinishedBuildItem; import io.quarkus.arc.processor.BeanInfo; import io.quarkus.arc.processor.BeanResolver; +import io.quarkus.core.Phase; +import io.quarkus.core.deployment.service.ServiceRegistrar; import io.quarkus.deployment.ApplicationArchive; import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; -import io.quarkus.deployment.annotations.ExecutionTime; -import io.quarkus.deployment.annotations.Record; import io.quarkus.deployment.builditem.ApplicationArchivesBuildItem; import io.quarkus.deployment.builditem.CombinedIndexBuildItem; import io.quarkus.deployment.builditem.nativeimage.NativeImageProxyDefinitionBuildItem; @@ -362,22 +362,22 @@ FilteredJaxbClassesToBeBoundBuildItem filterBoundClasses( } @BuildStep - @Record(ExecutionTime.STATIC_INIT) void bindClassesToJaxbContext( JaxbConfig config, FilteredJaxbClassesToBeBoundBuildItem filteredClassesToBeBound, SynthesisFinishedBuildItem beanContainerState, - JaxbContextConfigRecorder jaxbContextConfig /* Force the build time container to invoke this method */) { + ServiceRegistrar serviceRegistrar) { - jaxbContextConfig.reset(); final BeanResolver beanResolver = beanContainerState.getBeanResolver(); final Set beans = beanResolver .resolveBeans(Type.create(DotName.createSimple(JAXBContext.class), org.jboss.jandex.Type.Kind.CLASS)); - if (!beans.isEmpty()) { - jaxbContextConfig.addClassesToBeBound(filteredClassesToBeBound.getClasses()); - if (config.validateJaxbContext()) { - validateJaxbContext(filteredClassesToBeBound, beanResolver, beans); - } + final List> classes = beans.isEmpty() ? List.of() : List.copyOf(filteredClassesToBeBound.getClasses()); + serviceRegistrar + .forService("io.quarkus.jaxb.classes-to-be-bound") + .atPhase(Phase.STATIC_INIT) + .onStart(ctx -> JaxbContextConfigRecorder.bindClasses(classes)); + if (!beans.isEmpty() && config.validateJaxbContext()) { + validateJaxbContext(filteredClassesToBeBound, beanResolver, beans); } } diff --git a/extensions/jaxb/runtime/src/main/java/io/quarkus/jaxb/runtime/JaxbContextConfigRecorder.java b/extensions/jaxb/runtime/src/main/java/io/quarkus/jaxb/runtime/JaxbContextConfigRecorder.java index 7f95d48171dcc..4e500d991494e 100644 --- a/extensions/jaxb/runtime/src/main/java/io/quarkus/jaxb/runtime/JaxbContextConfigRecorder.java +++ b/extensions/jaxb/runtime/src/main/java/io/quarkus/jaxb/runtime/JaxbContextConfigRecorder.java @@ -5,20 +5,22 @@ import java.util.HashSet; import java.util.Set; -import io.quarkus.runtime.annotations.Recorder; - -@Recorder public class JaxbContextConfigRecorder { private volatile static Set> classesToBeBound = new HashSet<>(); - public void addClassesToBeBound(Collection> classes) { - this.classesToBeBound.addAll(classes); + public static void addClassesToBeBound(Collection> classes) { + classesToBeBound.addAll(classes); } - public void reset() { + public static void reset() { classesToBeBound.clear(); } + public static void bindClasses(Collection> classes) { + reset(); + addClassesToBeBound(classes); + } + public static Set> getClassesToBeBound() { return Collections.unmodifiableSet(classesToBeBound); } From c14f60a1f0d143a301722bbed69e41a7cd5dc97f Mon Sep 17 00:00:00 2001 From: Matheus Cruz Date: Wed, 14 Oct 2026 14:27:14 -0300 Subject: [PATCH 209/254] Remove bytecode recorder from extensions/observability-devservices Part of #57122. The dev resources shutdown hook now runs as a ServiceRegistrar service instead of a bytecode recorder. It registers the stop task through LastShutdownTasks.addRuntime, the same global "last shutdown task" bridge that now backs ShutdownContext.addLastShutdownTask, so dev resources still stop after everything else during shutdown. DevResourceShutdownRecorder is deleted since its single method was trivial and is now inlined directly into the service. Fixes #57449 --- .../deployment/DevResourcesProcessor.java | 14 +++++++------- .../runtime/DevResourceShutdownRecorder.java | 12 ------------ 2 files changed, 7 insertions(+), 19 deletions(-) delete mode 100644 extensions/observability-devservices/runtime/src/main/java/io/quarkus/observability/runtime/DevResourceShutdownRecorder.java diff --git a/extensions/observability-devservices/deployment/src/main/java/io/quarkus/observability/deployment/DevResourcesProcessor.java b/extensions/observability-devservices/deployment/src/main/java/io/quarkus/observability/deployment/DevResourcesProcessor.java index 1da7686404698..a4b034e215589 100644 --- a/extensions/observability-devservices/deployment/src/main/java/io/quarkus/observability/deployment/DevResourcesProcessor.java +++ b/extensions/observability-devservices/deployment/src/main/java/io/quarkus/observability/deployment/DevResourcesProcessor.java @@ -5,17 +5,16 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import io.quarkus.core.deployment.service.ServiceRegistrar; +import io.quarkus.core.impl.LastShutdownTasks; import io.quarkus.deployment.IsDevResourcesSupportedByLaunchMode; import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; import io.quarkus.deployment.annotations.BuildSteps; -import io.quarkus.deployment.annotations.ExecutionTime; -import io.quarkus.deployment.annotations.Record; import io.quarkus.deployment.builditem.FeatureBuildItem; import io.quarkus.deployment.builditem.LaunchModeBuildItem; import io.quarkus.deployment.builditem.RunTimeConfigBuilderBuildItem; -import io.quarkus.deployment.builditem.ShutdownContextBuildItem; -import io.quarkus.observability.runtime.DevResourceShutdownRecorder; +import io.quarkus.observability.devresource.DevResources; import io.quarkus.observability.runtime.DevResourcesConfigBuilder; import io.quarkus.observability.runtime.config.ObservabilityConfiguration; import io.quarkus.runtime.LaunchMode; @@ -44,9 +43,10 @@ public void registerDevResourcesConfigSource(LaunchModeBuildItem launchMode, } @BuildStep - @Record(ExecutionTime.RUNTIME_INIT) - public DevResourcesBuildItem shutdownDevResources(DevResourceShutdownRecorder recorder, ShutdownContextBuildItem shutdown) { - recorder.shutdown(shutdown); + public DevResourcesBuildItem shutdownDevResources(ServiceRegistrar serviceRegistrar) { + serviceRegistrar + .forService("io.quarkus.observability.dev-resources.shutdown") + .onStart(ctx -> LastShutdownTasks.addRuntime(DevResources::stop)); return new DevResourcesBuildItem(); } diff --git a/extensions/observability-devservices/runtime/src/main/java/io/quarkus/observability/runtime/DevResourceShutdownRecorder.java b/extensions/observability-devservices/runtime/src/main/java/io/quarkus/observability/runtime/DevResourceShutdownRecorder.java deleted file mode 100644 index 0d63c09b081dc..0000000000000 --- a/extensions/observability-devservices/runtime/src/main/java/io/quarkus/observability/runtime/DevResourceShutdownRecorder.java +++ /dev/null @@ -1,12 +0,0 @@ -package io.quarkus.observability.runtime; - -import io.quarkus.observability.devresource.DevResources; -import io.quarkus.runtime.ShutdownContext; -import io.quarkus.runtime.annotations.Recorder; - -@Recorder -public class DevResourceShutdownRecorder { - public void shutdown(ShutdownContext context) { - context.addLastShutdownTask(DevResources::stop); - } -} From 92359b6a718c450529f4d07ad340a12a29ac13bc Mon Sep 17 00:00:00 2001 From: Roberto Cortez Date: Wed, 14 Oct 2026 19:24:43 +0100 Subject: [PATCH 210/254] Honor QuarkusTestProfile#disableApplicationLifecycleObservers --- .../it/arc/LifecycleObserversService.java | 24 +++++++++++++++++ ...ableApplicationLifecycleObserversTest.java | 27 +++++++++++++++++++ .../test/junit/TestProfileAndProperties.java | 3 +++ 3 files changed, 54 insertions(+) create mode 100644 integration-tests/main/src/main/java/io/quarkus/it/arc/LifecycleObserversService.java create mode 100644 integration-tests/main/src/test/java/io/quarkus/it/main/DisableApplicationLifecycleObserversTest.java diff --git a/integration-tests/main/src/main/java/io/quarkus/it/arc/LifecycleObserversService.java b/integration-tests/main/src/main/java/io/quarkus/it/arc/LifecycleObserversService.java new file mode 100644 index 0000000000000..b5a7de0ec0d25 --- /dev/null +++ b/integration-tests/main/src/main/java/io/quarkus/it/arc/LifecycleObserversService.java @@ -0,0 +1,24 @@ +package io.quarkus.it.arc; + +import java.util.concurrent.atomic.AtomicBoolean; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; + +import io.quarkus.runtime.Startup; +import io.quarkus.runtime.StartupEvent; + +@ApplicationScoped +public class LifecycleObserversService { + public static final AtomicBoolean STARTUP_METHOD_CALLED = new AtomicBoolean(); + public static final AtomicBoolean STARTUP_EVENT_OBSERVED = new AtomicBoolean(); + + @Startup + void startup() { + STARTUP_METHOD_CALLED.set(true); + } + + void onStart(@Observes StartupEvent event) { + STARTUP_EVENT_OBSERVED.set(true); + } +} diff --git a/integration-tests/main/src/test/java/io/quarkus/it/main/DisableApplicationLifecycleObserversTest.java b/integration-tests/main/src/test/java/io/quarkus/it/main/DisableApplicationLifecycleObserversTest.java new file mode 100644 index 0000000000000..544a96c0b4c3d --- /dev/null +++ b/integration-tests/main/src/test/java/io/quarkus/it/main/DisableApplicationLifecycleObserversTest.java @@ -0,0 +1,27 @@ +package io.quarkus.it.main; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +import io.quarkus.it.arc.LifecycleObserversService; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.QuarkusTestProfile; +import io.quarkus.test.junit.TestProfile; + +@QuarkusTest +@TestProfile(DisableApplicationLifecycleObserversTest.DisableLifecycleObserversProfile.class) +class DisableApplicationLifecycleObserversTest { + @Test + void lifecycleObserversDisabled() { + assertThat(LifecycleObserversService.STARTUP_METHOD_CALLED).isFalse(); + assertThat(LifecycleObserversService.STARTUP_EVENT_OBSERVED).isFalse(); + } + + public static class DisableLifecycleObserversProfile implements QuarkusTestProfile { + @Override + public boolean disableApplicationLifecycleObservers() { + return true; + } + } +} diff --git a/test-framework/junit/src/main/java/io/quarkus/test/junit/TestProfileAndProperties.java b/test-framework/junit/src/main/java/io/quarkus/test/junit/TestProfileAndProperties.java index c0ef3c9649c93..b163f221a31e0 100644 --- a/test-framework/junit/src/main/java/io/quarkus/test/junit/TestProfileAndProperties.java +++ b/test-framework/junit/src/main/java/io/quarkus/test/junit/TestProfileAndProperties.java @@ -58,6 +58,9 @@ public static TestProfileAndProperties of(Class profileClass, LaunchMode laun }) .map(Class::getName).collect(Collectors.joining(","))); } + if (profileInstance.disableApplicationLifecycleObservers()) { + properties.put("quarkus.arc.test.disable-application-lifecycle-observers", "true"); + } String configProfile = profileInstance.getConfigProfile(); if (configProfile != null) { properties.put(launchMode.getProfileKey(), configProfile); From a7daacddcb71adf59fc817d459b6f3be566cd9b1 Mon Sep 17 00:00:00 2001 From: Andrea Boriero Date: Thu, 1 Oct 2026 13:33:26 +0200 Subject: [PATCH 211/254] Allow assigning entities to persistence units via class-level @PersistenceUnit annotations Fixes #52872 - Remove the build-time error that rejected class-level @PersistenceUnit on model classes - Process class-level annotations in JpaModelProcessor and assign the entity to the named persistence unit(s) - Track persistence units configured through class-level annotations separately, so validation correctly recognises them as a valid assignment mechanism - Update @PersistenceUnit.List @Target to include TYPE (required for repeatable use on classes) - Update the error message for unmapped named persistence units to mention class-level annotations - Convert MultiplePersistenceUnitsClassLevelAnnotationTest from an expected-exception test to a functional test - Add ClassLevelPersistenceUnitAnnotationTest for the dedicated class-level-only properties scenario --- .../JpaModelPerPersistenceUnitBuildItem.java | 9 +- .../deployment/model/JpaModelProcessor.java | 72 +++++-- ...assLevelPersistenceUnitAnnotationTest.java | 194 ++++++++++++++++++ ...sistenceUnitsClassLevelAnnotationTest.java | 36 +++- ...e-persistence-units-class-level.properties | 9 + .../hibernate/orm/PersistenceUnit.java | 16 +- 6 files changed, 309 insertions(+), 27 deletions(-) create mode 100644 extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/ClassLevelPersistenceUnitAnnotationTest.java create mode 100644 extensions/hibernate-orm/deployment/src/test/resources/application-multiple-persistence-units-class-level.properties diff --git a/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/JpaModelPerPersistenceUnitBuildItem.java b/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/JpaModelPerPersistenceUnitBuildItem.java index 01cad29637625..7ddee97ea0ee7 100644 --- a/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/JpaModelPerPersistenceUnitBuildItem.java +++ b/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/JpaModelPerPersistenceUnitBuildItem.java @@ -9,13 +9,16 @@ public final class JpaModelPerPersistenceUnitBuildItem extends SimpleBuildItem { private final Map modelPerPersistenceUnit; private final Set persistenceUnitsConfiguredThroughQuarkusConfiguration; private final Set persistenceUnitsConfiguredThroughPackageLevelAnnotations; + private final Set persistenceUnitsConfiguredThroughClassLevelAnnotations; public JpaModelPerPersistenceUnitBuildItem(Map modelPerPersistenceUnit, Set persistenceUnitsConfiguredThroughQuarkusConfiguration, - Set persistenceUnitsConfiguredThroughPackageLevelAnnotations) { + Set persistenceUnitsConfiguredThroughPackageLevelAnnotations, + Set persistenceUnitsConfiguredThroughClassLevelAnnotations) { this.modelPerPersistenceUnit = modelPerPersistenceUnit; this.persistenceUnitsConfiguredThroughQuarkusConfiguration = persistenceUnitsConfiguredThroughQuarkusConfiguration; this.persistenceUnitsConfiguredThroughPackageLevelAnnotations = persistenceUnitsConfiguredThroughPackageLevelAnnotations; + this.persistenceUnitsConfiguredThroughClassLevelAnnotations = persistenceUnitsConfiguredThroughClassLevelAnnotations; } public Map getModelPerPersistenceUnit() { @@ -29,4 +32,8 @@ public Set getPersistenceUnitsConfiguredThroughQuarkusConfiguration() { public Set getPersistenceUnitsConfiguredThroughPackageLevelAnnotations() { return persistenceUnitsConfiguredThroughPackageLevelAnnotations; } + + public Set getPersistenceUnitsConfiguredThroughClassLevelAnnotations() { + return persistenceUnitsConfiguredThroughClassLevelAnnotations; + } } diff --git a/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/model/JpaModelProcessor.java b/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/model/JpaModelProcessor.java index ae25a2c8343ef..7e9ebda376647 100644 --- a/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/model/JpaModelProcessor.java +++ b/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/model/JpaModelProcessor.java @@ -228,6 +228,7 @@ public JpaModelPerPersistenceUnitBuildItem buildJpaModelPerPersistenceUnit(Hiber Set persistenceUnitsConfiguredThroughQuarkusConfiguration = new LinkedHashSet<>(); Set persistenceUnitsConfiguredThroughPackageLevelAnnotations = new LinkedHashSet<>(); + Set persistenceUnitsConfiguredThroughClassLevelAnnotations = new LinkedHashSet<>(); if (hasPackagesInQuarkusConfig) { // Config based packages have priorities over annotations. @@ -274,16 +275,55 @@ public JpaModelPerPersistenceUnitBuildItem buildJpaModelPerPersistenceUnit(Hiber } } - Set modelClassesWithPersistenceUnitAnnotations = new TreeSet<>(); + Map> classLevelPersistenceUnitAssignments = new HashMap<>(); for (String modelClassName : jpaModel.getAllModelClassNames()) { ClassInfo modelClassInfo = index.getClassByName(DotName.createSimple(modelClassName)); Set relatedModelClassNames = getRelatedModelClassNames(index, jpaModel.getAllModelClassNames(), modelClassInfo); - if (modelClassInfo != null && (modelClassInfo.declaredAnnotation(ClassNames.QUARKUS_PERSISTENCE_UNIT) != null - || modelClassInfo.declaredAnnotation(ClassNames.QUARKUS_PERSISTENCE_UNIT_REPEATABLE_CONTAINER) != null)) { - modelClassesWithPersistenceUnitAnnotations.add(modelClassInfo.name().toString()); + // Check for class-level @PersistenceUnit annotations + if (modelClassInfo != null) { + Set classPersistenceUnits = new HashSet<>(); + AnnotationInstance singleAnnotation = modelClassInfo.declaredAnnotation(ClassNames.QUARKUS_PERSISTENCE_UNIT); + AnnotationInstance containerAnnotation = modelClassInfo + .declaredAnnotation(ClassNames.QUARKUS_PERSISTENCE_UNIT_REPEATABLE_CONTAINER); + + if (singleAnnotation != null) { + String persistenceUnitName = singleAnnotation.value().asString(); + if (persistenceUnitName != null && !persistenceUnitName.isEmpty()) { + classPersistenceUnits.add(persistenceUnitName); + } + } + + if (containerAnnotation != null) { + AnnotationInstance[] annotations = containerAnnotation.value().asNestedArray(); + for (AnnotationInstance annotation : annotations) { + String persistenceUnitName = annotation.value().asString(); + if (persistenceUnitName != null && !persistenceUnitName.isEmpty()) { + classPersistenceUnits.add(persistenceUnitName); + } + } + } + + if (!classPersistenceUnits.isEmpty()) { + classLevelPersistenceUnitAssignments.put(modelClassName, classPersistenceUnits); + persistenceUnitsConfiguredThroughClassLevelAnnotations.addAll(classPersistenceUnits); + for (String persistenceUnitName : classPersistenceUnits) { + var model = modelPerPersistenceUnit.computeIfAbsent(persistenceUnitName, + ignored -> new JpaPersistenceUnitModel()); + + if (jpaModel.getEntityClassNames().contains(modelClassName)) { + model.entityClassNames().add(modelClassName); + } + model.allModelClassNames().add(modelClassName); + + // also add the hierarchy to the persistence unit + // we would need to add all the underlying model to it but adding the hierarchy + // is necessary for Panache as we need to add PanacheEntity to the PU + model.allModelClassNames().addAll(relatedModelClassNames); + } + } } for (Entry> packageRuleEntry : packageRules.entrySet()) { @@ -308,6 +348,13 @@ public JpaModelPerPersistenceUnitBuildItem buildJpaModelPerPersistenceUnit(Hiber Set assignedEntityClassNames = new HashSet<>(); Set assignedModelClassAndPackageNames = new HashSet<>(); + + // Track classes assigned via class-level annotations + assignedEntityClassNames.addAll(classLevelPersistenceUnitAssignments.keySet().stream() + .filter(jpaModel.getEntityClassNames()::contains) + .toList()); + assignedModelClassAndPackageNames.addAll(classLevelPersistenceUnitAssignments.keySet()); + List nestedRepositories = new ArrayList<>(); for (AdditionalJpaModelBuildItem additionalJpaModel : additionalJpaModelBuildItems) { var className = additionalJpaModel.getClassName(); @@ -340,12 +387,6 @@ public JpaModelPerPersistenceUnitBuildItem buildJpaModelPerPersistenceUnit(Hiber } } - if (!modelClassesWithPersistenceUnitAnnotations.isEmpty()) { - throw new IllegalStateException(String.format(Locale.ROOT, - "@PersistenceUnit annotations are not supported at the class level on model classes:\n\t- %s\nUse the `.packages` configuration property or package-level annotations instead.", - String.join("\n\t- ", modelClassesWithPersistenceUnitAnnotations))); - } - for (String modelPackageName : jpaModel.getAllModelPackageNames()) { // Package rules keys are "normalized" package names, so we want to normalize the package on lookup: Set persistenceUnitNames = packageRules.get(normalizePackage(modelPackageName)); @@ -418,7 +459,8 @@ public JpaModelPerPersistenceUnitBuildItem buildJpaModelPerPersistenceUnit(Hiber return new JpaModelPerPersistenceUnitBuildItem(modelPerPersistenceUnit, persistenceUnitsConfiguredThroughQuarkusConfiguration, - persistenceUnitsConfiguredThroughPackageLevelAnnotations); + persistenceUnitsConfiguredThroughPackageLevelAnnotations, + persistenceUnitsConfiguredThroughClassLevelAnnotations); } @SuppressWarnings("deprecation") @@ -476,6 +518,8 @@ public void validateJpaModelPerPersistenceUnit(JpaModelPerPersistenceUnitBuildIt var pusWithPackageQuarkusConfig = jpaModelPerPersistenceUnit.getPersistenceUnitsConfiguredThroughQuarkusConfiguration(); var pusWithPackageLevelAnnotations = jpaModelPerPersistenceUnit .getPersistenceUnitsConfiguredThroughPackageLevelAnnotations(); + var pusWithClassLevelAnnotations = jpaModelPerPersistenceUnit + .getPersistenceUnitsConfiguredThroughClassLevelAnnotations(); Set pusWithoutPackageQuarkusConfig = new HashSet<>(puNames); pusWithoutPackageQuarkusConfig.removeAll(pusWithPackageQuarkusConfig); @@ -492,11 +536,13 @@ public void validateJpaModelPerPersistenceUnit(JpaModelPerPersistenceUnitBuildIt missingPackagePropertyKeys))); } } - if (pusWithPackageLevelAnnotations.isEmpty() && pusWithPackageQuarkusConfig.isEmpty()) { + if (pusWithPackageLevelAnnotations.isEmpty() && pusWithPackageQuarkusConfig.isEmpty() + && pusWithClassLevelAnnotations.isEmpty()) { validationErrors.produce(new ValidationErrorBuildItem(new ConfigurationException( """ Named persistence units are defined but the entities are not mapped to them. \ - You should either use the .packages Quarkus configuration property or package-level @PersistenceUnit annotations.\ + You should either use the .packages Quarkus configuration property, package-level @PersistenceUnit annotations, \ + or class-level @PersistenceUnit annotations.\ Refer to https://quarkus.io/guides/hibernate-orm#multiple-persistence-units for guidance. """, missingPackagePropertyKeys))); diff --git a/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/ClassLevelPersistenceUnitAnnotationTest.java b/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/ClassLevelPersistenceUnitAnnotationTest.java new file mode 100644 index 0000000000000..af3a8161268a1 --- /dev/null +++ b/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/ClassLevelPersistenceUnitAnnotationTest.java @@ -0,0 +1,194 @@ +package io.quarkus.hibernate.orm.multiplepersistenceunits; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import jakarta.inject.Inject; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityManager; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.transaction.Transactional; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.hibernate.orm.PersistenceUnit; +import io.quarkus.test.QuarkusExtensionTest; + +public class ClassLevelPersistenceUnitAnnotationTest { + + @RegisterExtension + static QuarkusExtensionTest runner = new QuarkusExtensionTest() + .withApplicationRoot((jar) -> jar + .addClass(EntityInInventoryPU.class) + .addClass(EntityInUsersPU.class) + .addClass(EntityWithMultiplePUs.class) + .addAsResource("application-multiple-persistence-units-class-level.properties", + "application.properties")); + + @Inject + @PersistenceUnit("users") + EntityManager usersEntityManager; + + @Inject + @PersistenceUnit("inventory") + EntityManager inventoryEntityManager; + + @Test + @Transactional + public void testEntityInInventoryPU() { + EntityInInventoryPU entity = new EntityInInventoryPU("test-inventory"); + inventoryEntityManager.persist(entity); + + EntityInInventoryPU savedEntity = inventoryEntityManager.find(EntityInInventoryPU.class, entity.getId()); + assertEquals(entity.getName(), savedEntity.getName()); + + // Entity should not be accessible from users PU + assertThatThrownBy(() -> usersEntityManager.find(EntityInInventoryPU.class, entity.getId())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unknown entity type"); + } + + @Test + @Transactional + public void testEntityInUsersPU() { + EntityInUsersPU entity = new EntityInUsersPU("test-user"); + usersEntityManager.persist(entity); + + EntityInUsersPU savedEntity = usersEntityManager.find(EntityInUsersPU.class, entity.getId()); + assertEquals(entity.getName(), savedEntity.getName()); + + // Entity should not be accessible from inventory PU + assertThatThrownBy(() -> inventoryEntityManager.find(EntityInUsersPU.class, entity.getId())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unknown entity type"); + } + + @Test + @Transactional + public void testEntityWithMultiplePUsInInventory() { + EntityWithMultiplePUs entity = new EntityWithMultiplePUs("shared-entity-inventory"); + inventoryEntityManager.persist(entity); + + EntityWithMultiplePUs savedEntity = inventoryEntityManager.find(EntityWithMultiplePUs.class, entity.getId()); + assertEquals(entity.getName(), savedEntity.getName()); + } + + @Test + @Transactional + public void testEntityWithMultiplePUsInUsers() { + EntityWithMultiplePUs entity = new EntityWithMultiplePUs("shared-entity-users"); + usersEntityManager.persist(entity); + + EntityWithMultiplePUs savedEntity = usersEntityManager.find(EntityWithMultiplePUs.class, entity.getId()); + assertEquals(entity.getName(), savedEntity.getName()); + } + + @Entity + @Table(name = "entity_inventory") + @PersistenceUnit("inventory") + public static class EntityInInventoryPU { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE) + private Long id; + + private String name; + + public EntityInInventoryPU() { + } + + public EntityInInventoryPU(String name) { + this.name = name; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + } + + @Entity + @Table(name = "entity_users") + @PersistenceUnit("users") + public static class EntityInUsersPU { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE) + private Long id; + + private String name; + + public EntityInUsersPU() { + } + + public EntityInUsersPU(String name) { + this.name = name; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + } + + @Entity + @Table(name = "entity_multiple") + @PersistenceUnit.List({ @PersistenceUnit("inventory"), @PersistenceUnit("users") }) + public static class EntityWithMultiplePUs { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE) + private Long id; + + private String name; + + public EntityWithMultiplePUs() { + } + + public EntityWithMultiplePUs(String name) { + this.name = name; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + } +} diff --git a/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/MultiplePersistenceUnitsClassLevelAnnotationTest.java b/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/MultiplePersistenceUnitsClassLevelAnnotationTest.java index 60224a7f0436d..a2473dca9b36b 100644 --- a/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/MultiplePersistenceUnitsClassLevelAnnotationTest.java +++ b/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/MultiplePersistenceUnitsClassLevelAnnotationTest.java @@ -1,11 +1,16 @@ package io.quarkus.hibernate.orm.multiplepersistenceunits; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import jakarta.inject.Inject; import jakarta.persistence.Entity; +import jakarta.persistence.EntityManager; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.transaction.Transactional; -import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; @@ -16,16 +21,33 @@ public class MultiplePersistenceUnitsClassLevelAnnotationTest { @RegisterExtension static QuarkusExtensionTest runner = new QuarkusExtensionTest() - .setExpectedException(IllegalStateException.class) .withApplicationRoot((jar) -> jar .addClass(EntityWithClassLevelPersistenceUnit.class) .addAsResource("application-multiple-persistence-units.properties", "application.properties")); + @Inject + @PersistenceUnit("inventory") + EntityManager inventoryEntityManager; + + @Inject + @PersistenceUnit("users") + EntityManager usersEntityManager; + @Test - public void testInvalidConfiguration() { - // deployment exception should happen first - Assertions.fail(); + @Transactional + public void testClassLevelAnnotation() { + EntityWithClassLevelPersistenceUnit entity = new EntityWithClassLevelPersistenceUnit("test-entity"); + inventoryEntityManager.persist(entity); + + EntityWithClassLevelPersistenceUnit savedEntity = inventoryEntityManager.find( + EntityWithClassLevelPersistenceUnit.class, entity.getId()); + assertEquals(entity.getName(), savedEntity.getName()); + + // Entity should not be accessible from users PU + assertThatThrownBy(() -> usersEntityManager.find(EntityWithClassLevelPersistenceUnit.class, entity.getId())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unknown entity type"); } @Entity @@ -39,6 +61,10 @@ public static class EntityWithClassLevelPersistenceUnit { public EntityWithClassLevelPersistenceUnit() { } + public EntityWithClassLevelPersistenceUnit(String name) { + this.name = name; + } + @Id @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "planeSeq") public long getId() { diff --git a/extensions/hibernate-orm/deployment/src/test/resources/application-multiple-persistence-units-class-level.properties b/extensions/hibernate-orm/deployment/src/test/resources/application-multiple-persistence-units-class-level.properties new file mode 100644 index 0000000000000..43522086470b0 --- /dev/null +++ b/extensions/hibernate-orm/deployment/src/test/resources/application-multiple-persistence-units-class-level.properties @@ -0,0 +1,9 @@ +quarkus.datasource.jdbc.max-size=1 + +quarkus.datasource.users.jdbc.min-size=2 + +quarkus.hibernate-orm.datasource= + +quarkus.hibernate-orm."users".datasource=users + +quarkus.hibernate-orm."inventory".datasource=inventory diff --git a/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/PersistenceUnit.java b/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/PersistenceUnit.java index 1bdfd2f285b77..99c84abc11b05 100644 --- a/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/PersistenceUnit.java +++ b/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/PersistenceUnit.java @@ -21,13 +21,13 @@ import io.quarkus.hibernate.orm.runtime.PersistenceUnitUtil; /** - * This annotation has two different purposes. - * It is a qualifier used to specify to which persistence unit the injected {@link EntityManagerFactory} or - * {@link EntityManager} belongs. - *

- * This allows for regular CDI bean injection of both interfaces. - *

- * It is also used to mark packages as part of a given persistence unit. + * This annotation has multiple purposes: + *

    + *
  • It is a qualifier used to specify to which persistence unit the injected {@link EntityManagerFactory} or + * {@link EntityManager} belongs. This allows for regular CDI bean injection of both interfaces.
  • + *
  • It is used to mark packages as part of a given persistence unit.
  • + *
  • It is used to mark classes (entities, mapped superclasses, embeddables) as part of a given persistence unit.
  • + *
*/ @Target({ TYPE, FIELD, METHOD, PARAMETER, PACKAGE }) @Retention(RUNTIME) @@ -54,7 +54,7 @@ public String value() { } } - @Target(PACKAGE) + @Target({ TYPE, PACKAGE }) @Retention(RUNTIME) @Documented @interface List { From f36fcef4a5b3318c3a3ec4440abb1261a876a6aa Mon Sep 17 00:00:00 2001 From: Andrea Boriero Date: Thu, 1 Oct 2026 17:04:16 +0200 Subject: [PATCH 212/254] Ignore class-level @PersistenceUnit annotations when .packages is configured Class-level @PersistenceUnit annotations were being processed even when .packages Quarkus configuration was present for a persistence unit, unlike package-level annotations which were already correctly ignored in that case. This caused entities to be silently assigned to persistence units via the annotation despite the mixing of configuration styles not being supported. - Skip class-level annotation processing when .packages config is present - Extend the existing mixing warning to also cover class-level annotations - Fix MultiplePersistenceUnitsClassLevelAnnotationTest, which was inadvertently relying on this behavior by mixing .packages config with a class-level annotation - Add MultiplePersistenceUnitsClassLevelAnnotationIgnoredWithPackagesTest to verify class-level annotations are ignored when .packages is set --- .../deployment/model/JpaModelProcessor.java | 29 ++++++++- ...evelAnnotationIgnoredWithPackagesTest.java | 59 +++++++++++++++++++ ...sistenceUnitsClassLevelAnnotationTest.java | 2 +- .../mixed/MixedConfigAndClassLevelEntity.java | 51 ++++++++++++++++ ...ass-level-ignored-with-packages.properties | 5 ++ 5 files changed, 142 insertions(+), 4 deletions(-) create mode 100644 extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/MultiplePersistenceUnitsClassLevelAnnotationIgnoredWithPackagesTest.java create mode 100644 extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/model/config/mixed/MixedConfigAndClassLevelEntity.java create mode 100644 extensions/hibernate-orm/deployment/src/test/resources/application-multiple-persistence-units-class-level-ignored-with-packages.properties diff --git a/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/model/JpaModelProcessor.java b/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/model/JpaModelProcessor.java index 7e9ebda376647..74c5a4b03dcb3 100644 --- a/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/model/JpaModelProcessor.java +++ b/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/model/JpaModelProcessor.java @@ -223,6 +223,8 @@ public JpaModelPerPersistenceUnitBuildItem buildJpaModelPerPersistenceUnit(Hiber boolean hasPackagesInQuarkusConfig = hasPackagesInQuarkusConfig(hibernateOrmConfig); Collection packageLevelPersistenceUnitAnnotations = getPackageLevelPersistenceUnitAnnotations( index); + Collection classLevelPersistenceUnitAnnotations = getClassLevelPersistenceUnitAnnotations( + index); Map> packageRules = new HashMap<>(); @@ -233,10 +235,10 @@ public JpaModelPerPersistenceUnitBuildItem buildJpaModelPerPersistenceUnit(Hiber if (hasPackagesInQuarkusConfig) { // Config based packages have priorities over annotations. // As long as there is one defined, annotations are ignored. - if (!packageLevelPersistenceUnitAnnotations.isEmpty()) { + if (!packageLevelPersistenceUnitAnnotations.isEmpty() || !classLevelPersistenceUnitAnnotations.isEmpty()) { // TODO shouldn't this be an error? LOG.warn( - "Mixing Quarkus configuration and @PersistenceUnit annotations to define the persistence units is not supported. Ignoring the annotations."); + "Mixing Quarkus configuration and @PersistenceUnit annotations (package-level or class-level) to define the persistence units is not supported. Ignoring the annotations."); } for (Entry candidatePersistenceUnitEntry : hibernateOrmConfig @@ -283,7 +285,9 @@ public JpaModelPerPersistenceUnitBuildItem buildJpaModelPerPersistenceUnit(Hiber modelClassInfo); // Check for class-level @PersistenceUnit annotations - if (modelClassInfo != null) { + // Config based packages have priority: when .packages is configured, class-level + // annotations are ignored (see warning above). + if (!hasPackagesInQuarkusConfig && modelClassInfo != null) { Set classPersistenceUnits = new HashSet<>(); AnnotationInstance singleAnnotation = modelClassInfo.declaredAnnotation(ClassNames.QUARKUS_PERSISTENCE_UNIT); AnnotationInstance containerAnnotation = modelClassInfo @@ -661,4 +665,23 @@ private static Collection getPackageLevelPersistenceUnitAnno return packageLevelPersistenceUnitAnnotations; } + + private static Collection getClassLevelPersistenceUnitAnnotations(IndexView index) { + Collection persistenceUnitAnnotations = index + .getAnnotationsWithRepeatable(ClassNames.QUARKUS_PERSISTENCE_UNIT, index); + Collection classLevelPersistenceUnitAnnotations = new ArrayList<>(); + + for (AnnotationInstance persistenceUnitAnnotation : persistenceUnitAnnotations) { + if (persistenceUnitAnnotation.target().kind() != Kind.CLASS) { + continue; + } + + if ("package-info".equals(persistenceUnitAnnotation.target().asClass().simpleName())) { + continue; + } + classLevelPersistenceUnitAnnotations.add(persistenceUnitAnnotation); + } + + return classLevelPersistenceUnitAnnotations; + } } diff --git a/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/MultiplePersistenceUnitsClassLevelAnnotationIgnoredWithPackagesTest.java b/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/MultiplePersistenceUnitsClassLevelAnnotationIgnoredWithPackagesTest.java new file mode 100644 index 0000000000000..bd3ddc5f32ad5 --- /dev/null +++ b/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/MultiplePersistenceUnitsClassLevelAnnotationIgnoredWithPackagesTest.java @@ -0,0 +1,59 @@ +package io.quarkus.hibernate.orm.multiplepersistenceunits; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import jakarta.inject.Inject; +import jakarta.persistence.EntityManager; +import jakarta.transaction.Transactional; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.hibernate.orm.PersistenceUnit; +import io.quarkus.hibernate.orm.multiplepersistenceunits.model.config.inventory.Plane; +import io.quarkus.hibernate.orm.multiplepersistenceunits.model.config.mixed.MixedConfigAndClassLevelEntity; +import io.quarkus.test.QuarkusExtensionTest; + +// Reproducer for the case where .packages Quarkus configuration is used together with +// a class-level @PersistenceUnit annotation on an entity whose package is assigned, +// through configuration, to a persistence unit different from the one targeted by the +// annotation. The .packages configuration must win and the annotation must be ignored. +public class MultiplePersistenceUnitsClassLevelAnnotationIgnoredWithPackagesTest { + + @RegisterExtension + static QuarkusExtensionTest runner = new QuarkusExtensionTest() + .withApplicationRoot((jar) -> jar + .addClass(MixedConfigAndClassLevelEntity.class) + .addClass(Plane.class) + .addAsResource("application-multiple-persistence-units-class-level-ignored-with-packages.properties", + "application.properties")); + + @Inject + @PersistenceUnit("users") + EntityManager usersEntityManager; + + @Inject + @PersistenceUnit("inventory") + EntityManager inventoryEntityManager; + + @Test + @Transactional + public void classLevelAnnotationIsIgnoredInFavorOfPackagesConfiguration() { + // MixedConfigAndClassLevelEntity is in a package assigned, via .packages configuration, + // to the "users" persistence unit, even though it also carries a class-level + // @PersistenceUnit("inventory") annotation. + MixedConfigAndClassLevelEntity entity = new MixedConfigAndClassLevelEntity("test"); + usersEntityManager.persist(entity); + + MixedConfigAndClassLevelEntity savedEntity = usersEntityManager.find(MixedConfigAndClassLevelEntity.class, + entity.getId()); + assertEquals(entity.getName(), savedEntity.getName()); + + // The class-level annotation targeting "inventory" must be ignored: + // the entity must not be known to the inventory persistence unit. + assertThatThrownBy(() -> inventoryEntityManager.find(MixedConfigAndClassLevelEntity.class, entity.getId())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unknown entity type"); + } +} diff --git a/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/MultiplePersistenceUnitsClassLevelAnnotationTest.java b/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/MultiplePersistenceUnitsClassLevelAnnotationTest.java index a2473dca9b36b..484a272fd7db8 100644 --- a/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/MultiplePersistenceUnitsClassLevelAnnotationTest.java +++ b/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/MultiplePersistenceUnitsClassLevelAnnotationTest.java @@ -23,7 +23,7 @@ public class MultiplePersistenceUnitsClassLevelAnnotationTest { static QuarkusExtensionTest runner = new QuarkusExtensionTest() .withApplicationRoot((jar) -> jar .addClass(EntityWithClassLevelPersistenceUnit.class) - .addAsResource("application-multiple-persistence-units.properties", + .addAsResource("application-multiple-persistence-units-class-level.properties", "application.properties")); @Inject diff --git a/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/model/config/mixed/MixedConfigAndClassLevelEntity.java b/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/model/config/mixed/MixedConfigAndClassLevelEntity.java new file mode 100644 index 0000000000000..b39517e208b21 --- /dev/null +++ b/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/model/config/mixed/MixedConfigAndClassLevelEntity.java @@ -0,0 +1,51 @@ +package io.quarkus.hibernate.orm.multiplepersistenceunits.model.config.mixed; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; + +import io.quarkus.hibernate.orm.PersistenceUnit; + +// This entity belongs to a package assigned, through Quarkus configuration (.packages), +// to the "users" persistence unit. The class-level @PersistenceUnit("inventory") annotation +// below must be ignored, since mixing .packages configuration with @PersistenceUnit +// annotations (package-level or class-level) is not supported: .packages configuration wins. +@Entity +@PersistenceUnit("inventory") +public class MixedConfigAndClassLevelEntity { + + private long id; + + private String name; + + public MixedConfigAndClassLevelEntity() { + } + + public MixedConfigAndClassLevelEntity(String name) { + this.name = name; + } + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "mixedSeq") + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + @Override + public String toString() { + return "MixedConfigAndClassLevelEntity:" + name; + } +} diff --git a/extensions/hibernate-orm/deployment/src/test/resources/application-multiple-persistence-units-class-level-ignored-with-packages.properties b/extensions/hibernate-orm/deployment/src/test/resources/application-multiple-persistence-units-class-level-ignored-with-packages.properties new file mode 100644 index 0000000000000..5610bb32f61f4 --- /dev/null +++ b/extensions/hibernate-orm/deployment/src/test/resources/application-multiple-persistence-units-class-level-ignored-with-packages.properties @@ -0,0 +1,5 @@ +quarkus.hibernate-orm."users".datasource=users +quarkus.hibernate-orm."users".packages=io.quarkus.hibernate.orm.multiplepersistenceunits.model.config.mixed + +quarkus.hibernate-orm."inventory".datasource=inventory +quarkus.hibernate-orm."inventory".packages=io.quarkus.hibernate.orm.multiplepersistenceunits.model.config.inventory From 8dfcc8f88e8ac39dbbc737069094e0389472e9da Mon Sep 17 00:00:00 2001 From: Andrea Boriero Date: Thu, 1 Oct 2026 17:37:03 +0200 Subject: [PATCH 213/254] Let class-level @PersistenceUnit annotations override package-level ones A model class in a package mapped to a persistence unit via a package-level @PersistenceUnit annotation was also being assigned to the persistence unit set by its own class-level @PersistenceUnit annotation, ending up registered in both. The class-level annotation is more specific and should take precedence. Skip package-rule matching for classes that already have an explicit class-level persistence unit assignment. --- .../deployment/model/JpaModelProcessor.java | 6 +++ ...nOverriddenByClassLevelAnnotationTest.java | 51 +++++++++++++++++++ .../annotation/override/OverriddenEntity.java | 46 +++++++++++++++++ .../annotation/override/package-info.java | 4 ++ ...sistence-units-package-override.properties | 9 ++++ 5 files changed, 116 insertions(+) create mode 100644 extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/PackageLevelAnnotationOverriddenByClassLevelAnnotationTest.java create mode 100644 extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/model/annotation/override/OverriddenEntity.java create mode 100644 extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/model/annotation/override/package-info.java create mode 100644 extensions/hibernate-orm/deployment/src/test/resources/application-multiple-persistence-units-package-override.properties diff --git a/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/model/JpaModelProcessor.java b/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/model/JpaModelProcessor.java index 74c5a4b03dcb3..95ec2d9ce8e5f 100644 --- a/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/model/JpaModelProcessor.java +++ b/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/model/JpaModelProcessor.java @@ -330,6 +330,12 @@ public JpaModelPerPersistenceUnitBuildItem buildJpaModelPerPersistenceUnit(Hiber } } + // A class-level @PersistenceUnit annotation takes precedence over a package-level one: + // skip package rules entirely for classes that already got an explicit class-level assignment. + if (classLevelPersistenceUnitAssignments.containsKey(modelClassName)) { + continue; + } + for (Entry> packageRuleEntry : packageRules.entrySet()) { if (modelClassName.startsWith(packageRuleEntry.getKey())) { for (String persistenceUnitName : packageRuleEntry.getValue()) { diff --git a/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/PackageLevelAnnotationOverriddenByClassLevelAnnotationTest.java b/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/PackageLevelAnnotationOverriddenByClassLevelAnnotationTest.java new file mode 100644 index 0000000000000..72b2332cfb444 --- /dev/null +++ b/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/PackageLevelAnnotationOverriddenByClassLevelAnnotationTest.java @@ -0,0 +1,51 @@ +package io.quarkus.hibernate.orm.multiplepersistenceunits; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import jakarta.inject.Inject; +import jakarta.persistence.EntityManager; +import jakarta.transaction.Transactional; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.hibernate.orm.PersistenceUnit; +import io.quarkus.hibernate.orm.multiplepersistenceunits.model.annotation.override.OverriddenEntity; +import io.quarkus.test.QuarkusExtensionTest; + +// An entity's class-level @PersistenceUnit annotation must take precedence over a +// package-level @PersistenceUnit annotation applied to the package the entity belongs to. +public class PackageLevelAnnotationOverriddenByClassLevelAnnotationTest { + + @RegisterExtension + static QuarkusExtensionTest runner = new QuarkusExtensionTest() + .withApplicationRoot((jar) -> jar + .addPackage(OverriddenEntity.class.getPackage().getName()) + .addAsResource("application-multiple-persistence-units-package-override.properties", + "application.properties")); + + @Inject + @PersistenceUnit("users") + EntityManager usersEntityManager; + + @Inject + @PersistenceUnit("inventory") + EntityManager inventoryEntityManager; + + @Test + @Transactional + public void entityIsOnlyInClassLevelPersistenceUnit() { + OverriddenEntity entity = new OverriddenEntity("test"); + usersEntityManager.persist(entity); + + OverriddenEntity savedEntity = usersEntityManager.find(OverriddenEntity.class, entity.getId()); + assertEquals(entity.getName(), savedEntity.getName()); + + // The package-level annotation says "inventory", but the class-level annotation ("users") + // must win: the entity should not be known to the "inventory" persistence unit. + assertThatThrownBy(() -> inventoryEntityManager.find(OverriddenEntity.class, entity.getId())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unknown entity type"); + } +} diff --git a/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/model/annotation/override/OverriddenEntity.java b/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/model/annotation/override/OverriddenEntity.java new file mode 100644 index 0000000000000..e825b84d47baa --- /dev/null +++ b/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/model/annotation/override/OverriddenEntity.java @@ -0,0 +1,46 @@ +package io.quarkus.hibernate.orm.multiplepersistenceunits.model.annotation.override; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; + +import io.quarkus.hibernate.orm.PersistenceUnit; + +// This class lives in a package mapped to the "inventory" persistence unit through the +// package-level @PersistenceUnit annotation declared in package-info.java, +// but it carries its own class-level @PersistenceUnit annotation pointing to "users". +// The class-level annotation is expected to take precedence over the package-level one. +@Entity +@PersistenceUnit("users") +public class OverriddenEntity { + + private long id; + + private String name; + + public OverriddenEntity() { + } + + public OverriddenEntity(String name) { + this.name = name; + } + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "overriddenEntitySeq") + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } +} diff --git a/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/model/annotation/override/package-info.java b/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/model/annotation/override/package-info.java new file mode 100644 index 0000000000000..1276e5a29aea8 --- /dev/null +++ b/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/model/annotation/override/package-info.java @@ -0,0 +1,4 @@ +@PersistenceUnit("inventory") +package io.quarkus.hibernate.orm.multiplepersistenceunits.model.annotation.override; + +import io.quarkus.hibernate.orm.PersistenceUnit; diff --git a/extensions/hibernate-orm/deployment/src/test/resources/application-multiple-persistence-units-package-override.properties b/extensions/hibernate-orm/deployment/src/test/resources/application-multiple-persistence-units-package-override.properties new file mode 100644 index 0000000000000..43522086470b0 --- /dev/null +++ b/extensions/hibernate-orm/deployment/src/test/resources/application-multiple-persistence-units-package-override.properties @@ -0,0 +1,9 @@ +quarkus.datasource.jdbc.max-size=1 + +quarkus.datasource.users.jdbc.min-size=2 + +quarkus.hibernate-orm.datasource= + +quarkus.hibernate-orm."users".datasource=users + +quarkus.hibernate-orm."inventory".datasource=inventory From ae329db29224790cc714c922012eb133541f0d40 Mon Sep 17 00:00:00 2001 From: Andrea Boriero Date: Fri, 2 Oct 2026 13:59:06 +0200 Subject: [PATCH 214/254] Document class-level @PersistenceUnit annotations for multiple persistence units --- docs/src/main/asciidoc/hibernate-orm.adoc | 71 +++++++++++++++++++---- 1 file changed, 61 insertions(+), 10 deletions(-) diff --git a/docs/src/main/asciidoc/hibernate-orm.adoc b/docs/src/main/asciidoc/hibernate-orm.adoc index ffdb8e7407201..9f416d5944dab 100644 --- a/docs/src/main/asciidoc/hibernate-orm.adoc +++ b/docs/src/main/asciidoc/hibernate-orm.adoc @@ -377,12 +377,19 @@ It is perfectly valid to have several persistence units pointing to the same dat [[multiple-persistence-units-attaching-model-classes]] ==== Attaching model classes to persistence units -There are two ways to attach model classes to persistence units, and they should not be mixed: +There are three ways to attach model classes to persistence units: * Via the `packages` configuration property; -* Via the `@io.quarkus.hibernate.orm.PersistenceUnit` package-level annotation. +* Via the `@io.quarkus.hibernate.orm.PersistenceUnit` package-level annotation; +* Via the `@io.quarkus.hibernate.orm.PersistenceUnit` class-level annotation. -If both are mixed, the annotations are ignored and only the `packages` configuration properties are taken into account. +The `packages` configuration property and the annotations (package-level or class-level) should not be mixed: +if the `packages` configuration property is used for at least one persistence unit, all `@PersistenceUnit` annotations are ignored +and only the `packages` configuration properties are taken into account. + +The package-level and class-level annotations, on the other hand, can be combined: +a class-level `@PersistenceUnit` annotation always takes precedence over a package-level one for that particular class, +which makes it convenient to assign a whole package to a persistence unit while moving a few specific classes to another one. Using the `packages` configuration property is simple: @@ -429,7 +436,7 @@ contact us on the link:{quarkus-mailing-list-index}[quarkus-dev mailing list]. ==== The second approach to attach model classes to a persistence unit is to use package-level `@io.quarkus.hibernate.orm.PersistenceUnit` annotations. -Again, the two approaches cannot be mixed. +This approach cannot be mixed with the `packages` configuration property. To obtain a configuration similar to the one above with the `packages` configuration property, create a `package-info.java` file with the following content: @@ -442,14 +449,58 @@ import io.quarkus.hibernate.orm.PersistenceUnit; ---- <1> Be careful, use the `@io.quarkus.hibernate.orm.PersistenceUnit` annotation, not the Jakarta Persistence one. -[CAUTION] -==== -We only support defining the `@PersistenceUnit` for model classes at the package level, -using the `@PersistenceUnit` annotation at the class level is not supported in this case. -==== - Note that, similarly to what we do with the configuration property, we take into account the annotated package but also all its subpackages. +The third approach is to annotate the model class itself with `@io.quarkus.hibernate.orm.PersistenceUnit`. +This approach also cannot be mixed with the `packages` configuration property. + +[source,java] +---- +@Entity +@PersistenceUnit("users") <1> +public class User { + // ... +} +---- +<1> Be careful, use the `@io.quarkus.hibernate.orm.PersistenceUnit` annotation, not the Jakarta Persistence one. + +A model class can be attached to several persistence units by repeating the annotation: + +[source,java] +---- +@Entity +@PersistenceUnit("users") +@PersistenceUnit("inventory") +public class SharedEntity { + // ... +} +---- + +As mentioned above, a class-level annotation takes precedence over a package-level one. +This is useful when most classes of a package belong to one persistence unit, +but a few classes need to be attached to a different one. + +All classes in the `org.acme.model.user` package are attached to the `users` persistence unit by default: + +[source,java] +---- +@PersistenceUnit("users") +package org.acme.model.user; + +import io.quarkus.hibernate.orm.PersistenceUnit; +---- + +Except for `Warehouse`, which is explicitly attached to the `inventory` persistence unit instead, overriding the package-level annotation: + +[source,java] +---- +@Entity +@PersistenceUnit("inventory") +public class Warehouse { + // ... +} +---- + [[persistence-unit-detection]] === Conditions for persistence unit configuration From 53e68f6cc4f1b6986667999469ab635d43e589d3 Mon Sep 17 00:00:00 2001 From: Andrea Boriero Date: Mon, 12 Oct 2026 16:11:11 +0200 Subject: [PATCH 215/254] Allow assigning entities to persistence units on a per-class basis improvements --- .../deployment/model/JpaModelProcessor.java | 97 +++------ ...assLevelPersistenceUnitAnnotationTest.java | 194 ------------------ ...evelAnnotationIgnoredWithPackagesTest.java | 12 +- ...sistenceUnitsClassLevelAnnotationTest.java | 149 ++++++++++++-- ...nOverriddenByClassLevelAnnotationTest.java | 27 ++- .../EntityWithoutClassLevelAnnotation.java | 42 ++++ ...ass-level-ignored-with-packages.properties | 5 - ...e-persistence-units-class-level.properties | 9 - ...sistence-units-package-override.properties | 9 - 9 files changed, 239 insertions(+), 305 deletions(-) delete mode 100644 extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/ClassLevelPersistenceUnitAnnotationTest.java create mode 100644 extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/model/annotation/override/EntityWithoutClassLevelAnnotation.java delete mode 100644 extensions/hibernate-orm/deployment/src/test/resources/application-multiple-persistence-units-class-level-ignored-with-packages.properties delete mode 100644 extensions/hibernate-orm/deployment/src/test/resources/application-multiple-persistence-units-class-level.properties delete mode 100644 extensions/hibernate-orm/deployment/src/test/resources/application-multiple-persistence-units-package-override.properties diff --git a/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/model/JpaModelProcessor.java b/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/model/JpaModelProcessor.java index 95ec2d9ce8e5f..c0845d20a72d2 100644 --- a/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/model/JpaModelProcessor.java +++ b/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/model/JpaModelProcessor.java @@ -277,82 +277,53 @@ public JpaModelPerPersistenceUnitBuildItem buildJpaModelPerPersistenceUnit(Hiber } } + // Config based packages have priority: when .packages is configured, class-level + // annotations are ignored (see warning above). Map> classLevelPersistenceUnitAssignments = new HashMap<>(); + if (!hasPackagesInQuarkusConfig) { + for (AnnotationInstance classLevelPersistenceUnitAnnotation : classLevelPersistenceUnitAnnotations) { + String modelClassName = classLevelPersistenceUnitAnnotation.target().asClass().name().toString(); + String persistenceUnitName = classLevelPersistenceUnitAnnotation.value().asString(); + if (persistenceUnitName != null && !persistenceUnitName.isEmpty()) { + classLevelPersistenceUnitAssignments.computeIfAbsent(modelClassName, c -> new HashSet<>()) + .add(persistenceUnitName); + persistenceUnitsConfiguredThroughClassLevelAnnotations.add(persistenceUnitName); + } + } + } for (String modelClassName : jpaModel.getAllModelClassNames()) { ClassInfo modelClassInfo = index.getClassByName(DotName.createSimple(modelClassName)); Set relatedModelClassNames = getRelatedModelClassNames(index, jpaModel.getAllModelClassNames(), modelClassInfo); - // Check for class-level @PersistenceUnit annotations - // Config based packages have priority: when .packages is configured, class-level - // annotations are ignored (see warning above). - if (!hasPackagesInQuarkusConfig && modelClassInfo != null) { - Set classPersistenceUnits = new HashSet<>(); - AnnotationInstance singleAnnotation = modelClassInfo.declaredAnnotation(ClassNames.QUARKUS_PERSISTENCE_UNIT); - AnnotationInstance containerAnnotation = modelClassInfo - .declaredAnnotation(ClassNames.QUARKUS_PERSISTENCE_UNIT_REPEATABLE_CONTAINER); - - if (singleAnnotation != null) { - String persistenceUnitName = singleAnnotation.value().asString(); - if (persistenceUnitName != null && !persistenceUnitName.isEmpty()) { - classPersistenceUnits.add(persistenceUnitName); - } - } - - if (containerAnnotation != null) { - AnnotationInstance[] annotations = containerAnnotation.value().asNestedArray(); - for (AnnotationInstance annotation : annotations) { - String persistenceUnitName = annotation.value().asString(); - if (persistenceUnitName != null && !persistenceUnitName.isEmpty()) { - classPersistenceUnits.add(persistenceUnitName); - } - } - } - - if (!classPersistenceUnits.isEmpty()) { - classLevelPersistenceUnitAssignments.put(modelClassName, classPersistenceUnits); - persistenceUnitsConfiguredThroughClassLevelAnnotations.addAll(classPersistenceUnits); - for (String persistenceUnitName : classPersistenceUnits) { - var model = modelPerPersistenceUnit.computeIfAbsent(persistenceUnitName, - ignored -> new JpaPersistenceUnitModel()); - - if (jpaModel.getEntityClassNames().contains(modelClassName)) { - model.entityClassNames().add(modelClassName); - } - model.allModelClassNames().add(modelClassName); - - // also add the hierarchy to the persistence unit - // we would need to add all the underlying model to it but adding the hierarchy - // is necessary for Panache as we need to add PanacheEntity to the PU - model.allModelClassNames().addAll(relatedModelClassNames); + // A class-level @PersistenceUnit annotation takes precedence over a package-level one: + // only apply package rules to classes without an explicit class-level assignment. + Set classPersistenceUnits = classLevelPersistenceUnitAssignments.get(modelClassName); + Set persistenceUnits = new HashSet<>(); + if (classPersistenceUnits != null) { + persistenceUnits.addAll(classPersistenceUnits); + } else { + for (Entry> packageRuleEntry : packageRules.entrySet()) { + if (modelClassName.startsWith(packageRuleEntry.getKey())) { + persistenceUnits.addAll(packageRuleEntry.getValue()); } } } - // A class-level @PersistenceUnit annotation takes precedence over a package-level one: - // skip package rules entirely for classes that already got an explicit class-level assignment. - if (classLevelPersistenceUnitAssignments.containsKey(modelClassName)) { - continue; - } + for (String persistenceUnitName : persistenceUnits) { + var model = modelPerPersistenceUnit.computeIfAbsent(persistenceUnitName, + ignored -> new JpaPersistenceUnitModel()); - for (Entry> packageRuleEntry : packageRules.entrySet()) { - if (modelClassName.startsWith(packageRuleEntry.getKey())) { - for (String persistenceUnitName : packageRuleEntry.getValue()) { - var model = modelPerPersistenceUnit.computeIfAbsent(persistenceUnitName, - ignored -> new JpaPersistenceUnitModel()); - - if (jpaModel.getEntityClassNames().contains(modelClassName)) { - model.entityClassNames().add(modelClassName); - } - model.allModelClassNames().add(modelClassName); - - // also add the hierarchy to the persistence unit - // we would need to add all the underlying model to it but adding the hierarchy - // is necessary for Panache as we need to add PanacheEntity to the PU - model.allModelClassNames().addAll(relatedModelClassNames); - } + if (jpaModel.getEntityClassNames().contains(modelClassName)) { + model.entityClassNames().add(modelClassName); } + model.allModelClassNames().add(modelClassName); + + // also add the hierarchy to the persistence unit + // we would need to add all the underlying model to it but adding the hierarchy + // is necessary for Panache as we need to add PanacheEntity to the PU + model.allModelClassNames().addAll(relatedModelClassNames); } } diff --git a/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/ClassLevelPersistenceUnitAnnotationTest.java b/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/ClassLevelPersistenceUnitAnnotationTest.java deleted file mode 100644 index af3a8161268a1..0000000000000 --- a/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/ClassLevelPersistenceUnitAnnotationTest.java +++ /dev/null @@ -1,194 +0,0 @@ -package io.quarkus.hibernate.orm.multiplepersistenceunits; - -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.junit.jupiter.api.Assertions.assertEquals; - -import jakarta.inject.Inject; -import jakarta.persistence.Entity; -import jakarta.persistence.EntityManager; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import jakarta.transaction.Transactional; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; - -import io.quarkus.hibernate.orm.PersistenceUnit; -import io.quarkus.test.QuarkusExtensionTest; - -public class ClassLevelPersistenceUnitAnnotationTest { - - @RegisterExtension - static QuarkusExtensionTest runner = new QuarkusExtensionTest() - .withApplicationRoot((jar) -> jar - .addClass(EntityInInventoryPU.class) - .addClass(EntityInUsersPU.class) - .addClass(EntityWithMultiplePUs.class) - .addAsResource("application-multiple-persistence-units-class-level.properties", - "application.properties")); - - @Inject - @PersistenceUnit("users") - EntityManager usersEntityManager; - - @Inject - @PersistenceUnit("inventory") - EntityManager inventoryEntityManager; - - @Test - @Transactional - public void testEntityInInventoryPU() { - EntityInInventoryPU entity = new EntityInInventoryPU("test-inventory"); - inventoryEntityManager.persist(entity); - - EntityInInventoryPU savedEntity = inventoryEntityManager.find(EntityInInventoryPU.class, entity.getId()); - assertEquals(entity.getName(), savedEntity.getName()); - - // Entity should not be accessible from users PU - assertThatThrownBy(() -> usersEntityManager.find(EntityInInventoryPU.class, entity.getId())) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("Unknown entity type"); - } - - @Test - @Transactional - public void testEntityInUsersPU() { - EntityInUsersPU entity = new EntityInUsersPU("test-user"); - usersEntityManager.persist(entity); - - EntityInUsersPU savedEntity = usersEntityManager.find(EntityInUsersPU.class, entity.getId()); - assertEquals(entity.getName(), savedEntity.getName()); - - // Entity should not be accessible from inventory PU - assertThatThrownBy(() -> inventoryEntityManager.find(EntityInUsersPU.class, entity.getId())) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("Unknown entity type"); - } - - @Test - @Transactional - public void testEntityWithMultiplePUsInInventory() { - EntityWithMultiplePUs entity = new EntityWithMultiplePUs("shared-entity-inventory"); - inventoryEntityManager.persist(entity); - - EntityWithMultiplePUs savedEntity = inventoryEntityManager.find(EntityWithMultiplePUs.class, entity.getId()); - assertEquals(entity.getName(), savedEntity.getName()); - } - - @Test - @Transactional - public void testEntityWithMultiplePUsInUsers() { - EntityWithMultiplePUs entity = new EntityWithMultiplePUs("shared-entity-users"); - usersEntityManager.persist(entity); - - EntityWithMultiplePUs savedEntity = usersEntityManager.find(EntityWithMultiplePUs.class, entity.getId()); - assertEquals(entity.getName(), savedEntity.getName()); - } - - @Entity - @Table(name = "entity_inventory") - @PersistenceUnit("inventory") - public static class EntityInInventoryPU { - - @Id - @GeneratedValue(strategy = GenerationType.SEQUENCE) - private Long id; - - private String name; - - public EntityInInventoryPU() { - } - - public EntityInInventoryPU(String name) { - this.name = name; - } - - public Long getId() { - return id; - } - - public void setId(Long id) { - this.id = id; - } - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - } - - @Entity - @Table(name = "entity_users") - @PersistenceUnit("users") - public static class EntityInUsersPU { - - @Id - @GeneratedValue(strategy = GenerationType.SEQUENCE) - private Long id; - - private String name; - - public EntityInUsersPU() { - } - - public EntityInUsersPU(String name) { - this.name = name; - } - - public Long getId() { - return id; - } - - public void setId(Long id) { - this.id = id; - } - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - } - - @Entity - @Table(name = "entity_multiple") - @PersistenceUnit.List({ @PersistenceUnit("inventory"), @PersistenceUnit("users") }) - public static class EntityWithMultiplePUs { - - @Id - @GeneratedValue(strategy = GenerationType.SEQUENCE) - private Long id; - - private String name; - - public EntityWithMultiplePUs() { - } - - public EntityWithMultiplePUs(String name) { - this.name = name; - } - - public Long getId() { - return id; - } - - public void setId(Long id) { - this.id = id; - } - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - } -} diff --git a/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/MultiplePersistenceUnitsClassLevelAnnotationIgnoredWithPackagesTest.java b/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/MultiplePersistenceUnitsClassLevelAnnotationIgnoredWithPackagesTest.java index bd3ddc5f32ad5..f289e00ebc15e 100644 --- a/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/MultiplePersistenceUnitsClassLevelAnnotationIgnoredWithPackagesTest.java +++ b/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/MultiplePersistenceUnitsClassLevelAnnotationIgnoredWithPackagesTest.java @@ -25,9 +25,15 @@ public class MultiplePersistenceUnitsClassLevelAnnotationIgnoredWithPackagesTest static QuarkusExtensionTest runner = new QuarkusExtensionTest() .withApplicationRoot((jar) -> jar .addClass(MixedConfigAndClassLevelEntity.class) - .addClass(Plane.class) - .addAsResource("application-multiple-persistence-units-class-level-ignored-with-packages.properties", - "application.properties")); + .addClass(Plane.class)) + .withConfiguration( + """ + quarkus.hibernate-orm."users".datasource=users + quarkus.hibernate-orm."users".packages=io.quarkus.hibernate.orm.multiplepersistenceunits.model.config.mixed + + quarkus.hibernate-orm."inventory".datasource=inventory + quarkus.hibernate-orm."inventory".packages=io.quarkus.hibernate.orm.multiplepersistenceunits.model.config.inventory + """); @Inject @PersistenceUnit("users") diff --git a/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/MultiplePersistenceUnitsClassLevelAnnotationTest.java b/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/MultiplePersistenceUnitsClassLevelAnnotationTest.java index 484a272fd7db8..d9ebd50fc9f39 100644 --- a/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/MultiplePersistenceUnitsClassLevelAnnotationTest.java +++ b/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/MultiplePersistenceUnitsClassLevelAnnotationTest.java @@ -9,6 +9,7 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.Table; import jakarta.transaction.Transactional; import org.junit.jupiter.api.Test; @@ -22,56 +23,166 @@ public class MultiplePersistenceUnitsClassLevelAnnotationTest { @RegisterExtension static QuarkusExtensionTest runner = new QuarkusExtensionTest() .withApplicationRoot((jar) -> jar - .addClass(EntityWithClassLevelPersistenceUnit.class) - .addAsResource("application-multiple-persistence-units-class-level.properties", - "application.properties")); - - @Inject - @PersistenceUnit("inventory") - EntityManager inventoryEntityManager; + .addClass(EntityInInventoryPU.class) + .addClass(EntityInUsersPU.class) + .addClass(EntityWithMultiplePUs.class)) + .withConfiguration(""" + quarkus.hibernate-orm.datasource= + quarkus.hibernate-orm."users".datasource=users + quarkus.hibernate-orm."inventory".datasource=inventory + """); @Inject @PersistenceUnit("users") EntityManager usersEntityManager; + @Inject + @PersistenceUnit("inventory") + EntityManager inventoryEntityManager; + @Test @Transactional - public void testClassLevelAnnotation() { - EntityWithClassLevelPersistenceUnit entity = new EntityWithClassLevelPersistenceUnit("test-entity"); + public void testEntityInInventoryPU() { + EntityInInventoryPU entity = new EntityInInventoryPU("test-inventory"); inventoryEntityManager.persist(entity); - EntityWithClassLevelPersistenceUnit savedEntity = inventoryEntityManager.find( - EntityWithClassLevelPersistenceUnit.class, entity.getId()); + EntityInInventoryPU savedEntity = inventoryEntityManager.find(EntityInInventoryPU.class, entity.getId()); assertEquals(entity.getName(), savedEntity.getName()); // Entity should not be accessible from users PU - assertThatThrownBy(() -> usersEntityManager.find(EntityWithClassLevelPersistenceUnit.class, entity.getId())) + assertThatThrownBy(() -> usersEntityManager.find(EntityInInventoryPU.class, entity.getId())) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("Unknown entity type"); } + @Test + @Transactional + public void testEntityInUsersPU() { + EntityInUsersPU entity = new EntityInUsersPU("test-user"); + usersEntityManager.persist(entity); + + EntityInUsersPU savedEntity = usersEntityManager.find(EntityInUsersPU.class, entity.getId()); + assertEquals(entity.getName(), savedEntity.getName()); + + // Entity should not be accessible from inventory PU + assertThatThrownBy(() -> inventoryEntityManager.find(EntityInUsersPU.class, entity.getId())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unknown entity type"); + } + + @Test + @Transactional + public void testEntityWithMultiplePUsInInventory() { + EntityWithMultiplePUs entity = new EntityWithMultiplePUs("shared-entity-inventory"); + inventoryEntityManager.persist(entity); + + EntityWithMultiplePUs savedEntity = inventoryEntityManager.find(EntityWithMultiplePUs.class, entity.getId()); + assertEquals(entity.getName(), savedEntity.getName()); + } + + @Test + @Transactional + public void testEntityWithMultiplePUsInUsers() { + EntityWithMultiplePUs entity = new EntityWithMultiplePUs("shared-entity-users"); + usersEntityManager.persist(entity); + + EntityWithMultiplePUs savedEntity = usersEntityManager.find(EntityWithMultiplePUs.class, entity.getId()); + assertEquals(entity.getName(), savedEntity.getName()); + } + @Entity + @Table(name = "entity_inventory") @PersistenceUnit("inventory") - public static class EntityWithClassLevelPersistenceUnit { + public static class EntityInInventoryPU { - private long id; + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE) + private Long id; private String name; - public EntityWithClassLevelPersistenceUnit() { + public EntityInInventoryPU() { } - public EntityWithClassLevelPersistenceUnit(String name) { + public EntityInInventoryPU(String name) { this.name = name; } + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + } + + @Entity + @Table(name = "entity_users") + @PersistenceUnit("users") + public static class EntityInUsersPU { + @Id - @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "planeSeq") - public long getId() { + @GeneratedValue(strategy = GenerationType.SEQUENCE) + private Long id; + + private String name; + + public EntityInUsersPU() { + } + + public EntityInUsersPU(String name) { + this.name = name; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + } + + @Entity + @Table(name = "entity_multiple") + @PersistenceUnit.List({ @PersistenceUnit("inventory"), @PersistenceUnit("users") }) + public static class EntityWithMultiplePUs { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE) + private Long id; + + private String name; + + public EntityWithMultiplePUs() { + } + + public EntityWithMultiplePUs(String name) { + this.name = name; + } + + public Long getId() { return id; } - public void setId(long id) { + public void setId(Long id) { this.id = id; } diff --git a/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/PackageLevelAnnotationOverriddenByClassLevelAnnotationTest.java b/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/PackageLevelAnnotationOverriddenByClassLevelAnnotationTest.java index 72b2332cfb444..46a82e0710db2 100644 --- a/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/PackageLevelAnnotationOverriddenByClassLevelAnnotationTest.java +++ b/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/PackageLevelAnnotationOverriddenByClassLevelAnnotationTest.java @@ -11,6 +11,7 @@ import org.junit.jupiter.api.extension.RegisterExtension; import io.quarkus.hibernate.orm.PersistenceUnit; +import io.quarkus.hibernate.orm.multiplepersistenceunits.model.annotation.override.EntityWithoutClassLevelAnnotation; import io.quarkus.hibernate.orm.multiplepersistenceunits.model.annotation.override.OverriddenEntity; import io.quarkus.test.QuarkusExtensionTest; @@ -21,9 +22,12 @@ public class PackageLevelAnnotationOverriddenByClassLevelAnnotationTest { @RegisterExtension static QuarkusExtensionTest runner = new QuarkusExtensionTest() .withApplicationRoot((jar) -> jar - .addPackage(OverriddenEntity.class.getPackage().getName()) - .addAsResource("application-multiple-persistence-units-package-override.properties", - "application.properties")); + .addPackage(OverriddenEntity.class.getPackage().getName())) + .withConfiguration(""" + quarkus.hibernate-orm.datasource= + quarkus.hibernate-orm."users".datasource=users + quarkus.hibernate-orm."inventory".datasource=inventory + """); @Inject @PersistenceUnit("users") @@ -48,4 +52,21 @@ public void entityIsOnlyInClassLevelPersistenceUnit() { .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("Unknown entity type"); } + + @Test + @Transactional + public void otherEntityInSamePackageStillFollowsPackageLevelAnnotation() { + EntityWithoutClassLevelAnnotation entity = new EntityWithoutClassLevelAnnotation("test"); + inventoryEntityManager.persist(entity); + + EntityWithoutClassLevelAnnotation savedEntity = inventoryEntityManager.find( + EntityWithoutClassLevelAnnotation.class, entity.getId()); + assertEquals(entity.getName(), savedEntity.getName()); + + // This entity has no class-level annotation, so the package-level annotation ("inventory") + // still applies: the entity should not be known to the "users" persistence unit. + assertThatThrownBy(() -> usersEntityManager.find(EntityWithoutClassLevelAnnotation.class, entity.getId())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unknown entity type"); + } } diff --git a/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/model/annotation/override/EntityWithoutClassLevelAnnotation.java b/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/model/annotation/override/EntityWithoutClassLevelAnnotation.java new file mode 100644 index 0000000000000..085b5340d9227 --- /dev/null +++ b/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/multiplepersistenceunits/model/annotation/override/EntityWithoutClassLevelAnnotation.java @@ -0,0 +1,42 @@ +package io.quarkus.hibernate.orm.multiplepersistenceunits.model.annotation.override; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; + +// This class lives in the same package as OverriddenEntity, mapped to the "inventory" +// persistence unit through the package-level @PersistenceUnit annotation, but carries no +// class-level annotation of its own: the package-level assignment must still apply to it. +@Entity +public class EntityWithoutClassLevelAnnotation { + + private long id; + + private String name; + + public EntityWithoutClassLevelAnnotation() { + } + + public EntityWithoutClassLevelAnnotation(String name) { + this.name = name; + } + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "entityWithoutClassLevelAnnotationSeq") + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } +} diff --git a/extensions/hibernate-orm/deployment/src/test/resources/application-multiple-persistence-units-class-level-ignored-with-packages.properties b/extensions/hibernate-orm/deployment/src/test/resources/application-multiple-persistence-units-class-level-ignored-with-packages.properties deleted file mode 100644 index 5610bb32f61f4..0000000000000 --- a/extensions/hibernate-orm/deployment/src/test/resources/application-multiple-persistence-units-class-level-ignored-with-packages.properties +++ /dev/null @@ -1,5 +0,0 @@ -quarkus.hibernate-orm."users".datasource=users -quarkus.hibernate-orm."users".packages=io.quarkus.hibernate.orm.multiplepersistenceunits.model.config.mixed - -quarkus.hibernate-orm."inventory".datasource=inventory -quarkus.hibernate-orm."inventory".packages=io.quarkus.hibernate.orm.multiplepersistenceunits.model.config.inventory diff --git a/extensions/hibernate-orm/deployment/src/test/resources/application-multiple-persistence-units-class-level.properties b/extensions/hibernate-orm/deployment/src/test/resources/application-multiple-persistence-units-class-level.properties deleted file mode 100644 index 43522086470b0..0000000000000 --- a/extensions/hibernate-orm/deployment/src/test/resources/application-multiple-persistence-units-class-level.properties +++ /dev/null @@ -1,9 +0,0 @@ -quarkus.datasource.jdbc.max-size=1 - -quarkus.datasource.users.jdbc.min-size=2 - -quarkus.hibernate-orm.datasource= - -quarkus.hibernate-orm."users".datasource=users - -quarkus.hibernate-orm."inventory".datasource=inventory diff --git a/extensions/hibernate-orm/deployment/src/test/resources/application-multiple-persistence-units-package-override.properties b/extensions/hibernate-orm/deployment/src/test/resources/application-multiple-persistence-units-package-override.properties deleted file mode 100644 index 43522086470b0..0000000000000 --- a/extensions/hibernate-orm/deployment/src/test/resources/application-multiple-persistence-units-package-override.properties +++ /dev/null @@ -1,9 +0,0 @@ -quarkus.datasource.jdbc.max-size=1 - -quarkus.datasource.users.jdbc.min-size=2 - -quarkus.hibernate-orm.datasource= - -quarkus.hibernate-orm."users".datasource=users - -quarkus.hibernate-orm."inventory".datasource=inventory From a1e4425923d5cf4bdedc61d54bbe56964639091d Mon Sep 17 00:00:00 2001 From: Andrea Boriero Date: Mon, 12 Oct 2026 16:42:13 +0200 Subject: [PATCH 216/254] Document class-level @PersistenceUnit annotations for multiple persistence units --- docs/src/main/asciidoc/hibernate-orm.adoc | 59 ++++++++++++++--------- 1 file changed, 35 insertions(+), 24 deletions(-) diff --git a/docs/src/main/asciidoc/hibernate-orm.adoc b/docs/src/main/asciidoc/hibernate-orm.adoc index 9f416d5944dab..c8d0f10ab98df 100644 --- a/docs/src/main/asciidoc/hibernate-orm.adoc +++ b/docs/src/main/asciidoc/hibernate-orm.adoc @@ -388,8 +388,7 @@ if the `packages` configuration property is used for at least one persistence un and only the `packages` configuration properties are taken into account. The package-level and class-level annotations, on the other hand, can be combined: -a class-level `@PersistenceUnit` annotation always takes precedence over a package-level one for that particular class, -which makes it convenient to assign a whole package to a persistence unit while moving a few specific classes to another one. +a class-level `@PersistenceUnit` annotation always takes precedence over a package-level one for that particular class. Using the `packages` configuration property is simple: @@ -477,29 +476,41 @@ public class SharedEntity { ---- As mentioned above, a class-level annotation takes precedence over a package-level one. -This is useful when most classes of a package belong to one persistence unit, -but a few classes need to be attached to a different one. -All classes in the `org.acme.model.user` package are attached to the `users` persistence unit by default: - -[source,java] ----- -@PersistenceUnit("users") -package org.acme.model.user; - -import io.quarkus.hibernate.orm.PersistenceUnit; ----- - -Except for `Warehouse`, which is explicitly attached to the `inventory` persistence unit instead, overriding the package-level annotation: - -[source,java] ----- -@Entity -@PersistenceUnit("inventory") -public class Warehouse { - // ... -} ----- +The following table summarizes how the three approaches interact when combined: + +[cols="2,2,2,4", options="header"] +|=== +|`packages` configuration property +|Package-level annotation +|Class-level annotation +|Result + +|`users` +|`inventory` +|`inventory` +|Only `users`; configuration overrides both annotations. + +|None +|`users` +|`inventory` +|Only `inventory`; the class annotation overrides the package annotation. + +|None +|`users` +|None +|`users`, from the package annotation. + +|None +|None +|`users` +|`users`, from the class annotation. + +|None +|None +|None +|Default persistence unit if no package annotations are used anywhere; otherwise unassigned. +|=== [[persistence-unit-detection]] === Conditions for persistence unit configuration From a607431d13e8be8c1858295eea389a7092938db2 Mon Sep 17 00:00:00 2001 From: melloware Date: Wed, 14 Oct 2026 15:25:38 -0400 Subject: [PATCH 217/254] Fix reload group identifier for array-valued test resource annotations TestResourceComparisonInfoArrayValue used the default record toString(), which includes the array identity hash. getReloadGroupIdentifier() relies on toString(), so test classes with identical array-valued test resource meta-annotations got distinct keys and FacadeClassLoader created (and retained) a separate application/classloader per test class. Fixes #57464 Co-Authored-By: Claude Opus 5.5 --- .../test/common/TestResourceManager.java | 11 ++++++++ .../test/common/TestResourceManagerTest.java | 27 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/test-framework/common/src/main/java/io/quarkus/test/common/TestResourceManager.java b/test-framework/common/src/main/java/io/quarkus/test/common/TestResourceManager.java index f9a8fa1e2aa63..8731f3f12dba6 100644 --- a/test-framework/common/src/main/java/io/quarkus/test/common/TestResourceManager.java +++ b/test-framework/common/src/main/java/io/quarkus/test/common/TestResourceManager.java @@ -718,6 +718,17 @@ public int hashCode() { return Objects.hashCode(array); } + + /** + * Content-based so that {@link #getReloadGroupIdentifier(Set)}, which relies on {@code toString()}, produces the same + * identifier for equal arrays; the default record {@code toString()} would include the array identity hash. + */ + @Override + public String toString() { + // wrapping in an Object[] lets deepToString handle both primitive and object arrays + String value = Arrays.deepToString(new Object[] { array }); + return value.substring(1, value.length() - 1); + } } private static class TestResourceRunnable implements Runnable { diff --git a/test-framework/common/src/test/java/io/quarkus/test/common/TestResourceManagerTest.java b/test-framework/common/src/test/java/io/quarkus/test/common/TestResourceManagerTest.java index af27e062ece23..47669c9fcc86e 100644 --- a/test-framework/common/src/test/java/io/quarkus/test/common/TestResourceManagerTest.java +++ b/test-framework/common/src/test/java/io/quarkus/test/common/TestResourceManagerTest.java @@ -97,6 +97,33 @@ void arrayBasedMetaAnnotationWithDifferentPrimitivesDoReload() { Assertions.assertTrue(TestResourceManager.testResourcesRequireReload(info1, info2)); } + @Test + void arrayBasedMetaAnnotationHasSameReloadGroupIdentifier() { + TestResourceManager manager1 = new TestResourceManager(ArrayTest1.class); + TestResourceManager manager2 = new TestResourceManager(ArrayTest2.class); + + Assertions.assertEquals(TestResourceManager.getReloadGroupIdentifier(manager1.testResourceComparisonInfo()), + TestResourceManager.getReloadGroupIdentifier(manager2.testResourceComparisonInfo())); + } + + @Test + void primitiveArrayBasedMetaAnnotationHasSameReloadGroupIdentifier() { + TestResourceManager manager1 = new TestResourceManager(PrimitiveArrayTest1.class); + TestResourceManager manager2 = new TestResourceManager(PrimitiveArrayTest2.class); + + Assertions.assertEquals(TestResourceManager.getReloadGroupIdentifier(manager1.testResourceComparisonInfo()), + TestResourceManager.getReloadGroupIdentifier(manager2.testResourceComparisonInfo())); + } + + @Test + void arrayBasedMetaAnnotationWithDifferentValuesHasDifferentReloadGroupIdentifier() { + TestResourceManager manager1 = new TestResourceManager(ArrayTest1.class); + TestResourceManager manager2 = new TestResourceManager(DiffereringArrayTest.class); + + Assertions.assertNotEquals(TestResourceManager.getReloadGroupIdentifier(manager1.testResourceComparisonInfo()), + TestResourceManager.getReloadGroupIdentifier(manager2.testResourceComparisonInfo())); + } + @WithTestResource(value = FirstLifecycleManager.class, scope = TestResourceScope.GLOBAL) @WithTestResource(value = SecondLifecycleManager.class, scope = TestResourceScope.GLOBAL) public static class MyTest { From 51ae007af0efba564c4035353b5d3573b0d0911a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:41:35 +0000 Subject: [PATCH 218/254] Build(deps): Bump org.htmlunit:htmlunit from 5.5.0 to 5.6.0 Bumps [org.htmlunit:htmlunit](https://github.com/HtmlUnit/htmlunit) from 5.5.0 to 5.6.0. - [Release notes](https://github.com/HtmlUnit/htmlunit/releases) - [Commits](https://github.com/HtmlUnit/htmlunit/compare/5.5.0...5.6.0) --- updated-dependencies: - dependency-name: org.htmlunit:htmlunit dependency-version: 5.6.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- build-parent/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build-parent/pom.xml b/build-parent/pom.xml index 0cace1906dafc..e8d5949827ddb 100644 --- a/build-parent/pom.xml +++ b/build-parent/pom.xml @@ -31,7 +31,7 @@ 2.3.0 3.0.0 - 5.5.0 + 5.6.0 3.28.2 2.1.0 3.0.beta1 From 53362dfab1433112d832b8737519036d6c7b44f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Vav=C5=99=C3=ADk?= Date: Wed, 14 Oct 2026 22:45:55 +0200 Subject: [PATCH 219/254] chore(kc-authz): remove bytecode recorder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closes: https://github.com/quarkusio/quarkus/issues/57472 Signed-off-by: Michal Vavřík --- .../KeycloakPolicyEnforcerBuildStep.java | 10 ++--- ...EnforcerBodyHandlerRequiredEvaluator.java} | 41 ++++++++----------- 2 files changed, 20 insertions(+), 31 deletions(-) rename extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/runtime/{KeycloakPolicyEnforcerRecorder.java => KeycloakPolicyEnforcerBodyHandlerRequiredEvaluator.java} (55%) diff --git a/extensions/keycloak-authorization/deployment/src/main/java/io/quarkus/keycloak/pep/deployment/KeycloakPolicyEnforcerBuildStep.java b/extensions/keycloak-authorization/deployment/src/main/java/io/quarkus/keycloak/pep/deployment/KeycloakPolicyEnforcerBuildStep.java index d72c1c1359ead..46a0e48182f8c 100644 --- a/extensions/keycloak-authorization/deployment/src/main/java/io/quarkus/keycloak/pep/deployment/KeycloakPolicyEnforcerBuildStep.java +++ b/extensions/keycloak-authorization/deployment/src/main/java/io/quarkus/keycloak/pep/deployment/KeycloakPolicyEnforcerBuildStep.java @@ -6,24 +6,20 @@ import io.quarkus.deployment.Feature; import io.quarkus.deployment.annotations.BuildStep; import io.quarkus.deployment.annotations.BuildSteps; -import io.quarkus.deployment.annotations.ExecutionTime; -import io.quarkus.deployment.annotations.Record; import io.quarkus.deployment.builditem.ExtensionSslNativeSupportBuildItem; import io.quarkus.keycloak.pep.runtime.DefaultPolicyEnforcerResolver; import io.quarkus.keycloak.pep.runtime.KeycloakPolicyEnforcerAuthorizer; -import io.quarkus.keycloak.pep.runtime.KeycloakPolicyEnforcerRecorder; +import io.quarkus.keycloak.pep.runtime.KeycloakPolicyEnforcerBodyHandlerRequiredEvaluator; import io.quarkus.oidc.deployment.OidcBuildTimeConfig; import io.quarkus.vertx.http.deployment.RequireBodyHandlerBuildItem; @BuildSteps(onlyIf = KeycloakPolicyEnforcerBuildStep.IsEnabled.class) public class KeycloakPolicyEnforcerBuildStep { - @Record(ExecutionTime.RUNTIME_INIT) @BuildStep - RequireBodyHandlerBuildItem requireBody(OidcBuildTimeConfig oidcBuildTimeConfig, - KeycloakPolicyEnforcerRecorder recorder) { + RequireBodyHandlerBuildItem requireBody(OidcBuildTimeConfig oidcBuildTimeConfig) { if (oidcBuildTimeConfig.enabled()) { - return new RequireBodyHandlerBuildItem(recorder.createBodyHandlerRequiredEvaluator()); + return new RequireBodyHandlerBuildItem(new KeycloakPolicyEnforcerBodyHandlerRequiredEvaluator()); } return null; } diff --git a/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/runtime/KeycloakPolicyEnforcerRecorder.java b/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/runtime/KeycloakPolicyEnforcerBodyHandlerRequiredEvaluator.java similarity index 55% rename from extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/runtime/KeycloakPolicyEnforcerRecorder.java rename to extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/runtime/KeycloakPolicyEnforcerBodyHandlerRequiredEvaluator.java index bc5931f6bddc3..4c35202823d28 100644 --- a/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/runtime/KeycloakPolicyEnforcerRecorder.java +++ b/extensions/keycloak-authorization/runtime/src/main/java/io/quarkus/keycloak/pep/runtime/KeycloakPolicyEnforcerBodyHandlerRequiredEvaluator.java @@ -3,33 +3,27 @@ import java.util.Map; import java.util.function.BooleanSupplier; -import io.quarkus.keycloak.pep.runtime.KeycloakPolicyEnforcerTenantConfig.KeycloakConfigPolicyEnforcer.PathConfig; -import io.quarkus.runtime.RuntimeValue; -import io.quarkus.runtime.annotations.Recorder; +import org.eclipse.microprofile.config.ConfigProvider; -@Recorder -public class KeycloakPolicyEnforcerRecorder { - private final RuntimeValue runtimeConfig; +import io.quarkus.keycloak.pep.runtime.KeycloakPolicyEnforcerTenantConfig.KeycloakConfigPolicyEnforcer.PathConfig; +import io.smallrye.config.SmallRyeConfig; - public KeycloakPolicyEnforcerRecorder(final RuntimeValue runtimeConfig) { - this.runtimeConfig = runtimeConfig; - } +public final class KeycloakPolicyEnforcerBodyHandlerRequiredEvaluator implements BooleanSupplier { - public BooleanSupplier createBodyHandlerRequiredEvaluator() { - return new BooleanSupplier() { - @Override - public boolean getAsBoolean() { - if (isBodyHandlerRequired(runtimeConfig.getValue().defaultTenant())) { - return true; - } - for (KeycloakPolicyEnforcerTenantConfig tenantConfig : runtimeConfig.getValue().namedTenants().values()) { - if (isBodyHandlerRequired(tenantConfig)) { - return true; - } - } - return false; + @Override + public boolean getAsBoolean() { + // right now this supplier is invoked exactly once per HTTP router finalization, so we don't need to optimize it + KeycloakPolicyEnforcerConfig runtimeConfig = ConfigProvider.getConfig().unwrap(SmallRyeConfig.class) + .getConfigMapping(KeycloakPolicyEnforcerConfig.class); + if (isBodyHandlerRequired(runtimeConfig.defaultTenant())) { + return true; + } + for (KeycloakPolicyEnforcerTenantConfig tenantConfig : runtimeConfig.namedTenants().values()) { + if (isBodyHandlerRequired(tenantConfig)) { + return true; } - }; + } + return false; } private static boolean isBodyHandlerRequired(KeycloakPolicyEnforcerTenantConfig config) { @@ -57,5 +51,4 @@ private static boolean isBodyClaimInformationPointDefined(Map Date: Wed, 14 Oct 2026 17:27:08 -0400 Subject: [PATCH 220/254] Create the Kubernetes resource classes bean with a bean creator KubernetesSerializationRecorder loaded the KubernetesResource classes found at build time and recorded the array as the value of the @KubernetesResources Class[] bean. The bean now gets the classes as a Class[] parameter of KubernetesResourcesCreator, which replaces the recorder. --- .../KubernetesResourceBuildStep.java | 23 ++++++++++---- .../KubernetesResourcesBeanTest.java | 31 +++++++++++++++++++ .../internal/KubernetesResourcesCreator.java | 14 +++++++++ .../KubernetesSerializationRecorder.java | 24 -------------- 4 files changed, 62 insertions(+), 30 deletions(-) create mode 100644 extensions/kubernetes-client/deployment/src/test/java/io/quarkus/kubernetes/client/deployment/KubernetesResourcesBeanTest.java create mode 100644 extensions/kubernetes-client/runtime-internal/src/main/java/io/quarkus/kubernetes/client/runtime/internal/KubernetesResourcesCreator.java delete mode 100644 extensions/kubernetes-client/runtime-internal/src/main/java/io/quarkus/kubernetes/client/runtime/internal/KubernetesSerializationRecorder.java diff --git a/extensions/kubernetes-client/deployment/src/main/java/io/quarkus/kubernetes/client/deployment/KubernetesResourceBuildStep.java b/extensions/kubernetes-client/deployment/src/main/java/io/quarkus/kubernetes/client/deployment/KubernetesResourceBuildStep.java index fb6b651c2cc8b..7aeca2ec6ea9c 100644 --- a/extensions/kubernetes-client/deployment/src/main/java/io/quarkus/kubernetes/client/deployment/KubernetesResourceBuildStep.java +++ b/extensions/kubernetes-client/deployment/src/main/java/io/quarkus/kubernetes/client/deployment/KubernetesResourceBuildStep.java @@ -3,6 +3,7 @@ import java.util.HashSet; import java.util.ServiceLoader; import java.util.Set; +import java.util.stream.Stream; import jakarta.inject.Singleton; @@ -16,11 +17,9 @@ import io.quarkus.bootstrap.classloading.QuarkusClassLoader; import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; -import io.quarkus.deployment.annotations.ExecutionTime; -import io.quarkus.deployment.annotations.Record; import io.quarkus.deployment.builditem.nativeimage.ServiceProviderBuildItem; import io.quarkus.kubernetes.client.KubernetesResources; -import io.quarkus.kubernetes.client.runtime.internal.KubernetesSerializationRecorder; +import io.quarkus.kubernetes.client.runtime.internal.KubernetesResourcesCreator; import io.quarkus.kubernetes.client.spi.KubernetesResourcesBuildItem; public class KubernetesResourceBuildStep { @@ -44,18 +43,30 @@ void scanKubernetesResourceClasses( } @BuildStep - @Record(ExecutionTime.STATIC_INIT) SyntheticBeanBuildItem kubernetesResourceClasses( - KubernetesSerializationRecorder recorder, KubernetesResourcesBuildItem kubernetesResourcesBuildItem, BuildProducer additionalBeans) { additionalBeans.produce(AdditionalBeanBuildItem.unremovableOf(KubernetesResources.class)); + final ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); + final Class[] resourceClasses = Stream.of(kubernetesResourcesBuildItem.getResourceClasses()) + .sorted().distinct() + .map(className -> loadClass(className, classLoader)) + .toArray(Class[]::new); final var classArray = Type.create(DotName.createSimple(Class[].class.getName()), Type.Kind.ARRAY); return SyntheticBeanBuildItem .configure(Object.class).providerType(classArray).addType(classArray) .scope(Singleton.class) .qualifiers(AnnotationInstance.builder(KubernetesResources.class).build()) - .runtimeValue(recorder.initKubernetesResources(kubernetesResourcesBuildItem.getResourceClasses())) + .creator(KubernetesResourcesCreator.class) + .param(KubernetesResourcesCreator.RESOURCE_CLASSES_PARAM, resourceClasses) .done(); } + + private static Class loadClass(String className, ClassLoader classLoader) { + try { + return Class.forName(className, false, classLoader); + } catch (ClassNotFoundException e) { + throw new IllegalStateException("Unable to load Kubernetes resource class " + className, e); + } + } } diff --git a/extensions/kubernetes-client/deployment/src/test/java/io/quarkus/kubernetes/client/deployment/KubernetesResourcesBeanTest.java b/extensions/kubernetes-client/deployment/src/test/java/io/quarkus/kubernetes/client/deployment/KubernetesResourcesBeanTest.java new file mode 100644 index 0000000000000..75e5b8f1a3c09 --- /dev/null +++ b/extensions/kubernetes-client/deployment/src/test/java/io/quarkus/kubernetes/client/deployment/KubernetesResourcesBeanTest.java @@ -0,0 +1,31 @@ +package io.quarkus.kubernetes.client.deployment; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import jakarta.inject.Inject; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.KubernetesResource; +import io.quarkus.kubernetes.client.KubernetesResources; +import io.quarkus.test.QuarkusExtensionTest; + +public class KubernetesResourcesBeanTest { + + @Inject + @KubernetesResources + Class[] resourceClasses; + + @RegisterExtension + static QuarkusExtensionTest runner = new QuarkusExtensionTest() + .overrideConfigKey("quarkus.kubernetes-client.devservices.enabled", "false"); + + @Test + public void test() { + assertTrue(resourceClasses.length > 0, "No Kubernetes resource classes were found"); + for (Class resourceClass : resourceClasses) { + assertTrue(KubernetesResource.class.isAssignableFrom(resourceClass), resourceClass.getName()); + } + } +} diff --git a/extensions/kubernetes-client/runtime-internal/src/main/java/io/quarkus/kubernetes/client/runtime/internal/KubernetesResourcesCreator.java b/extensions/kubernetes-client/runtime-internal/src/main/java/io/quarkus/kubernetes/client/runtime/internal/KubernetesResourcesCreator.java new file mode 100644 index 0000000000000..d1d016629197e --- /dev/null +++ b/extensions/kubernetes-client/runtime-internal/src/main/java/io/quarkus/kubernetes/client/runtime/internal/KubernetesResourcesCreator.java @@ -0,0 +1,14 @@ +package io.quarkus.kubernetes.client.runtime.internal; + +import io.quarkus.arc.BeanCreator; +import io.quarkus.arc.SyntheticCreationalContext; + +public class KubernetesResourcesCreator implements BeanCreator[]> { + + public static final String RESOURCE_CLASSES_PARAM = "resourceClasses"; + + @Override + public Class[] create(SyntheticCreationalContext[]> context) { + return (Class[]) context.getParams().get(RESOURCE_CLASSES_PARAM); + } +} diff --git a/extensions/kubernetes-client/runtime-internal/src/main/java/io/quarkus/kubernetes/client/runtime/internal/KubernetesSerializationRecorder.java b/extensions/kubernetes-client/runtime-internal/src/main/java/io/quarkus/kubernetes/client/runtime/internal/KubernetesSerializationRecorder.java deleted file mode 100644 index 08cd183f44ef0..0000000000000 --- a/extensions/kubernetes-client/runtime-internal/src/main/java/io/quarkus/kubernetes/client/runtime/internal/KubernetesSerializationRecorder.java +++ /dev/null @@ -1,24 +0,0 @@ -package io.quarkus.kubernetes.client.runtime.internal; - -import java.util.HashSet; -import java.util.Set; - -import io.fabric8.kubernetes.api.model.KubernetesResource; -import io.quarkus.runtime.RuntimeValue; -import io.quarkus.runtime.annotations.Recorder; - -@Recorder -public class KubernetesSerializationRecorder { - - public RuntimeValue[]> initKubernetesResources(String[] resourceClassNames) { - final Set> resourceClasses = new HashSet<>(); - for (var resourceClassName : resourceClassNames) { - try { - resourceClasses.add( - Class.forName(resourceClassName, false, KubernetesSerializationRecorder.class.getClassLoader())); - } catch (ClassNotFoundException e) { - } - } - return new RuntimeValue<>(resourceClasses.toArray(Class[]::new)); - } -} From 8861c0f3e0d013e96170be90a76ca4c9f48e7a8f Mon Sep 17 00:00:00 2001 From: jnbdz <237008+jnbdz@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:31:33 -0400 Subject: [PATCH 221/254] Fix jlink image startup after the service graph and client proxy changes Every application packaged with quarkus-jlink fails to start on main. Two recent changes exposed gaps in the modular model: - The generated main class now drives the service graph through io.quarkus.core.impl (#55264). quarkus-core is an automatic module, and automatic modules keep their .impl packages private, so the application module could not link against the package. - Generated beans now reference their client proxy class from a static initializer (c40e24383c7). A proxy generated into another module, which happens for a producer whose return type lives elsewhere and for JDK types (io.quarkus.arc.generator in the application module), failed to link at bean initialization. Move the service graph classes to io.quarkus.core.service.impl, a package for generated service code, and SortedNullSafeMap to io.quarkus.core.util. Export io.quarkus.core.service.impl to the application module and let the application module take add-exports like any other module. Treat client proxies as ArC-generated classes so their module links against ArC, and link each module holding a generated bean against the module holding the type of its proxy field. --- .../service/impl/LambdaTransliterator.java | 6 +- .../steps/LastShutdownTasksProcessor.java | 2 +- .../deployment/steps/MainClassBuildStep.java | 16 +++--- .../impl/LambdaTransliteratorTest.java | 4 +- .../{ => service}/impl/JfrServiceMonitor.java | 2 +- .../{ => service}/impl/LastShutdownTasks.java | 2 +- .../impl/NoOpServiceMonitor.java | 2 +- .../impl/NodeShutdownContext.java | 2 +- .../core/{ => service}/impl/ServiceGraph.java | 2 +- .../{ => service}/impl/ServiceMonitor.java | 2 +- .../core/{ => service}/impl/ServiceNode.java | 2 +- .../core/service/impl/package-info.java | 7 +++ .../{impl => util}/SortedNullSafeMap.java | 2 +- .../{ => service}/impl/ServiceGraphTest.java | 2 +- .../{impl => util}/SortedNullSafeMapTest.java | 2 +- .../modular/deployment/ModularitySteps.java | 56 ++++++++++++++++++- 16 files changed, 86 insertions(+), 25 deletions(-) rename core/runtime/src/main/java/io/quarkus/core/{ => service}/impl/JfrServiceMonitor.java (99%) rename core/runtime/src/main/java/io/quarkus/core/{ => service}/impl/LastShutdownTasks.java (98%) rename core/runtime/src/main/java/io/quarkus/core/{ => service}/impl/NoOpServiceMonitor.java (96%) rename core/runtime/src/main/java/io/quarkus/core/{ => service}/impl/NodeShutdownContext.java (98%) rename core/runtime/src/main/java/io/quarkus/core/{ => service}/impl/ServiceGraph.java (99%) rename core/runtime/src/main/java/io/quarkus/core/{ => service}/impl/ServiceMonitor.java (97%) rename core/runtime/src/main/java/io/quarkus/core/{ => service}/impl/ServiceNode.java (99%) create mode 100644 core/runtime/src/main/java/io/quarkus/core/service/impl/package-info.java rename core/runtime/src/main/java/io/quarkus/core/{impl => util}/SortedNullSafeMap.java (99%) rename core/runtime/src/test/java/io/quarkus/core/{ => service}/impl/ServiceGraphTest.java (99%) rename core/runtime/src/test/java/io/quarkus/core/{impl => util}/SortedNullSafeMapTest.java (99%) diff --git a/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/LambdaTransliterator.java b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/LambdaTransliterator.java index ed3b18e57c009..125e8021b0d0d 100644 --- a/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/LambdaTransliterator.java +++ b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/LambdaTransliterator.java @@ -26,9 +26,9 @@ import org.jboss.logging.Logger; -import io.quarkus.core.impl.ServiceGraph; -import io.quarkus.core.impl.ServiceNode; -import io.quarkus.core.impl.SortedNullSafeMap; +import io.quarkus.core.service.impl.ServiceGraph; +import io.quarkus.core.service.impl.ServiceNode; +import io.quarkus.core.util.SortedNullSafeMap; import io.quarkus.runtime.RuntimeValue; import io.quarkus.runtime.StartupContext; import io.smallrye.classfile.BootstrapMethodEntry; diff --git a/core/deployment/src/main/java/io/quarkus/deployment/steps/LastShutdownTasksProcessor.java b/core/deployment/src/main/java/io/quarkus/deployment/steps/LastShutdownTasksProcessor.java index 141d293580da3..aae41cc91f31e 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/steps/LastShutdownTasksProcessor.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/steps/LastShutdownTasksProcessor.java @@ -4,7 +4,7 @@ import io.quarkus.core.Phase; import io.quarkus.core.deployment.service.ServiceRegistrar; -import io.quarkus.core.impl.LastShutdownTasks; +import io.quarkus.core.service.impl.LastShutdownTasks; import io.quarkus.deployment.annotations.BuildStep; import io.quarkus.runtime.ShutdownContext; diff --git a/core/deployment/src/main/java/io/quarkus/deployment/steps/MainClassBuildStep.java b/core/deployment/src/main/java/io/quarkus/deployment/steps/MainClassBuildStep.java index a84ad8cdf5998..eb2629a2678f2 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/steps/MainClassBuildStep.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/steps/MainClassBuildStep.java @@ -46,11 +46,11 @@ import io.quarkus.core.deployment.service.impl.LambdaTransliterator; import io.quarkus.core.deployment.service.impl.ServiceValueRetentionBuildItem; import io.quarkus.core.deployment.service.impl.TransliteratedAction; -import io.quarkus.core.impl.JfrServiceMonitor; -import io.quarkus.core.impl.NoOpServiceMonitor; -import io.quarkus.core.impl.NodeShutdownContext; -import io.quarkus.core.impl.ServiceGraph; -import io.quarkus.core.impl.ServiceNode; +import io.quarkus.core.service.impl.JfrServiceMonitor; +import io.quarkus.core.service.impl.NoOpServiceMonitor; +import io.quarkus.core.service.impl.NodeShutdownContext; +import io.quarkus.core.service.impl.ServiceGraph; +import io.quarkus.core.service.impl.ServiceNode; import io.quarkus.deployment.Capabilities; import io.quarkus.deployment.Capability; import io.quarkus.deployment.GeneratedClassGizmo2Adaptor; @@ -137,9 +137,9 @@ public class MainClassBuildStep { public static final String GENERATE_APP_CDS_SYSTEM_PROPERTY = "quarkus.appcds.generate"; /** Class descriptor for ServiceGraph. */ - private static final String SERVICE_GRAPH_CLASS = "io.quarkus.core.impl.ServiceGraph"; + private static final String SERVICE_GRAPH_CLASS = "io.quarkus.core.service.impl.ServiceGraph"; /** Class descriptor for ServiceNode. */ - private static final String SERVICE_NODE_CLASS = "io.quarkus.core.impl.ServiceNode"; + private static final String SERVICE_NODE_CLASS = "io.quarkus.core.service.impl.ServiceNode"; // Class Descriptors (CD_) private static final ClassDesc CD_Application = ClassDesc.of(Application.class.getName()); @@ -262,7 +262,7 @@ public class MainClassBuildStep { .of(ClassDesc.of(NodeShutdownContext.class.getName()), boolean.class); private static final ConstructorDesc CMD_StringBuilder_ctor_String = ConstructorDesc.of(StringBuilder.class, String.class); private static final ConstructorDesc CMD_ServiceGraph_ctor_StartupContext_ServiceMonitor = ConstructorDesc - .of(CD_ServiceGraph, CD_StartupContext, ClassDesc.of("io.quarkus.core.impl.ServiceMonitor")); + .of(CD_ServiceGraph, CD_StartupContext, ClassDesc.of("io.quarkus.core.service.impl.ServiceMonitor")); private static final ConstructorDesc CMD_ServiceNode_ctor_String_MethodHandle_ServiceGraph_int = ConstructorDesc.of( CD_ServiceNode, ConstantDescs.CD_String, ClassDesc.of(MethodHandle.class.getName()), CD_ServiceGraph, ConstantDescs.CD_int); diff --git a/core/deployment/src/test/java/io/quarkus/core/deployment/service/impl/LambdaTransliteratorTest.java b/core/deployment/src/test/java/io/quarkus/core/deployment/service/impl/LambdaTransliteratorTest.java index a82f72311201f..00c51cf0b7e47 100644 --- a/core/deployment/src/test/java/io/quarkus/core/deployment/service/impl/LambdaTransliteratorTest.java +++ b/core/deployment/src/test/java/io/quarkus/core/deployment/service/impl/LambdaTransliteratorTest.java @@ -24,8 +24,8 @@ import io.quarkus.core.deployment.service.Action1; import io.quarkus.core.deployment.service.Action2; import io.quarkus.core.deployment.service.impl.LambdaTransliterator.TransliterationException; -import io.quarkus.core.impl.ServiceGraph; -import io.quarkus.core.impl.ServiceNode; +import io.quarkus.core.service.impl.ServiceGraph; +import io.quarkus.core.service.impl.ServiceNode; import io.quarkus.runtime.RuntimeValue; import io.quarkus.runtime.StartupContext; diff --git a/core/runtime/src/main/java/io/quarkus/core/impl/JfrServiceMonitor.java b/core/runtime/src/main/java/io/quarkus/core/service/impl/JfrServiceMonitor.java similarity index 99% rename from core/runtime/src/main/java/io/quarkus/core/impl/JfrServiceMonitor.java rename to core/runtime/src/main/java/io/quarkus/core/service/impl/JfrServiceMonitor.java index 6c99020776524..9bf89455dc1a5 100644 --- a/core/runtime/src/main/java/io/quarkus/core/impl/JfrServiceMonitor.java +++ b/core/runtime/src/main/java/io/quarkus/core/service/impl/JfrServiceMonitor.java @@ -1,4 +1,4 @@ -package io.quarkus.core.impl; +package io.quarkus.core.service.impl; import java.util.concurrent.ConcurrentHashMap; diff --git a/core/runtime/src/main/java/io/quarkus/core/impl/LastShutdownTasks.java b/core/runtime/src/main/java/io/quarkus/core/service/impl/LastShutdownTasks.java similarity index 98% rename from core/runtime/src/main/java/io/quarkus/core/impl/LastShutdownTasks.java rename to core/runtime/src/main/java/io/quarkus/core/service/impl/LastShutdownTasks.java index aefdb39a3e06e..d174c2d6d0e1f 100644 --- a/core/runtime/src/main/java/io/quarkus/core/impl/LastShutdownTasks.java +++ b/core/runtime/src/main/java/io/quarkus/core/service/impl/LastShutdownTasks.java @@ -1,4 +1,4 @@ -package io.quarkus.core.impl; +package io.quarkus.core.service.impl; import java.util.ArrayDeque; diff --git a/core/runtime/src/main/java/io/quarkus/core/impl/NoOpServiceMonitor.java b/core/runtime/src/main/java/io/quarkus/core/service/impl/NoOpServiceMonitor.java similarity index 96% rename from core/runtime/src/main/java/io/quarkus/core/impl/NoOpServiceMonitor.java rename to core/runtime/src/main/java/io/quarkus/core/service/impl/NoOpServiceMonitor.java index 283fa0231dc3d..66565dd552cad 100644 --- a/core/runtime/src/main/java/io/quarkus/core/impl/NoOpServiceMonitor.java +++ b/core/runtime/src/main/java/io/quarkus/core/service/impl/NoOpServiceMonitor.java @@ -1,4 +1,4 @@ -package io.quarkus.core.impl; +package io.quarkus.core.service.impl; /** * A service monitor that does nothing. diff --git a/core/runtime/src/main/java/io/quarkus/core/impl/NodeShutdownContext.java b/core/runtime/src/main/java/io/quarkus/core/service/impl/NodeShutdownContext.java similarity index 98% rename from core/runtime/src/main/java/io/quarkus/core/impl/NodeShutdownContext.java rename to core/runtime/src/main/java/io/quarkus/core/service/impl/NodeShutdownContext.java index b52aac3b9247f..5e36e1a288c0d 100644 --- a/core/runtime/src/main/java/io/quarkus/core/impl/NodeShutdownContext.java +++ b/core/runtime/src/main/java/io/quarkus/core/service/impl/NodeShutdownContext.java @@ -1,4 +1,4 @@ -package io.quarkus.core.impl; +package io.quarkus.core.service.impl; import java.util.ArrayDeque; diff --git a/core/runtime/src/main/java/io/quarkus/core/impl/ServiceGraph.java b/core/runtime/src/main/java/io/quarkus/core/service/impl/ServiceGraph.java similarity index 99% rename from core/runtime/src/main/java/io/quarkus/core/impl/ServiceGraph.java rename to core/runtime/src/main/java/io/quarkus/core/service/impl/ServiceGraph.java index 9f677e84acbde..49c7f56c8f953 100644 --- a/core/runtime/src/main/java/io/quarkus/core/impl/ServiceGraph.java +++ b/core/runtime/src/main/java/io/quarkus/core/service/impl/ServiceGraph.java @@ -1,4 +1,4 @@ -package io.quarkus.core.impl; +package io.quarkus.core.service.impl; import java.lang.invoke.ConstantBootstraps; import java.lang.invoke.MethodHandles; diff --git a/core/runtime/src/main/java/io/quarkus/core/impl/ServiceMonitor.java b/core/runtime/src/main/java/io/quarkus/core/service/impl/ServiceMonitor.java similarity index 97% rename from core/runtime/src/main/java/io/quarkus/core/impl/ServiceMonitor.java rename to core/runtime/src/main/java/io/quarkus/core/service/impl/ServiceMonitor.java index 0d1634deb1d87..082ff9594bfd7 100644 --- a/core/runtime/src/main/java/io/quarkus/core/impl/ServiceMonitor.java +++ b/core/runtime/src/main/java/io/quarkus/core/service/impl/ServiceMonitor.java @@ -1,4 +1,4 @@ -package io.quarkus.core.impl; +package io.quarkus.core.service.impl; /** * Monitors service startup and shutdown transitions. diff --git a/core/runtime/src/main/java/io/quarkus/core/impl/ServiceNode.java b/core/runtime/src/main/java/io/quarkus/core/service/impl/ServiceNode.java similarity index 99% rename from core/runtime/src/main/java/io/quarkus/core/impl/ServiceNode.java rename to core/runtime/src/main/java/io/quarkus/core/service/impl/ServiceNode.java index 40ec889ed7b47..cd7070d63ff61 100644 --- a/core/runtime/src/main/java/io/quarkus/core/impl/ServiceNode.java +++ b/core/runtime/src/main/java/io/quarkus/core/service/impl/ServiceNode.java @@ -1,4 +1,4 @@ -package io.quarkus.core.impl; +package io.quarkus.core.service.impl; import java.lang.invoke.ConstantBootstraps; import java.lang.invoke.MethodHandle; diff --git a/core/runtime/src/main/java/io/quarkus/core/service/impl/package-info.java b/core/runtime/src/main/java/io/quarkus/core/service/impl/package-info.java new file mode 100644 index 0000000000000..00f24fa558679 --- /dev/null +++ b/core/runtime/src/main/java/io/quarkus/core/service/impl/package-info.java @@ -0,0 +1,7 @@ +/** + * Service graph runtime support. + *

+ * The classes in this package are used by the service code that Quarkus generates at build time, + * such as the generated application main class. They are not a general API and may change at any time. + */ +package io.quarkus.core.service.impl; diff --git a/core/runtime/src/main/java/io/quarkus/core/impl/SortedNullSafeMap.java b/core/runtime/src/main/java/io/quarkus/core/util/SortedNullSafeMap.java similarity index 99% rename from core/runtime/src/main/java/io/quarkus/core/impl/SortedNullSafeMap.java rename to core/runtime/src/main/java/io/quarkus/core/util/SortedNullSafeMap.java index 5a8984c801f0e..db0ada7f7700e 100644 --- a/core/runtime/src/main/java/io/quarkus/core/impl/SortedNullSafeMap.java +++ b/core/runtime/src/main/java/io/quarkus/core/util/SortedNullSafeMap.java @@ -1,4 +1,4 @@ -package io.quarkus.core.impl; +package io.quarkus.core.util; import java.util.Collections; import java.util.Map; diff --git a/core/runtime/src/test/java/io/quarkus/core/impl/ServiceGraphTest.java b/core/runtime/src/test/java/io/quarkus/core/service/impl/ServiceGraphTest.java similarity index 99% rename from core/runtime/src/test/java/io/quarkus/core/impl/ServiceGraphTest.java rename to core/runtime/src/test/java/io/quarkus/core/service/impl/ServiceGraphTest.java index 0a6d17a8e3831..1d21ee1e7d80f 100644 --- a/core/runtime/src/test/java/io/quarkus/core/impl/ServiceGraphTest.java +++ b/core/runtime/src/test/java/io/quarkus/core/service/impl/ServiceGraphTest.java @@ -1,4 +1,4 @@ -package io.quarkus.core.impl; +package io.quarkus.core.service.impl; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; diff --git a/core/runtime/src/test/java/io/quarkus/core/impl/SortedNullSafeMapTest.java b/core/runtime/src/test/java/io/quarkus/core/util/SortedNullSafeMapTest.java similarity index 99% rename from core/runtime/src/test/java/io/quarkus/core/impl/SortedNullSafeMapTest.java rename to core/runtime/src/test/java/io/quarkus/core/util/SortedNullSafeMapTest.java index 2bdd57e8768b1..6f5b64397591c 100644 --- a/core/runtime/src/test/java/io/quarkus/core/impl/SortedNullSafeMapTest.java +++ b/core/runtime/src/test/java/io/quarkus/core/util/SortedNullSafeMapTest.java @@ -1,4 +1,4 @@ -package io.quarkus.core.impl; +package io.quarkus.core.util; import static java.util.Map.entry; import static org.assertj.core.api.Assertions.assertThat; diff --git a/extensions/packaging/modular/deployment/src/main/java/io/quarkus/modular/deployment/ModularitySteps.java b/extensions/packaging/modular/deployment/src/main/java/io/quarkus/modular/deployment/ModularitySteps.java index 44783856219a2..d36c4a539dac2 100644 --- a/extensions/packaging/modular/deployment/src/main/java/io/quarkus/modular/deployment/ModularitySteps.java +++ b/extensions/packaging/modular/deployment/src/main/java/io/quarkus/modular/deployment/ModularitySteps.java @@ -59,6 +59,7 @@ import io.smallrye.classfile.Attributes; import io.smallrye.classfile.ClassFile; import io.smallrye.classfile.ClassModel; +import io.smallrye.classfile.FieldModel; import io.smallrye.classfile.attribute.ModuleAttribute; import io.smallrye.classfile.attribute.ModuleMainClassAttribute; import io.smallrye.classfile.attribute.RuntimeInvisibleAnnotationsAttribute; @@ -149,6 +150,8 @@ public ApplicationModuleInfoBuildItem buildModularityModel( Set arcGeneratedPackages = new HashSet<>(); Set arcRuntimeGeneratedPackages = new HashSet<>(); Set extraAppModuleDepPackages = new HashSet<>(); + // bean package -> packages of the client proxies its generated beans link against + Map> clientProxyPackagesByBeanPackage = new HashMap<>(); // Collect the initial set of modules that must be on the boot path. Set bootPathModules = bootPathItems.stream().map(BootModulePathBuildItem::moduleName) @@ -213,9 +216,21 @@ public ApplicationModuleInfoBuildItem buildModularityModel( if (bn.endsWith("_Synthetic_Bean") || bn.endsWith("_Subclass")) { arcRuntimeGeneratedPackages.add(pn); } - if (bn.endsWith("_Bean") || bn.endsWith("_ArcAnnotationLiteral")) { + if (bn.endsWith("_Bean") || bn.endsWith("_ArcAnnotationLiteral") || bn.endsWith("_ClientProxy")) { arcGeneratedPackages.add(pn); } + if (bn.endsWith("_Bean")) { + // a bean initializes its client proxy class eagerly; the proxy may live in another module + ClassModel cm = ClassFile.of().parse(gcbi.getClassData()); + for (FieldModel fm : cm.fields()) { + ClassDesc type = fm.fieldTypeSymbol(); + if (fm.fieldName().equalsString("proxy") && type.displayName().endsWith("_ClientProxy") + && !type.packageName().equals(pn)) { + clientProxyPackagesByBeanPackage.computeIfAbsent(pn, ignored -> new HashSet<>()) + .add(type.packageName()); + } + } + } if (bn.contains("_ComponentsProvider")) { // todo: temporarily scrape the constant pool to add extra dependencies to the app module ClassModel cm = ClassFile.of().parse(gcbi.getClassData()); @@ -333,6 +348,12 @@ public ApplicationModuleInfoBuildItem buildModularityModel( extraDepsMap.computeIfAbsent("io.vertx.core", ModularitySteps::newMap) .put("io.quarkus.vertx", Modifier.Set.of(Modifier.READ, Modifier.LINKED, Modifier.SYNTHETIC)); + // The generated main class in the application module drives the service graph in `io.quarkus.core.service.impl`. + // todo: core should use ModuleExportBuildItem for this once an extension can name the application module. + addExportsByModule.computeIfAbsent(appModuleName, ModularitySteps::newMap) + .computeIfAbsent("io.quarkus.core", ignored -> new HashSet<>()) + .add("io.quarkus.core.service.impl"); + // Now go through the process to build a (Quarkus) module descriptor for each artifact. for (ResolvedDependency dependency : knownNamedModules.values()) { if (!dependency.isRuntimeCp()) { @@ -452,6 +473,26 @@ public ApplicationModuleInfoBuildItem buildModularityModel( }); } + // Link each module holding a generated bean against the module holding its client proxy (#52933). + for (Map.Entry> entry : clientProxyPackagesByBeanPackage.entrySet()) { + String beanModule = modulesByPackageTemporary.getOrDefault(entry.getKey(), appModuleName); + for (String proxyPackage : entry.getValue()) { + String proxyModule = modulesByPackageTemporary.getOrDefault(proxyPackage, appModuleName); + if (proxyModule.equals(beanModule)) { + continue; + } + if (beanModule.equals(appModuleName)) { + extraDepsMap.computeIfAbsent(appModuleName, ModularitySteps::newMap) + .merge(proxyModule, Modifier.Set.of(Modifier.READ, Modifier.LINKED, Modifier.SYNTHETIC), + Modifier.Set::withAll); + } else { + modulesByName.computeIfPresent(beanModule, (ignored, mi) -> mi.withMoreDependencies(List.of( + new DependencyInfo(proxyModule, + Modifier.Set.of(Modifier.READ, Modifier.LINKED, Modifier.SYNTHETIC), Map.of())))); + } + } + } + // Compute the set of extra app module dependencies from ArC component providers. // todo: Remove once #52933 is sorted out. for (String pn : extraAppModuleDepPackages) { @@ -514,6 +555,19 @@ public ApplicationModuleInfoBuildItem buildModularityModel( .stream() .map((e) -> new DependencyInfo(e.getKey(), e.getValue(), Map.of())) .toList()); + // Add extra exports from build items. + Map> ebi = addExportsByModule.getOrDefault(appModuleName, Map.of()); + if (!ebi.isEmpty()) { + mi = mi.withMoreDependencies(ebi + .entrySet() + .stream() + .map((e) -> new DependencyInfo( + e.getKey(), + Modifier.Set.of(Modifier.READ, Modifier.OPTIONAL), + e.getValue().stream().collect( + Collectors.toMap(Function.identity(), ignored -> PackageAccess.EXPORTED)))) + .toList()); + } // tabulate any used JDK modules. mi.dependencies().stream() .map(DependencyInfo::moduleName) From 1b1ffaf0225c088a9ac3a9de4c8789cc0d322da5 Mon Sep 17 00:00:00 2001 From: jnbdz <237008+jnbdz@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:41:17 -0400 Subject: [PATCH 222/254] Document that disabling native monitoring stops the JFR-based JVM metrics The OpenTelemetry extension adds jfr to quarkus.native.monitoring when metrics are enabled. With quarkus.native.monitoring=none the native executable starts, but the JFR-based JVM metrics are not produced. --- docs/src/main/asciidoc/opentelemetry-metrics.adoc | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/src/main/asciidoc/opentelemetry-metrics.adoc b/docs/src/main/asciidoc/opentelemetry-metrics.adoc index d1fd1b22a7b27..a1a3203188c56 100644 --- a/docs/src/main/asciidoc/opentelemetry-metrics.adoc +++ b/docs/src/main/asciidoc/opentelemetry-metrics.adoc @@ -689,6 +689,8 @@ These are the metrics produced by the OpenTelemetry extension when metrics are e [WARNING] ==== The instrumentation for JVM metrics uses JFR events and not all native VM implementations support the listed events. For more details, please see https://github.com/open-telemetry/opentelemetry-java-instrumentation/tree/main/instrumentation/runtime-telemetry/library[OpenTelemetry's Runtime Telemetry documentation]. + +Setting `quarkus.native.monitoring=none` also disables the `jfr` monitoring option that the extension enables for these events: the native executable still starts, but the JFR-based JVM metrics are not produced. ==== The native image assessment above was performed using GraalVM 23.0.2. From ffdcafe082f59f182137bb781da0223254dd1a57 Mon Sep 17 00:00:00 2001 From: jnbdz <237008+jnbdz@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:33:24 -0400 Subject: [PATCH 223/254] Give quarkus-qute its own module name An application with quarkus-qute could not be packaged with quarkus-jlink: qute-core declares the module io.quarkus.qute, and the name derived for io.quarkus:quarkus-qute is io.quarkus.qute as well, so building the modular model failed with a duplicate key. Name the extension io.quarkus.qute.runtime, as is done for quarkus-arc. --- .../maven/dependency/ResolvedDependency.java | 1 + .../ResolvedDependencyModuleNameTest.java | 34 +++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 independent-projects/bootstrap/app-model/src/test/java/io/quarkus/maven/dependency/ResolvedDependencyModuleNameTest.java diff --git a/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/maven/dependency/ResolvedDependency.java b/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/maven/dependency/ResolvedDependency.java index d24ba67345133..d980247396c68 100644 --- a/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/maven/dependency/ResolvedDependency.java +++ b/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/maven/dependency/ResolvedDependency.java @@ -126,6 +126,7 @@ static String computeModuleName(ResolvedDependency dep) { String moduleName = switch (groupId) { case "io.quarkus" -> switch (artifactId) { case "quarkus-arc" -> "io.quarkus.arc.runtime"; + case "quarkus-qute" -> "io.quarkus.qute.runtime"; default -> null; }; case "io.smallrye.config" -> switch (artifactId) { diff --git a/independent-projects/bootstrap/app-model/src/test/java/io/quarkus/maven/dependency/ResolvedDependencyModuleNameTest.java b/independent-projects/bootstrap/app-model/src/test/java/io/quarkus/maven/dependency/ResolvedDependencyModuleNameTest.java new file mode 100644 index 0000000000000..0dc9db9c4597a --- /dev/null +++ b/independent-projects/bootstrap/app-model/src/test/java/io/quarkus/maven/dependency/ResolvedDependencyModuleNameTest.java @@ -0,0 +1,34 @@ +package io.quarkus.maven.dependency; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class ResolvedDependencyModuleNameTest { + + @TempDir + Path content; + + @Test + void quteExtensionDoesNotTakeTheQuteEngineModuleName() { + assertThat(moduleName("io.quarkus", "quarkus-qute")).isEqualTo("io.quarkus.qute.runtime"); + } + + @Test + void derivedNameDropsTheRepeatedGroupPart() { + assertThat(moduleName("io.quarkus", "quarkus-cache")).isEqualTo("io.quarkus.cache"); + } + + private String moduleName(String groupId, String artifactId) { + return ResolvedDependencyBuilder.newInstance() + .setGroupId(groupId) + .setArtifactId(artifactId) + .setVersion("1.0") + .setResolvedPath(content) + .build() + .getModuleName(); + } +} From 2a4e5f7dd51d3b33cee64e02771f4ff48a2258a1 Mon Sep 17 00:00:00 2001 From: jnbdz <237008+jnbdz@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:09:15 -0400 Subject: [PATCH 224/254] Let REST Client calls work in a jlink image In a jlink image, a call through a REST Client failed in three ways: - io.quarkus.rest.client, io.quarkus.rest.client.jackson and the generated client classes in the application module use org.jboss.resteasy.reactive.client.impl, which ModularitySteps keeps private because resteasy-reactive-client is an automatic module. - ClientBuilderImpl looks up its ClientContextResolver through ServiceLoader, and resteasy-reactive-client had no service dependency on rest-client-jaxrs, which provides it, so the empty default context was used and every client failed as not indexed. Export the package to those modules and add the service dependency. --- .../RestClientReactiveJacksonProcessor.java | 7 +++++++ .../JaxrsClientReactiveProcessor.java | 17 +++++++++++++++++ .../deployment/RestClientReactiveProcessor.java | 7 +++++++ 3 files changed, 31 insertions(+) diff --git a/extensions/resteasy-reactive/rest-client-jackson/deployment/src/main/java/io/quarkus/rest/client/reactive/jackson/deployment/RestClientReactiveJacksonProcessor.java b/extensions/resteasy-reactive/rest-client-jackson/deployment/src/main/java/io/quarkus/rest/client/reactive/jackson/deployment/RestClientReactiveJacksonProcessor.java index 60a8c31de7bde..312a9a981dcc0 100644 --- a/extensions/resteasy-reactive/rest-client-jackson/deployment/src/main/java/io/quarkus/rest/client/reactive/jackson/deployment/RestClientReactiveJacksonProcessor.java +++ b/extensions/resteasy-reactive/rest-client-jackson/deployment/src/main/java/io/quarkus/rest/client/reactive/jackson/deployment/RestClientReactiveJacksonProcessor.java @@ -16,6 +16,7 @@ import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; import io.quarkus.deployment.builditem.FeatureBuildItem; +import io.quarkus.deployment.builditem.ModuleExportBuildItem; import io.quarkus.rest.client.reactive.deployment.AnnotationToRegisterIntoClientContextBuildItem; import io.quarkus.rest.client.reactive.jackson.ClientObjectMapper; import io.quarkus.rest.client.reactive.jackson.runtime.serialisers.ClientJacksonMessageBodyReader; @@ -38,6 +39,12 @@ public class RestClientReactiveJacksonProcessor { private static final List HANDLED_READ_MEDIA_TYPES = List.of(MediaType.APPLICATION_JSON, APPLICATION_NDJSON, APPLICATION_STREAM_JSON); + @BuildStep + void exportClientInternals(BuildProducer exports) { + exports.produce(new ModuleExportBuildItem("io.quarkus.resteasy.reactive.client", "io.quarkus.rest.client.jackson", + "org.jboss.resteasy.reactive.client.impl")); + } + @BuildStep void feature(BuildProducer features) { features.produce(new FeatureBuildItem(Feature.REST_CLIENT_JACKSON)); diff --git a/extensions/resteasy-reactive/rest-client-jaxrs/deployment/src/main/java/io/quarkus/jaxrs/client/reactive/deployment/JaxrsClientReactiveProcessor.java b/extensions/resteasy-reactive/rest-client-jaxrs/deployment/src/main/java/io/quarkus/jaxrs/client/reactive/deployment/JaxrsClientReactiveProcessor.java index d738d4fbc7378..f2f5f42e7d92a 100644 --- a/extensions/resteasy-reactive/rest-client-jaxrs/deployment/src/main/java/io/quarkus/jaxrs/client/reactive/deployment/JaxrsClientReactiveProcessor.java +++ b/extensions/resteasy-reactive/rest-client-jaxrs/deployment/src/main/java/io/quarkus/jaxrs/client/reactive/deployment/JaxrsClientReactiveProcessor.java @@ -156,11 +156,13 @@ import io.quarkus.deployment.builditem.BytecodeTransformerBuildItem; import io.quarkus.deployment.builditem.CombinedIndexBuildItem; import io.quarkus.deployment.builditem.GeneratedClassBuildItem; +import io.quarkus.deployment.builditem.ModuleExportBuildItem; import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; import io.quarkus.deployment.builditem.nativeimage.ReflectiveHierarchyBuildItem; import io.quarkus.deployment.builditem.nativeimage.RuntimeInitializedClassBuildItem; import io.quarkus.deployment.builditem.nativeimage.ServiceProviderBuildItem; import io.quarkus.deployment.metrics.MetricsCapabilityBuildItem; +import io.quarkus.deployment.pkg.builditem.CurateOutcomeBuildItem; import io.quarkus.deployment.recording.RecorderContext; import io.quarkus.deployment.util.JandexUtil; import io.quarkus.gizmo.AssignableResultHandle; @@ -180,6 +182,7 @@ import io.quarkus.jaxrs.client.reactive.runtime.RestClientBase; import io.quarkus.jaxrs.client.reactive.runtime.ToObjectArray; import io.quarkus.jaxrs.client.reactive.runtime.impl.MultipartResponseDataBase; +import io.quarkus.modular.spi.items.AddDependencyBuildItem; import io.quarkus.resteasy.reactive.common.deployment.ApplicationResultBuildItem; import io.quarkus.resteasy.reactive.common.deployment.ParameterContainersBuildItem; import io.quarkus.resteasy.reactive.common.deployment.QuarkusFactoryCreator; @@ -194,6 +197,7 @@ import io.quarkus.resteasy.reactive.spi.MessageBodyWriterOverrideBuildItem; import io.quarkus.runtime.RuntimeValue; import io.quarkus.runtime.metrics.MetricsFactory; +import io.smallrye.modules.desc.Dependency; import io.smallrye.mutiny.Multi; import io.smallrye.mutiny.Uni; import io.vertx.core.buffer.Buffer; @@ -250,6 +254,19 @@ public class JaxrsClientReactiveProcessor { private static final String MULTIPART_FORM_DATA = "multipart/form-data"; + @BuildStep + ModuleExportBuildItem exportClientInternalsToGeneratedClients(CurateOutcomeBuildItem curateOutcome) { + return new ModuleExportBuildItem("io.quarkus.resteasy.reactive.client", + curateOutcome.getApplicationModel().getAppArtifact().getModuleName(), + "org.jboss.resteasy.reactive.client.impl"); + } + + @BuildStep + AddDependencyBuildItem clientContextResolverServices() { + return new AddDependencyBuildItem("io.quarkus.resteasy.reactive.client", "io.quarkus.rest.client.jaxrs", + Dependency.Modifier.Set.of(Dependency.Modifier.SERVICES, Dependency.Modifier.OPTIONAL)); + } + @BuildStep void registerClientResponseBuilder(BuildProducer serviceProviders) { serviceProviders.produce(new ServiceProviderBuildItem(ResponseBuilderFactory.class.getName(), diff --git a/extensions/resteasy-reactive/rest-client/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/RestClientReactiveProcessor.java b/extensions/resteasy-reactive/rest-client/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/RestClientReactiveProcessor.java index 24cdf07f1dd9f..d2c8e4d656ad1 100644 --- a/extensions/resteasy-reactive/rest-client/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/RestClientReactiveProcessor.java +++ b/extensions/resteasy-reactive/rest-client/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/RestClientReactiveProcessor.java @@ -89,6 +89,7 @@ import io.quarkus.deployment.builditem.GeneratedResourceBuildItem; import io.quarkus.deployment.builditem.GeneratedServiceProviderBuildItem; import io.quarkus.deployment.builditem.LaunchModeBuildItem; +import io.quarkus.deployment.builditem.ModuleExportBuildItem; import io.quarkus.deployment.builditem.RunTimeConfigBuilderBuildItem; import io.quarkus.deployment.builditem.ShutdownContextBuildItem; import io.quarkus.deployment.builditem.StaticInitConfigBuilderBuildItem; @@ -166,6 +167,12 @@ class RestClientReactiveProcessor { private record ProviderToRegister(String className, int priority, boolean fromTCCL) { } + @BuildStep + void exportClientInternals(BuildProducer exports) { + exports.produce(new ModuleExportBuildItem("io.quarkus.resteasy.reactive.client", "io.quarkus.rest.client", + "org.jboss.resteasy.reactive.client.impl")); + } + @BuildStep void announceFeature(BuildProducer features) { features.produce(new FeatureBuildItem(Feature.REST_CLIENT)); From 9b3264590dda429c2dc95d4b8c33c0c55432b6c6 Mon Sep 17 00:00:00 2001 From: Georgios Andrianakis Date: Thu, 15 Oct 2026 13:47:36 +0300 Subject: [PATCH 225/254] Fix Javadoc of LambdaTransliterator --- .../core/deployment/service/impl/LambdaTransliterator.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/LambdaTransliterator.java b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/LambdaTransliterator.java index ed3b18e57c009..3af2f5db84591 100644 --- a/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/LambdaTransliterator.java +++ b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/LambdaTransliterator.java @@ -75,7 +75,7 @@ *

* The transliteration process is split into two phases: *

    - *
  1. {@link #extract(Serializable, Class, List, List, boolean, boolean, List) extract()} — + *
  2. {@link #extract(Serializable, Class, List, List, List, List, boolean, boolean, boolean, String) extract()} — * eagerly extracts the lambda metadata and body during build step execution, * producing a {@link TransliteratedAction.ActionService} data record
  3. *
  4. {@link #generateConsolidatedClass(String, List) generateConsolidatedClass()} — From 6b51c88c1178662cb7da8f66c255bf6b54d0d102 Mon Sep 17 00:00:00 2001 From: Matheus Cruz Date: Thu, 15 Oct 2026 09:06:34 -0300 Subject: [PATCH 226/254] Use StartContext#onStop instead Signed-off-by: Matheus Cruz --- .../observability/deployment/DevResourcesProcessor.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/extensions/observability-devservices/deployment/src/main/java/io/quarkus/observability/deployment/DevResourcesProcessor.java b/extensions/observability-devservices/deployment/src/main/java/io/quarkus/observability/deployment/DevResourcesProcessor.java index a4b034e215589..1fd2d638627b3 100644 --- a/extensions/observability-devservices/deployment/src/main/java/io/quarkus/observability/deployment/DevResourcesProcessor.java +++ b/extensions/observability-devservices/deployment/src/main/java/io/quarkus/observability/deployment/DevResourcesProcessor.java @@ -6,7 +6,6 @@ import org.slf4j.LoggerFactory; import io.quarkus.core.deployment.service.ServiceRegistrar; -import io.quarkus.core.impl.LastShutdownTasks; import io.quarkus.deployment.IsDevResourcesSupportedByLaunchMode; import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; @@ -46,7 +45,7 @@ public void registerDevResourcesConfigSource(LaunchModeBuildItem launchMode, public DevResourcesBuildItem shutdownDevResources(ServiceRegistrar serviceRegistrar) { serviceRegistrar .forService("io.quarkus.observability.dev-resources.shutdown") - .onStart(ctx -> LastShutdownTasks.addRuntime(DevResources::stop)); + .onStart(ctx -> ctx.onStop(DevResources::stop)); return new DevResourcesBuildItem(); } From dc0a5b2e39d2d81d345165dd016389224f7a4abf Mon Sep 17 00:00:00 2001 From: Luca Molteni Date: Mon, 12 Oct 2026 14:40:55 +0200 Subject: [PATCH 227/254] Add focused Hibernate ORM processor tests --- .../deployment/HibernateOrmProcessorTest.java | 34 ++++ .../orm/deployment/ProcessorTestStubs.java | 129 +++++++++++++ .../jakartadata/JakartaDataProcessorTest.java | 31 ++++ .../model/JpaModelProcessorTest.java | 172 ++++++++++++++++++ .../PersistenceXmlProcessorTest.java | 150 +++++++++++++++ 5 files changed, 516 insertions(+) create mode 100644 extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/deployment/HibernateOrmProcessorTest.java create mode 100644 extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/deployment/ProcessorTestStubs.java create mode 100644 extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/deployment/model/JpaModelProcessorTest.java create mode 100644 extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/deployment/persistencexml/PersistenceXmlProcessorTest.java diff --git a/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/deployment/HibernateOrmProcessorTest.java b/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/deployment/HibernateOrmProcessorTest.java new file mode 100644 index 0000000000000..bed68100839c8 --- /dev/null +++ b/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/deployment/HibernateOrmProcessorTest.java @@ -0,0 +1,34 @@ +package io.quarkus.hibernate.orm.deployment; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import org.hibernate.boot.beanvalidation.BeanValidationIntegrator; +import org.junit.jupiter.api.Test; + +import io.quarkus.deployment.Capabilities; +import io.quarkus.deployment.Capability; +import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; + +class HibernateOrmProcessorTest { + + private final HibernateOrmProcessor processor = new HibernateOrmProcessor(); + + @Test + void registerValidatorBeans() { + List reflection = new ArrayList<>(); + + processor.enrollBeanValidationTypeSafeActivatorForReflection(new Capabilities(Set.of()), reflection::add); + assertThat(reflection).isEmpty(); + + processor.enrollBeanValidationTypeSafeActivatorForReflection( + new Capabilities(Set.of(Capability.HIBERNATE_VALIDATOR)), reflection::add); + assertThat(reflection).hasSize(2); + assertThat(reflection).flatExtracting(ReflectiveClassBuildItem::getClassNames).containsExactlyInAnyOrder( + "org.hibernate.boot.beanvalidation.TypeSafeActivator", BeanValidationIntegrator.JAKARTA_BV_CHECK_CLASS); + } + +} diff --git a/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/deployment/ProcessorTestStubs.java b/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/deployment/ProcessorTestStubs.java new file mode 100644 index 0000000000000..0ee998a1fad6a --- /dev/null +++ b/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/deployment/ProcessorTestStubs.java @@ -0,0 +1,129 @@ +package io.quarkus.hibernate.orm.deployment; + +import java.nio.charset.Charset; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Properties; +import java.util.Set; + +import jakarta.persistence.PersistenceUnitTransactionType; + +import org.jboss.jandex.DotName; + +import io.quarkus.datasource.common.runtime.DatabaseKind; +import io.quarkus.hibernate.orm.deployment.component.PersistenceUnitDefinitionBuildItem; +import io.quarkus.hibernate.orm.runtime.HibernateOrmPersistenceUnitProviderHelper; +import io.quarkus.hibernate.orm.runtime.boot.QuarkusPersistenceUnitDescriptor; +import io.quarkus.hibernate.orm.runtime.boot.xml.RecordableXmlMapping; +import io.quarkus.hibernate.orm.runtime.migration.MultiTenancyStrategy; +import io.quarkus.hibernate.orm.runtime.recording.RecordedConfig; +import io.quarkus.runtime.configuration.CharsetConverter; +import io.quarkus.runtime.util.ProgrammingParadigm; +import io.quarkus.runtime.util.Reason; +import io.smallrye.config.PropertiesConfigSource; +import io.smallrye.config.SmallRyeConfigBuilder; + +public final class ProcessorTestStubs { + + private ProcessorTestStubs() { + } + + public static HibernateOrmConfig emptyHibernateORMConfig() { + return hibernateORMConfig(Map.of()); + } + + public static HibernateOrmConfig hibernateORMConfig(Map properties) { + return new SmallRyeConfigBuilder() + .addDefaultInterceptors() + .withConverter(Charset.class, 100, new CharsetConverter()) + .withMappingIgnore("quarkus.hibernate-orm.database.charset") + .withSources(new PropertiesConfigSource(properties, "test", 100)) + .withMapping(HibernateOrmConfig.class) + .build() + .getConfigMapping(HibernateOrmConfig.class); + } + + public static JpaModelBuildItem emptyJpaModel() { + return jpaModel(Set.of(), Set.of(), Set.of(), Set.of()); + } + + public static JpaModelBuildItem jpaModelWithManagedClasses(String... managedClasses) { + return jpaModel(Set.of(), Set.of(), Set.of(managedClasses), Set.of()); + } + + public static JpaModelBuildItem jpaModelWithPotentialCdiBeans(DotName... potentialCdiBeans) { + return jpaModel(Set.of(), Set.of(), Set.of(), Set.of(potentialCdiBeans)); + } + + public static JpaModelBuildItem jpaModelWithPackagesAndEntities(Set packages, Set entities) { + return jpaModel(packages, entities, entities, Set.of()); + } + + public static JpaModelBuildItem jpaModelForScanner(Set packages, Set entities, + Set managedClasses) { + return jpaModel(packages, entities, managedClasses, Set.of()); + } + + public static PersistenceUnitDefinitionBuildItem persistenceUnitDefinition(String name, + ProgrammingParadigm paradigm, String dataSource) { + return new PersistenceUnitDefinitionBuildItem(name, paradigm, List.of(new Reason("test")), null, + Optional.of(dataSource), Optional.empty()); + } + + private static JpaModelBuildItem jpaModel(Set packages, Set entities, Set managedClasses, + Set potentialCdiBeans) { + return new JpaModelBuildItem(packages, entities, managedClasses, potentialCdiBeans, Map.of()); + } + + public static PersistenceUnitDescriptorBuildItem persistenceUnitDescriptor() { + return persistenceUnitDescriptor(List.of()); + } + + public static PersistenceUnitDescriptorBuildItem persistenceUnitDescriptor(String name, + MultiTenancyStrategy multiTenancyStrategy) { + return persistenceUnitDescriptor(name, List.of(), multiTenancyStrategy); + } + + public static PersistenceUnitDescriptorBuildItem persistenceUnitDescriptorWithXmlMapping( + RecordableXmlMapping xmlMapping) { + return persistenceUnitDescriptor(List.of(xmlMapping)); + } + + public static PersistenceUnitDescriptorBuildItem persistenceUnitDescriptorWithoutXmlMappings() { + return persistenceUnitDescriptor(List.of()); + } + + private static PersistenceUnitDescriptorBuildItem persistenceUnitDescriptor(List xmlMappings) { + return persistenceUnitDescriptor("test", xmlMappings, MultiTenancyStrategy.NONE); + } + + private static PersistenceUnitDescriptorBuildItem persistenceUnitDescriptor(String name, + List xmlMappings, MultiTenancyStrategy multiTenancyStrategy) { + var descriptor = new QuarkusPersistenceUnitDescriptor( + name, + new HibernateOrmPersistenceUnitProviderHelper(), + PersistenceUnitTransactionType.JTA, + List.of(), + List.of(), + new Properties(), + false); + + var recordedConfig = new RecordedConfig( + Optional.empty(), + Optional.of(DatabaseKind.H2), + Optional.of(DatabaseKind.H2), + Optional.empty(), + false, + Optional.empty(), + Set.of(), + multiTenancyStrategy, + null, Map.of()); + + return new PersistenceUnitDescriptorBuildItem(descriptor, + recordedConfig, + xmlMappings, + false, + false); + } +} diff --git a/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/deployment/jakartadata/JakartaDataProcessorTest.java b/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/deployment/jakartadata/JakartaDataProcessorTest.java index d811337e92312..e0141851eec37 100644 --- a/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/deployment/jakartadata/JakartaDataProcessorTest.java +++ b/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/deployment/jakartadata/JakartaDataProcessorTest.java @@ -13,16 +13,47 @@ import jakarta.data.repository.Repository; import jakarta.persistence.Entity; +import org.hibernate.annotations.processing.Find; import org.hibernate.annotations.processing.HQL; import org.hibernate.annotations.processing.SQL; import org.jboss.jandex.Indexer; import org.junit.jupiter.api.Test; +import io.quarkus.deployment.Capabilities; +import io.quarkus.deployment.Capability; import io.quarkus.deployment.builditem.CombinedIndexBuildItem; import io.quarkus.hibernate.orm.deployment.spi.AdditionalJpaModelBuildItem; +import io.quarkus.security.spi.SecuredInterfaceAnnotationBuildItem; class JakartaDataProcessorTest { + private final JakartaDataProcessor processor = new JakartaDataProcessor(); + + @Test + void producesNothingWhenSecurityIsAbsent() { + var produced = new ArrayList(); + + processor.registerJakartaDataRepositorySecurityAnnotations(new Capabilities(Set.of()), produced::add); + + assertThat(produced).isEmpty(); + } + + @Test + void registersTheRepositoryTypeAndEveryRepositoryMethodAnnotationWhenSecurityIsPresent() { + var produced = new ArrayList(); + + processor.registerJakartaDataRepositorySecurityAnnotations( + new Capabilities(Set.of(Capability.SECURITY)), produced::add); + + assertThat(produced) + .extracting(item -> item.getAnnotationName().toString()) + .containsExactlyInAnyOrder( + "jakarta.data.repository.Repository", + Find.class.getName(), + HQL.class.getName(), + SQL.class.getName()); + } + @Test void registersAnnotatedAndUnannotatedRepositoryInterfacesOnceInTheirPersistenceUnits() throws IOException { var indexer = new Indexer(); diff --git a/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/deployment/model/JpaModelProcessorTest.java b/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/deployment/model/JpaModelProcessorTest.java new file mode 100644 index 0000000000000..aff37e0f1a082 --- /dev/null +++ b/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/deployment/model/JpaModelProcessorTest.java @@ -0,0 +1,172 @@ +package io.quarkus.hibernate.orm.deployment.model; + +import static io.quarkus.hibernate.orm.deployment.ProcessorTestStubs.emptyHibernateORMConfig; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.jboss.jandex.Index; +import org.junit.jupiter.api.Test; + +import io.quarkus.deployment.builditem.AdditionalApplicationArchiveMarkerBuildItem; +import io.quarkus.deployment.builditem.CombinedIndexBuildItem; +import io.quarkus.hibernate.orm.PersistenceUnit; +import io.quarkus.hibernate.orm.deployment.ClassNames; +import io.quarkus.hibernate.orm.deployment.ProcessorTestStubs; +import io.quarkus.hibernate.orm.deployment.spi.AdditionalJpaModelBuildItem; +import io.quarkus.hibernate.orm.deployment.spi.AdditionalPersistenceUnitBuildItem; +import io.quarkus.hibernate.orm.runtime.PersistenceUnitUtil; +import io.quarkus.hibernate.orm.singlepersistenceunit.DefaultEntity; +import io.quarkus.hibernate.orm.xml.persistence.MyEntity; +import io.quarkus.runtime.util.Reason; + +class JpaModelProcessorTest { + + private final JpaModelProcessor processor = new JpaModelProcessor(); + + @Test + void alwaysAddsThePersistenceUnitAnnotation() { + assertThat(processor.addPersistenceUnitAnnotationToIndex().getClassesToIndex()) + .containsExactly(ClassNames.QUARKUS_PERSISTENCE_UNIT.toString()); + } + + @Test + void convertsEntityPackageToAnArchiveMarkerPath() { + var config = ProcessorTestStubs.hibernateORMConfig(Map.of( + "quarkus.hibernate-orm.packages", "org.acme.defaultpu", + "quarkus.hibernate-orm.\"inventory\".packages", "org.acme.inventory")); + var markers = new ArrayList(); + + processor.includeArchivesHostingEntityPackagesInIndex(config, markers::add); + + assertThat(markers).extracting(AdditionalApplicationArchiveMarkerBuildItem::getFile) + .containsExactlyInAnyOrder("org/acme/defaultpu", "org/acme/inventory"); + } + + @Test + void contributeAdditionalPersistenceUnitModelClassesTest() { + var additionalPersistenceUnit = AdditionalPersistenceUnitBuildItem.builder("inventory", new Reason("test")) + .managedClasses(List.of(DefaultEntity.class.getName(), MyEntity.class.getName())) + .build(); + + var additionalModel = new ArrayList(); + + processor.contributeAdditionalPersistenceUnitModelClasses( + List.of(additionalPersistenceUnit), additionalModel::add); + + assertThat(additionalModel).extracting(AdditionalJpaModelBuildItem::getClassName) + .containsExactlyInAnyOrder(DefaultEntity.class.getName(), MyEntity.class.getName()); + assertThat(additionalModel).allSatisfy(item -> assertThat(item.getPersistenceUnits()).containsExactly("inventory")); + } + + @Test + void contributeOnePersistenceUnit() { + var additionalPersistenceUnit = AdditionalPersistenceUnitBuildItem.builder("inventory", new Reason("test")) + .managedClasses(List.of(DefaultEntity.class.getName(), MyEntity.class.getName())) + .mappingFiles(List.of("META-INF/default.orm.xml", "META-INF/inventory.orm.xml")) + .build(); + var persistenceUnitContributions = new ArrayList(); + + processor.contributeAdditionalPersistenceUnitsToJpaModel(persistenceUnitContributions::add, + List.of(additionalPersistenceUnit)); + + assertThat(persistenceUnitContributions).hasSize(1); + var contribution = persistenceUnitContributions.getFirst(); + + assertThat(contribution.persistenceUnitName).isEqualTo("inventory"); + assertThat(contribution.persistenceUnitRootURL).isNull(); + assertThat(contribution.explicitlyListedClassNames) + .containsExactlyInAnyOrder(DefaultEntity.class.getName(), MyEntity.class.getName()); + assertThat(contribution.explicitlyListedMappingFiles) + .containsExactlyInAnyOrder("META-INF/default.orm.xml", "META-INF/inventory.orm.xml"); + } + + @Test + void createsAndPopulatesDefaultPersistenceUnitModel() throws IOException { + var model = ProcessorTestStubs.jpaModelWithPackagesAndEntities( + Set.of(DefaultEntity.class.getPackageName(), MyEntity.class.getPackageName()), + Set.of(DefaultEntity.class.getName(), MyEntity.class.getName())); + + Index index = Index.of(DefaultEntity.class, MyEntity.class, PersistenceUnit.class, PersistenceUnit.List.class); + + var result = processor.buildJpaModelPerPersistenceUnit( + emptyHibernateORMConfig(), + List.of(), + model, + new CombinedIndexBuildItem(index, index)); + + assertThat(result.getModelPerPersistenceUnit()).containsOnlyKeys(PersistenceUnitUtil.DEFAULT_PERSISTENCE_UNIT_NAME); + + var defaultModel = result.getModelPerPersistenceUnit().get(PersistenceUnitUtil.DEFAULT_PERSISTENCE_UNIT_NAME); + + assertThat(defaultModel.entityClassNames()) + .containsExactlyInAnyOrder(DefaultEntity.class.getName(), MyEntity.class.getName()); + assertThat(defaultModel.allModelClassNames()).containsExactlyInAnyOrder( + DefaultEntity.class.getName(), + MyEntity.class.getName()); + assertThat(defaultModel.modelPackageNames()).containsExactlyInAnyOrder( + DefaultEntity.class.getPackageName(), + MyEntity.class.getPackageName()); + } + + @Test + void createsAndPopulatesDefaultAndNamedPersistenceUnitModels() throws IOException { + var config = ProcessorTestStubs.hibernateORMConfig(Map.of( + "quarkus.hibernate-orm.packages", DefaultEntity.class.getPackageName(), + "quarkus.hibernate-orm.\"inventory\".packages", MyEntity.class.getPackageName())); + + var model = ProcessorTestStubs.jpaModelWithPackagesAndEntities( + Set.of(DefaultEntity.class.getPackageName(), MyEntity.class.getPackageName()), + Set.of(DefaultEntity.class.getName(), MyEntity.class.getName())); + + Index index = Index.of(DefaultEntity.class, MyEntity.class, PersistenceUnit.class, PersistenceUnit.List.class); + + var result = processor.buildJpaModelPerPersistenceUnit( + config, + List.of(), + model, + new CombinedIndexBuildItem(index, index)); + + assertThat(result.getPersistenceUnitsConfiguredThroughQuarkusConfiguration()) + .containsExactlyInAnyOrder(PersistenceUnitUtil.DEFAULT_PERSISTENCE_UNIT_NAME, "inventory"); + + var defaultModel = result.getModelPerPersistenceUnit().get(PersistenceUnitUtil.DEFAULT_PERSISTENCE_UNIT_NAME); + assertThat(defaultModel.entityClassNames()).containsExactly(DefaultEntity.class.getName()); + assertThat(defaultModel.allModelClassNames()).containsExactly(DefaultEntity.class.getName()); + assertThat(defaultModel.modelPackageNames()).containsExactly(DefaultEntity.class.getPackageName()); + + var inventoryModel = result.getModelPerPersistenceUnit().get("inventory"); + assertThat(inventoryModel.entityClassNames()).containsExactly(MyEntity.class.getName()); + assertThat(inventoryModel.allModelClassNames()).containsExactly(MyEntity.class.getName()); + assertThat(inventoryModel.modelPackageNames()).containsExactly(MyEntity.class.getPackageName()); + } + + @Test + void useAdditionalJpaModelToAssignEntityToNamedPersistenceUnit() throws IOException { + + var assignMyEntityToInventoryPU = new AdditionalJpaModelBuildItem(MyEntity.class.getName(), Set.of("inventory")); + + var model = ProcessorTestStubs.jpaModelWithPackagesAndEntities( + Set.of(DefaultEntity.class.getPackageName(), MyEntity.class.getPackageName()), + Set.of(DefaultEntity.class.getName(), MyEntity.class.getName())); + + Index index = Index.of(DefaultEntity.class, MyEntity.class, PersistenceUnit.class, PersistenceUnit.List.class); + + var result = processor.buildJpaModelPerPersistenceUnit( + emptyHibernateORMConfig(), + List.of(assignMyEntityToInventoryPU), + model, + new CombinedIndexBuildItem(index, index)); + + assertThat(result.getModelPerPersistenceUnit().get("inventory").entityClassNames()) + .containsExactly(MyEntity.class.getName()); + + assertThat(result.getModelPerPersistenceUnit().get(PersistenceUnitUtil.DEFAULT_PERSISTENCE_UNIT_NAME) + .entityClassNames()).containsExactly(DefaultEntity.class.getName()); + } + +} diff --git a/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/deployment/persistencexml/PersistenceXmlProcessorTest.java b/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/deployment/persistencexml/PersistenceXmlProcessorTest.java new file mode 100644 index 0000000000000..cdb19b7dd783c --- /dev/null +++ b/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/deployment/persistencexml/PersistenceXmlProcessorTest.java @@ -0,0 +1,150 @@ +package io.quarkus.hibernate.orm.deployment.persistencexml; + +import static io.quarkus.hibernate.orm.deployment.ProcessorTestStubs.emptyJpaModel; +import static io.quarkus.hibernate.orm.deployment.ProcessorTestStubs.hibernateORMConfig; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.net.MalformedURLException; +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import jakarta.persistence.PersistenceUnitTransactionType; + +import org.hibernate.jpa.boot.internal.ParsedPersistenceXmlDescriptor; +import org.junit.jupiter.api.Test; + +import io.quarkus.deployment.Capabilities; +import io.quarkus.hibernate.orm.deployment.JpaModelPerPersistenceUnitBuildItem; +import io.quarkus.hibernate.orm.deployment.PersistenceXmlDescriptorBuildItem; +import io.quarkus.hibernate.orm.deployment.model.JpaModelPersistenceUnitContributionBuildItem; +import io.quarkus.hibernate.orm.deployment.spi.AdditionalPersistenceUnitBuildItem; +import io.quarkus.hibernate.orm.runtime.PersistenceUnitUtil; +import io.quarkus.runtime.configuration.ConfigurationException; +import io.quarkus.runtime.util.Reason; + +class PersistenceXmlProcessorTest { + + private static final String INTEGRATOR_SERVICE_FILE = "META-INF/services/org.hibernate.integrator.spi.Integrator"; + + private final PersistenceXmlProcessor processor = new PersistenceXmlProcessor(); + + @Test + void defaultValues() { + var watchedFiles = processor.hotDeploymentWatchedFiles(hibernateORMConfig(Map.of())); + + assertThat(watchedFiles).extracting(item -> item.getLocation()) + .containsExactly("META-INF/persistence.xml", INTEGRATOR_SERVICE_FILE); + + assertThat(watchedFiles).allSatisfy(item -> assertThat(item.isRestartNeeded()).isTrue()); + } + + @Test + void stopsWatchingPersistenceXmlWhenItIsIgnoredByConfiguration() { + var config = hibernateORMConfig(Map.of("quarkus.hibernate-orm.persistence-xml.ignore", "true")); + + var watchedFiles = processor.hotDeploymentWatchedFiles(config); + + assertThat(watchedFiles).extracting(item -> item.getLocation()) + .doesNotContain("META-INF/persistence.xml"); + assertThat(watchedFiles).extracting(item -> item.getLocation()) + .contains(INTEGRATOR_SERVICE_FILE); + } + + @Test + void stopsWatchingPersistenceXmlWhenItIsIgnoredByTheLegacySystemProperty() { + String previous = System.getProperty("SKIP_PARSE_PERSISTENCE_XML"); + try { + System.setProperty("SKIP_PARSE_PERSISTENCE_XML", "true"); + + var watchedFiles = processor.hotDeploymentWatchedFiles(hibernateORMConfig(Map.of())); + + assertThat(watchedFiles).extracting(item -> item.getLocation()).containsExactly(INTEGRATOR_SERVICE_FILE); + } finally { + if (previous == null) { + System.clearProperty("SKIP_PARSE_PERSISTENCE_XML"); + } else { + System.setProperty("SKIP_PARSE_PERSISTENCE_XML", previous); + } + } + } + + @Test + void contributesEveryParsedDescriptorToJpaModelDiscovery() throws MalformedURLException { + var inventory = xmlDescriptor("inventory", DefaultModel.class.getName(), "META-INF/inventory.orm.xml"); + var users = xmlDescriptor("users", UserModel.class.getName(), "META-INF/users.orm.xml"); + var contributions = new ArrayList(); + + processor.contributePersistenceXmlToJpaModel(contributions::add, + List.of(new PersistenceXmlDescriptorBuildItem(inventory), new PersistenceXmlDescriptorBuildItem(users))); + + assertThat(contributions).hasSize(2); + var inventoryContribution = contributionForPersistenceUnit(contributions, "inventory"); + assertThat(inventoryContribution.persistenceUnitRootURL).isEqualTo(inventory.getPersistenceUnitRootUrl()); + assertThat(inventoryContribution.explicitlyListedClassNames).containsExactly(DefaultModel.class.getName()); + assertThat(inventoryContribution.explicitlyListedMappingFiles).containsExactly("META-INF/inventory.orm.xml"); + + var usersContribution = contributionForPersistenceUnit(contributions, "users"); + assertThat(usersContribution.persistenceUnitRootURL).isEqualTo(users.getPersistenceUnitRootUrl()); + assertThat(usersContribution.explicitlyListedClassNames).containsExactly(UserModel.class.getName()); + assertThat(usersContribution.explicitlyListedMappingFiles).containsExactly("META-INF/users.orm.xml"); + } + + @Test + void rejectsAnExtensionContributedDefaultPersistenceUnit() { + var additional = AdditionalPersistenceUnitBuildItem + .builder(PersistenceUnitUtil.DEFAULT_PERSISTENCE_UNIT_NAME, new Reason("test")).build(); + + assertThatThrownBy(() -> buildPersistenceUnits(List.of(), List.of(additional))) + .isInstanceOf(ConfigurationException.class) + .hasMessage("An extension attempted to contribute the default persistence unit through the SPI." + + " Contributed persistence units must use a non-default name."); + } + + @Test + void rejectsAnExtensionContributedPersistenceUnitThatDuplicatesPersistenceXml() throws MalformedURLException { + var descriptor = new PersistenceXmlDescriptorBuildItem( + xmlDescriptor("inventory", DefaultModel.class.getName(), "META-INF/inventory.orm.xml")); + var additional = AdditionalPersistenceUnitBuildItem.builder("inventory", new Reason("test")).build(); + + assertThatThrownBy(() -> buildPersistenceUnits(List.of(descriptor), List.of(additional))) + .isInstanceOf(ConfigurationException.class) + .hasMessageContaining("Persistence unit 'inventory' is contributed by an extension") + .hasMessageContaining("also configured by the application"); + } + + private void buildPersistenceUnits(List descriptors, + List additional) { + processor.buildBlockingPersistenceUnitFromPersistenceXml(hibernateORMConfig(Map.of()), descriptors, List.of(), + additional, emptyJpaModel(), new JpaModelPerPersistenceUnitBuildItem(Map.of(), Set.of(), Set.of()), + new Capabilities(Set.of()), ignored -> { + }, List.of()); + } + + private static JpaModelPersistenceUnitContributionBuildItem contributionForPersistenceUnit( + List contributions, String persistenceUnitName) { + return contributions.stream() + .filter(item -> item.persistenceUnitName.equals(persistenceUnitName)) + .findFirst() + .orElseThrow(); + } + + private static ParsedPersistenceXmlDescriptor xmlDescriptor(String name, String managedClass, String mappingFile) + throws MalformedURLException { + var descriptor = new ParsedPersistenceXmlDescriptor(URI.create("file:/" + name + "/").toURL()); + descriptor.setName(name); + descriptor.setTransactionType(PersistenceUnitTransactionType.JTA); + descriptor.addClasses(managedClass); + descriptor.addMappingFiles(mappingFile); + return descriptor; + } + + static class DefaultModel { + } + + static class UserModel { + } +} From 74f048f173285ca7e6ef390abb2702e8c31adcc7 Mon Sep 17 00:00:00 2001 From: "David M. Lloyd" Date: Thu, 15 Oct 2026 07:38:03 -0500 Subject: [PATCH 228/254] Make sure retained service values are reproducible Fixes #57495 --- .../java/io/quarkus/deployment/steps/MainClassBuildStep.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/deployment/src/main/java/io/quarkus/deployment/steps/MainClassBuildStep.java b/core/deployment/src/main/java/io/quarkus/deployment/steps/MainClassBuildStep.java index a84ad8cdf5998..f0d9cb488a8f4 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/steps/MainClassBuildStep.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/steps/MainClassBuildStep.java @@ -880,7 +880,7 @@ private static Set computePostStartupKeys(List keys) { - Expr array = b0.newArray(ConstantDescs.CD_String, keys.stream().map(Const::of).toArray(Expr[]::new)); + Expr array = b0.newArray(ConstantDescs.CD_String, keys.stream().sorted().map(Const::of).toArray(Expr[]::new)); Expr keySet = b0.invokeStatic(MethodDesc.of(Set.class, "of", Set.class, Object[].class), array); b0.invokeVirtual(MethodDesc.of(StartupContext.class, "retainServiceValues", void.class, Set.class), sc, keySet); From f723164f5ea71f8118846383dd9513c908ad4a99 Mon Sep 17 00:00:00 2001 From: Aswin Date: Thu, 15 Oct 2026 18:08:51 +0530 Subject: [PATCH 229/254] Fix typo in OIDC logout URL parameter name Fix typo in OIDC code flow docs: post-logout-uri-param The User-initiated logout section referred to quarkus.oidc.logout.post-logout-url-param, but the property is quarkus.oidc.logout.post-logout-uri-param, as used in the Auth0 example in the same section. --- .../main/asciidoc/security-oidc-code-flow-authentication.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/main/asciidoc/security-oidc-code-flow-authentication.adoc b/docs/src/main/asciidoc/security-oidc-code-flow-authentication.adoc index 8800989e1bf3c..b7f6e780c7021 100644 --- a/docs/src/main/asciidoc/security-oidc-code-flow-authentication.adoc +++ b/docs/src/main/asciidoc/security-oidc-code-flow-authentication.adoc @@ -1515,7 +1515,7 @@ However, this is not a problem for Quarkus, as the logout mechanisms of such OID According to the https://openid.net/specs/openid-connect-session-1_0.html#RPLogout[RP-initiated logout] specification, the `quarkus.oidc.logout.post-logout-path` property is represented as a `post_logout_redirect_uri` query parameter, which is not recognized by the providers that do not support this specification. -You can use `quarkus.oidc.logout.post-logout-url-param` to work around this issue. +You can use `quarkus.oidc.logout.post-logout-uri-param` to work around this issue. You can also request that more logout query parameters be added with `quarkus.oidc.logout.extra-params`. For example, here is how you can support a logout with `Auth0`: From 8a5508cba3cef4ce78657ec40cfb35559d0de12b Mon Sep 17 00:00:00 2001 From: Luca Molteni Date: Thu, 15 Oct 2026 14:45:48 +0200 Subject: [PATCH 230/254] Adapt persistence XML processor test to class-level persistence units --- .../deployment/persistencexml/PersistenceXmlProcessorTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/deployment/persistencexml/PersistenceXmlProcessorTest.java b/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/deployment/persistencexml/PersistenceXmlProcessorTest.java index cdb19b7dd783c..a78b8478cd349 100644 --- a/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/deployment/persistencexml/PersistenceXmlProcessorTest.java +++ b/extensions/hibernate-orm/deployment/src/test/java/io/quarkus/hibernate/orm/deployment/persistencexml/PersistenceXmlProcessorTest.java @@ -119,7 +119,7 @@ void rejectsAnExtensionContributedPersistenceUnitThatDuplicatesPersistenceXml() private void buildPersistenceUnits(List descriptors, List additional) { processor.buildBlockingPersistenceUnitFromPersistenceXml(hibernateORMConfig(Map.of()), descriptors, List.of(), - additional, emptyJpaModel(), new JpaModelPerPersistenceUnitBuildItem(Map.of(), Set.of(), Set.of()), + additional, emptyJpaModel(), new JpaModelPerPersistenceUnitBuildItem(Map.of(), Set.of(), Set.of(), Set.of()), new Capabilities(Set.of()), ignored -> { }, List.of()); } From 4fc8cc8f4b30fabfa4aaeab1d1f81f4cc0f5b831 Mon Sep 17 00:00:00 2001 From: "David M. Lloyd" Date: Thu, 15 Oct 2026 08:29:15 -0500 Subject: [PATCH 231/254] Fix some minor formatting issues in the `recorders-to-services` doc --- docs/src/main/asciidoc/recorders-to-services-migration.adoc | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/src/main/asciidoc/recorders-to-services-migration.adoc b/docs/src/main/asciidoc/recorders-to-services-migration.adoc index 737e1b16c6c4b..cd1f6e91479ae 100644 --- a/docs/src/main/asciidoc/recorders-to-services-migration.adoc +++ b/docs/src/main/asciidoc/recorders-to-services-migration.adoc @@ -148,7 +148,7 @@ reg.forService("io.quarkus.my-ext.setup") During the transitional period, the `ServiceRegistrar` provides bridge APIs to allow incremental migration of extensions. -=== 1. Recorder $\rightarrow$ Service Bridge +=== 1. Recorder-to-Service Bridge If you have a value produced by a legacy recorder that a new service needs to depend on, register it using `aliasRecorderValue`: [source,java] @@ -165,7 +165,7 @@ void setupLegacyAndService(MyRecorder recorder, ServiceRegistrar reg) { Downstream services can now simply use `.require(MyLegacyValue.class)`. -=== 2. Service $\rightarrow$ Recorder Bridge +=== 2. Service-to-Recorder Bridge If an unconverted legacy recorder or an existing build item consumes a value that is now produced by a service, use the service-to-recorder bridge methods: * **`serviceAsRuntimeValue`**: Produces a `RuntimeValue` proxy for a runtime service. @@ -190,6 +190,7 @@ void bridgeServiceToLegacy(ServiceRegistrar reg, BuildProducer === ClassLoader & Thread Context ClassLoader (TCCL) Recorder bytecode always ran with the **runtime classloader as the TCCL**. Any recorder code that captured `Thread.currentThread().getContextClassLoader()` for later use relied on that. + * After converting, ensure that the service still runs with the runtime CL as TCCL, and that captured/inherited CLs are the runtime CL (not the system `AppClassLoader`). * Clean up threads or `ThreadLocal`s your service owns in `onStop()`; never leak the `QuarkusClassLoader`. From 47d02545fc5f036e35118404ddf9584bc3cc949d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20=C3=89pardaud?= Date: Thu, 15 Oct 2026 13:41:48 +0000 Subject: [PATCH 232/254] Fix capabilities section in writing-extensions guide Document the extension descriptor (quarkus-extension-maven-plugin) as the preferred way to declare capabilities and link to the capabilities guide. Fix the CapabilityBuildItem example to use the two-argument constructor, remove the incorrect claim that multiple extensions can provide the same capability, and call Capability an interface rather than an enum. Co-Authored-By: Claude Sonnet 5.5 --- .../src/main/asciidoc/writing-extensions.adoc | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/docs/src/main/asciidoc/writing-extensions.adoc b/docs/src/main/asciidoc/writing-extensions.adoc index c678053553fbc..5155f2d3fe590 100644 --- a/docs/src/main/asciidoc/writing-extensions.adoc +++ b/docs/src/main/asciidoc/writing-extensions.adoc @@ -2872,19 +2872,39 @@ cd rest-json ===== Capabilities A _capability_ represents a technical capability that can be queried by other extensions. -An extension may provide multiple capabilities and multiple extensions can provide the same capability. +An extension may provide multiple capabilities, but only a single provider of a given capability is allowed in an application: if more than one is detected, the build fails. By default, capabilities are not displayed to users. Capabilities should be used when checking for the presence of an extension rather than class path based checks. -Capabilities can be registered in a <> method that produces a `CapabilityBuildItem`: +The preferred way to declare the capabilities an extension provides (or requires) is in the extension descriptor, by configuring the `quarkus-extension-maven-plugin` in the runtime module's `pom.xml`: + +[source,xml] +---- + + io.quarkus + quarkus-extension-maven-plugin + + + org.acme.test-transactions + org.acme.test-metrics + + + +---- + +See xref:capabilities.adoc[Extension capabilities] for the full details, including required capabilities and the Gradle equivalent. + +Capabilities can also be registered in a <> method that produces a `CapabilityBuildItem`. +However, capabilities declared this way are not part of the extension descriptor, so they are not visible to the Quarkus dev tools +and cannot be taken into account when checking extension compatibility while creating a project or adding extensions: .TestProcessor#capability() [source,java] ---- @BuildStep void capabilities(BuildProducer capabilityProducer) { - capabilityProducer.produce(new CapabilityBuildItem("org.acme.test-transactions")); - capabilityProducer.produce(new CapabilityBuildItem("org.acme.test-metrics")); + capabilityProducer.produce(new CapabilityBuildItem("org.acme.test-transactions", "org.acme:test-extension")); + capabilityProducer.produce(new CapabilityBuildItem("org.acme.test-metrics", "org.acme:test-extension")); } ---- @@ -2902,7 +2922,7 @@ Extensions can consume registered capabilities using the `Capabilities` build it ---- Capabilities should follow the naming conventions of Java packages; e.g. `io.quarkus.security.jpa`. -Capabilities provided by core extensions should be listed in the `io.quarkus.deployment.Capability` enum and their name should always start with the `io.quarkus` prefix. +Capabilities provided by core extensions should be listed in the `io.quarkus.deployment.Capability` interface and their name should always start with the `io.quarkus` prefix. ==== Bean Defining Annotations The CDI layer processes CDI beans that are either explicitly registered or that it discovers based on bean defining annotations as defined in https://jakarta.ee/specifications/cdi/4.1/jakarta-cdi-spec-4.1.html#bean_defining_annotations[2.5.1. Bean defining annotations]. You can expand this set of annotations to include annotations your extension processes using a `BeanDefiningAnnotationBuildItem` as shown in this `TestProcessor#registerBeanDefinningAnnotations` example: From 928f658f3c45200ae6efcd8720d18cac940d029e Mon Sep 17 00:00:00 2001 From: "David M. Lloyd" Date: Thu, 15 Oct 2026 08:42:27 -0500 Subject: [PATCH 233/254] Add some more useful tips --- .../skills/converting-recorders-to-services/SKILL.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.agents/skills/converting-recorders-to-services/SKILL.md b/.agents/skills/converting-recorders-to-services/SKILL.md index d891adb8a335d..fd0c346eff66a 100644 --- a/.agents/skills/converting-recorders-to-services/SKILL.md +++ b/.agents/skills/converting-recorders-to-services/SKILL.md @@ -139,8 +139,9 @@ This resolves the producing step's nodes (with passthrough resolution for steps that have no service graph nodes) and creates ordering edges. **`afterBuildItem()` is deprecated** — it exists only for recorder -coexistence. Once the producing recorder is converted to a service, -replace with `require()` or `after()`, or drop it, as appropriate. +coexistence. Before adding one, check whether the target has already +been converted to a service; if so, use `require()` or `after()` +directly instead. #### Cross-phase dependencies @@ -201,6 +202,12 @@ Recorder proxies resolve via `startupContext.getServiceValue()` (for standard recorder proxies). The service deploy body stores values in the `serviceValues` map for this resolution. +**After converting**, search for other extensions that use +`afterBuildItem()` referencing build items your old recorder produced. +Those consumers can now migrate from `afterBuildItem()` to +`require()`/`after()` on your new service directly, removing one more +link in the legacy bridge chain. + ### 7. Synthetic beans **Before:** From d7a71b7d651a9f2d8fb1d983f88e69c9ab40f444 Mon Sep 17 00:00:00 2001 From: "David M. Lloyd" Date: Thu, 15 Oct 2026 08:42:57 -0500 Subject: [PATCH 234/254] Update the recorder-to-service guide to bring it up to date with the conversion skill; also tighten it up a little --- .../recorders-to-services-migration.adoc | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/docs/src/main/asciidoc/recorders-to-services-migration.adoc b/docs/src/main/asciidoc/recorders-to-services-migration.adoc index cd1f6e91479ae..0786d05c1e797 100644 --- a/docs/src/main/asciidoc/recorders-to-services-migration.adoc +++ b/docs/src/main/asciidoc/recorders-to-services-migration.adoc @@ -14,7 +14,7 @@ The `ServiceRegistrar` service system replaces `@Recorder` with a typed service Services get **no** implicit ordering from the build step graph. All ordering comes from `require()`, `after()`, `before()`, or `afterBuildItem()`. === Services are Independent from Recorders in the Same Step -Even if a build step method contains both a `@Record` call and an `ServiceRegistrar` call, the service and recorder are separate nodes in the build execution plan with no implicit ordering between them. +Even if a build step method contains both a `@Record` call and a `ServiceRegistrar` call, the service and recorder are separate nodes in the build execution plan with no implicit ordering between them. === Values Flow Through the Dependency Graph Service values are passed from producer to consumer via indexed dependency access on `ServiceNode`, not through the `StartupContext` maps (except when using recorder proxy bridges). @@ -68,8 +68,6 @@ For each `@Recorder` method, determine: === 3. Convert the Build Step -Let's look at a classic conversion. - ==== Before: [source,java] ---- @@ -123,7 +121,8 @@ reg.forService("io.quarkus.arc.lifecycle") ---- This resolves the producing step's nodes and creates ordering edges. -NOTE: `afterBuildItem()` is deprecated — it exists only for recorder coexistence. Once the producing recorder is converted to a service, replace with `require()` or `after()`, or drop it as appropriate. + +NOTE: `afterBuildItem()` is deprecated — it exists only for recorder coexistence. Before adding one, check whether the target has already been converted to a service; if so, use `require()` or `after()` directly instead. ==== Cross-Phase Dependencies Runtime services can `require()` static-init services. The framework automatically creates a `CROSS_PHASE_PROXY` node that reads the value from the `serviceValues` map. @@ -148,7 +147,7 @@ reg.forService("io.quarkus.my-ext.setup") During the transitional period, the `ServiceRegistrar` provides bridge APIs to allow incremental migration of extensions. -=== 1. Recorder-to-Service Bridge +=== Recorder-to-Service Bridge If you have a value produced by a legacy recorder that a new service needs to depend on, register it using `aliasRecorderValue`: [source,java] @@ -157,7 +156,7 @@ If you have a value produced by a legacy recorder that a new service needs to de @Record(ExecutionTime.RUNTIME_INIT) void setupLegacyAndService(MyRecorder recorder, ServiceRegistrar reg) { RuntimeValue val = recorder.createLegacyValue(); - + // Publish legacy value to the ServiceRegistrar graph reg.aliasRecorderValue(MyLegacyValue.class, val); } @@ -165,7 +164,7 @@ void setupLegacyAndService(MyRecorder recorder, ServiceRegistrar reg) { Downstream services can now simply use `.require(MyLegacyValue.class)`. -=== 2. Service-to-Recorder Bridge +=== Service-to-Recorder Bridge If an unconverted legacy recorder or an existing build item consumes a value that is now produced by a service, use the service-to-recorder bridge methods: * **`serviceAsRuntimeValue`**: Produces a `RuntimeValue` proxy for a runtime service. @@ -184,21 +183,27 @@ void bridgeServiceToLegacy(ServiceRegistrar reg, BuildProducer } ---- +=== Updating Downstream Consumers +After converting a recorder to a service, search for other extensions that use `afterBuildItem()` referencing build items your old recorder produced. Those consumers can now migrate from `afterBuildItem()` to `require()` or `after()` on your new service directly, removing one more link in the legacy bridge chain. + --- == Common Conversion Pitfalls -=== ClassLoader & Thread Context ClassLoader (TCCL) -Recorder bytecode always ran with the **runtime classloader as the TCCL**. Any recorder code that captured `Thread.currentThread().getContextClassLoader()` for later use relied on that. +=== Global Mutable State +Recorders ran sequentially, so global mutable state (static maps, shared lists, singletons populated during startup) was incidentally safe. Services run concurrently — any global state that survives conversion must be made thread-safe. Prefer eliminating it entirely: the dependency graph carries values directly, so each entry becomes a named service and each lookup becomes `require()` or `request()`. -* After converting, ensure that the service still runs with the runtime CL as TCCL, and that captured/inherited CLs are the runtime CL (not the system `AppClassLoader`). -* Clean up threads or `ThreadLocal`s your service owns in `onStop()`; never leak the `QuarkusClassLoader`. +=== ClassLoader & Thread Context ClassLoader (TCCL) +A wrong TCCL surfaces as `ServiceLoader` "not a subtype" errors or dev-mode failures. Threads created during startup inherit the creating thread's TCCL — a long-lived thread that captures the per-app runtime CL leaks the `QuarkusClassLoader` across restarts. Clean up threads or `ThreadLocal`s your service owns in `onStop()`; never rely on a global "reset all TCCLs" sweep. === Concrete Class Proxying `staticInitServiceAsRecorderValue(ConcreteClass.class)` fails if the class has a non-trivial constructor. Use `staticInitServiceAsRuntimeValue()` instead — `RuntimeValue` is always proxyable. === Package-Private Visibility -Lambda bytecode runs at runtime but references runtime classes directly. Package-private classes become inaccessible from the generated consolidated class. Ensure any classes referenced within the lambda are `public`. +Lambda bytecode runs at runtime but references runtime classes directly. Package-private classes become inaccessible from the generated consolidated class. Ensure any classes referenced within the lambda are `public`, or move them to a `.impl` subpackage and make them public. === Never Reference Deployment Classes inside Service Lambdas -Always place helper methods, custom interfaces, or static factory methods inside your **runtime** module classes (which are present at runtime). Doing so inside your processor class compiles successfully but throws `NoClassDefFoundError` at runtime. +Service lambda bodies execute at **runtime** — deployment classes are absent from the runtime classpath. Anonymous inner classes inside instance-method lambdas silently capture `this` (the processor), causing `TransliterationException`. Moving the instantiation to a `private static` helper in the processor avoids the capture but still references a deployment class, causing `NoClassDefFoundError`. Place all helper methods and factories in **runtime** module classes. + +=== Teardown-Ordering Parity +Start ordering is explicit — but so is **stop ordering**, and that is the easy thing to lose. The recorder model gave cleanup incidental ordering from build-step order; a converted service's `onStop` runs only in reverse dependency order. If a cleanup implicitly depended on running before/after another, declare it with `before()` or `after()`. Lost teardown ordering surfaces as unrelated downstream symptoms — verify shutdown explicitly. From f9e744ab1309d34dafcbcf0b1b783805d00088ef Mon Sep 17 00:00:00 2001 From: Tiziano Basile Date: Sat, 5 Sep 2026 11:11:22 +0200 Subject: [PATCH 235/254] Fail the build when classic Panache is used with Quarkus Data Using the Quarkus Data Hibernate (Panache Next) extension together with a classic Panache extension resulted in a cryptic "Unable to build Hibernate SessionFactory" error at startup, because both register overlapping persistence unit setup. Detect the conflict at build time by inspecting the produced FeatureBuildItem list and fail early with a clear, actionable message that names both extensions and tells the user to keep only one of them. Also document the incompatibility in the Quarkus Data Hibernate guide. Signed-off-by: Tiziano Basile --- ...uarkus-data-hibernate-getting-started.adoc | 10 +++ extensions/data/hibernate/deployment/pom.xml | 16 +++++ .../QuarkusDataHibernateProcessor.java | 36 +++++++++++ .../test/PanacheOneConflictTest.java | 63 +++++++++++++++++++ 4 files changed, 125 insertions(+) create mode 100644 extensions/data/hibernate/deployment/src/test/java/io/quarkus/data/hibernate/deployment/test/PanacheOneConflictTest.java diff --git a/docs/src/main/asciidoc/quarkus-data-hibernate-getting-started.adoc b/docs/src/main/asciidoc/quarkus-data-hibernate-getting-started.adoc index 1c2aa7acd2ef0..1eab757ae33ae 100644 --- a/docs/src/main/asciidoc/quarkus-data-hibernate-getting-started.adoc +++ b/docs/src/main/asciidoc/quarkus-data-hibernate-getting-started.adoc @@ -41,6 +41,16 @@ include::{includes}/devtools/extension-add.adoc[] The first dependency, `quarkus-data-hibernate`, is the Quarkus extension that provides everything you need for database access. The second dependency, `quarkus-jdbc-postgresql`, is the JDBC driver that tells Quarkus how to talk to a PostgreSQL database. If you're using a different database (MySQL, MariaDB, H2, etc.), pick the corresponding `quarkus-jdbc-*` driver instead (see xref:datasource.adoc#configure-a-jdbc-datasource[Configure a JDBC datasource]). Quarkus Data Hibernate cannot work without a JDBC driver <>. +[WARNING] +==== +Quarkus Data Hibernate (Panache Next) and the classic Panache (Panache 1) extensions cannot be used together. +Adding both `quarkus-data-hibernate` and any classic Panache extension +(such as `quarkus-hibernate-orm-panache`, `quarkus-hibernate-orm-panache-kotlin`, +`quarkus-hibernate-reactive-panache` or `quarkus-hibernate-reactive-panache-kotlin`) to the same +application makes the build fail with an explicit error. These two data access layers are mutually +exclusive, so pick only one of them. +==== + Quarkus Data Hibernate uses the Quarkus Data annotation processor, a key component that reads your repository interfaces at compile time, validates queries, and generates implementation classes. These generated classes are compiled together with your code and wired up automatically by Quarkus. They are also simple to read, in case you want to understand how it works internally. diff --git a/extensions/data/hibernate/deployment/pom.xml b/extensions/data/hibernate/deployment/pom.xml index 2c35f777ba07e..c8d273c7e0196 100644 --- a/extensions/data/hibernate/deployment/pom.xml +++ b/extensions/data/hibernate/deployment/pom.xml @@ -113,6 +113,22 @@ + + + io.quarkus + quarkus-hibernate-orm-panache-deployment + ${project.version} + pom + test + + + * + * + + + diff --git a/extensions/data/hibernate/deployment/src/main/java/io/quarkus/data/hibernate/deployment/QuarkusDataHibernateProcessor.java b/extensions/data/hibernate/deployment/src/main/java/io/quarkus/data/hibernate/deployment/QuarkusDataHibernateProcessor.java index e58445ea1ce68..66dd4309baa31 100644 --- a/extensions/data/hibernate/deployment/src/main/java/io/quarkus/data/hibernate/deployment/QuarkusDataHibernateProcessor.java +++ b/extensions/data/hibernate/deployment/src/main/java/io/quarkus/data/hibernate/deployment/QuarkusDataHibernateProcessor.java @@ -59,6 +59,7 @@ import io.quarkus.hibernate.orm.deployment.spi.JpaModelPersistenceUnitMappingBuildItem; import io.quarkus.panache.common.deployment.PanacheMethodCustomizerBuildItem; import io.quarkus.panache.hibernate.common.deployment.HibernateEnhancersRegisteredBuildItem; +import io.quarkus.runtime.configuration.ConfigurationException; import io.quarkus.security.spi.SecuredInterfaceAnnotationBuildItem; import io.quarkus.security.spi.SecuredTopLevelInterfaceBuildItem; import io.quarkus.security.spi.SecurityTransformer; @@ -102,11 +103,46 @@ public final class QuarkusDataHibernateProcessor { private static final DotName DOTNAME_ID = DotName.createSimple(Id.class.getName()); + /** + * Features of the classic Panache (Panache 1) Hibernate extensions, mapped to their Maven artifact ids. Quarkus Data + * (Panache Next) replaces these and cannot coexist with them: both register overlapping persistence unit setup, so + * the presence of any of them alongside Quarkus Data is a configuration error we detect and reject early. The + * artifact id is what a user needs to add or remove in their build file, so that is what we report in the error. + */ + private static final Map CLASSIC_PANACHE_FEATURE_ARTIFACTS = Map.of( + Feature.HIBERNATE_ORM_PANACHE.getName(), "quarkus-hibernate-orm-panache", + Feature.HIBERNATE_ORM_PANACHE_KOTLIN.getName(), "quarkus-hibernate-orm-panache-kotlin", + Feature.HIBERNATE_REACTIVE_PANACHE.getName(), "quarkus-hibernate-reactive-panache", + Feature.HIBERNATE_REACTIVE_PANACHE_KOTLIN.getName(), "quarkus-hibernate-reactive-panache-kotlin"); + @BuildStep FeatureBuildItem featureBuildItem() { return new FeatureBuildItem(Feature.QUARKUS_DATA_HIBERNATE); } + @BuildStep + void detectClassicPanacheOnClasspath(List features, + ValidationPhaseBuildItem validationPhase, + BuildProducer validationErrors) { + // The most robust detection signal available here is the FeatureBuildItem list: every extension unconditionally + // registers its own FeatureBuildItem, and consuming the whole list guarantees this step runs after all of them. + // There is no Capability defined for classic Panache, and relying on a class-on-classpath check would require a + // hard-coded internal class name that is more fragile than the feature name each extension owns. + for (FeatureBuildItem feature : features) { + String classicPanacheArtifact = CLASSIC_PANACHE_FEATURE_ARTIFACTS.get(feature.getName()); + if (classicPanacheArtifact != null) { + validationErrors.produce(new ValidationPhaseBuildItem.ValidationErrorBuildItem(new ConfigurationException( + "Quarkus Data (Panache Next, '" + Feature.QUARKUS_DATA_HIBERNATE.getName() + + "') and classic Panache ('" + classicPanacheArtifact + + "') were both detected on the classpath, but they cannot be used together.\n" + + "These two data access layers are mutually exclusive: please use only one of them.\n" + + "Either migrate your code to Quarkus Data and remove the classic Panache extension(s)," + + " or keep classic Panache and remove the Quarkus Data extension(s)."))); + return; + } + } + } + @BuildStep void produceModel(BuildProducer models) { // only useful for the index resolution: hibernate will register it to be transformed, but BuildMojo diff --git a/extensions/data/hibernate/deployment/src/test/java/io/quarkus/data/hibernate/deployment/test/PanacheOneConflictTest.java b/extensions/data/hibernate/deployment/src/test/java/io/quarkus/data/hibernate/deployment/test/PanacheOneConflictTest.java new file mode 100644 index 0000000000000..c3f4abdc0b3f9 --- /dev/null +++ b/extensions/data/hibernate/deployment/src/test/java/io/quarkus/data/hibernate/deployment/test/PanacheOneConflictTest.java @@ -0,0 +1,63 @@ +package io.quarkus.data.hibernate.deployment.test; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.builder.Version; +import io.quarkus.maven.dependency.Dependency; +import io.quarkus.test.QuarkusExtensionTest; + +/** + * Quarkus Data (Panache Next) and classic Panache (Panache 1) cannot be used together. When both are on the classpath + * the build must fail early with a clear message rather than with a cryptic error caused by their overlapping setup. + */ +public class PanacheOneConflictTest { + + @RegisterExtension + static QuarkusExtensionTest runner = new QuarkusExtensionTest() + // Force the classic Panache (Panache 1) extension onto this single test's classpath, next to Quarkus Data. + .setForcedDependencies(List.of( + Dependency.of("io.quarkus", "quarkus-hibernate-orm-panache-deployment", Version.getVersion()))) + .assertException(throwable -> { + String messages = collectMessages(throwable); + assertThat(messages) + .contains("Quarkus Data") + .contains("classic Panache") + .contains("cannot be used together") + .contains("quarkus-data-hibernate") + .contains("hibernate-orm-panache"); + }) + .withApplicationRoot((jar) -> jar + .addAsResource("application-test.properties", "application.properties") + .addClasses(MyEntity.class)); + + @Test + void conflictShouldFailTheBuild() { + Assertions.fail("The build should have failed because classic Panache and Quarkus Data are both present"); + } + + private static String collectMessages(Throwable throwable) { + StringBuilder builder = new StringBuilder(); + List seen = new ArrayList<>(); + Throwable current = throwable; + while (current != null && !seen.contains(current)) { + seen.add(current); + if (current.getMessage() != null) { + builder.append(current.getMessage()).append('\n'); + } + for (Throwable suppressed : current.getSuppressed()) { + if (suppressed.getMessage() != null) { + builder.append(suppressed.getMessage()).append('\n'); + } + } + current = current.getCause(); + } + return builder.toString(); + } +} From 28cb2ca7185968d530ef97f20d480cdbd189167b Mon Sep 17 00:00:00 2001 From: Tiziano Basile Date: Sun, 13 Sep 2026 15:43:01 +0200 Subject: [PATCH 236/254] Detect classic Panache via capabilities instead of features Each classic Panache (Panache 1) Hibernate extension now declares a capability in its extension descriptor, and Quarkus Data detects the conflict by querying Capabilities rather than scanning the produced FeatureBuildItem list. Capabilities are the canonical mechanism for this kind of extension-presence check and, being declared in the descriptor, are also visible to the Quarkus dev tools. Signed-off-by: Tiziano Basile --- .../io/quarkus/deployment/Capability.java | 4 +++ .../QuarkusDataHibernateProcessor.java | 30 ++++++++----------- .../resources/META-INF/quarkus-extension.yaml | 3 ++ .../resources/META-INF/quarkus-extension.yaml | 3 ++ .../resources/META-INF/quarkus-extension.yaml | 5 +++- .../resources/META-INF/quarkus-extension.yaml | 5 +++- 6 files changed, 31 insertions(+), 19 deletions(-) diff --git a/core/deployment/src/main/java/io/quarkus/deployment/Capability.java b/core/deployment/src/main/java/io/quarkus/deployment/Capability.java index ffc5cd1120892..ed269c377f966 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/Capability.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/Capability.java @@ -121,8 +121,12 @@ public interface Capability { String CONTAINER_IMAGE_OPENSHIFT = QUARKUS_PREFIX + ".container.image.openshift"; String CONTAINER_IMAGE_BUILDPACK = QUARKUS_PREFIX + ".container.image.buildpack"; String HIBERNATE_ORM = QUARKUS_PREFIX + ".hibernate.orm"; + String HIBERNATE_ORM_PANACHE = HIBERNATE_ORM + ".panache"; + String HIBERNATE_ORM_PANACHE_KOTLIN = HIBERNATE_ORM_PANACHE + ".kotlin"; String HIBERNATE_ENVERS = QUARKUS_PREFIX + ".hibernate.envers"; String HIBERNATE_REACTIVE = QUARKUS_PREFIX + ".hibernate.reactive"; + String HIBERNATE_REACTIVE_PANACHE = HIBERNATE_REACTIVE + ".panache"; + String HIBERNATE_REACTIVE_PANACHE_KOTLIN = HIBERNATE_REACTIVE_PANACHE + ".kotlin"; String HIBERNATE_VALIDATOR = QUARKUS_PREFIX + ".hibernate.validator"; String OPENTELEMETRY_TRACER = QUARKUS_PREFIX + ".opentelemetry.tracer"; String OPENTELEMETRY_METRICS = QUARKUS_PREFIX + ".opentelemetry.metrics"; diff --git a/extensions/data/hibernate/deployment/src/main/java/io/quarkus/data/hibernate/deployment/QuarkusDataHibernateProcessor.java b/extensions/data/hibernate/deployment/src/main/java/io/quarkus/data/hibernate/deployment/QuarkusDataHibernateProcessor.java index 66dd4309baa31..046d6c2504b20 100644 --- a/extensions/data/hibernate/deployment/src/main/java/io/quarkus/data/hibernate/deployment/QuarkusDataHibernateProcessor.java +++ b/extensions/data/hibernate/deployment/src/main/java/io/quarkus/data/hibernate/deployment/QuarkusDataHibernateProcessor.java @@ -104,16 +104,15 @@ public final class QuarkusDataHibernateProcessor { private static final DotName DOTNAME_ID = DotName.createSimple(Id.class.getName()); /** - * Features of the classic Panache (Panache 1) Hibernate extensions, mapped to their Maven artifact ids. Quarkus Data - * (Panache Next) replaces these and cannot coexist with them: both register overlapping persistence unit setup, so - * the presence of any of them alongside Quarkus Data is a configuration error we detect and reject early. The - * artifact id is what a user needs to add or remove in their build file, so that is what we report in the error. + * Capabilities of the classic Panache (Panache 1) Hibernate extensions, mapped to their Maven artifact ids. + * Quarkus Data (Panache Next) replaces these and cannot coexist with them: both register overlapping persistence + * unit setup. We report the artifact id because that is what a user adds or removes in their build file. */ - private static final Map CLASSIC_PANACHE_FEATURE_ARTIFACTS = Map.of( - Feature.HIBERNATE_ORM_PANACHE.getName(), "quarkus-hibernate-orm-panache", - Feature.HIBERNATE_ORM_PANACHE_KOTLIN.getName(), "quarkus-hibernate-orm-panache-kotlin", - Feature.HIBERNATE_REACTIVE_PANACHE.getName(), "quarkus-hibernate-reactive-panache", - Feature.HIBERNATE_REACTIVE_PANACHE_KOTLIN.getName(), "quarkus-hibernate-reactive-panache-kotlin"); + private static final Map CLASSIC_PANACHE_CAPABILITY_ARTIFACTS = Map.of( + Capability.HIBERNATE_ORM_PANACHE, "quarkus-hibernate-orm-panache", + Capability.HIBERNATE_ORM_PANACHE_KOTLIN, "quarkus-hibernate-orm-panache-kotlin", + Capability.HIBERNATE_REACTIVE_PANACHE, "quarkus-hibernate-reactive-panache", + Capability.HIBERNATE_REACTIVE_PANACHE_KOTLIN, "quarkus-hibernate-reactive-panache-kotlin"); @BuildStep FeatureBuildItem featureBuildItem() { @@ -121,16 +120,13 @@ FeatureBuildItem featureBuildItem() { } @BuildStep - void detectClassicPanacheOnClasspath(List features, + void detectClassicPanacheOnClasspath(Capabilities capabilities, ValidationPhaseBuildItem validationPhase, BuildProducer validationErrors) { - // The most robust detection signal available here is the FeatureBuildItem list: every extension unconditionally - // registers its own FeatureBuildItem, and consuming the whole list guarantees this step runs after all of them. - // There is no Capability defined for classic Panache, and relying on a class-on-classpath check would require a - // hard-coded internal class name that is more fragile than the feature name each extension owns. - for (FeatureBuildItem feature : features) { - String classicPanacheArtifact = CLASSIC_PANACHE_FEATURE_ARTIFACTS.get(feature.getName()); - if (classicPanacheArtifact != null) { + // Each classic Panache extension declares a capability in its extension descriptor. + for (Map.Entry classicPanache : CLASSIC_PANACHE_CAPABILITY_ARTIFACTS.entrySet()) { + if (capabilities.isPresent(classicPanache.getKey())) { + String classicPanacheArtifact = classicPanache.getValue(); validationErrors.produce(new ValidationPhaseBuildItem.ValidationErrorBuildItem(new ConfigurationException( "Quarkus Data (Panache Next, '" + Feature.QUARKUS_DATA_HIBERNATE.getName() + "') and classic Panache ('" + classicPanacheArtifact diff --git a/extensions/panache/hibernate-orm-panache-kotlin/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/extensions/panache/hibernate-orm-panache-kotlin/runtime/src/main/resources/META-INF/quarkus-extension.yaml index 9b9bdc7c48871..0b84729185206 100644 --- a/extensions/panache/hibernate-orm-panache-kotlin/runtime/src/main/resources/META-INF/quarkus-extension.yaml +++ b/extensions/panache/hibernate-orm-panache-kotlin/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -12,6 +12,9 @@ metadata: categories: - "data" status: "stable" + capabilities: + provides: + - "io.quarkus.hibernate.orm.panache.kotlin" config: - "quarkus.datasource." - "quarkus.hibernate-orm." diff --git a/extensions/panache/hibernate-orm-panache/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/extensions/panache/hibernate-orm-panache/runtime/src/main/resources/META-INF/quarkus-extension.yaml index 806a57acfed68..3664fcc857412 100644 --- a/extensions/panache/hibernate-orm-panache/runtime/src/main/resources/META-INF/quarkus-extension.yaml +++ b/extensions/panache/hibernate-orm-panache/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -13,6 +13,9 @@ metadata: categories: - "data" status: "stable" + capabilities: + provides: + - "io.quarkus.hibernate.orm.panache" config: - "quarkus.datasource." - "quarkus.hibernate-orm." diff --git a/extensions/panache/hibernate-reactive-panache-kotlin/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/extensions/panache/hibernate-reactive-panache-kotlin/runtime/src/main/resources/META-INF/quarkus-extension.yaml index e921d9fa004af..7a6727409ea01 100644 --- a/extensions/panache/hibernate-reactive-panache-kotlin/runtime/src/main/resources/META-INF/quarkus-extension.yaml +++ b/extensions/panache/hibernate-reactive-panache-kotlin/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -10,4 +10,7 @@ metadata: - "kotlin" categories: - "data" - status: "preview" \ No newline at end of file + status: "preview" + capabilities: + provides: + - "io.quarkus.hibernate.reactive.panache.kotlin" \ No newline at end of file diff --git a/extensions/panache/hibernate-reactive-panache/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/extensions/panache/hibernate-reactive-panache/runtime/src/main/resources/META-INF/quarkus-extension.yaml index 693612d2d70ea..94e8055d988c1 100644 --- a/extensions/panache/hibernate-reactive-panache/runtime/src/main/resources/META-INF/quarkus-extension.yaml +++ b/extensions/panache/hibernate-reactive-panache/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -10,4 +10,7 @@ metadata: guide: "https://quarkus.io/guides/hibernate-reactive-panache" categories: - "data" - status: "preview" \ No newline at end of file + status: "preview" + capabilities: + provides: + - "io.quarkus.hibernate.reactive.panache" \ No newline at end of file From 1eb59e122b86e6079924f63893cb10c73325509a Mon Sep 17 00:00:00 2001 From: Tiziano Basile Date: Sat, 10 Oct 2026 08:12:31 +0200 Subject: [PATCH 237/254] Declare classic Panache capabilities in the extension plugin config Capabilities listed under metadata in quarkus-extension.yaml are not recorded in quarkus-extension.properties, so they were never visible at build time and the conflict check could not trigger. Signed-off-by: Tiziano Basile --- .../panache/hibernate-orm-panache-kotlin/runtime/pom.xml | 5 +++++ .../src/main/resources/META-INF/quarkus-extension.yaml | 3 --- extensions/panache/hibernate-orm-panache/runtime/pom.xml | 5 +++++ .../src/main/resources/META-INF/quarkus-extension.yaml | 3 --- .../hibernate-reactive-panache-kotlin/runtime/pom.xml | 5 +++++ .../src/main/resources/META-INF/quarkus-extension.yaml | 5 +---- .../panache/hibernate-reactive-panache/runtime/pom.xml | 5 +++++ .../src/main/resources/META-INF/quarkus-extension.yaml | 5 +---- 8 files changed, 22 insertions(+), 14 deletions(-) diff --git a/extensions/panache/hibernate-orm-panache-kotlin/runtime/pom.xml b/extensions/panache/hibernate-orm-panache-kotlin/runtime/pom.xml index ae0c27536aeab..4cde0463c4a0c 100644 --- a/extensions/panache/hibernate-orm-panache-kotlin/runtime/pom.xml +++ b/extensions/panache/hibernate-orm-panache-kotlin/runtime/pom.xml @@ -137,6 +137,11 @@ io.quarkus quarkus-extension-maven-plugin + + + io.quarkus.hibernate.orm.panache.kotlin + + diff --git a/extensions/panache/hibernate-orm-panache-kotlin/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/extensions/panache/hibernate-orm-panache-kotlin/runtime/src/main/resources/META-INF/quarkus-extension.yaml index 0b84729185206..9b9bdc7c48871 100644 --- a/extensions/panache/hibernate-orm-panache-kotlin/runtime/src/main/resources/META-INF/quarkus-extension.yaml +++ b/extensions/panache/hibernate-orm-panache-kotlin/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -12,9 +12,6 @@ metadata: categories: - "data" status: "stable" - capabilities: - provides: - - "io.quarkus.hibernate.orm.panache.kotlin" config: - "quarkus.datasource." - "quarkus.hibernate-orm." diff --git a/extensions/panache/hibernate-orm-panache/runtime/pom.xml b/extensions/panache/hibernate-orm-panache/runtime/pom.xml index 81343399823e0..793eada2fd379 100644 --- a/extensions/panache/hibernate-orm-panache/runtime/pom.xml +++ b/extensions/panache/hibernate-orm-panache/runtime/pom.xml @@ -89,6 +89,11 @@ io.quarkus quarkus-extension-maven-plugin + + + io.quarkus.hibernate.orm.panache + + diff --git a/extensions/panache/hibernate-orm-panache/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/extensions/panache/hibernate-orm-panache/runtime/src/main/resources/META-INF/quarkus-extension.yaml index 3664fcc857412..806a57acfed68 100644 --- a/extensions/panache/hibernate-orm-panache/runtime/src/main/resources/META-INF/quarkus-extension.yaml +++ b/extensions/panache/hibernate-orm-panache/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -13,9 +13,6 @@ metadata: categories: - "data" status: "stable" - capabilities: - provides: - - "io.quarkus.hibernate.orm.panache" config: - "quarkus.datasource." - "quarkus.hibernate-orm." diff --git a/extensions/panache/hibernate-reactive-panache-kotlin/runtime/pom.xml b/extensions/panache/hibernate-reactive-panache-kotlin/runtime/pom.xml index 66c387fef8de3..13895a924a02b 100644 --- a/extensions/panache/hibernate-reactive-panache-kotlin/runtime/pom.xml +++ b/extensions/panache/hibernate-reactive-panache-kotlin/runtime/pom.xml @@ -147,6 +147,11 @@ io.quarkus quarkus-extension-maven-plugin + + + io.quarkus.hibernate.reactive.panache.kotlin + + diff --git a/extensions/panache/hibernate-reactive-panache-kotlin/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/extensions/panache/hibernate-reactive-panache-kotlin/runtime/src/main/resources/META-INF/quarkus-extension.yaml index 7a6727409ea01..e921d9fa004af 100644 --- a/extensions/panache/hibernate-reactive-panache-kotlin/runtime/src/main/resources/META-INF/quarkus-extension.yaml +++ b/extensions/panache/hibernate-reactive-panache-kotlin/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -10,7 +10,4 @@ metadata: - "kotlin" categories: - "data" - status: "preview" - capabilities: - provides: - - "io.quarkus.hibernate.reactive.panache.kotlin" \ No newline at end of file + status: "preview" \ No newline at end of file diff --git a/extensions/panache/hibernate-reactive-panache/runtime/pom.xml b/extensions/panache/hibernate-reactive-panache/runtime/pom.xml index c9c0057f712fe..20310312535e0 100644 --- a/extensions/panache/hibernate-reactive-panache/runtime/pom.xml +++ b/extensions/panache/hibernate-reactive-panache/runtime/pom.xml @@ -93,6 +93,11 @@ io.quarkus quarkus-extension-maven-plugin + + + io.quarkus.hibernate.reactive.panache + + diff --git a/extensions/panache/hibernate-reactive-panache/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/extensions/panache/hibernate-reactive-panache/runtime/src/main/resources/META-INF/quarkus-extension.yaml index 94e8055d988c1..693612d2d70ea 100644 --- a/extensions/panache/hibernate-reactive-panache/runtime/src/main/resources/META-INF/quarkus-extension.yaml +++ b/extensions/panache/hibernate-reactive-panache/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -10,7 +10,4 @@ metadata: guide: "https://quarkus.io/guides/hibernate-reactive-panache" categories: - "data" - status: "preview" - capabilities: - provides: - - "io.quarkus.hibernate.reactive.panache" \ No newline at end of file + status: "preview" \ No newline at end of file From c5e73f0f058caf5b79a8545ff6815cb96b17190c Mon Sep 17 00:00:00 2001 From: "David M. Lloyd" Date: Thu, 15 Oct 2026 10:22:17 -0500 Subject: [PATCH 238/254] Major cleanups to service sections of the extensions doc --- .../src/main/asciidoc/writing-extensions.adoc | 130 ++++++++++++++---- 1 file changed, 106 insertions(+), 24 deletions(-) diff --git a/docs/src/main/asciidoc/writing-extensions.adoc b/docs/src/main/asciidoc/writing-extensions.adoc index c678053553fbc..be3aed1151840 100644 --- a/docs/src/main/asciidoc/writing-extensions.adoc +++ b/docs/src/main/asciidoc/writing-extensions.adoc @@ -1492,11 +1492,16 @@ class MyDevModeProcessor { <2> The main build step will only be executed in dev mode. <3> The other build step will only be executed in dev mode. -[id='bytecode-recording'] +[id='application-startup-services'] === Application Startup and Services -Quarkus uses a compile-time built, runtime-executed dependency graph of lightweight startup services instead of executing build steps directly at runtime. This allows for explicit dependency declarations, deterministic initialization ordering, and skipping boot-time reflection and proxy-creation overhead. +Quarkus extensions need to perform initialization work at application startup: creating CDI beans, opening connections, binding sockets, starting subsystems. Extensions declare this work as lightweight services that form an explicit dependency graph. Each service specifies exactly what it needs to run (other services, configuration, build-time state) and what it produces (typed values, initialization side effects). At runtime, Quarkus executes this graph directly: services start as soon as their dependencies are satisfied, allowing independent work to proceed in parallel while respecting ordering constraints. + +Services are build-time declarations that generate runtime bytecode. You provide a lambda expression that runs at startup. The lambda is transliterated into bytecode at build time, with captured values embedded as constants. This preserves Quarkus's build-time/runtime split while giving you direct control over initialization. + +The core guarantee is **deterministic partial ordering**: if service A depends on service B, then B always completes before A starts. Services with no dependency relationship may start in any order or concurrently, but any given dependency chain always executes in the same order. Shutdown reverses the graph: services stop in the exact reverse of their start dependencies, so cleanup always happens in a safe sequence. +[id='service-registrar-api'] ==== Services and the ServiceRegistrar API The main entry point for defining startup services is `io.quarkus.core.deployment.service.ServiceRegistrar`. An instance of `ServiceRegistrar` can be injected directly into any `@BuildStep` method: @@ -1504,27 +1509,21 @@ The main entry point for defining startup services is `io.quarkus.core.deploymen [source%nowrap,java] ---- @BuildStep -ServiceStartBuildItem setup(ServiceRegistrar reg) { +void setup(ServiceRegistrar reg) { reg.forService("io.quarkus.hello") .onStart(ctx -> System.out.println("Hello World")); - return new ServiceStartBuildItem("hello"); } ---- ===== Service Types -Services can be typed (producing a value at startup) or void (producing no value, identified by a unique name): - -* *Typed Services*: Defined via `reg.forService(Class)` or `reg.forService(Class, String name)`. -* *Void Services*: Defined via `reg.forService(String name)` or `reg.forService(List nameParts)`. By convention, void service names use hierarchical dot-separated names, e.g., `"io.quarkus.."`. +Services can be typed or void. Typed services produce a value at startup and are defined via `reg.forService(Class)` or `reg.forService(Class, String name)`. The value they produce becomes available to other services that depend on them. Void services produce no value and are identified solely by name, defined via `reg.forService(String name)` or `reg.forService(List nameParts)`. By convention, void service names use hierarchical dot-separated names like `"io.quarkus.."`. ===== Synchronous and Asynchronous Start Actions -Services can be started either synchronously or asynchronously, depending on which method is called on the service builder: - -* *Synchronous Start* (`onStart`): The service is considered started when the action returns. The action receives a `StartContext` (for void services) or returns the service value directly (for typed services). +Services can be started either synchronously or asynchronously. When using synchronous start (`onStart`), the service is considered started when the action returns. The action receives a `StartContext` as its first parameter, followed by any declared dependencies. Typed services return the service value; void services return nothing. -* *Asynchronous Start* (`onStartAsync`): The action is initially invoked in the calling thread, but the service is not considered started until a completion method is called. The action receives an `AsyncVoidStartContext` (for void services) or `AsyncStartContext` (for typed services). The completion method can be called from the same thread or from a different thread, allowing the action to schedule asynchronous work. +When using asynchronous start (`onStartAsync`), the action is initially invoked in the calling thread, but the service is not considered started until a completion method is called. The action receives an `AsyncVoidStartContext` (for void services) or `AsyncStartContext` (for typed services). The completion method can be called from the same thread or from a different thread, allowing the action to schedule asynchronous work. .Example of synchronous start [source%nowrap,java] @@ -1537,7 +1536,7 @@ reg.forService(MyService.class) [source%nowrap,java] ---- reg.forService(MyService.class) - .require(ExecutorService.class) + .require(ScheduledExecutorService.class) .onStartAsync((ctx, executor) -> { // Action runs in the calling thread initially executor.execute(() -> { @@ -1565,11 +1564,19 @@ reg.forService("io.quarkus.my-async-service") }); ---- -The async start context provides three completion methods: +The asynchronous start context provides three completion methods: + +* `startComplete()` or `startComplete(T value)` indicates that the service start operation completed successfully. +* `startFailed(Throwable)` indicates that the service failed to start. +* `startCanceled()` indicates that the service start was canceled. -* `startComplete()` or `startComplete(T value)`: Indicates successful completion -* `startFailed(Throwable)`: Indicates the service failed to start -* `startCanceled()`: Indicates the service start was canceled +===== When to Use Asynchronous Start or Stop + +In most cases, synchronous start and stop are adequate. Use `onStart()` and `ctx.onStop()` when the initialization or cleanup work completes before the method returns. + +Use asynchronous start and stop when the underlying resource being initialized uses a callback-based completion mechanism. If starting a server, opening a connection, or initializing a subsystem signals completion via a callback, `CompletableFuture`, or listener rather than blocking until complete, wire that callback to the asynchronous context's completion method. + +In other words, use `onStartAsync()` when initialization continues after the action returns and you need to signal completion from a different thread or callback. The same principle applies to stop handlers: use `onStopAsync()` when shutdown is callback-driven, `onStop()` when it completes synchronously. ==== Service Startup Phases @@ -1584,7 +1591,7 @@ reg.forService("io.quarkus.my-service") The available startup phases (defined in `io.quarkus.core.Phase`) in execution order are: -1. `STATIC_INIT`: Runs during static initialization of the generated application class. Results are baked into the native image heap. +1. `STATIC_INIT`: Runs during static initialization of the generated application class. Results are baked into the native image heap when building a native image. 2. `CONFIG`: Registers configuration property classes, validates configuration, and reports deprecations. 3. `LOGGING`: Reconfigures the log manager with real runtime configuration (log levels, handlers, formatters). 4. `INFRASTRUCTURE`: Creates foundational infrastructure (event loops, executors, TLS registries, transaction managers). @@ -1600,6 +1607,18 @@ The available startup phases (defined in `io.quarkus.core.Phase`) in execution o The runtime does not wait for all services in phase _N_ to complete before starting services in phase _N+1_. Services start as soon as their individual dependencies are satisfied. ==== +==== Execution Model + +At build time, Quarkus constructs a directed acyclic graph (DAG) of service nodes based on the dependency declarations you provide. Each service is a node; each `require()`, `after()`, or `before()` call creates an edge. At runtime, the generated startup code executes this graph directly. + +Services form a partial order: if service A depends on service B, then B always completes before A starts. Services with no dependency relationship may execute in any order or concurrently. The runtime may exploit this parallelism: for example, independent services may start simultaneously, constrained only by their declared dependencies. + +The execution guarantee is deterministic: given the same dependency graph, the same dependency chains always execute in the same order across runs. What varies is the interleaving of independent work. If services A and B have no dependency relationship, they may start in either order or overlap in time, but if A depends on C and B depends on C, then C always completes before both A and B start. + +When a service fails to start (by throwing an exception from `onStart()` or calling `startFailed()` from an asynchronous context), the failure propagates to all services that transitively depend on it. Those dependent services never start. Services that have no dependency path to the failed service still execute and complete their initialization. The application startup fails overall, but the partial graph that did execute represents a consistent state: every service that started had all its dependencies satisfied. All successfully started services are then stopped cleanly before the process exits. + +Shutdown (whether triggered by failure or normal application termination) reverses the dependency graph. Services stop in the exact reverse order of their start dependencies: if A started after B, then A stops before B. This ensures that cleanup always happens in a safe sequence — a service's dependencies remain available throughout its stop handler. + ==== Declaring Dependencies and Ordering Every service dependency and execution ordering constraint must be explicitly declared on the service itself. **Build step graph dependencies (e.g. `@Consume`/`@Produce` or method parameter ordering) have zero influence on service execution order.** @@ -1635,6 +1654,67 @@ reg.forService("io.quarkus.vertx.netty-cleanup") }); ---- +===== Optional Service Dependencies + +When a service might or might not be present, use `request()` instead of `require()`. The dependency is injected as `Optional`: + +[source%nowrap,java] +---- +reg.forService("io.quarkus.my-feature") + .request(MetricsFactory.class) + .onStart((ctx, metricsFactory) -> { + if (metricsFactory.isPresent()) { + // Configure metrics + } + }); +---- + +Use `request()` when depending on services from optional extensions or when the service might be conditionally produced. + +===== Consuming Multiple Services + +To consume all services of a given type, use `consumeAll(Class)`. The services are injected as a `Map` where keys are the service names: + +[source%nowrap,java] +---- +reg.forService("io.quarkus.http.auth") + .consumeAll(HttpAuthMechanism.class) + .onStart((ctx, mechanisms) -> { + for (Map.Entry entry : mechanisms.entrySet()) { + String name = entry.getKey(); + HttpAuthMechanism mechanism = entry.getValue(); + // Register each mechanism + } + }); +---- + +Services consumed by `consumeAll()` must be named (created via `forService(Class, String name)`). Only one level of the naming hierarchy is consumed: `consumeAll(Foo.class)` will consume services registered as `forService(Foo.class, "bar")` but will not consume services registered as `forService(Foo.class, List.of("bar", "baz"))`. + +===== Optional Service Production + +A service can declare that it may produce no value at runtime by calling `.optional()` on the builder. This changes the action's return type to `Optional`: + +[source%nowrap,java] +---- +reg.forService(CacheProvider.class) + .optional() + .onStart(ctx -> { + if (cacheConfig.enabled()) { + return Optional.of(new CacheProvider()); + } else { + return Optional.empty(); + } + }); +---- + +Returning `Optional.empty()` marks the service as present-but-valueless. This is different from a service failing to start: + +* Services that `require()` an optional service will fail at runtime if the service returned empty. +* Services that `request()` an optional service will receive an empty `Optional` if the service returned empty. +* Services using `consumeAll()` skip optional services that returned empty — they do not appear in the map. + +Use `.optional()` for services that may legitimately have nothing to produce based on configuration or runtime conditions. + ==== Lambda Capture Rules Because the action lambda bytecode (defined inside `.onStart()`) is executed at runtime, values from the build step can be captured inside the lambda under strict rules. @@ -1642,13 +1722,14 @@ Because the action lambda bytecode (defined inside `.onStart()`) is executed at ===== Supported Captures * Primitives, `String`, `Class`, and enum values. * Immutable collections (e.g., via `List.of()`, `Set.copyOf()`, `Map.copyOf()`). -* Configuration mappings from `BUILD_AND_RUN_TIME_FIXED` and `RUN_TIME` phases. +* Configuration mappings from `BUILD_AND_RUN_TIME_FIXED` phase. * `Constable` values (any type implementing `java.lang.constant.Constable` with `describeConstable()`). * Record values, provided all their components are recursively capturable. ===== Unsupported Captures (Build Errors) * Mutable collections (always copy them via `List.copyOf()` or `Set.copyOf()` before the lambda). * `BUILD_TIME` configuration objects (extract the specific values into local variables first). +* `RUN_TIME` configuration objects (use `.require(ConfigType.class)` instead). * Arbitrary runtime objects (obtain them via `.require()` instead). ==== Critical Pitfalls & Best Practices @@ -1661,7 +1742,9 @@ Because service lambda bodies run at runtime, they must never reference, instant * *Solution*: Always place helper methods, custom interfaces, or static factory methods inside your **runtime** module classes (which are present on both classpaths). ===== 2. Package-Private Visibility -Lambda bytecode runs at runtime but references runtime classes directly. Package-private classes can become inaccessible from the generated consolidated class. Make classes public or move them to public subpackages. +Service lambdas are transliterated into generated bytecode that executes in a runtime context where package-private access can fail. The failure mode is subtle — it typically occurs when the lambda contains nested lambdas, anonymous classes, or method references that indirectly reference package-private runtime types. If you encounter an `IllegalAccessError` at runtime involving a package-private class that appears to be reachable from your service lambda, this is the cause. + +The solution is to make referenced runtime classes `public`. If you are promoting a class from package-private to public solely for service access (and it was not previously part of your public API), move it to a `.impl` subpackage to signal that it remains internal. ===== 3. Use `require()` instead of direct class queries Always access CDI beans through `.require(BeanContainer.class)` instead of direct `Arc.container()` calls to ensure proper dependency graph tracking and teardown ordering. @@ -1686,11 +1769,9 @@ Services can register cleanup actions inside the `onStart` or `onStartAsync` act ===== Synchronous and Asynchronous Stop Handlers -Similar to start actions, stop handlers can be either synchronous or asynchronous: +Similar to start actions, stop handlers can be either synchronous or asynchronous. When using synchronous stop (`ctx.onStop(Runnable)`), the service is considered stopped when the runnable returns. -* *Synchronous Stop* (`ctx.onStop(Runnable)`): The service is considered stopped when the runnable returns. - -* *Asynchronous Stop* (`ctx.onStopAsync(Consumer)`): The stop handler is initially invoked in the calling thread, but the service is not considered stopped until `AsyncStopContext.stopComplete()` is called. The completion method can be called from the same thread or from a different thread, allowing the handler to schedule asynchronous cleanup work. +When using asynchronous stop (`ctx.onStopAsync(Consumer)`), the stop handler is initially invoked in the calling thread, but the service is not considered stopped until `AsyncStopContext.stopComplete()` is called. The completion method can be called from the same thread or from a different thread, allowing the handler to schedule asynchronous cleanup work. .Example of synchronous stop [source%nowrap,java] @@ -1724,6 +1805,7 @@ reg.forService(MyService.class) NOTE: The `AsyncStopContext.stopComplete()` method is idempotent. Calling it multiple times has no additional effect beyond the first call. +[id='bytecode-recording'] [id='legacy-bytecode-recording'] === Legacy Bytecode Recorders (Deprecated) From 6c6669ab810d92be055299090213541d7aad2b6a Mon Sep 17 00:00:00 2001 From: "David M. Lloyd" Date: Thu, 15 Oct 2026 10:22:38 -0500 Subject: [PATCH 239/254] Fix an inaccuracy about config capture in the conversion skill --- .agents/skills/converting-recorders-to-services/SKILL.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.agents/skills/converting-recorders-to-services/SKILL.md b/.agents/skills/converting-recorders-to-services/SKILL.md index fd0c346eff66a..04e12063c64cc 100644 --- a/.agents/skills/converting-recorders-to-services/SKILL.md +++ b/.agents/skills/converting-recorders-to-services/SKILL.md @@ -166,9 +166,10 @@ serviceRegistrar.forService("io.quarkus.my-ext.setup") #### Config mappings - `BUILD_TIME` — extract values into locals before the lambda. -- `BUILD_AND_RUN_TIME_FIXED` — use `.require(ConfigType.class)` (resolved - directly from SmallRye Config, no graph edge). -- `RUN_TIME` — use `.require(ConfigType.class)` (same mechanism). +- `BUILD_AND_RUN_TIME_FIXED` — capture directly (available at both build + and runtime, captured via `ConfigCaptureInterceptor`). +- `RUN_TIME` — use `.require(ConfigType.class)` (injected as a service + dependency). ### 5. Stop ordering with `before()` From cdef424573c55c98797b09f2654194e6ae768e86 Mon Sep 17 00:00:00 2001 From: "David M. Lloyd" Date: Thu, 15 Oct 2026 10:29:14 -0500 Subject: [PATCH 240/254] Update skills which casually reference recorders to reference services instead --- .agents/skills/classloading-and-runtime-dev/SKILL.md | 8 +++++--- .agents/skills/coding-style/SKILL.md | 3 +-- .agents/skills/creating-extensions/SKILL.md | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.agents/skills/classloading-and-runtime-dev/SKILL.md b/.agents/skills/classloading-and-runtime-dev/SKILL.md index f42d3d61087d7..222afc41429ee 100644 --- a/.agents/skills/classloading-and-runtime-dev/SKILL.md +++ b/.agents/skills/classloading-and-runtime-dev/SKILL.md @@ -19,8 +19,10 @@ description: > - **Runtime code MUST NOT reference deployment classes.** Violations cause `ClassNotFoundException` at runtime. - **Deployment code CAN reference runtime classes.** -- **Recorders bridge the gap.** A `@Recorder` lives in the runtime module but - is invoked from deployment build steps. +- **Services bridge the gap.** Service lambdas are defined in deployment build + steps but execute at runtime. The lambda body can only reference runtime + classes; attempting to reference deployment classes will cause + `ClassNotFoundException` at runtime. ## Runtime-Dev Modules @@ -74,7 +76,7 @@ Forgetting `conditionalDevDependencies` is particularly confusing because: ## Common Mistakes - Importing a deployment class in runtime code -- Using a build-time-only library in a recorder +- Using a build-time-only library in a service lambda - Accessing runtime config during augmentation (build time) - Putting user-facing API classes in deployment instead of runtime - Forgetting `conditionalDevDependencies` for `runtime-dev` modules diff --git a/.agents/skills/coding-style/SKILL.md b/.agents/skills/coding-style/SKILL.md index 6f05bcd85c0b2..d59e0e5257f85 100644 --- a/.agents/skills/coding-style/SKILL.md +++ b/.agents/skills/coding-style/SKILL.md @@ -21,7 +21,7 @@ description: > - Prefer package-private (default) visibility for internal implementation classes - Use `public` for user-facing API and for classes/methods that need to be accessed - across packages (e.g., processors, recorders, build items). Prefer package-private + across packages (e.g., processors, build items). Prefer package-private for purely internal implementation details - Use `protected` when designing for extension/subclassing - Internal classes should go in `.internal` or `.impl` sub-packages @@ -30,7 +30,6 @@ description: > ## Naming - Deployment processors: `Processor.java` -- Recorders: `Recorder.java` - Build items: `BuildItem.java` - Config interfaces: `Config.java` or `BuildTimeConfig.java` / `RuntimeConfig.java` - Artifact IDs: `quarkus-` (runtime), `quarkus--deployment` (deployment) diff --git a/.agents/skills/creating-extensions/SKILL.md b/.agents/skills/creating-extensions/SKILL.md index fd1447acb5c83..9a3dbccfbe106 100644 --- a/.agents/skills/creating-extensions/SKILL.md +++ b/.agents/skills/creating-extensions/SKILL.md @@ -13,7 +13,7 @@ Every extension lives under `extensions//` with this structure: ``` extensions// - runtime/ # Runtime classes, recorders, beans (quarkus-) + runtime/ # Runtime classes, beans, service implementations (quarkus-) runtime-api/ # (optional) Public APIs for loose coupling (quarkus--api) deployment/ # @BuildStep processors (quarkus--deployment) deployment-spi/ # (optional) Public build items for cross-extension use (quarkus--deployment-spi) From 48c7272b200697956141becb8229fe7881fac44d Mon Sep 17 00:00:00 2001 From: "David M. Lloyd" Date: Thu, 15 Oct 2026 10:33:46 -0500 Subject: [PATCH 241/254] Update `writing-build-steps` skill to replace recorders with services --- .agents/skills/writing-build-steps/SKILL.md | 77 +++++++++++++++------ 1 file changed, 54 insertions(+), 23 deletions(-) diff --git a/.agents/skills/writing-build-steps/SKILL.md b/.agents/skills/writing-build-steps/SKILL.md index 621c20bb028a7..20939367888bc 100644 --- a/.agents/skills/writing-build-steps/SKILL.md +++ b/.agents/skills/writing-build-steps/SKILL.md @@ -55,35 +55,66 @@ injects a config mapping, you **must** reference that config mapping in a build step — otherwise the injection will fail with `UnsatisfiedResolutionException`. See the `working-with-config` skill for details. -## Recorders +## Services -To execute code at runtime from a build step, use a `@Recorder`. The recorder -class lives in the **runtime** module. Its methods are called from deployment -build steps, but the actual execution happens at application startup — the -recorder generates bytecode that replays those method calls at runtime. +To execute code at runtime from a build step, register a service using +`ServiceRegistrar`. Services are lightweight startup tasks that form a +dependency graph. The service lambda is defined in the deployment build step +but executes at runtime. ```java @BuildStep -@Record(ExecutionTime.RUNTIME_INIT) -void setupAtRuntime(MyRecorder recorder, SomeBuildItem item) { - recorder.initialize(item.getValue()); +void setupAtRuntime(ServiceRegistrar reg, SomeBuildItem item) { + reg.forService("io.quarkus.my-feature.setup") + .onStart(ctx -> { + // This lambda runs at runtime + MyFeature.initialize(item.getValue()); + }); } ``` -### Execution Times +### Service Types + +- **Typed services** produce a value: `reg.forService(MyService.class)` +- **Void services** produce no value, identified by name: `reg.forService("io.quarkus.feature.setup")` + +Use typed services when other services need to depend on the value produced. +Use void services for side effects like initialization tasks. + +### Phases + +Services execute in ordered phases. The default is `Phase.APPLICATION`. Use +`.atPhase(Phase)` to customize: - **`STATIC_INIT`** — Runs during static initialization (before `main()`). - Safe for native image. Prefer this when possible as it allows more work to - be done at build time in native mode. -- **`RUNTIME_INIT`** — Runs at application startup (during `main()`). Use when - the code needs runtime-only resources (e.g., network, threads). - -### Recorder Constraints - -- Recorder methods must only use types available in the **runtime** module -- Do not pass deployment-only types to recorder methods -- Do not use build-time-only libraries inside recorder implementations -- Recorder method parameters must be recordable (the bytecode recorder writes - code that reconstructs the object, similar to how `ObjectMapper` would - serialize it — but to bytecode, not JSON). Use simple value types, collections, - or types with appropriate constructors/setters. This is NOT Java serialization + Results are baked into the native image heap when building a native image. +- **`CONFIG`** — Registers configuration property classes. +- **`LOGGING`** — Reconfigures the log manager. +- **`INFRASTRUCTURE`** — Creates foundational infrastructure (executors, event loops). +- **`BINDING`** — Binds server sockets. +- **`DATA`** — Establishes database connections. +- **`INIT`** — Performs one-time initialization (migrations, validation). +- **`APPLICATION`** — Registers request-handling logic (default). +- **`SERVING`** — Starts accepting requests. + +### Dependencies + +Declare all dependencies explicitly on the service itself: + +- `.require(SomeService.class)` — depends on and consumes a service value +- `.request(SomeService.class)` — optional dependency, injected as `Optional` +- `.after(SomeService.class)` — ordering-only dependency +- `.before(SomeService.class)` — this service must start before the specified service + +Build step graph dependencies have zero influence on service execution order. + +### Lambda Capture Constraints + +Service lambda bodies run at runtime and can only reference runtime classes: + +- **Supported captures**: primitives, `String`, `Class`, enum values, immutable + collections, `BUILD_AND_RUN_TIME_FIXED` config, `Constable` values, records +- **Unsupported**: mutable collections, `BUILD_TIME` config, `RUN_TIME` config + (use `.require()` instead), arbitrary runtime objects (use `.require()`) +- **Never reference deployment classes** in the lambda body — this causes + `ClassNotFoundException` at runtime From d24c808e4abc339e31cf0326095bcc5e2a2ed08c Mon Sep 17 00:00:00 2001 From: Andreas Maechler Date: Tue, 14 Apr 2026 16:26:23 -0600 Subject: [PATCH 242/254] Fix Gradle showStandardStreams for Quarkus tests Override ConsoleHandler's stale System.out reference and remove foreign handlers from the JUL root logger during Quarkus logging initialization. --- .../quarkus/runtime/logging/LoggingSetup.java | 13 +++++- .../runtime/logging/LoggingSetupRecorder.java | 11 ++++- .../test-logging-output-capture/build.gradle | 45 +++++++++++++++++++ .../settings.gradle | 15 +++++++ .../main/java/org/acme/ExampleService.java | 11 +++++ .../org/acme/LoggingOutputCaptureTest.java | 39 ++++++++++++++++ .../gradle/TestLoggingOutputCaptureTest.java | 38 ++++++++++++++++ 7 files changed, 168 insertions(+), 4 deletions(-) create mode 100644 integration-tests/gradle/src/main/resources/test-logging-output-capture/build.gradle create mode 100644 integration-tests/gradle/src/main/resources/test-logging-output-capture/settings.gradle create mode 100644 integration-tests/gradle/src/main/resources/test-logging-output-capture/src/main/java/org/acme/ExampleService.java create mode 100644 integration-tests/gradle/src/main/resources/test-logging-output-capture/src/test/java/org/acme/LoggingOutputCaptureTest.java create mode 100644 integration-tests/gradle/src/test/java/io/quarkus/gradle/TestLoggingOutputCaptureTest.java diff --git a/core/runtime/src/main/java/io/quarkus/runtime/logging/LoggingSetup.java b/core/runtime/src/main/java/io/quarkus/runtime/logging/LoggingSetup.java index ad16f19e12356..227dea9f5fd87 100644 --- a/core/runtime/src/main/java/io/quarkus/runtime/logging/LoggingSetup.java +++ b/core/runtime/src/main/java/io/quarkus/runtime/logging/LoggingSetup.java @@ -182,6 +182,8 @@ public static ConsoleHandlerResult buildConsoleHandler( } final ConsoleHandler consoleHandler = new ConsoleHandler( config.stderr() ? ConsoleHandler.Target.SYSTEM_ERR : ConsoleHandler.Target.SYSTEM_OUT, formatter); + // Use current System.out/err, not the static reference captured at class-load time + consoleHandler.setOutputStream(config.stderr() ? System.err : System.out); consoleHandler.setLevel(config.level()); consoleHandler.setErrorManager(errorManager); applyFilter(includeFilters, errorManager, cleanupFilter, config.filter(), namedFilters, consoleHandler); @@ -1157,8 +1159,15 @@ public static void configureRuntimeLogging( } // apply the cleanup filter to the handlers already installed on the root logger - for (Handler handler : LogManager.getLogManager().getLogger("").getHandlers()) { - handler.setFilter(cleanupFilter); + final java.util.logging.Logger julRootLogger = LogManager.getLogManager().getLogger(""); + for (Handler handler : julRootLogger.getHandlers()) { + if (handler instanceof ExtHandler) { + handler.setFilter(cleanupFilter); + } else { + // Drop any non-JBoss-LogManager handler from the JUL root logger. + log.debugf("Removing foreign handler %s from the root logger", handler.getClass().getName()); + julRootLogger.removeHandler(handler); + } } ArrayList handlers = new ArrayList<>( diff --git a/core/runtime/src/main/java/io/quarkus/runtime/logging/LoggingSetupRecorder.java b/core/runtime/src/main/java/io/quarkus/runtime/logging/LoggingSetupRecorder.java index f30822da312a1..b9ae378172f18 100644 --- a/core/runtime/src/main/java/io/quarkus/runtime/logging/LoggingSetupRecorder.java +++ b/core/runtime/src/main/java/io/quarkus/runtime/logging/LoggingSetupRecorder.java @@ -163,8 +163,15 @@ public void accept(String loggerName, CleanupFilterConfig config) { }); } LogCleanupFilter cleanupFiler = new LogCleanupFilter(filterElements, shutdownNotifier); - for (Handler handler : LogManager.getLogManager().getLogger("").getHandlers()) { - handler.setFilter(cleanupFiler); + final java.util.logging.Logger julRootLogger = LogManager.getLogManager().getLogger(""); + for (Handler handler : julRootLogger.getHandlers()) { + if (handler instanceof ExtHandler) { + handler.setFilter(cleanupFiler); + } else { + // Drop any non-JBoss-LogManager handler from the JUL root logger. + log.debugf("Removing foreign handler %s from the root logger", handler.getClass().getName()); + julRootLogger.removeHandler(handler); + } } Map namedFilters = LoggingSetup.createNamedFilters(discoveredLogComponents); diff --git a/integration-tests/gradle/src/main/resources/test-logging-output-capture/build.gradle b/integration-tests/gradle/src/main/resources/test-logging-output-capture/build.gradle new file mode 100644 index 0000000000000..e56ce76c59ac7 --- /dev/null +++ b/integration-tests/gradle/src/main/resources/test-logging-output-capture/build.gradle @@ -0,0 +1,45 @@ +plugins { + id 'java' + id 'io.quarkus' +} + +repositories { + mavenLocal { + content { + includeGroupByRegex 'io.quarkus.*' + includeGroup 'org.hibernate.orm' + } + } + mavenCentral() +} + +dependencies { + implementation enforcedPlatform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}") + implementation 'io.quarkus:quarkus-arc' + + testImplementation 'io.quarkus:quarkus-junit' +} + +compileJava { + options.compilerArgs << '-parameters' +} + +test { + systemProperty "java.util.logging.manager", "org.jboss.logmanager.LogManager" + + // Suppress test output at all Gradle log levels (lifecycle, info, debug). + // The Quarkus Gradle test wrapper runs with --info, which uses the info-level + // testLogging config rather than the default lifecycle-level one. + testLogging { + showStandardStreams = false + events("failed", "skipped", "passed") + } + testLogging.info { + showStandardStreams = false + events("failed", "skipped", "passed") + } + testLogging.debug { + showStandardStreams = false + events("failed", "skipped", "passed") + } +} diff --git a/integration-tests/gradle/src/main/resources/test-logging-output-capture/settings.gradle b/integration-tests/gradle/src/main/resources/test-logging-output-capture/settings.gradle new file mode 100644 index 0000000000000..215e28568d68c --- /dev/null +++ b/integration-tests/gradle/src/main/resources/test-logging-output-capture/settings.gradle @@ -0,0 +1,15 @@ +pluginManagement { + repositories { + mavenLocal { + content { + includeGroupByRegex 'io.quarkus.*' + } + } + mavenCentral() + gradlePluginPortal() + } + plugins { + id 'io.quarkus' version "${quarkusPluginVersion}" + } +} +rootProject.name='test-logging-output-capture' diff --git a/integration-tests/gradle/src/main/resources/test-logging-output-capture/src/main/java/org/acme/ExampleService.java b/integration-tests/gradle/src/main/resources/test-logging-output-capture/src/main/java/org/acme/ExampleService.java new file mode 100644 index 0000000000000..30661875c104c --- /dev/null +++ b/integration-tests/gradle/src/main/resources/test-logging-output-capture/src/main/java/org/acme/ExampleService.java @@ -0,0 +1,11 @@ +package org.acme; + +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class ExampleService { + + public String greet() { + return "hello"; + } +} diff --git a/integration-tests/gradle/src/main/resources/test-logging-output-capture/src/test/java/org/acme/LoggingOutputCaptureTest.java b/integration-tests/gradle/src/main/resources/test-logging-output-capture/src/test/java/org/acme/LoggingOutputCaptureTest.java new file mode 100644 index 0000000000000..de57e38254ad4 --- /dev/null +++ b/integration-tests/gradle/src/main/resources/test-logging-output-capture/src/test/java/org/acme/LoggingOutputCaptureTest.java @@ -0,0 +1,39 @@ +package org.acme; + +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Test; +import org.jboss.logging.Logger; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Verifies that Quarkus logger output is properly captured by Gradle's test + * output capture mechanism, so that testLogging.showStandardStreams = false + * can suppress it. + * + * The test itself always passes. The Gradle wrapper test that invokes this + * project checks that the marker strings below do NOT appear in the build + * output when showStandardStreams = false. + */ +@QuarkusTest +public class LoggingOutputCaptureTest { + + private static final Logger LOG = Logger.getLogger(LoggingOutputCaptureTest.class); + + @Inject + ExampleService exampleService; + + @Test + void testLoggerOutput() { + LOG.info("MARKER_INFO_LOG_OUTPUT"); + LOG.warn("MARKER_WARN_LOG_OUTPUT"); + LOG.error("MARKER_ERROR_LOG_OUTPUT"); + LOG.error("error with cause", new RuntimeException("MARKER_ERROR_STACKTRACE")); + + // Also test System.out, which should be suppressed too + System.out.println("MARKER_STDOUT_OUTPUT"); + + assertEquals("hello", exampleService.greet()); + } +} diff --git a/integration-tests/gradle/src/test/java/io/quarkus/gradle/TestLoggingOutputCaptureTest.java b/integration-tests/gradle/src/test/java/io/quarkus/gradle/TestLoggingOutputCaptureTest.java new file mode 100644 index 0000000000000..58f9392a1a707 --- /dev/null +++ b/integration-tests/gradle/src/test/java/io/quarkus/gradle/TestLoggingOutputCaptureTest.java @@ -0,0 +1,38 @@ +package io.quarkus.gradle; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; + +import org.junit.jupiter.api.Test; + +/** + * Verifies that Quarkus logger output respects Gradle's testLogging.showStandardStreams = false + * setting. When this setting is false, logger output (INFO, WARN, etc.) should not appear in + * the Gradle build console output. + * + * This is a regression test for https://github.com/quarkusio/quarkus/issues/48763 + */ +public class TestLoggingOutputCaptureTest extends QuarkusGradleWrapperTestBase { + + @Test + public void testLoggerOutputRespectsShowStandardStreams() throws Exception { + File projectDir = getProjectDir("test-logging-output-capture"); + BuildResult buildResult = runGradleWrapper(projectDir, "test"); + + // The test should pass + assertThat(buildResult.getTasks()).containsKey(":test"); + assertThat(BuildResult.isSuccessful(buildResult.getTasks().get(":test"))).isTrue(); + + String output = buildResult.getOutput(); + + assertThat(output).contains("testLoggerOutput"); + + // Logger output should NOT appear in the build output because showStandardStreams = false + assertThat(output).doesNotContain("MARKER_INFO_LOG_OUTPUT"); + assertThat(output).doesNotContain("MARKER_WARN_LOG_OUTPUT"); + assertThat(output).doesNotContain("MARKER_ERROR_LOG_OUTPUT"); + assertThat(output).doesNotContain("MARKER_ERROR_STACKTRACE"); + assertThat(output).doesNotContain("MARKER_STDOUT_OUTPUT"); + } +} From 8711eb8f3d4b90a5ab05e0cc1af7cf3741174263 Mon Sep 17 00:00:00 2001 From: "David M. Lloyd" Date: Thu, 15 Oct 2026 15:41:11 -0500 Subject: [PATCH 243/254] Modularize `value-registry` --- .../bootstrap/value-registry/src/main/java/module-info.java | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 independent-projects/bootstrap/value-registry/src/main/java/module-info.java diff --git a/independent-projects/bootstrap/value-registry/src/main/java/module-info.java b/independent-projects/bootstrap/value-registry/src/main/java/module-info.java new file mode 100644 index 0000000000000..1e6ea94022113 --- /dev/null +++ b/independent-projects/bootstrap/value-registry/src/main/java/module-info.java @@ -0,0 +1,3 @@ +module io.quarkus.value.registry { + exports io.quarkus.value.registry; +} \ No newline at end of file From 70cdd6051baefe90520d81ebc045ae2a07b90af8 Mon Sep 17 00:00:00 2001 From: "David M. Lloyd" Date: Thu, 15 Oct 2026 16:49:21 -0500 Subject: [PATCH 244/254] Services: add support for capturing date/time/tz and related types --- .../service/impl/JavaTimeSerializer.java | 79 ++ .../service/impl/LambdaTransliterator.java | 1 + .../impl/SerializedCaptureEmitter.java | 180 +++- .../service/impl/SerializedJavaTime.java | 863 ++++++++++++++++++ .../impl/LambdaTransliteratorTest.java | 251 +++++ 5 files changed, 1373 insertions(+), 1 deletion(-) create mode 100644 core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/JavaTimeSerializer.java create mode 100644 core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/SerializedJavaTime.java diff --git a/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/JavaTimeSerializer.java b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/JavaTimeSerializer.java new file mode 100644 index 0000000000000..92cbe894bb971 --- /dev/null +++ b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/JavaTimeSerializer.java @@ -0,0 +1,79 @@ +package io.quarkus.core.deployment.service.impl; + +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.MonthDay; +import java.time.OffsetDateTime; +import java.time.OffsetTime; +import java.time.Period; +import java.time.Year; +import java.time.YearMonth; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; + +import io.smallrye.serial.Serialized; +import io.smallrye.serial.spi.ObjectSerializer; + +/** + * A custom {@link ObjectSerializer} for {@code smallrye-serial} that intercepts + * {@code java.time} date/time/zone objects, producing a {@link SerializedJavaTime} + * instead of attempting standard Java serialization. + *

    + * This runs at {@code PRIORITY_REPLACE + 1} to intercept the objects before + * {@code writeReplace()} would convert them into the package-private + * {@code java.time.Ser} externalizable proxy. By handling them directly, we + * avoid the need to parse the binary externalizable protocol and instead use + * the public API to decompose each object into its constituent field values. + *

    + * Note: {@link ZoneOffset} must be matched before {@link ZoneId} because + * {@code ZoneOffset} extends {@code ZoneId}. + */ +final class JavaTimeSerializer implements ObjectSerializer { + + /** + * Singleton instance. + */ + static final JavaTimeSerializer INSTANCE = new JavaTimeSerializer(); + + private JavaTimeSerializer() { + } + + @Override + public Serialized serialize(Context ctxt, Object object) throws IOException { + return switch (object) { + case Duration d -> new SerializedJavaTime.OfDuration(d.getSeconds(), d.getNano()); + case Instant i -> new SerializedJavaTime.OfInstant(i.getEpochSecond(), i.getNano()); + case LocalDate ld -> new SerializedJavaTime.OfLocalDate(ld.getYear(), ld.getMonthValue(), ld.getDayOfMonth()); + case LocalTime lt -> new SerializedJavaTime.OfLocalTime(lt.getHour(), lt.getMinute(), lt.getSecond(), + lt.getNano()); + case LocalDateTime ldt -> new SerializedJavaTime.OfLocalDateTime(ldt.getYear(), ldt.getMonthValue(), + ldt.getDayOfMonth(), ldt.getHour(), ldt.getMinute(), ldt.getSecond(), ldt.getNano()); + // ZoneOffset before ZoneId: ZoneOffset extends ZoneId + case ZoneOffset zo -> new SerializedJavaTime.OfZoneOffset(zo.getTotalSeconds()); + case ZonedDateTime zdt -> new SerializedJavaTime.OfZonedDateTime(zdt.getYear(), zdt.getMonthValue(), + zdt.getDayOfMonth(), zdt.getHour(), zdt.getMinute(), zdt.getSecond(), zdt.getNano(), + zdt.getOffset().getTotalSeconds(), zdt.getZone().getId()); + case OffsetTime ot -> new SerializedJavaTime.OfOffsetTime(ot.getHour(), ot.getMinute(), ot.getSecond(), + ot.getNano(), ot.getOffset().getTotalSeconds()); + case OffsetDateTime odt -> new SerializedJavaTime.OfOffsetDateTime(odt.getYear(), odt.getMonthValue(), + odt.getDayOfMonth(), odt.getHour(), odt.getMinute(), odt.getSecond(), odt.getNano(), + odt.getOffset().getTotalSeconds()); + case Year y -> new SerializedJavaTime.OfYear(y.getValue()); + case YearMonth ym -> new SerializedJavaTime.OfYearMonth(ym.getYear(), ym.getMonthValue()); + case MonthDay md -> new SerializedJavaTime.OfMonthDay(md.getMonthValue(), md.getDayOfMonth()); + case Period p -> new SerializedJavaTime.OfPeriod(p.getYears(), p.getMonths(), p.getDays()); + case ZoneId z -> new SerializedJavaTime.OfZoneId(z.getId()); + default -> ctxt.next(); + }; + } + + @Override + public int priority() { + return PRIORITY_REPLACE + 1; + } +} diff --git a/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/LambdaTransliterator.java b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/LambdaTransliterator.java index 125e8021b0d0d..f3c0ba64fa2a7 100644 --- a/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/LambdaTransliterator.java +++ b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/LambdaTransliterator.java @@ -822,6 +822,7 @@ private static SerialContext buildSerialContext() { .addSerializer(ConstantDescSerializer.INSTANCE) .addSerializer(ConfigMappingSerializer.INSTANCE) .addSerializer(QuarkusClassLoaderSerializer.INSTANCE) + .addSerializer(JavaTimeSerializer.INSTANCE) .build(); } diff --git a/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/SerializedCaptureEmitter.java b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/SerializedCaptureEmitter.java index ecf8b6c487690..ac2bd9947cb81 100644 --- a/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/SerializedCaptureEmitter.java +++ b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/SerializedCaptureEmitter.java @@ -113,6 +113,7 @@ CaptureEmitter emitFor(Serialized serialized, ClassDesc targetType) { doDedup = true; yield res; } + case SerializedJavaTime sjt -> emitJavaTime(sjt); default -> throw new LambdaTransliterator.TransliterationException( "Unsupported serialized type: " + serialized.getClass().getName()); }; @@ -271,6 +272,167 @@ private static CaptureEmitter emitQuarkusClassLoader(SerializedQuarkusClassLoade }; } + // ── java.time types ── + + private static final ClassDesc CD_Duration = ClassDesc.of("java.time.Duration"); + private static final ClassDesc CD_Instant = ClassDesc.of("java.time.Instant"); + private static final ClassDesc CD_LocalDate = ClassDesc.of("java.time.LocalDate"); + private static final ClassDesc CD_LocalTime = ClassDesc.of("java.time.LocalTime"); + private static final ClassDesc CD_LocalDateTime = ClassDesc.of("java.time.LocalDateTime"); + private static final ClassDesc CD_ZoneOffset = ClassDesc.of("java.time.ZoneOffset"); + private static final ClassDesc CD_ZoneId = ClassDesc.of("java.time.ZoneId"); + private static final ClassDesc CD_ZonedDateTime = ClassDesc.of("java.time.ZonedDateTime"); + private static final ClassDesc CD_OffsetTime = ClassDesc.of("java.time.OffsetTime"); + private static final ClassDesc CD_OffsetDateTime = ClassDesc.of("java.time.OffsetDateTime"); + private static final ClassDesc CD_Year = ClassDesc.of("java.time.Year"); + private static final ClassDesc CD_YearMonth = ClassDesc.of("java.time.YearMonth"); + private static final ClassDesc CD_MonthDay = ClassDesc.of("java.time.MonthDay"); + private static final ClassDesc CD_Period = ClassDesc.of("java.time.Period"); + + private static final ClassDesc CD_String = ConstantDescs.CD_String; + + /** + * Emit bytecode to reconstruct a {@code java.time} value from its decomposed fields. + *

    + * Simple types emit a sequence of constant loads followed by a static factory call. + * Composite types (e.g. {@code ZonedDateTime}) first construct their component objects + * on the operand stack, then invoke the composite factory method. + */ + private static CaptureEmitter emitJavaTime(SerializedJavaTime sjt) { + return switch (sjt) { + case SerializedJavaTime.OfDuration d -> code -> { + code.loadConstant(d.seconds()); + code.loadConstant((long) d.nanos()); + code.invokestatic(CD_Duration, "ofSeconds", + MethodTypeDesc.of(CD_Duration, CD_long, CD_long)); + }; + case SerializedJavaTime.OfInstant i -> code -> { + code.loadConstant(i.seconds()); + code.loadConstant((long) i.nanos()); + code.invokestatic(CD_Instant, "ofEpochSecond", + MethodTypeDesc.of(CD_Instant, CD_long, CD_long)); + }; + case SerializedJavaTime.OfLocalDate ld -> code -> { + code.loadConstant(ld.year()); + code.loadConstant(ld.month()); + code.loadConstant(ld.day()); + code.invokestatic(CD_LocalDate, "of", + MethodTypeDesc.of(CD_LocalDate, CD_int, CD_int, CD_int)); + }; + case SerializedJavaTime.OfLocalTime lt -> code -> { + code.loadConstant(lt.hour()); + code.loadConstant(lt.minute()); + code.loadConstant(lt.second()); + code.loadConstant(lt.nano()); + code.invokestatic(CD_LocalTime, "of", + MethodTypeDesc.of(CD_LocalTime, CD_int, CD_int, CD_int, CD_int)); + }; + case SerializedJavaTime.OfLocalDateTime ldt -> code -> { + code.loadConstant(ldt.year()); + code.loadConstant(ldt.month()); + code.loadConstant(ldt.day()); + code.loadConstant(ldt.hour()); + code.loadConstant(ldt.minute()); + code.loadConstant(ldt.second()); + code.loadConstant(ldt.nano()); + code.invokestatic(CD_LocalDateTime, "of", + MethodTypeDesc.of(CD_LocalDateTime, CD_int, CD_int, CD_int, CD_int, CD_int, CD_int, CD_int)); + }; + case SerializedJavaTime.OfZoneOffset zo -> code -> { + code.loadConstant(zo.totalSeconds()); + code.invokestatic(CD_ZoneOffset, "ofTotalSeconds", + MethodTypeDesc.of(CD_ZoneOffset, CD_int)); + }; + case SerializedJavaTime.OfZoneId z -> code -> { + code.loadConstant(z.id()); + code.invokestatic(CD_ZoneId, "of", + MethodTypeDesc.of(CD_ZoneId, CD_String)); + }; + case SerializedJavaTime.OfZonedDateTime zdt -> code -> { + // LocalDateTime.of(year, month, day, hour, minute, second, nano) + code.loadConstant(zdt.year()); + code.loadConstant(zdt.month()); + code.loadConstant(zdt.day()); + code.loadConstant(zdt.hour()); + code.loadConstant(zdt.minute()); + code.loadConstant(zdt.second()); + code.loadConstant(zdt.nano()); + code.invokestatic(CD_LocalDateTime, "of", + MethodTypeDesc.of(CD_LocalDateTime, CD_int, CD_int, CD_int, CD_int, CD_int, CD_int, CD_int)); + // ZoneOffset.ofTotalSeconds(offsetTotalSeconds) + code.loadConstant(zdt.offsetTotalSeconds()); + code.invokestatic(CD_ZoneOffset, "ofTotalSeconds", + MethodTypeDesc.of(CD_ZoneOffset, CD_int)); + // ZoneId.of(zoneId) + code.loadConstant(zdt.zoneId()); + code.invokestatic(CD_ZoneId, "of", + MethodTypeDesc.of(CD_ZoneId, CD_String)); + // ZonedDateTime.ofStrict(LocalDateTime, ZoneOffset, ZoneId) + code.invokestatic(CD_ZonedDateTime, "ofStrict", + MethodTypeDesc.of(CD_ZonedDateTime, CD_LocalDateTime, CD_ZoneOffset, CD_ZoneId)); + }; + case SerializedJavaTime.OfOffsetTime ot -> code -> { + // LocalTime.of(hour, minute, second, nano) + code.loadConstant(ot.hour()); + code.loadConstant(ot.minute()); + code.loadConstant(ot.second()); + code.loadConstant(ot.nano()); + code.invokestatic(CD_LocalTime, "of", + MethodTypeDesc.of(CD_LocalTime, CD_int, CD_int, CD_int, CD_int)); + // ZoneOffset.ofTotalSeconds(offsetTotalSeconds) + code.loadConstant(ot.offsetTotalSeconds()); + code.invokestatic(CD_ZoneOffset, "ofTotalSeconds", + MethodTypeDesc.of(CD_ZoneOffset, CD_int)); + // OffsetTime.of(LocalTime, ZoneOffset) + code.invokestatic(CD_OffsetTime, "of", + MethodTypeDesc.of(CD_OffsetTime, CD_LocalTime, CD_ZoneOffset)); + }; + case SerializedJavaTime.OfOffsetDateTime odt -> code -> { + // LocalDateTime.of(year, month, day, hour, minute, second, nano) + code.loadConstant(odt.year()); + code.loadConstant(odt.month()); + code.loadConstant(odt.day()); + code.loadConstant(odt.hour()); + code.loadConstant(odt.minute()); + code.loadConstant(odt.second()); + code.loadConstant(odt.nano()); + code.invokestatic(CD_LocalDateTime, "of", + MethodTypeDesc.of(CD_LocalDateTime, CD_int, CD_int, CD_int, CD_int, CD_int, CD_int, CD_int)); + // ZoneOffset.ofTotalSeconds(offsetTotalSeconds) + code.loadConstant(odt.offsetTotalSeconds()); + code.invokestatic(CD_ZoneOffset, "ofTotalSeconds", + MethodTypeDesc.of(CD_ZoneOffset, CD_int)); + // OffsetDateTime.of(LocalDateTime, ZoneOffset) + code.invokestatic(CD_OffsetDateTime, "of", + MethodTypeDesc.of(CD_OffsetDateTime, CD_LocalDateTime, CD_ZoneOffset)); + }; + case SerializedJavaTime.OfYear y -> code -> { + code.loadConstant(y.year()); + code.invokestatic(CD_Year, "of", + MethodTypeDesc.of(CD_Year, CD_int)); + }; + case SerializedJavaTime.OfYearMonth ym -> code -> { + code.loadConstant(ym.year()); + code.loadConstant(ym.month()); + code.invokestatic(CD_YearMonth, "of", + MethodTypeDesc.of(CD_YearMonth, CD_int, CD_int)); + }; + case SerializedJavaTime.OfMonthDay md -> code -> { + code.loadConstant(md.month()); + code.loadConstant(md.day()); + code.invokestatic(CD_MonthDay, "of", + MethodTypeDesc.of(CD_MonthDay, CD_int, CD_int)); + }; + case SerializedJavaTime.OfPeriod p -> code -> { + code.loadConstant(p.years()); + code.loadConstant(p.months()); + code.loadConstant(p.days()); + code.invokestatic(CD_Period, "of", + MethodTypeDesc.of(CD_Period, CD_int, CD_int, CD_int)); + }; + }; + } + // ── Records ── /** @@ -660,6 +822,8 @@ private static CaptureEmitter emitMapOf(CaptureEmitter[] keys, CaptureEmitter[] ca.descriptorString().compareTo(((SerializedClass) b).descriptorString()); case SerializedEnum ea -> compareEnums(ea, (SerializedEnum) b); + case SerializedJavaTime ja -> + compareJavaTime(ja, (SerializedJavaTime) b); default -> throw new LambdaTransliterator.TransliterationException( "Cannot deterministically sort serialized value of type " + a.getClass().getName() @@ -678,7 +842,8 @@ private static int typeOrdinal(Serialized s) { case SerializedConfigMapping ignored -> 3; case SerializedClass ignored -> 4; case SerializedEnum ignored -> 5; - default -> 6; + case SerializedJavaTime ignored -> 6; + default -> 7; }; } @@ -712,6 +877,19 @@ private static int compareEnums(SerializedEnum a, SerializedEnum b) { return a.constantName().string().compareTo(b.constantName().string()); } + /** + * Compare two {@link SerializedJavaTime} values by their record type name, + * then by field values in declaration order. + */ + private static int compareJavaTime(SerializedJavaTime a, SerializedJavaTime b) { + int c = a.getClass().getSimpleName().compareTo(b.getClass().getSimpleName()); + if (c != 0) { + return c; + } + // same record type — compare field values via toString (records have deterministic toString) + return a.toString().compareTo(b.toString()); + } + /** * Sort set elements in place for build reproducibility. * No-op for arrays with fewer than two elements. diff --git a/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/SerializedJavaTime.java b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/SerializedJavaTime.java new file mode 100644 index 0000000000000..9e47daea0c1cc --- /dev/null +++ b/core/deployment/src/main/java/io/quarkus/core/deployment/service/impl/SerializedJavaTime.java @@ -0,0 +1,863 @@ +package io.quarkus.core.deployment.service.impl; + +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.MonthDay; +import java.time.OffsetDateTime; +import java.time.OffsetTime; +import java.time.Period; +import java.time.Year; +import java.time.YearMonth; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.Arrays; + +import io.smallrye.serial.Serialized; + +/** + * A {@link Serialized} representation of a {@code java.time} value. + *

    + * Produced by {@link JavaTimeSerializer} for the fourteen standard date/time/zone + * types in the {@code java.time} package. Each inner record holds the decomposed + * field values needed to reconstruct the object via its public factory method. + *

    + * Composite types (e.g. {@code ZonedDateTime}) are flattened: their component + * values are stored directly rather than as nested {@code SerializedJavaTime} + * instances, so the emitter can construct them in a single sequence of bytecode + * instructions without intermediate objects on the operand stack management. + */ +sealed abstract class SerializedJavaTime extends Serialized { + + private final int hashCode; + private String toString; + + private SerializedJavaTime(final int hashCode) { + this.hashCode = getClass().getName().hashCode() * 31 + hashCode; + } + + public final int hashCode() { + return hashCode; + } + + public final String toString() { + String toString = this.toString; + if (toString == null) { + toString = this.toString = computeToString(); + } + return toString; + } + + abstract String computeToString(); + + /** + * {@link Duration}: decomposed into seconds and nanosecond adjustment. + * Reconstructed via {@code Duration.ofSeconds(long, long)}. + * + */ + static final class OfDuration extends SerializedJavaTime { + private final long seconds; + private final int nanos; + + /** + * @param seconds the seconds component + * @param nanos the nanosecond component (0 to 999,999,999) + */ + OfDuration(long seconds, int nanos) { + super(hash(seconds, nanos)); + this.seconds = seconds; + this.nanos = nanos; + } + + public long seconds() { + return seconds; + } + + public int nanos() { + return nanos; + } + + @Override + public boolean equals(Object obj) { + return obj instanceof OfDuration other && equals(other); + } + + public boolean equals(OfDuration other) { + return this == other || other != null && seconds == other.seconds && nanos == other.nanos; + } + + @Override + String computeToString() { + return "OfDuration[" + + "seconds=" + seconds + ", " + + "nanos=" + nanos + ']'; + } + } + + /** + * {@link Instant}: decomposed into epoch seconds and nanosecond adjustment. + * Reconstructed via {@code Instant.ofEpochSecond(long, long)}. + * + */ + static final class OfInstant extends SerializedJavaTime { + private final long seconds; + private final int nanos; + + /** + * @param seconds the epoch seconds + * @param nanos the nanosecond component (0 to 999,999,999) + */ + OfInstant(long seconds, int nanos) { + super(hash(seconds, nanos)); + this.seconds = seconds; + this.nanos = nanos; + } + + public long seconds() { + return seconds; + } + + public int nanos() { + return nanos; + } + + @Override + public boolean equals(Object obj) { + return obj instanceof OfInstant other && equals(other); + } + + public boolean equals(OfInstant other) { + return this == other || other != null && seconds == other.seconds && nanos == other.nanos; + } + + @Override + String computeToString() { + return "OfInstant[" + + "seconds=" + seconds + ", " + + "nanos=" + nanos + ']'; + } + } + + /** + * {@link LocalDate}: decomposed into year, month, day. + * Reconstructed via {@code LocalDate.of(int, int, int)}. + * + */ + static final class OfLocalDate extends SerializedJavaTime { + private final int year; + private final int month; + private final int day; + + /** + * @param year the year + * @param month the month-of-year (1 to 12) + * @param day the day-of-month (1 to 31) + */ + OfLocalDate(int year, int month, int day) { + super(hash(year, month, day)); + this.year = year; + this.month = month; + this.day = day; + } + + public int year() { + return year; + } + + public int month() { + return month; + } + + public int day() { + return day; + } + + @Override + public boolean equals(Object obj) { + return obj instanceof OfLocalDate other && equals(other); + } + + public boolean equals(OfLocalDate other) { + return this == other || other != null && year == other.year && month == other.month && day == other.day; + } + + @Override + String computeToString() { + return "OfLocalDate[" + + "year=" + year + ", " + + "month=" + month + ", " + + "day=" + day + ']'; + } + } + + /** + * {@link LocalTime}: decomposed into hour, minute, second, nano. + * Reconstructed via {@code LocalTime.of(int, int, int, int)}. + * + */ + static final class OfLocalTime extends SerializedJavaTime { + private final int hour; + private final int minute; + private final int second; + private final int nano; + + /** + * @param hour the hour-of-day (0 to 23) + * @param minute the minute-of-hour (0 to 59) + * @param second the second-of-minute (0 to 59) + * @param nano the nanosecond (0 to 999,999,999) + */ + OfLocalTime(int hour, int minute, int second, int nano) { + super(hash(hour, minute, second, nano)); + this.hour = hour; + this.minute = minute; + this.second = second; + this.nano = nano; + } + + public int hour() { + return hour; + } + + public int minute() { + return minute; + } + + public int second() { + return second; + } + + public int nano() { + return nano; + } + + @Override + public boolean equals(Object obj) { + return obj instanceof OfLocalTime other && equals(other); + } + + public boolean equals(OfLocalTime other) { + return this == other || other != null && hour == other.hour && minute == other.minute && second == other.second + && nano == other.nano; + } + + @Override + String computeToString() { + return "OfLocalTime[" + + "hour=" + hour + ", " + + "minute=" + minute + ", " + + "second=" + second + ", " + + "nano=" + nano + ']'; + } + } + + /** + * {@link LocalDateTime}: flattened date and time components. + * Reconstructed via {@code LocalDateTime.of(int, int, int, int, int, int, int)}. + * + */ + static final class OfLocalDateTime extends SerializedJavaTime { + private final int year; + private final int month; + private final int day; + private final int hour; + private final int minute; + private final int second; + private final int nano; + + /** + * @param year the year + * @param month the month-of-year (1 to 12) + * @param day the day-of-month (1 to 31) + * @param hour the hour-of-day (0 to 23) + * @param minute the minute-of-hour (0 to 59) + * @param second the second-of-minute (0 to 59) + * @param nano the nanosecond (0 to 999,999,999) + */ + OfLocalDateTime(int year, int month, int day, int hour, int minute, int second, int nano) { + super(hash(year, month, day, hour, minute, second, nano)); + this.year = year; + this.month = month; + this.day = day; + this.hour = hour; + this.minute = minute; + this.second = second; + this.nano = nano; + } + + public int year() { + return year; + } + + public int month() { + return month; + } + + public int day() { + return day; + } + + public int hour() { + return hour; + } + + public int minute() { + return minute; + } + + public int second() { + return second; + } + + public int nano() { + return nano; + } + + @Override + public boolean equals(Object obj) { + return obj instanceof OfLocalDateTime other && equals(other); + } + + public boolean equals(OfLocalDateTime other) { + return this == other || other != null && year == other.year && month == other.month && day == other.day + && hour == other.hour && minute == other.minute && second == other.second && nano == other.nano; + } + + @Override + String computeToString() { + return "OfLocalDateTime[" + + "year=" + year + ", " + + "month=" + month + ", " + + "day=" + day + ", " + + "hour=" + hour + ", " + + "minute=" + minute + ", " + + "second=" + second + ", " + + "nano=" + nano + ']'; + } + } + + /** + * {@link ZoneOffset}: decomposed into total offset seconds. + * Reconstructed via {@code ZoneOffset.ofTotalSeconds(int)}. + * + */ + static final class OfZoneOffset extends SerializedJavaTime { + private final int totalSeconds; + + /** + * @param totalSeconds the total zone offset in seconds (-64800 to +64800) + */ + OfZoneOffset(int totalSeconds) { + super(hash(totalSeconds)); + this.totalSeconds = totalSeconds; + } + + public int totalSeconds() { + return totalSeconds; + } + + @Override + public boolean equals(Object obj) { + return obj instanceof OfZoneOffset other && equals(other); + } + + public boolean equals(OfZoneOffset other) { + return this == other || other != null && totalSeconds == other.totalSeconds; + } + + @Override + String computeToString() { + return "OfZoneOffset[" + + "totalSeconds=" + totalSeconds + ']'; + } + } + + /** + * {@link ZoneId} (non-offset zones, i.e. {@code ZoneRegion}): + * stored as the zone ID string. + * Reconstructed via {@code ZoneId.of(String)}. + * + */ + static final class OfZoneId extends SerializedJavaTime { + private final String id; + + /** + * @param id the zone ID string (e.g. "Europe/Paris") + */ + OfZoneId(String id) { + super(id.hashCode()); + this.id = id; + } + + public String id() { + return id; + } + + @Override + public boolean equals(Object obj) { + return obj instanceof OfZoneId other && equals(other); + } + + public boolean equals(OfZoneId other) { + return this == other || other != null && id.equals(other.id); + } + + @Override + String computeToString() { + return "OfZoneId[" + + "id=" + id + ']'; + } + } + + /** + * {@link ZonedDateTime}: flattened date/time, offset, and zone components. + * Reconstructed via {@code ZonedDateTime.ofStrict(LocalDateTime, ZoneOffset, ZoneId)}. + * + */ + static final class OfZonedDateTime extends SerializedJavaTime { + private final int year; + private final int month; + private final int day; + private final int hour; + private final int minute; + private final int second; + private final int nano; + private final int offsetTotalSeconds; + private final String zoneId; + + /** + * @param year the year + * @param month the month-of-year (1 to 12) + * @param day the day-of-month (1 to 31) + * @param hour the hour-of-day (0 to 23) + * @param minute the minute-of-hour (0 to 59) + * @param second the second-of-minute (0 to 59) + * @param nano the nanosecond (0 to 999,999,999) + * @param offsetTotalSeconds the offset in seconds + * @param zoneId the zone ID string + */ + OfZonedDateTime(int year, int month, int day, int hour, int minute, int second, int nano, + int offsetTotalSeconds, String zoneId) { + super(hash(year, month, day, hour, minute, second, nano, offsetTotalSeconds, zoneId.hashCode())); + this.year = year; + this.month = month; + this.day = day; + this.hour = hour; + this.minute = minute; + this.second = second; + this.nano = nano; + this.offsetTotalSeconds = offsetTotalSeconds; + this.zoneId = zoneId; + } + + public int year() { + return year; + } + + public int month() { + return month; + } + + public int day() { + return day; + } + + public int hour() { + return hour; + } + + public int minute() { + return minute; + } + + public int second() { + return second; + } + + public int nano() { + return nano; + } + + public int offsetTotalSeconds() { + return offsetTotalSeconds; + } + + public String zoneId() { + return zoneId; + } + + @Override + public boolean equals(Object obj) { + return obj instanceof OfZonedDateTime other && equals(other); + } + + public boolean equals(OfZonedDateTime other) { + return this == other || other != null && year == other.year && month == other.month && day == other.day + && hour == other.hour && minute == other.minute && second == other.second && nano == other.nano + && offsetTotalSeconds == other.offsetTotalSeconds && zoneId.equals(other.zoneId); + } + + @Override + String computeToString() { + return "OfZonedDateTime[" + + "year=" + year + ", " + + "month=" + month + ", " + + "day=" + day + ", " + + "hour=" + hour + ", " + + "minute=" + minute + ", " + + "second=" + second + ", " + + "nano=" + nano + ", " + + "offsetTotalSeconds=" + offsetTotalSeconds + ", " + + "zoneId=" + zoneId + ']'; + } + } + + /** + * {@link OffsetTime}: flattened time and offset components. + * Reconstructed via {@code OffsetTime.of(LocalTime, ZoneOffset)}. + * + */ + static final class OfOffsetTime extends SerializedJavaTime { + private final int hour; + private final int minute; + private final int second; + private final int nano; + private final int offsetTotalSeconds; + + /** + * @param hour the hour-of-day (0 to 23) + * @param minute the minute-of-hour (0 to 59) + * @param second the second-of-minute (0 to 59) + * @param nano the nanosecond (0 to 999,999,999) + * @param offsetTotalSeconds the offset in seconds + */ + OfOffsetTime(int hour, int minute, int second, int nano, int offsetTotalSeconds) { + super(hash(hour, minute, second, nano, offsetTotalSeconds)); + this.hour = hour; + this.minute = minute; + this.second = second; + this.nano = nano; + this.offsetTotalSeconds = offsetTotalSeconds; + } + + public int hour() { + return hour; + } + + public int minute() { + return minute; + } + + public int second() { + return second; + } + + public int nano() { + return nano; + } + + public int offsetTotalSeconds() { + return offsetTotalSeconds; + } + + @Override + public boolean equals(Object obj) { + return obj instanceof OfOffsetTime other && equals(other); + } + + public boolean equals(OfOffsetTime other) { + return other == this || other != null && hour == other.hour && minute == other.minute && second == other.second + && nano == other.nano && offsetTotalSeconds == other.offsetTotalSeconds; + } + + @Override + String computeToString() { + return "OfOffsetTime[" + + "hour=" + hour + ", " + + "minute=" + minute + ", " + + "second=" + second + ", " + + "nano=" + nano + ", " + + "offsetTotalSeconds=" + offsetTotalSeconds + ']'; + } + } + + /** + * {@link OffsetDateTime}: flattened date/time and offset components. + * Reconstructed via {@code OffsetDateTime.of(LocalDateTime, ZoneOffset)}. + * + */ + static final class OfOffsetDateTime extends SerializedJavaTime { + private final int year; + private final int month; + private final int day; + private final int hour; + private final int minute; + private final int second; + private final int nano; + private final int offsetTotalSeconds; + + /** + * @param year the year + * @param month the month-of-year (1 to 12) + * @param day the day-of-month (1 to 31) + * @param hour the hour-of-day (0 to 23) + * @param minute the minute-of-hour (0 to 59) + * @param second the second-of-minute (0 to 59) + * @param nano the nanosecond (0 to 999,999,999) + * @param offsetTotalSeconds the offset in seconds + */ + OfOffsetDateTime(int year, int month, int day, int hour, int minute, int second, int nano, + int offsetTotalSeconds) { + super(hash(year, month, day, hour, minute, second, nano, offsetTotalSeconds)); + this.year = year; + this.month = month; + this.day = day; + this.hour = hour; + this.minute = minute; + this.second = second; + this.nano = nano; + this.offsetTotalSeconds = offsetTotalSeconds; + } + + public int year() { + return year; + } + + public int month() { + return month; + } + + public int day() { + return day; + } + + public int hour() { + return hour; + } + + public int minute() { + return minute; + } + + public int second() { + return second; + } + + public int nano() { + return nano; + } + + public int offsetTotalSeconds() { + return offsetTotalSeconds; + } + + @Override + public boolean equals(Object obj) { + return obj instanceof OfOffsetDateTime other && equals(other); + } + + public boolean equals(OfOffsetDateTime other) { + return this == other || other != null && year == other.year && month == other.month && day == other.day + && hour == other.hour && minute == other.minute && second == other.second && nano == other.nano + && offsetTotalSeconds == other.offsetTotalSeconds; + } + + @Override + String computeToString() { + return "OfOffsetDateTime[" + + "year=" + year + ", " + + "month=" + month + ", " + + "day=" + day + ", " + + "hour=" + hour + ", " + + "minute=" + minute + ", " + + "second=" + second + ", " + + "nano=" + nano + ", " + + "offsetTotalSeconds=" + offsetTotalSeconds + ']'; + } + } + + /** + * {@link Year}: decomposed into the year value. + * Reconstructed via {@code Year.of(int)}. + * + */ + static final class OfYear extends SerializedJavaTime { + private final int year; + + /** + * @param year the year + */ + OfYear(int year) { + super(hash(year)); + this.year = year; + } + + public int year() { + return year; + } + + @Override + public boolean equals(Object obj) { + return obj instanceof OfYear other && equals(other); + } + + public boolean equals(OfYear other) { + return this == other || other != null && year == other.year; + } + + @Override + String computeToString() { + return "OfYear[" + + "year=" + year + ']'; + } + } + + /** + * {@link YearMonth}: decomposed into year and month. + * Reconstructed via {@code YearMonth.of(int, int)}. + * + */ + static final class OfYearMonth extends SerializedJavaTime { + private final int year; + private final int month; + + /** + * @param year the year + * @param month the month-of-year (1 to 12) + */ + OfYearMonth(int year, int month) { + super(hash(year, month)); + this.year = year; + this.month = month; + } + + public int year() { + return year; + } + + public int month() { + return month; + } + + @Override + public boolean equals(Object obj) { + return obj instanceof OfYearMonth other && equals(other); + } + + public boolean equals(OfYearMonth other) { + return this == other || other != null && year == other.year && month == other.month; + } + + @Override + String computeToString() { + return "OfYearMonth[" + + "year=" + year + ", " + + "month=" + month + ']'; + } + } + + /** + * {@link MonthDay}: decomposed into month and day. + * Reconstructed via {@code MonthDay.of(int, int)}. + * + */ + static final class OfMonthDay extends SerializedJavaTime { + private final int month; + private final int day; + + /** + * @param month the month-of-year (1 to 12) + * @param day the day-of-month (1 to 31) + */ + OfMonthDay(int month, int day) { + super(hash(month, day)); + this.month = month; + this.day = day; + } + + public int month() { + return month; + } + + public int day() { + return day; + } + + @Override + public boolean equals(Object obj) { + return obj instanceof OfMonthDay other && equals(other); + } + + public boolean equals(OfMonthDay other) { + return this == other || other != null && this.month == other.month && this.day == other.day; + } + + @Override + String computeToString() { + return "OfMonthDay[" + + "month=" + month + ", " + + "day=" + day + ']'; + } + } + + /** + * {@link Period}: decomposed into years, months, days. + * Reconstructed via {@code Period.of(int, int, int)}. + * + */ + static final class OfPeriod extends SerializedJavaTime { + private final int years; + private final int months; + private final int days; + + /** + * @param years the years component + * @param months the months component + * @param days the days component + */ + OfPeriod(int years, int months, int days) { + super(hash(years, months, days)); + this.years = years; + this.months = months; + this.days = days; + } + + public int years() { + return years; + } + + public int months() { + return months; + } + + public int days() { + return days; + } + + @Override + public boolean equals(Object obj) { + return obj instanceof OfPeriod other && equals(other); + } + + public boolean equals(OfPeriod other) { + return this == other || other != null && years == other.years && months == other.months && days == other.days; + } + + @Override + String computeToString() { + return "OfPeriod[" + + "years=" + years + ", " + + "months=" + months + ", " + + "days=" + days + ']'; + } + } + + // helpers + + static int hash(long... values) { + return Arrays.hashCode(values); + } +} diff --git a/core/deployment/src/test/java/io/quarkus/core/deployment/service/impl/LambdaTransliteratorTest.java b/core/deployment/src/test/java/io/quarkus/core/deployment/service/impl/LambdaTransliteratorTest.java index 00c51cf0b7e47..bb571491b26c0 100644 --- a/core/deployment/src/test/java/io/quarkus/core/deployment/service/impl/LambdaTransliteratorTest.java +++ b/core/deployment/src/test/java/io/quarkus/core/deployment/service/impl/LambdaTransliteratorTest.java @@ -9,6 +9,20 @@ import java.lang.reflect.Method; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.MonthDay; +import java.time.OffsetDateTime; +import java.time.OffsetTime; +import java.time.Period; +import java.time.Year; +import java.time.YearMonth; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -753,6 +767,243 @@ void capturedRecordWithCollectionTransliterates() throws Exception { } + // ── java.time capture tests ── + + @Test + void capturedDurationTransliterates() throws Exception { + Duration captured = Duration.ofSeconds(3661, 123456789); + Action1 action = (StartContext ctx) -> captured; + + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, Object.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test"); + + ServiceNode node = deployAndReturn(extracted); + assertThat(node.value()).isEqualTo(captured); + } + + @Test + void capturedZeroDurationTransliterates() throws Exception { + Duration captured = Duration.ZERO; + Action1 action = (StartContext ctx) -> captured; + + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, Object.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test"); + + ServiceNode node = deployAndReturn(extracted); + assertThat(node.value()).isEqualTo(captured); + } + + @Test + void capturedInstantTransliterates() throws Exception { + Instant captured = Instant.ofEpochSecond(1700000000L, 500000000); + Action1 action = (StartContext ctx) -> captured; + + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, Object.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test"); + + ServiceNode node = deployAndReturn(extracted); + assertThat(node.value()).isEqualTo(captured); + } + + @Test + void capturedLocalDateTransliterates() throws Exception { + LocalDate captured = LocalDate.of(2026, 10, 15); + Action1 action = (StartContext ctx) -> captured; + + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, Object.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test"); + + ServiceNode node = deployAndReturn(extracted); + assertThat(node.value()).isEqualTo(captured); + } + + @Test + void capturedLocalTimeTransliterates() throws Exception { + LocalTime captured = LocalTime.of(14, 30, 45, 123456789); + Action1 action = (StartContext ctx) -> captured; + + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, Object.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test"); + + ServiceNode node = deployAndReturn(extracted); + assertThat(node.value()).isEqualTo(captured); + } + + @Test + void capturedMidnightLocalTimeTransliterates() throws Exception { + LocalTime captured = LocalTime.MIDNIGHT; + Action1 action = (StartContext ctx) -> captured; + + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, Object.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test"); + + ServiceNode node = deployAndReturn(extracted); + assertThat(node.value()).isEqualTo(captured); + } + + @Test + void capturedLocalDateTimeTransliterates() throws Exception { + LocalDateTime captured = LocalDateTime.of(2026, 10, 15, 14, 30, 45, 123456789); + Action1 action = (StartContext ctx) -> captured; + + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, Object.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test"); + + ServiceNode node = deployAndReturn(extracted); + assertThat(node.value()).isEqualTo(captured); + } + + @Test + void capturedZoneOffsetTransliterates() throws Exception { + ZoneOffset captured = ZoneOffset.ofHoursMinutes(5, 30); + Action1 action = (StartContext ctx) -> captured; + + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, Object.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test"); + + ServiceNode node = deployAndReturn(extracted); + assertThat(node.value()).isEqualTo(captured); + } + + @Test + void capturedUtcZoneOffsetTransliterates() throws Exception { + ZoneOffset captured = ZoneOffset.UTC; + Action1 action = (StartContext ctx) -> captured; + + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, Object.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test"); + + ServiceNode node = deployAndReturn(extracted); + assertThat(node.value()).isEqualTo(captured); + } + + @Test + void capturedZoneIdTransliterates() throws Exception { + ZoneId captured = ZoneId.of("Europe/Paris"); + Action1 action = (StartContext ctx) -> captured; + + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, Object.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test"); + + ServiceNode node = deployAndReturn(extracted); + assertThat(node.value()).isEqualTo(captured); + } + + @Test + void capturedZonedDateTimeWithRegionTransliterates() throws Exception { + ZonedDateTime captured = ZonedDateTime.of( + LocalDateTime.of(2026, 10, 15, 14, 30, 45, 123456789), + ZoneId.of("America/Chicago")); + Action1 action = (StartContext ctx) -> captured; + + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, Object.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test"); + + ServiceNode node = deployAndReturn(extracted); + assertThat(node.value()).isEqualTo(captured); + } + + @Test + void capturedZonedDateTimeWithOffsetZoneTransliterates() throws Exception { + ZonedDateTime captured = ZonedDateTime.of( + LocalDateTime.of(2026, 6, 15, 12, 0), + ZoneOffset.ofHours(5)); + Action1 action = (StartContext ctx) -> captured; + + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, Object.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test"); + + ServiceNode node = deployAndReturn(extracted); + assertThat(node.value()).isEqualTo(captured); + } + + @Test + void capturedOffsetTimeTransliterates() throws Exception { + OffsetTime captured = OffsetTime.of( + LocalTime.of(10, 15, 30), ZoneOffset.ofHours(-5)); + Action1 action = (StartContext ctx) -> captured; + + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, Object.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test"); + + ServiceNode node = deployAndReturn(extracted); + assertThat(node.value()).isEqualTo(captured); + } + + @Test + void capturedOffsetDateTimeTransliterates() throws Exception { + OffsetDateTime captured = OffsetDateTime.of( + LocalDateTime.of(2026, 10, 15, 14, 30, 45, 123456789), + ZoneOffset.ofHoursMinutes(5, 45)); + Action1 action = (StartContext ctx) -> captured; + + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, Object.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test"); + + ServiceNode node = deployAndReturn(extracted); + assertThat(node.value()).isEqualTo(captured); + } + + @Test + void capturedYearTransliterates() throws Exception { + Year captured = Year.of(2026); + Action1 action = (StartContext ctx) -> captured; + + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, Object.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test"); + + ServiceNode node = deployAndReturn(extracted); + assertThat(node.value()).isEqualTo(captured); + } + + @Test + void capturedYearMonthTransliterates() throws Exception { + YearMonth captured = YearMonth.of(2026, 10); + Action1 action = (StartContext ctx) -> captured; + + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, Object.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test"); + + ServiceNode node = deployAndReturn(extracted); + assertThat(node.value()).isEqualTo(captured); + } + + @Test + void capturedMonthDayTransliterates() throws Exception { + MonthDay captured = MonthDay.of(12, 25); + Action1 action = (StartContext ctx) -> captured; + + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, Object.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test"); + + ServiceNode node = deployAndReturn(extracted); + assertThat(node.value()).isEqualTo(captured); + } + + @Test + void capturedPeriodTransliterates() throws Exception { + Period captured = Period.of(1, 6, 15); + Action1 action = (StartContext ctx) -> captured; + + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, Object.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test"); + + ServiceNode node = deployAndReturn(extracted); + assertThat(node.value()).isEqualTo(captured); + } + + @Test + void capturedNegativePeriodTransliterates() throws Exception { + Period captured = Period.of(-1, -3, -10); + Action1 action = (StartContext ctx) -> captured; + + TransliteratedAction.ActionService extracted = LambdaTransliterator.extract( + action, Object.class, List.of(), List.of(), List.of(), List.of(), false, false, false, "test"); + + ServiceNode node = deployAndReturn(extracted); + assertThat(node.value()).isEqualTo(captured); + } + // ── Helpers ── /** Action that completes a node with a bound value. */ From bc20ec72c89cdcb6728aab6e02bcd663cba688da Mon Sep 17 00:00:00 2001 From: Matheus Cruz <56329339+mathecruz@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:21:10 -0300 Subject: [PATCH 245/254] Update docs/src/main/asciidoc/hibernate-orm.adoc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Yoann Rodière --- docs/src/main/asciidoc/hibernate-orm.adoc | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/docs/src/main/asciidoc/hibernate-orm.adoc b/docs/src/main/asciidoc/hibernate-orm.adoc index 3888469bbcc37..758332f57a71d 100644 --- a/docs/src/main/asciidoc/hibernate-orm.adoc +++ b/docs/src/main/asciidoc/hibernate-orm.adoc @@ -1297,17 +1297,13 @@ capable of exposing metrics that Hibernate ORM collects at runtime. To enable ex on the `/q/metrics` endpoint, make sure your project depends on a metrics extension and set the configuration property `quarkus.hibernate-orm.metrics.enabled` to `true`. -To log session-level metrics for each Hibernate session, enable `DEBUG` logging for the -`org.hibernate.session.metrics` category: +Without Micrometer, to simply log session-level metrics for each Hibernate session, enable statistics (`quarkus.hibernate-orm.statistics`) and enable `DEBUG` logging for the `org.hibernate.session.metrics` category: [source,properties] ---- quarkus.hibernate-orm.statistics=true quarkus.log.category."org.hibernate.session.metrics".level=DEBUG ---- - -This only has an effect if statistics are enabled (`quarkus.hibernate-orm.statistics`). - == Limitations and other things you should know Quarkus does not modify the libraries it uses; this rule applies to Hibernate ORM as well: when using From 368e8f7a565e3e130c72b1fc78d424fcd4302286 Mon Sep 17 00:00:00 2001 From: Matheus Cruz Date: Thu, 15 Oct 2026 19:55:36 -0300 Subject: [PATCH 246/254] Make ReactiveDataSourceUtil#activeDataSourceNames get any (Any.Literal.INSTANCE) Pool beans Signed-off-by: Matheus Cruz --- .../reactive/datasource/runtime/ReactiveDataSourceUtil.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/extensions/reactive-datasource/runtime/src/main/java/io/quarkus/reactive/datasource/runtime/ReactiveDataSourceUtil.java b/extensions/reactive-datasource/runtime/src/main/java/io/quarkus/reactive/datasource/runtime/ReactiveDataSourceUtil.java index 5f65eaf5a5276..39e04f6a9b616 100644 --- a/extensions/reactive-datasource/runtime/src/main/java/io/quarkus/reactive/datasource/runtime/ReactiveDataSourceUtil.java +++ b/extensions/reactive-datasource/runtime/src/main/java/io/quarkus/reactive/datasource/runtime/ReactiveDataSourceUtil.java @@ -5,6 +5,7 @@ import java.util.Optional; import java.util.Set; +import jakarta.enterprise.inject.Any; import jakarta.enterprise.inject.Default; import jakarta.enterprise.inject.spi.Bean; @@ -42,7 +43,7 @@ public static Optional dataSourceIfActive(Class type, Str public static Set activeDataSourceNames() { Set activeNames = new LinkedHashSet<>(); - for (var handle : Arc.container().select(Pool.class).handles()) { + for (var handle : Arc.container().select(Pool.class, Any.Literal.INSTANCE).handles()) { var bean = handle.getBean(); if (bean != null && bean.isActive()) { String name = dataSourceName(bean); From b1101299e3a743bdfe013290826b72f09edeb55a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Phillip=20Kr=C3=BCger?= Date: Thu, 24 Sep 2026 13:01:08 +1000 Subject: [PATCH 247/254] Export the Dev UI observability dashboard to Grafana --- .../skills/writing-extension-devui/SKILL.md | 5 +- docs/src/main/asciidoc/dev-ui.adoc | 25 +- .../MetricsBackendBuildItem.java | 18 + .../ObservabilitySectionProcessor.java | 25 ++ .../store/metrics/PrometheusNamingId.java | 19 ++ .../dev-ui/qwc/qwc-observability-card-base.js | 20 +- .../dev-ui/qwc/qwc-observability-dashboard.js | 100 +++++- extensions/devui/runtime/pom.xml | 11 + .../metrics/MetricsDevUIJsonRPCService.java | 18 + .../grafana/GrafanaDashboardBuilder.java | 321 ++++++++++++++++++ .../metrics/grafana/PrometheusNaming.java | 213 ++++++++++++ .../grafana/GrafanaDashboardBuilderTest.java | 314 +++++++++++++++++ .../metrics/grafana/PrometheusNamingTest.java | 138 ++++++++ .../MicrometerMetricsDevUIProcessor.java | 13 +- .../export/PrometheusRegistryProcessor.java | 5 + .../runtime/devui/DevUiMetricsSampler.java | 5 +- .../OpenTelemetryMetricsDevUIProcessor.java | 24 +- .../GrafanaDashboardExportDevUITest.java | 72 ++++ .../devui/ObservabilitySectionDevUITest.java | 7 + 19 files changed, 1343 insertions(+), 10 deletions(-) create mode 100644 extensions/devui/observability-store/src/main/java/io/quarkus/devui/observability/store/metrics/PrometheusNamingId.java create mode 100644 extensions/devui/runtime/src/main/java/io/quarkus/devui/runtime/observability/metrics/grafana/GrafanaDashboardBuilder.java create mode 100644 extensions/devui/runtime/src/main/java/io/quarkus/devui/runtime/observability/metrics/grafana/PrometheusNaming.java create mode 100644 extensions/devui/runtime/src/test/java/io/quarkus/devui/runtime/observability/metrics/grafana/GrafanaDashboardBuilderTest.java create mode 100644 extensions/devui/runtime/src/test/java/io/quarkus/devui/runtime/observability/metrics/grafana/PrometheusNamingTest.java create mode 100644 extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/devui/GrafanaDashboardExportDevUITest.java diff --git a/.agents/skills/writing-extension-devui/SKILL.md b/.agents/skills/writing-extension-devui/SKILL.md index 718794f0e2c92..c3e858c78ce95 100644 --- a/.agents/skills/writing-extension-devui/SKILL.md +++ b/.agents/skills/writing-extension-devui/SKILL.md @@ -167,7 +167,10 @@ a card, which is what metrics does - meters are picked individually instead. Meters need no build item: everything registered with Micrometer or the OpenTelemetry SDK is offered in the dashboard's picker automatically. Only a new metrics *backend* (one that samples into `MetricsTimeSeriesStore`) produces a -`MetricsBackendBuildItem`. +`MetricsBackendBuildItem`, whose second argument says how those meters are named +once exported to Prometheus (`PrometheusNamingId.MICROMETER_PROMETHEUS`, +`PrometheusNamingId.OTLP`, or null when that is unknown) so the dashboard can be +exported for Grafana. Full documentation: `docs/src/main/asciidoc/dev-ui.adoc`, "Observability dashboard". diff --git a/docs/src/main/asciidoc/dev-ui.adoc b/docs/src/main/asciidoc/dev-ui.adoc index 59c6a0495e04b..4c26f6ee9a3dd 100644 --- a/docs/src/main/asciidoc/dev-ui.adoc +++ b/docs/src/main/asciidoc/dev-ui.adoc @@ -1836,7 +1836,7 @@ and its JSON-RPC calls, subscriptions and hot reload behave exactly as they do o Extend `ObservabilityCardBase` rather than `QwcHotReloadElement` in that component. Core Dev UI puts it in the import map under the bare specifier `observability-card-base`; it extends `QwcHotReloadElement` itself, so you keep `hotReload()`, and adds the affordances every signal -component ends up wanting, currently `exportCsv(rows, filename)`: +component ends up wanting, currently `exportCsv(rows, filename)` and `exportJson(value, filename)`: [source,javascript] ---- @@ -1864,6 +1864,29 @@ then registers the JSON-RPC service and the metrics signal once, no matter how m active. See `DevUiMetricsSampler` (Micrometer) and `DevUiMetricsExporter` (OpenTelemetry) for the two existing implementations. +A backend should also say how the same metrics are named once the application exports them, with +the second argument of the build item (`PrometheusNamingId.MICROMETER_PROMETHEUS` or +`PrometheusNamingId.OTLP`, from `quarkus-devui-observability-store`). That is a different +question from where the Dev UI captured them: the dashboard stores each meter's own name, while a +Grafana dashboard has to query the name the meter carries after export, which the two routes spell +differently. Leave it `null` when the export naming is not known, and the *Export Grafana* button +below is simply not offered for those meters. + +==== Exporting the dashboard to Grafana + +The dashboard exports itself as a Grafana dashboard, for a Grafana that holds the same telemetry: +the one xref:observability-devservices-lgtm.adoc[the LGTM Dev Service] starts, or any other +reading the same Prometheus and Tempo. *Export Grafana* downloads a dashboard file, which Grafana +imports under *Dashboards -> New -> Import*; it asks which data sources to use, so the file is not +tied to one Grafana. + +Each card becomes the panel that draws the same statistic it draws in Dev UI: a counter becomes a +rate, a gauge a Grafana gauge, a timer the mean of each interval with its maximum beside it, a +meter with a histogram a heatmap over its buckets, and the traces card a Tempo table of this +application's traces, a row per trace that opens into its spans the way the traces page lists them. +What the panels query depends on how the application exports its +metrics, which is why a card whose meters take an export route Dev UI does not know is left out. + === Advanced: Custom cards You can customize the card that is being displayed on the extension page if you do not want to use the default built-in card. diff --git a/extensions/devui/deployment-spi/src/main/java/io/quarkus/devui/spi/observability/MetricsBackendBuildItem.java b/extensions/devui/deployment-spi/src/main/java/io/quarkus/devui/spi/observability/MetricsBackendBuildItem.java index f1ef3a7df19d5..e8195c6e1b9e4 100644 --- a/extensions/devui/deployment-spi/src/main/java/io/quarkus/devui/spi/observability/MetricsBackendBuildItem.java +++ b/extensions/devui/deployment-spi/src/main/java/io/quarkus/devui/spi/observability/MetricsBackendBuildItem.java @@ -10,12 +10,30 @@ public final class MetricsBackendBuildItem extends MultiBuildItem { private final String source; + private final String prometheusNaming; public MetricsBackendBuildItem(String source) { + this(source, null); + } + + /** + * @param source which backend captures the metrics for the Dev UI, {@code micrometer} or {@code otel} + * @param prometheusNaming how the same metrics are named once exported to Prometheus, which is a + * different question: the Dev UI captures the meter's own name, while a Grafana dashboard has to + * use the exported one. One of the {@code PrometheusNamingId} constants + * ({@code micrometer-prometheus} or {@code otlp}), or {@code null} when the application exports its + * metrics in some other way, or not at all. + */ + public MetricsBackendBuildItem(String source, String prometheusNaming) { this.source = source; + this.prometheusNaming = prometheusNaming; } public String getSource() { return source; } + + public String getPrometheusNaming() { + return prometheusNaming; + } } diff --git a/extensions/devui/deployment/src/main/java/io/quarkus/devui/deployment/observability/ObservabilitySectionProcessor.java b/extensions/devui/deployment/src/main/java/io/quarkus/devui/deployment/observability/ObservabilitySectionProcessor.java index 70b340eecb492..29f629ea40641 100644 --- a/extensions/devui/deployment/src/main/java/io/quarkus/devui/deployment/observability/ObservabilitySectionProcessor.java +++ b/extensions/devui/deployment/src/main/java/io/quarkus/devui/deployment/observability/ObservabilitySectionProcessor.java @@ -9,6 +9,7 @@ import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; import io.quarkus.devui.deployment.InternalPageBuildItem; +import io.quarkus.devui.spi.observability.MetricsBackendBuildItem; import io.quarkus.devui.spi.observability.ObservabilitySignalBuildItem; import io.quarkus.devui.spi.page.Page; @@ -28,6 +29,7 @@ public class ObservabilitySectionProcessor { @BuildStep(onlyIf = IsLocalDevelopment.class) void observabilitySection(List signals, + List metricsBackends, BuildProducer menuProducer) { if (signals.isEmpty()) { return; @@ -56,7 +58,30 @@ void observabilitySection(List signals, page.addBuildTimeData("observabilitySignals", signalData, "The telemetry signals (e.g. traces) contributed by observability extensions"); + // Null when the metrics of this application do not reach Prometheus under a naming known here, in + // which case the dashboard offers no Grafana export rather than an export with wrong queries. + page.addBuildTimeData("prometheusNaming", prometheusNaming(metricsBackends), + "How this application's metrics are named once exported to Prometheus, " + + "used to export the dashboard for Grafana"); menuProducer.produce(page); } + + /** + * The export naming shared by the metrics backends. With more than one backend answering differently + * there is no single set of names to query, so no export is offered. + */ + private static String prometheusNaming(List backends) { + String naming = null; + for (MetricsBackendBuildItem backend : backends) { + if (backend.getPrometheusNaming() == null) { + return null; + } + if (naming != null && !naming.equals(backend.getPrometheusNaming())) { + return null; + } + naming = backend.getPrometheusNaming(); + } + return naming; + } } diff --git a/extensions/devui/observability-store/src/main/java/io/quarkus/devui/observability/store/metrics/PrometheusNamingId.java b/extensions/devui/observability-store/src/main/java/io/quarkus/devui/observability/store/metrics/PrometheusNamingId.java new file mode 100644 index 0000000000000..f437a0cf3ebda --- /dev/null +++ b/extensions/devui/observability-store/src/main/java/io/quarkus/devui/observability/store/metrics/PrometheusNamingId.java @@ -0,0 +1,19 @@ +package io.quarkus.devui.observability.store.metrics; + +/** + * How a metrics backend's meters are named once exported to Prometheus, as the backend's build step declares + * it (the second argument of {@code MetricsBackendBuildItem}) and the Dev UI reads it back to export the + * dashboard for Grafana. Kept here because this module is the one the metrics backends and the Dev UI both + * depend on. + */ +public final class PrometheusNamingId { + + /** Micrometer's Prometheus registry, scraped from {@code /q/metrics}. */ + public static final String MICROMETER_PROMETHEUS = "micrometer-prometheus"; + + /** OTLP, renamed by Prometheus itself when it receives it. */ + public static final String OTLP = "otlp"; + + private PrometheusNamingId() { + } +} diff --git a/extensions/devui/resources/src/main/resources/dev-ui/qwc/qwc-observability-card-base.js b/extensions/devui/resources/src/main/resources/dev-ui/qwc/qwc-observability-card-base.js index 1d9596e5617db..13ebbedc03352 100644 --- a/extensions/devui/resources/src/main/resources/dev-ui/qwc/qwc-observability-card-base.js +++ b/extensions/devui/resources/src/main/resources/dev-ui/qwc/qwc-observability-card-base.js @@ -3,7 +3,7 @@ import { notifier } from 'notifier'; /** * Shared base for Observability signal components. Provides common shell affordances - * (currently CSV export) so each signal component reuses them. Registered in the Dev UI + * (currently CSV and JSON export) so each signal component reuses them. Registered in the Dev UI * import map by core devui as the bare specifier 'observability-card-base'. * * Extends QwcHotReloadElement so signal components can react to dev-mode live reloads: @@ -31,13 +31,27 @@ export class ObservabilityCardBase extends QwcHotReloadElement { for (const row of rows) { lines.push(headers.map(h => escape(row[h])).join(',')); } - const blob = new Blob([lines.join('\n')], { type: 'text/csv' }); + this.download(lines.join('\n'), 'text/csv', filename); + notifier.showInfoMessage('Exported ' + rows.length + ' rows'); + } + + /** + * Export an object to a downloaded JSON file. + * @param {Object} value + * @param {string} filename + */ + exportJson(value, filename) { + this.download(JSON.stringify(value, null, 2), 'application/json', filename); + } + + /** Hand the browser a file to save. */ + download(content, mimeType, filename) { + const blob = new Blob([content], { type: mimeType }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = filename; link.click(); URL.revokeObjectURL(url); - notifier.showInfoMessage('Exported ' + rows.length + ' rows'); } } diff --git a/extensions/devui/resources/src/main/resources/dev-ui/qwc/qwc-observability-dashboard.js b/extensions/devui/resources/src/main/resources/dev-ui/qwc/qwc-observability-dashboard.js index 0481d17d7b315..94de8a7a9e982 100644 --- a/extensions/devui/resources/src/main/resources/dev-ui/qwc/qwc-observability-dashboard.js +++ b/extensions/devui/resources/src/main/resources/dev-ui/qwc/qwc-observability-dashboard.js @@ -1,12 +1,13 @@ import { html, css } from 'lit'; import { html as staticHtml, unsafeStatic } from 'lit/static-html.js'; import { repeat } from 'lit/directives/repeat.js'; -import { observabilitySignals } from 'devui-data'; +import { observabilitySignals, prometheusNaming } from 'devui-data'; import { devuiState } from 'devui-state'; import { JsonRpc } from 'jsonrpc'; import { StorageController } from 'storage-controller'; import { RouterController } from 'router-controller'; import { ObservabilityCardBase } from 'observability-card-base'; +import { notifier } from 'notifier'; import 'echarts-line'; import 'echarts-histogram'; import 'echarts-gauge'; @@ -75,6 +76,10 @@ const RATIO_METRICS = new Set([ ]); const RATIO_UNIT = 'ratio'; +// Meters whose primary value is a recording count, with the amounts recorded riding along. Their +// catalog entry says cumulative, so without captured samples _plotFor draws them as a rate. +const DISTRIBUTION_TYPES = new Set(['TIMER', 'SUMMARY', 'HISTOGRAM']); + // Micrometer and OpenTelemetry spell the same units differently (OTel follows UCUM), and // neither is what you want on an axis. Map both onto the family the formatter understands. const UNIT_FAMILIES = { @@ -346,6 +351,19 @@ export class QwcObservabilityDashboard extends ObservabilityCardBase { return this._tags.get(name); } + /** + * Whether a Grafana dashboard can be made of what is on this one. The export is answered by the metrics + * JSON-RPC service, which only exists when a metrics backend registered it, so an application with only + * traces has nothing to ask - and metric cards additionally need an export naming this build knows. + */ + _exportableToGrafana(cards) { + if (!this._metricsAvailable) { + return false; + } + return cards.some(id => id.startsWith(SIGNAL_PREFIX) + || (prometheusNaming && id.startsWith(METRIC_PREFIX))); + } + _selectedMetricNames() { return (this._cards ?? []) .filter(id => id.startsWith(METRIC_PREFIX)) @@ -1015,6 +1033,80 @@ export class QwcObservabilityDashboard extends ObservabilityCardBase { this.exportCsv(rows, 'metrics.csv'); } + /** + * The dashboard as a Grafana dashboard, for the Grafana that holds the same telemetry - the one the + * LGTM Dev Service starts, or any other reading the same Prometheus and Tempo. + * + * The cards are sent as they are shown, each with the plot this page chose for it, so the exported + * panels draw the same statistic as the cards. The queries have to use the names the metrics carry + * once exported, which depends on how this application exports them: prometheusNaming says which, + * and is null when the export is not one whose naming is known, in which case only signal cards + * (traces, which are in Tempo under the application's service name) are exported. + */ + _exportGrafana() { + const cards = []; + // The cards as shown: the stored list follows the developer between applications, so it can name + // meters this one never registered. + for (const id of this._visibleCards()) { + if (id.startsWith(SIGNAL_PREFIX)) { + const key = id.substring(SIGNAL_PREFIX.length); + const signal = this._signals.find(s => s.key === key); + cards.push({ kind: 'signal', id: key, title: signal?.title ?? key }); + } else if (id.startsWith(METRIC_PREFIX) && prometheusNaming) { + const name = id.substring(METRIC_PREFIX.length); + const plot = this._plotFor(name); + const series = Object.values(this._sections[name] ?? {}); + const meta = this._metricMeta(name); + // The meter type decides the names of a few series, e.g. a long task timer is published + // per seconds active whatever unit it is captured with. + const type = series[0]?.type ?? meta?.type ?? ''; + cards.push({ + kind: 'metric', + name, + // Nothing captured yet (or the history was cleared) leaves _plotFor with only the + // catalog, which calls a timer cumulative: it is still a distribution in Prometheus. + plot: series.length === 0 && DISTRIBUTION_TYPES.has(type.toUpperCase()) + ? 'distribution' : plot.kind, + // The meter's own unit, which is what the exported name carries: plot.unit can be + // the synthetic RATIO_UNIT, which no exporter writes into a name. + unit: series[0]?.unit ?? meta?.unit ?? '', + ratio: plot.unit === RATIO_UNIT, + type, + // Where a maximum is published differs by route, so the server picks: Micrometer + // publishes one beside the meter when it tracks one (a function timer does not), + // while over OTLP only a meter of its own, .max, carries it. + maxCaptured: series.some(s => (s.distribution?.maxes ?? []).some(Number.isFinite)), + maxMeter: this._metricMeta(`${name}.max`) !== null, + }); + } + } + // Metric cards are left out when the export naming is not known: say so, rather than hand over a + // dashboard that looks complete but is missing them. + const leftOut = prometheusNaming ? 0 + : this._visibleCards().filter(id => id.startsWith(METRIC_PREFIX)).length; + if (cards.length === 0) { + notifier.showWarningMessage(leftOut > 0 + ? 'Nothing to export: metric cards need metrics exported to Prometheus, over OTLP or by the Micrometer Prometheus registry' + : 'Nothing to export: this dashboard has no cards that Grafana can show'); + return; + } + this.jsonRpc.exportGrafanaDashboard({ + cards, + naming: prometheusNaming, + title: `Quarkus Dev UI - ${devuiState.applicationInfo.applicationName}`, + }).then(resp => { + this.exportJson(resp.result, 'quarkus-dev-ui-dashboard.json'); + if (leftOut > 0) { + notifier.showWarningMessage(`Dashboard exported without its ${leftOut} metric card(s): this application ` + + 'does not export its metrics to Prometheus in a way the export knows how to name.'); + } else { + notifier.showInfoMessage('Dashboard exported. Import it in Grafana under Dashboards, New, Import.'); + } + }).catch(error => { + notifier.showErrorMessage('The dashboard could not be exported: ' + (error?.error?.message || error)); + }); + } + // ---------------------------------------------------------------- render render() { @@ -1091,6 +1183,12 @@ export class QwcObservabilityDashboard extends ObservabilityCardBase { Export CSV ` : ''} + ${this._exportableToGrafana(cards) ? html` + + + Export Grafana + ` : ''} Edit dashboard diff --git a/extensions/devui/runtime/pom.xml b/extensions/devui/runtime/pom.xml index 4d05059ba811a..16bbf2bd1f3fa 100644 --- a/extensions/devui/runtime/pom.xml +++ b/extensions/devui/runtime/pom.xml @@ -34,6 +34,17 @@ io.quarkus quarkus-devmcp + + + io.quarkus + quarkus-junit-internal + test + + + org.assertj + assertj-core + test + diff --git a/extensions/devui/runtime/src/main/java/io/quarkus/devui/runtime/observability/metrics/MetricsDevUIJsonRPCService.java b/extensions/devui/runtime/src/main/java/io/quarkus/devui/runtime/observability/metrics/MetricsDevUIJsonRPCService.java index 71d9dbd1d75ce..a191d039e73d6 100644 --- a/extensions/devui/runtime/src/main/java/io/quarkus/devui/runtime/observability/metrics/MetricsDevUIJsonRPCService.java +++ b/extensions/devui/runtime/src/main/java/io/quarkus/devui/runtime/observability/metrics/MetricsDevUIJsonRPCService.java @@ -6,11 +6,15 @@ import jakarta.inject.Inject; +import org.eclipse.microprofile.config.ConfigProvider; + import io.quarkus.devui.observability.store.metrics.MetricCatalogEntry; import io.quarkus.devui.observability.store.metrics.MetricDistribution; import io.quarkus.devui.observability.store.metrics.MetricSample; import io.quarkus.devui.observability.store.metrics.MetricSeriesSnapshot; import io.quarkus.devui.observability.store.metrics.MetricsTimeSeriesStore; +import io.quarkus.devui.runtime.observability.metrics.grafana.GrafanaDashboardBuilder; +import io.quarkus.devui.runtime.observability.metrics.grafana.PrometheusNaming; import io.smallrye.mutiny.Multi; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; @@ -89,6 +93,20 @@ public int meterCount() { return store.meterCount(); } + /** + * The cards of the Dev UI dashboard as a Grafana dashboard, so the same view can be opened against a + * real Grafana - the one the LGTM Dev Service starts, or any other reading the same data. + * + * @param cards the cards as shown, in dashboard order, each carrying the plot the Dev UI chose for it + * @param naming which export route the application's metrics take, as the build steps determined it + * @param title the dashboard title + */ + public JsonObject exportGrafanaDashboard(List> cards, String naming, String title) { + return new GrafanaDashboardBuilder(PrometheusNaming.fromId(naming), + GrafanaDashboardBuilder.serviceName(ConfigProvider.getConfig())) + .build(cards == null ? List.of() : cards, title); + } + /** Discards the captured points; the selection and the catalog are left alone. */ public boolean clear() { store.clear(); diff --git a/extensions/devui/runtime/src/main/java/io/quarkus/devui/runtime/observability/metrics/grafana/GrafanaDashboardBuilder.java b/extensions/devui/runtime/src/main/java/io/quarkus/devui/runtime/observability/metrics/grafana/GrafanaDashboardBuilder.java new file mode 100644 index 0000000000000..d32c2cefa8fef --- /dev/null +++ b/extensions/devui/runtime/src/main/java/io/quarkus/devui/runtime/observability/metrics/grafana/GrafanaDashboardBuilder.java @@ -0,0 +1,321 @@ +package io.quarkus.devui.runtime.observability.metrics.grafana; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.eclipse.microprofile.config.Config; + +import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; + +/** + * Builds a Grafana dashboard out of the cards on the Dev UI observability dashboard, so the same view can + * be opened against a real Grafana, for example the one the LGTM Dev Service starts. + *

    + * The client sends the cards it is showing, each with the plot it chose for the meter, and this turns every + * one into the equivalent panel. The plot is not worked out again here on purpose: the Dev UI decides how a + * meter is drawn from what was actually captured for it, and the export has to agree with what the developer + * is looking at. + *

    + * The data sources are left as dashboard inputs ({@code ${DS_PROMETHEUS}}), which is what Grafana's own + * "export for sharing externally" does: on import Grafana asks which data source to use, so the dashboard is + * not tied to the ids of one instance. + */ +public final class GrafanaDashboardBuilder { + + /** + * The semantic convention resource attribute naming the service ({@code ServiceAttributes.SERVICE_NAME}). + * Spelled out because the Dev UI has no OpenTelemetry dependency; {@code GrafanaDashboardExportDevUITest} in + * the OpenTelemetry extension holds it to the semconv key. + */ + static final String SERVICE_NAME_ATTRIBUTE = "service.name"; + private static final String PROMETHEUS_INPUT = "DS_PROMETHEUS"; + private static final String TEMPO_INPUT = "DS_TEMPO"; + private static final int PANEL_WIDTH = 12; + private static final int PANEL_HEIGHT = 8; + private static final int FULL_WIDTH = 24; + private static final int SIGNAL_HEIGHT = 10; + private static final String LONG_TASK_TIMER = "LONG_TASK_TIMER"; + // How many spans of each trace the traces panel holds under its trace. Tempo's default is 3, which cuts a + // request with nested spans short. + private static final int SPANS_PER_TRACE = 100; + // The meter types that are gauges on the way to Prometheus, as the Micrometer sampler and the OpenTelemetry + // exporter report them. A non-monotonic OpenTelemetry sum is drawn like a gauge, but it is not one to + // Prometheus, which is what decides whether a unit of "1" becomes "_ratio". + private static final Set GAUGE_TYPES = Set.of("GAUGE", "DOUBLE_GAUGE", "LONG_GAUGE"); + + private final PrometheusNaming naming; + private final String serviceName; + + /** + * @param naming how the metrics are named in Prometheus, or {@code null} when that is not known, in which + * case no metric card is exported + * @param serviceName the service name this application reports its telemetry under + */ + public GrafanaDashboardBuilder(PrometheusNaming naming, String serviceName) { + this.naming = naming; + this.serviceName = serviceName; + } + + /** + * The service name this application's traces are in Tempo under, worked out the way the OpenTelemetry + * extension sets the {@code service.name} resource attribute: {@code quarkus.otel.service.name} when it is + * set to something other than its default (the application name), then {@code service.name} from + * {@code quarkus.otel.resource.attributes}, then the application name. + */ + public static String serviceName(Config config) { + String applicationName = config.getOptionalValue("quarkus.application.name", String.class).orElse(null); + String configured = config.getOptionalValue("quarkus.otel.service.name", String.class) + .filter(name -> !name.equals(applicationName == null ? "unset" : applicationName)) + .orElse(null); + if (configured != null) { + return configured; + } + String fromAttributes = null; + for (String attribute : config.getOptionalValues("quarkus.otel.resource.attributes", String.class) + .orElse(List.of())) { + String[] parts = attribute.split("=", 2); + if (parts.length == 2 && SERVICE_NAME_ATTRIBUTE.equals(parts[0].trim())) { + // The last one wins, as it does when the extension reads the list into a map. + fromAttributes = parts[1].trim(); + } + } + if (fromAttributes != null) { + return fromAttributes; + } + return applicationName == null ? "quarkus-application" : applicationName; + } + + /** + * @param cards the cards as the Dev UI shows them, in dashboard order + * @param title the dashboard title + * @return a dashboard Grafana accepts on its import screen + */ + public JsonObject build(List> cards, String title) { + JsonArray panels = new JsonArray(); + boolean tempoUsed = false; + int x = 0; + int y = 0; + int rowHeight = 0; + for (Map card : cards) { + boolean signal = "signal".equals(string(card, "kind")); + JsonObject panel = signal ? signalPanel(card) : metricPanel(card); + if (panel == null) { + // Nothing to draw for this card, and nothing to move along either: a skipped card must not + // leave a hole in the layout. + continue; + } + int width = signal ? FULL_WIDTH : PANEL_WIDTH; + int height = signal ? SIGNAL_HEIGHT : PANEL_HEIGHT; + if (x + width > FULL_WIDTH) { + // The row ends, so it is the height of the row just filled that the next one starts below. + x = 0; + y += rowHeight; + rowHeight = 0; + } + tempoUsed |= signal; + panel.put("id", panels.size() + 1) + .put("gridPos", new JsonObject().put("h", height).put("w", width).put("x", x).put("y", y)); + panels.add(panel); + x += width; + rowHeight = Math.max(rowHeight, height); + if (x >= FULL_WIDTH) { + x = 0; + y += rowHeight; + rowHeight = 0; + } + } + + JsonArray inputs = new JsonArray().add(input(PROMETHEUS_INPUT, "Prometheus", "prometheus")); + if (tempoUsed) { + inputs.add(input(TEMPO_INPUT, "Tempo", "tempo")); + } + return new JsonObject() + .put("__inputs", inputs) + .put("title", title == null || title.isBlank() ? "Quarkus Dev UI observability" : title) + .put("description", "Exported from the Quarkus Dev UI observability dashboard of " + + serviceName + ".") + .put("tags", new JsonArray().add("quarkus").add("dev-ui")) + .put("schemaVersion", 39) + .put("editable", true) + .put("time", new JsonObject().put("from", "now-15m").put("to", "now")) + .put("refresh", "10s") + .put("panels", panels); + } + + private JsonObject metricPanel(Map card) { + String meter = string(card, "name"); + if (naming == null || meter == null || meter.isBlank()) { + return null; + } + String plot = string(card, "plot"); + String unit = string(card, "unit"); + String type = string(card, "type"); + boolean gauge = type != null && GAUGE_TYPES.contains(type); + String base = naming.baseName(meter, unit, gauge); + JsonArray targets = new JsonArray(); + String panelType = "timeseries"; + boolean stepped = false; + + switch (plot == null ? "value" : plot) { + case "rate" -> targets.add(target("rate(" + naming.counterName(meter, unit) + "[$__rate_interval])", + "A", null)); + case "gauge" -> { + panelType = "gauge"; + targets.add(target(base, "A", null)); + } + case "step" -> { + stepped = true; + targets.add(target(LONG_TASK_TIMER.equals(type) + ? naming.longTaskActiveName(meter, unit) + : base, "A", null)); + } + case "histogram" -> { + panelType = "heatmap"; + targets.add(target("sum by (le) (increase(" + base + "_bucket[$__rate_interval]))", "A", "{{le}}") + .put("format", "heatmap")); + } + case "distribution" -> { + // The mean of each interval, which is what the Dev UI card draws: the amount recorded in the + // interval over the number of recordings in it. + targets.add(target("rate(" + base + "_sum[$__rate_interval]) / rate(" + base + + "_count[$__rate_interval])", "A", "mean")); + String max = naming.maxName(meter, unit, bool(card, "maxCaptured"), bool(card, "maxMeter")); + if (max != null) { + targets.add(target(max, "B", "max")); + } + } + default -> targets.add(target(base, "A", null)); + } + + JsonObject custom = new JsonObject(); + if (stepped) { + // A meter that only ever moves in whole steps: sloping between two readings would draw values + // that never occurred, exactly as on the Dev UI card. + custom.put("lineInterpolation", "stepAfter"); + } + JsonObject defaults = new JsonObject().put("custom", custom); + String grafanaUnit = bool(card, "ratio") ? "percentunit" : grafanaUnit(unit, plot, gauge); + if (grafanaUnit != null) { + defaults.put("unit", grafanaUnit); + } + return new JsonObject() + .put("type", panelType) + .put("title", meter) + .put("datasource", datasource("prometheus", PROMETHEUS_INPUT)) + .put("fieldConfig", new JsonObject().put("defaults", defaults).put("overrides", new JsonArray())) + .put("targets", targets); + } + + /** + * A signal card stands for a whole Dev UI page, e.g. the captured traces. Only traces have an equivalent + * here: they are in Tempo, queried by the service name this application reports. + */ + private JsonObject signalPanel(Map card) { + String id = string(card, "id"); + if (!"traces".equals(id)) { + return null; + } + // The data source goes on the query too: without it Grafana runs the query against the dashboard's + // default data source, which is Prometheus, and the panel stays empty. + // A row per trace, which opens into the spans of that trace, as the Dev UI traces page lists them: a + // row per span instead would put the spans of a request beside each other with nothing holding them + // together. Tempo answers a trace row with its spans in a "nested" column, which the table panel reads + // as the rows under that one. A span row only carries its name when the query selects it. + JsonObject target = new JsonObject() + .put("refId", "A") + .put("datasource", datasource("tempo", TEMPO_INPUT)) + .put("queryType", "traceql") + .put("tableType", "traces") + .put("limit", 50) + .put("spss", SPANS_PER_TRACE) + .put("query", "{ resource." + SERVICE_NAME_ATTRIBUTE + " = \"" + traceQlString(serviceName) + + "\" } | select(name)"); + return new JsonObject() + .put("type", "table") + .put("title", "Traces") + .put("datasource", datasource("tempo", TEMPO_INPUT)) + .put("targets", new JsonArray().add(target)); + } + + private static JsonObject target(String expr, String refId, String legend) { + JsonObject target = new JsonObject() + .put("refId", refId) + .put("expr", expr) + .put("datasource", datasource("prometheus", PROMETHEUS_INPUT)); + if (legend != null) { + target.put("legendFormat", legend); + } + return target; + } + + private static JsonObject datasource(String type, String inputName) { + return new JsonObject().put("type", type).put("uid", "${" + inputName + "}"); + } + + private static JsonObject input(String name, String label, String pluginId) { + return new JsonObject() + .put("name", name) + .put("label", label) + .put("description", "") + .put("type", "datasource") + .put("pluginId", pluginId) + .put("pluginName", label); + } + + /** The contents of a double-quoted TraceQL string, which escapes quotes and backslashes. */ + private static String traceQlString(String value) { + return value.replace("\\", "\\\\").replace("\"", "\\\""); + } + + /** + * The Grafana field unit for a captured unit, so a panel reads in the same unit as its Dev UI card. + * {@code null} leaves the panel unitless, which is right for a count. + */ + private static String grafanaUnit(String unit, String plot, boolean gauge) { + if ("rate".equals(plot)) { + return rateUnit(unit); + } + if (unit == null) { + return null; + } + return switch (unit.trim()) { + case "s", "seconds" -> "s"; + case "ms", "milliseconds" -> "ms"; + case "us", "microseconds" -> "µs"; + case "ns", "nanoseconds" -> "ns"; + case "By", "bytes" -> "bytes"; + case "Cel", "celsius" -> "celsius"; + case "%", "percent" -> "percent"; + // A gauge of "1" is a fraction of one, which Grafana shows as a percentage of its own accord; a + // non-monotonic sum of "1" is a plain count, e.g. jvm.cpu.limit. + case "1" -> gauge ? "percentunit" : null; + default -> null; + }; + } + + /** + * The Grafana unit for the per-second rate of a counter in the given unit: the rate of a byte counter is a + * throughput, and a counter of seconds grows by the fraction of each second spent on whatever it counts. + */ + private static String rateUnit(String unit) { + String cleaned = unit == null ? "" : unit.replaceAll("\\{[^}]*}", "").trim(); + return switch (cleaned) { + case "", "1", "none" -> "cps"; + case "By", "bytes" -> "binBps"; + case "s", "seconds" -> "percentunit"; + // A custom unit: Grafana appends the suffix to the value as it stands. + default -> "suffix: " + cleaned + "/s"; + }; + } + + private static String string(Map card, String key) { + Object value = card.get(key); + return value == null ? null : String.valueOf(value); + } + + private static boolean bool(Map card, String key) { + return Boolean.TRUE.equals(card.get(key)) || "true".equals(string(card, key)); + } +} diff --git a/extensions/devui/runtime/src/main/java/io/quarkus/devui/runtime/observability/metrics/grafana/PrometheusNaming.java b/extensions/devui/runtime/src/main/java/io/quarkus/devui/runtime/observability/metrics/grafana/PrometheusNaming.java new file mode 100644 index 0000000000000..e020b91f54d12 --- /dev/null +++ b/extensions/devui/runtime/src/main/java/io/quarkus/devui/runtime/observability/metrics/grafana/PrometheusNaming.java @@ -0,0 +1,213 @@ +package io.quarkus.devui.runtime.observability.metrics.grafana; + +import java.util.List; +import java.util.Map; + +import io.quarkus.devui.observability.store.metrics.PrometheusNamingId; + +/** + * Translates a meter name as the Dev UI captured it into the time series names the same meter ends up + * with in Prometheus. + *

    + * The Dev UI stores the meter's own name and unit, which is not what Prometheus stores: the names there + * depend on how the application exports its metrics, and the two routes Quarkus offers rename differently. + * A dashboard exported for Grafana has to use the exported names, so this is the one piece the export + * cannot guess. + */ +public enum PrometheusNaming { + + /** + * Micrometer's Prometheus registry, scraped from {@code /q/metrics}. Dots become underscores, the base + * unit is appended unless the name already ends with it, counters gain {@code _total} and the + * distribution types gain {@code _count}, {@code _sum}, {@code _max} and {@code _bucket}. + */ + MICROMETER_PROMETHEUS, + + /** + * OTLP, as Prometheus itself translates it when it receives OTLP (its {@code UnderscoreEscapingWithSuffixes} + * strategy, which is the default and what the LGTM Dev Service runs with). The unit is spelled out as a + * word ({@code ms} to {@code milliseconds}), {@code {annotations}} are dropped, a rate unit becomes + * {@code _per_second}, and a unit that already appears as a word in the name is not repeated. + */ + OTLP; + + // UCUM units as OpenTelemetry spells them, mapped the way Prometheus spells them out. Anything not + // listed is used as it stands, which is what Prometheus does with a unit it does not know. + private static final Map UCUM_UNITS = Map.ofEntries( + Map.entry("d", "days"), + Map.entry("h", "hours"), + Map.entry("min", "minutes"), + Map.entry("s", "seconds"), + Map.entry("ms", "milliseconds"), + Map.entry("us", "microseconds"), + Map.entry("ns", "nanoseconds"), + Map.entry("By", "bytes"), + Map.entry("KiBy", "kibibytes"), + Map.entry("MiBy", "mebibytes"), + Map.entry("GiBy", "gibibytes"), + Map.entry("TiBy", "tibibytes"), + Map.entry("KBy", "kilobytes"), + Map.entry("MBy", "megabytes"), + Map.entry("GBy", "gigabytes"), + Map.entry("TBy", "terabytes"), + Map.entry("m", "meters"), + Map.entry("V", "volts"), + Map.entry("A", "amperes"), + Map.entry("J", "joules"), + Map.entry("W", "watts"), + Map.entry("g", "grams"), + Map.entry("Cel", "celsius"), + Map.entry("Hz", "hertz"), + Map.entry("%", "percent")); + + /** + * The Dev UI captures a Micrometer duration as {@code s} (see {@code DevUiMetricsSampler}), which is the + * abbreviation the card headers read in, while the Prometheus registry writes the unit out in the name. + */ + private static final Map MICROMETER_TIME_UNITS = Map.of( + "s", "seconds", + "ms", "milliseconds", + "us", "microseconds", + "\u00b5s", "microseconds", + "ns", "nanoseconds", + "min", "minutes", + "h", "hours", + "d", "days"); + + private static final Map UCUM_PER_UNITS = Map.of( + "s", "second", + "m", "minute", + "h", "hour", + "d", "day", + "w", "week", + "mo", "month", + "y", "year"); + + /** + * The naming a build step declared, by its {@link PrometheusNamingId}. {@code null} when the id is null or + * not one known here: the metrics then reach Prometheus under names this cannot work out, and no metric + * panel is exported rather than one whose queries return nothing. + */ + public static PrometheusNaming fromId(String id) { + if (PrometheusNamingId.MICROMETER_PROMETHEUS.equals(id)) { + return MICROMETER_PROMETHEUS; + } + if (PrometheusNamingId.OTLP.equals(id)) { + return OTLP; + } + return null; + } + + /** + * The base time series name, without the {@code _count}/{@code _sum}/{@code _bucket} suffix that the + * distribution types add. + * + * @param meterName the name as captured, e.g. {@code http.server.requests} + * @param unit the unit as captured: a Micrometer base unit, or a UCUM unit for an OTel metric + * @param gauge whether the meter is a gauge, which is the only kind that gets {@code _ratio} + */ + public String baseName(String meterName, String unit, boolean gauge) { + String name = escape(meterName); + String suffix = unitSuffix(unit, gauge); + if (suffix.isEmpty()) { + return name; + } + return switch (this) { + // Micrometer leaves a name that already ends in its unit alone. + case MICROMETER_PROMETHEUS -> name.endsWith("_" + suffix) ? name : name + "_" + suffix; + // Prometheus skips a unit word that appears ANYWHERE in the name, not just at the end: + // jvm.threads.live with unit "threads" stays jvm_threads_live. + case OTLP -> appendUnlessPresent(name, suffix); + }; + } + + /** The name of a counter, which carries the {@code _total} suffix on both routes. */ + public String counterName(String meterName, String unit) { + String name = baseName(meterName, unit, false); + return name.endsWith("_total") ? name : name + "_total"; + } + + /** + * The series holding the maximum of a distribution, or {@code null} when there is none to draw. + * Micrometer publishes a {@code _max} series beside the meter; over OTLP the maximum arrives as a meter + * of its own ({@code http.server.requests.max}), which only a Micrometer timer bridged to OpenTelemetry + * publishes. A native OpenTelemetry histogram may record a maximum, but Prometheus keeps no series of it. + * + * @param maxCaptured whether a maximum was captured for this meter itself + * @param maxMeter whether a {@code .max} meter of its own is known + */ + public String maxName(String meterName, String unit, boolean maxCaptured, boolean maxMeter) { + return switch (this) { + // A function timer tracks totals only, so there is no _max series for it. + case MICROMETER_PROMETHEUS -> maxCaptured ? baseName(meterName, unit, false) + "_max" : null; + case OTLP -> maxMeter ? baseName(meterName + ".max", unit, true) : null; + }; + } + + /** + * The series holding the number of tasks a long task timer has in flight. Micrometer publishes it as + * {@code _seconds_active_count}, whatever unit the Dev UI captured for the meter; over OTLP the + * meter arrives split into {@code .active} and {@code .duration}, each an ordinary meter + * with a card of its own, so there is nothing special to do. + */ + public String longTaskActiveName(String meterName, String unit) { + return switch (this) { + case MICROMETER_PROMETHEUS -> escape(meterName) + "_seconds_active_count"; + case OTLP -> baseName(meterName, unit, true); + }; + } + + private String unitSuffix(String unit, boolean gauge) { + String cleaned = unit == null ? "" : unit.trim(); + if (cleaned.isEmpty() || "none".equalsIgnoreCase(cleaned)) { + return ""; + } + if (this == MICROMETER_PROMETHEUS) { + // A Micrometer base unit is otherwise already a word, e.g. "bytes" or "threads". + return escape(MICROMETER_TIME_UNITS.getOrDefault(cleaned, cleaned)); + } + // An annotation carries no unit of its own: "{requests}" is dropped, "{requests}/s" is a rate. + String stripped = cleaned.replaceAll("\\{[^}]*}", "").trim(); + if ("1".equals(stripped)) { + return gauge ? "ratio" : ""; + } + int slash = stripped.indexOf('/'); + String main = slash < 0 ? stripped : stripped.substring(0, slash).trim(); + String per = slash < 0 ? "" : stripped.substring(slash + 1).trim(); + StringBuilder suffix = new StringBuilder(); + if (!main.isEmpty()) { + suffix.append(escape(UCUM_UNITS.getOrDefault(main, main))); + } + if (!per.isEmpty()) { + if (suffix.length() > 0) { + suffix.append('_'); + } + suffix.append("per_").append(escape(UCUM_PER_UNITS.getOrDefault(per, per))); + } + return suffix.toString(); + } + + private static String appendUnlessPresent(String name, String suffix) { + String main = suffix; + String per = ""; + int perAt = suffix.indexOf("per_"); + if (perAt >= 0) { + main = suffix.substring(0, perAt).replaceAll("_$", ""); + per = suffix.substring(perAt); + } + String result = name; + if (!main.isEmpty() && !List.of(result.split("_")).contains(main)) { + result = result + "_" + main; + } + if (!per.isEmpty() && !result.endsWith(per)) { + result = result + "_" + per; + } + return result; + } + + /** Everything Prometheus does not allow in a name becomes an underscore, with runs collapsed. */ + private static String escape(String value) { + String escaped = value.replaceAll("[^a-zA-Z0-9_:]", "_").replaceAll("_{2,}", "_"); + return escaped.replaceAll("^_+", "").replaceAll("_+$", ""); + } +} diff --git a/extensions/devui/runtime/src/test/java/io/quarkus/devui/runtime/observability/metrics/grafana/GrafanaDashboardBuilderTest.java b/extensions/devui/runtime/src/test/java/io/quarkus/devui/runtime/observability/metrics/grafana/GrafanaDashboardBuilderTest.java new file mode 100644 index 0000000000000..6f5182530eb8d --- /dev/null +++ b/extensions/devui/runtime/src/test/java/io/quarkus/devui/runtime/observability/metrics/grafana/GrafanaDashboardBuilderTest.java @@ -0,0 +1,314 @@ +package io.quarkus.devui.runtime.observability.metrics.grafana; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; + +import org.eclipse.microprofile.config.Config; +import org.junit.jupiter.api.Test; + +import io.smallrye.config.PropertiesConfigSource; +import io.smallrye.config.SmallRyeConfigBuilder; +import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; + +public class GrafanaDashboardBuilderTest { + + private static final GrafanaDashboardBuilder OTLP = new GrafanaDashboardBuilder(PrometheusNaming.OTLP, "orders"); + private static final GrafanaDashboardBuilder MICROMETER = new GrafanaDashboardBuilder( + PrometheusNaming.MICROMETER_PROMETHEUS, "orders"); + + private static Map metric(String name, String plot, String unit) { + return Map.of("kind", "metric", "name", name, "plot", plot, "unit", unit); + } + + private static JsonObject panel(JsonObject dashboard, int index) { + return dashboard.getJsonArray("panels").getJsonObject(index); + } + + private static String expr(JsonObject panel, int target) { + return panel.getJsonArray("targets").getJsonObject(target).getString("expr"); + } + + private static String unit(JsonObject panel) { + return panel.getJsonObject("fieldConfig").getJsonObject("defaults").getString("unit"); + } + + private static Config config(Map properties) { + return new SmallRyeConfigBuilder() + .withSources(new PropertiesConfigSource(properties, "test", 100)) + .build(); + } + + @Test + public void aCounterCardBecomesARatePanel() { + JsonObject dashboard = MICROMETER.build(List.of(metric("probe.orders", "rate", "")), "Orders"); + + JsonObject panel = panel(dashboard, 0); + assertThat(dashboard.getString("title")).isEqualTo("Orders"); + assertThat(panel.getString("type")).isEqualTo("timeseries"); + assertThat(panel.getString("title")).isEqualTo("probe.orders"); + assertThat(expr(panel, 0)).isEqualTo("rate(probe_orders_total[$__rate_interval])"); + } + + @Test + public void aGaugeCardBecomesAGaugePanelInItsOwnUnit() { + JsonObject dashboard = OTLP.build(List.of(metric("jvm.memory.used", "gauge", "By")), null); + + JsonObject panel = panel(dashboard, 0); + assertThat(panel.getString("type")).isEqualTo("gauge"); + assertThat(expr(panel, 0)).isEqualTo("jvm_memory_used_bytes"); + assertThat(panel.getJsonObject("fieldConfig").getJsonObject("defaults").getString("unit")) + .isEqualTo("bytes"); + } + + @Test + public void aSteppedCardKeepsItsStepsInGrafana() { + JsonObject panel = panel(MICROMETER.build(List.of(metric("probe.long.task", "step", "tasks")), null), 0); + + assertThat(panel.getJsonObject("fieldConfig").getJsonObject("defaults").getJsonObject("custom") + .getString("lineInterpolation")).isEqualTo("stepAfter"); + } + + @Test + public void aLongTaskTimerQueriesItsActiveCount() { + Map card = Map.of("kind", "metric", "name", "probe.long.task", "plot", "step", + "unit", "tasks", "type", "LONG_TASK_TIMER"); + + assertThat(expr(panel(MICROMETER.build(List.of(card), null), 0), 0)) + .isEqualTo("probe_long_task_seconds_active_count"); + } + + @Test + public void aTimerIsQueriedBySecondsAlthoughItIsCapturedAsS() { + Map card = Map.of("kind", "metric", "name", "probe.work", "plot", "distribution", + "unit", "s", "type", "TIMER", "maxCaptured", true); + + JsonObject panel = panel(MICROMETER.build(List.of(card), null), 0); + assertThat(expr(panel, 0)) + .isEqualTo("rate(probe_work_seconds_sum[$__rate_interval]) / rate(probe_work_seconds_count[$__rate_interval])"); + assertThat(expr(panel, 1)).isEqualTo("probe_work_seconds_max"); + } + + @Test + public void aDistributionCardDrawsTheMeanOfEachIntervalAndTheMaximum() { + Map card = Map.of("kind", "metric", "name", "probe.work", "plot", "distribution", + "unit", "seconds", "maxCaptured", true); + JsonObject panel = panel(MICROMETER.build(List.of(card), null), 0); + + assertThat(expr(panel, 0)) + .isEqualTo("rate(probe_work_seconds_sum[$__rate_interval]) / rate(probe_work_seconds_count[$__rate_interval])"); + assertThat(panel.getJsonArray("targets").getJsonObject(0).getString("legendFormat")).isEqualTo("mean"); + assertThat(expr(panel, 1)).isEqualTo("probe_work_seconds_max"); + } + + @Test + public void aDistributionWithoutACapturedMaximumDrawsOnlyTheMean() { + // A function timer tracks totals only: Micrometer publishes no _max series for it. + JsonObject panel = panel(MICROMETER.build(List.of(metric("probe.function.timer", "distribution", "s")), + null), 0); + + assertThat(panel.getJsonArray("targets")).hasSize(1); + } + + @Test + public void aDistributionOverOtlpOnlyDrawsTheMaximumWhenItsMeterIsKnown() { + // A native OTel histogram records a maximum, so the page reports one captured, but Prometheus holds + // no series of it: only a Micrometer timer bridged to OpenTelemetry publishes a .max meter. + Map nativeHistogram = Map.of("kind", "metric", "name", "probe.native.duration", + "plot", "distribution", "unit", "s", "type", "HISTOGRAM", "maxCaptured", true, "maxMeter", false); + assertThat(panel(OTLP.build(List.of(nativeHistogram), null), 0).getJsonArray("targets")).hasSize(1); + + Map bridgedTimer = Map.of("kind", "metric", "name", "http.server.requests", + "plot", "distribution", "unit", "ms", "type", "HISTOGRAM", "maxCaptured", true, "maxMeter", true); + JsonObject panel = panel(OTLP.build(List.of(bridgedTimer), null), 0); + assertThat(panel.getJsonArray("targets")).hasSize(2); + assertThat(expr(panel, 1)).isEqualTo("http_server_requests_max_milliseconds"); + } + + @Test + public void micrometerDrawsTheMaximumItCapturedWhateverMetersShareItsName() { + // A meter named .max says nothing about Micrometer's own _max series. + Map functionTimer = Map.of("kind", "metric", "name", "probe.function.timer", + "plot", "distribution", "unit", "s", "type", "TIMER", "maxCaptured", false, "maxMeter", true); + + assertThat(panel(MICROMETER.build(List.of(functionTimer), null), 0).getJsonArray("targets")).hasSize(1); + } + + @Test + public void onlyAnOtlpGaugeWithUnitOneIsARatio() { + // jvm.cpu.limit is a non-monotonic sum: drawn as a current value, but not a gauge to Prometheus. + Map nonMonotonicSum = Map.of("kind", "metric", "name", "jvm.cpu.limit", "plot", "value", + "unit", "1", "type", "DOUBLE_SUM"); + JsonObject limit = panel(OTLP.build(List.of(nonMonotonicSum), null), 0); + assertThat(expr(limit, 0)).isEqualTo("jvm_cpu_limit"); + // A count of processors, not a fraction. + assertThat(unit(limit)).isNull(); + + Map gauge = Map.of("kind", "metric", "name", "jvm.cpu.recent_utilization", + "plot", "gauge", "unit", "1", "type", "DOUBLE_GAUGE"); + JsonObject panel = panel(OTLP.build(List.of(gauge), null), 0); + assertThat(expr(panel, 0)).isEqualTo("jvm_cpu_recent_utilization_ratio"); + assertThat(unit(panel)).isEqualTo("percentunit"); + } + + @Test + public void aRatioMeterIsQueriedByItsOwnNameAndShownAsAPercentage() { + // Micrometer gives system.cpu.usage no unit; the page only calls it a ratio to format it. + Map card = Map.of("kind", "metric", "name", "system.cpu.usage", "plot", "gauge", + "unit", "", "type", "GAUGE", "ratio", true); + + JsonObject panel = panel(MICROMETER.build(List.of(card), null), 0); + assertThat(expr(panel, 0)).isEqualTo("system_cpu_usage"); + assertThat(unit(panel)).isEqualTo("percentunit"); + } + + @Test + public void aRateIsShownPerSecond() { + assertThat(unit(panel(MICROMETER.build(List.of(metric("probe.payload.received", "rate", "bytes")), null), 0))) + .isEqualTo("binBps"); + assertThat(unit(panel(OTLP.build(List.of(metric("probe.native.received", "rate", "By")), null), 0))) + .isEqualTo("binBps"); + // Seconds counted per second is the fraction of the time spent, e.g. CPU time. + assertThat(unit(panel(MICROMETER.build(List.of(metric("process.cpu.time", "rate", "seconds")), null), 0))) + .isEqualTo("percentunit"); + assertThat(unit(panel(MICROMETER.build(List.of(metric("probe.orders", "rate", "")), null), 0))) + .isEqualTo("cps"); + assertThat(unit(panel(OTLP.build(List.of(metric("probe.native.orders", "rate", "{order}")), null), 0))) + .isEqualTo("cps"); + assertThat(unit(panel(MICROMETER.build(List.of(metric("probe.items", "rate", "items")), null), 0))) + .isEqualTo("suffix: items/s"); + } + + @Test + public void withoutAKnownNamingOnlyTheSignalCardsAreExported() { + JsonArray panels = new GrafanaDashboardBuilder(PrometheusNaming.fromId(null), "orders").build(List.of( + metric("jvm.memory.used", "gauge", "By"), + Map.of("kind", "signal", "id", "traces", "title", "Traces")), null).getJsonArray("panels"); + + assertThat(panels).hasSize(1); + assertThat(panels.getJsonObject(0).getString("title")).isEqualTo("Traces"); + } + + @Test + public void theTracesQueryEscapesTheServiceName() { + JsonObject panel = panel(new GrafanaDashboardBuilder(PrometheusNaming.OTLP, "say \"hi\" \\o/") + .build(List.of(Map.of("kind", "signal", "id", "traces")), null), 0); + + assertThat(panel.getJsonArray("targets").getJsonObject(0).getString("query")) + .isEqualTo("{ resource.service.name = \"say \\\"hi\\\" \\\\o/\" } | select(name)"); + } + + @Test + public void theServiceNameIsResolvedAsOpenTelemetryResolvesIt() { + assertThat(GrafanaDashboardBuilder.serviceName(config(Map.of("quarkus.application.name", "orders")))) + .isEqualTo("orders"); + // quarkus.otel.service.name defaults to the application name, which does not override the attributes. + assertThat(GrafanaDashboardBuilder.serviceName(config(Map.of( + "quarkus.application.name", "orders", + "quarkus.otel.service.name", "orders", + "quarkus.otel.resource.attributes", "deployment.environment=dev,service.name=orders-svc")))) + .isEqualTo("orders-svc"); + assertThat(GrafanaDashboardBuilder.serviceName(config(Map.of( + "quarkus.application.name", "orders", + "quarkus.otel.service.name", "orders-api", + "quarkus.otel.resource.attributes", "service.name=orders-svc")))) + .isEqualTo("orders-api"); + } + + @Test + public void aHistogramCardBecomesAHeatmapOverTheBuckets() { + JsonObject panel = panel(MICROMETER.build(List.of(metric("probe.work.histogram", "histogram", "seconds")), + null), 0); + + assertThat(panel.getString("type")).isEqualTo("heatmap"); + assertThat(expr(panel, 0)) + .isEqualTo("sum by (le) (increase(probe_work_histogram_seconds_bucket[$__rate_interval]))"); + } + + @Test + public void aTracesCardBecomesATempoPanelForThisApplication() { + JsonObject dashboard = OTLP.build(List.of(Map.of("kind", "signal", "id", "traces", "title", "Traces")), null); + + JsonObject panel = panel(dashboard, 0); + assertThat(panel.getJsonObject("datasource").getString("type")).isEqualTo("tempo"); + assertThat(panel.getJsonArray("targets").getJsonObject(0).getString("query")) + .isEqualTo("{ resource.service.name = \"orders\" } | select(name)"); + // A row per trace, opening into its spans, as the Dev UI traces page lists them, and every span of the + // trace under it: Tempo holds three back by default. + assertThat(panel.getJsonArray("targets").getJsonObject(0).getString("tableType")).isEqualTo("traces"); + assertThat(panel.getJsonArray("targets").getJsonObject(0).getInteger("spss")).isEqualTo(100); + // Without a data source on the query itself the panel queries Prometheus and stays empty. + assertThat(panel.getJsonArray("targets").getJsonObject(0).getJsonObject("datasource").getString("type")) + .isEqualTo("tempo"); + // Grafana asks which data sources to use on import, so the dashboard is not tied to one instance. + assertThat(dashboard.getJsonArray("__inputs").stream().map(JsonObject.class::cast) + .map(input -> input.getString("name"))).containsExactly("DS_PROMETHEUS", "DS_TEMPO"); + } + + @Test + public void tempoIsOnlyAskedForWhenATracesCardIsExported() { + JsonObject dashboard = OTLP.build(List.of(metric("jvm.memory.used", "gauge", "By")), null); + + assertThat(dashboard.getJsonArray("__inputs")).hasSize(1); + } + + @Test + public void cardsKeepTheirOrderAndAreLaidOutTwoToARow() { + JsonArray panels = OTLP.build(List.of( + metric("first", "value", ""), + metric("second", "value", ""), + metric("third", "value", "")), null).getJsonArray("panels"); + + assertThat(panels.stream().map(JsonObject.class::cast).map(p -> p.getString("title"))) + .containsExactly("first", "second", "third"); + assertThat(panels.getJsonObject(0).getJsonObject("gridPos").getInteger("x")).isZero(); + assertThat(panels.getJsonObject(1).getJsonObject("gridPos").getInteger("x")).isEqualTo(12); + assertThat(panels.getJsonObject(2).getJsonObject("gridPos").getInteger("y")).isEqualTo(8); + } + + @Test + public void aSignalCardTakesAWholeRow() { + JsonArray panels = OTLP.build(List.of( + metric("first", "value", ""), + Map.of("kind", "signal", "id", "traces", "title", "Traces"), + metric("second", "value", "")), null).getJsonArray("panels"); + + assertThat(panels.getJsonObject(1).getJsonObject("gridPos").getInteger("w")).isEqualTo(24); + assertThat(panels.getJsonObject(1).getJsonObject("gridPos").getInteger("x")).isZero(); + assertThat(panels.getJsonObject(2).getJsonObject("gridPos").getInteger("x")).isZero(); + // The row a card starts below is as tall as that row was, not as tall as the card itself. + assertThat(panels.getJsonObject(1).getJsonObject("gridPos").getInteger("y")).isEqualTo(8); + assertThat(panels.getJsonObject(2).getJsonObject("gridPos").getInteger("y")).isEqualTo(18); + } + + @Test + public void aCardWithNothingToDrawLeavesNoHoleInTheLayout() { + JsonArray panels = OTLP.build(List.of( + Map.of("kind", "signal", "id", "something-else"), + metric("first", "value", ""), + metric("second", "value", "")), null).getJsonArray("panels"); + + assertThat(panels).hasSize(2); + assertThat(panels.getJsonObject(0).getJsonObject("gridPos").getInteger("y")).isZero(); + assertThat(panels.getJsonObject(0).getJsonObject("gridPos").getInteger("x")).isZero(); + assertThat(panels.getJsonObject(1).getJsonObject("gridPos").getInteger("x")).isEqualTo(12); + } + + @Test + public void aSignalWithNoGrafanaEquivalentIsLeftOut() { + JsonObject dashboard = OTLP.build(List.of(Map.of("kind", "signal", "id", "something-else")), null); + + assertThat(dashboard.getJsonArray("panels")).isEmpty(); + } + + @Test + public void anEmptyDashboardStillCarriesItsTitleAndDescription() { + JsonObject dashboard = OTLP.build(List.of(), null); + + assertThat(dashboard.getString("title")).isEqualTo("Quarkus Dev UI observability"); + assertThat(dashboard.getString("description")).contains("orders"); + assertThat(dashboard.getJsonArray("panels")).isEmpty(); + } +} diff --git a/extensions/devui/runtime/src/test/java/io/quarkus/devui/runtime/observability/metrics/grafana/PrometheusNamingTest.java b/extensions/devui/runtime/src/test/java/io/quarkus/devui/runtime/observability/metrics/grafana/PrometheusNamingTest.java new file mode 100644 index 0000000000000..b9f987a19fed8 --- /dev/null +++ b/extensions/devui/runtime/src/test/java/io/quarkus/devui/runtime/observability/metrics/grafana/PrometheusNamingTest.java @@ -0,0 +1,138 @@ +package io.quarkus.devui.runtime.observability.metrics.grafana; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +import io.quarkus.devui.observability.store.metrics.PrometheusNamingId; + +/** + * The expected names here were not derived from the specifications: they were read out of a running + * Prometheus. A probe application registered a meter of every shape and was run three ways against the LGTM + * Dev Service image (Micrometer with the Prometheus registry scraped, Micrometer bridged to OpenTelemetry, + * and OpenTelemetry on its own), and the names Prometheus ended up holding are the ones asserted below. + *

    + * The units passed in are the ones the Dev UI captures, which are not always the ones the meter + * declares: {@code DevUiMetricsSampler} records a duration as {@code s} and a long task timer as + * {@code tasks}, while Prometheus writes {@code seconds} into the name in both cases. + */ +public class PrometheusNamingTest { + + @Test + public void micrometerAppendsTheBaseUnitAndTotal() { + PrometheusNaming naming = PrometheusNaming.MICROMETER_PROMETHEUS; + + assertThat(naming.baseName("jvm.memory.used", "bytes", true)).isEqualTo("jvm_memory_used_bytes"); + assertThat(naming.baseName("jvm.threads.live", "threads", true)).isEqualTo("jvm_threads_live_threads"); + assertThat(naming.baseName("probe.queue.size", "items", true)).isEqualTo("probe_queue_size_items"); + assertThat(naming.counterName("probe.orders", null)).isEqualTo("probe_orders_total"); + assertThat(naming.counterName("probe.payload.received", "bytes")) + .isEqualTo("probe_payload_received_bytes_total"); + // "s" as the Dev UI captures a duration, "seconds" as Prometheus spells it. + assertThat(naming.baseName("probe.work", "s", false)).isEqualTo("probe_work_seconds"); + assertThat(naming.baseName("probe.work", "seconds", false)).isEqualTo("probe_work_seconds"); + assertThat(naming.baseName("probe.response.size", "bytes", false)).isEqualTo("probe_response_size_bytes"); + } + + @Test + public void micrometerLeavesANameThatAlreadyEndsInItsUnitAlone() { + PrometheusNaming naming = PrometheusNaming.MICROMETER_PROMETHEUS; + + assertThat(naming.baseName("jvm.memory.used.bytes", "bytes", true)).isEqualTo("jvm_memory_used_bytes"); + assertThat(naming.counterName("worker.pool.completed.total", null)).isEqualTo("worker_pool_completed_total"); + } + + @Test + public void micrometerHasAMaxSeriesOfItsOwn() { + assertThat(PrometheusNaming.MICROMETER_PROMETHEUS.maxName("probe.work", "s", true, false)) + .isEqualTo("probe_work_seconds_max"); + // A function timer tracks totals only, so there is no maximum to draw. + assertThat(PrometheusNaming.MICROMETER_PROMETHEUS.maxName("probe.function.timer", "s", false, false)).isNull(); + } + + @Test + public void aLongTaskTimerIsNamedAfterSecondsWhateverUnitWasCaptured() { + // Captured as "tasks" (the active task count), published as probe_long_task_seconds_active_count. + assertThat(PrometheusNaming.MICROMETER_PROMETHEUS.longTaskActiveName("probe.long.task", "tasks")) + .isEqualTo("probe_long_task_seconds_active_count"); + // Over OTLP the meter arrives already split, so the active count is an ordinary meter. + assertThat(PrometheusNaming.OTLP.longTaskActiveName("probe.long.task.active", "{tasks}")) + .isEqualTo("probe_long_task_active"); + } + + @Test + public void otlpSpellsTheUnitOut() { + PrometheusNaming naming = PrometheusNaming.OTLP; + + assertThat(naming.baseName("jvm.memory.used", "By", true)).isEqualTo("jvm_memory_used_bytes"); + assertThat(naming.baseName("probe.native.duration", "s", false)).isEqualTo("probe_native_duration_seconds"); + assertThat(naming.baseName("http.server.requests", "ms", false)) + .isEqualTo("http_server_requests_milliseconds"); + assertThat(naming.baseName("probe.native.temperature", "Cel", true)) + .isEqualTo("probe_native_temperature_celsius"); + } + + @Test + public void otlpDoesNotRepeatAUnitAlreadyInTheName() { + PrometheusNaming naming = PrometheusNaming.OTLP; + + // The unit word is skipped wherever it appears in the name, not only at the end. + assertThat(naming.baseName("jvm.threads.live", "threads", true)).isEqualTo("jvm_threads_live"); + assertThat(naming.baseName("process.files.open", "files", true)).isEqualTo("process_files_open"); + assertThat(naming.counterName("jvm.classes.unloaded", "classes")).isEqualTo("jvm_classes_unloaded_total"); + // ... but "buffer" is not "buffers", so this one does get the unit. + assertThat(naming.baseName("jvm.buffer.count", "buffers", true)).isEqualTo("jvm_buffer_count_buffers"); + } + + @Test + public void otlpDropsAnnotationsAndSpellsOutRates() { + PrometheusNaming naming = PrometheusNaming.OTLP; + + assertThat(naming.counterName("probe.native.orders", "{order}")).isEqualTo("probe_native_orders_total"); + assertThat(naming.baseName("probe.native.active", "{task}", false)).isEqualTo("probe_native_active"); + assertThat(naming.counterName("probe.native.requests.rate", "{request}/s")) + .isEqualTo("probe_native_requests_rate_per_second_total"); + } + + @Test + public void otlpAddsRatioOnlyForGauges() { + PrometheusNaming naming = PrometheusNaming.OTLP; + + assertThat(naming.baseName("jvm.cpu.recent_utilization", "1", true)) + .isEqualTo("jvm_cpu_recent_utilization_ratio"); + // "ratio" is a unit word like any other, so a name that already says it is left alone. + assertThat(naming.baseName("probe.native.ratio", "1", true)).isEqualTo("probe_native_ratio"); + // jvm.cpu.limit is a non-monotonic sum, which Prometheus leaves without the ratio suffix. + assertThat(naming.baseName("jvm.cpu.limit", "1", false)).isEqualTo("jvm_cpu_limit"); + } + + @Test + public void otlpTakesTheMaximumFromACompanionMeter() { + PrometheusNaming naming = PrometheusNaming.OTLP; + + assertThat(naming.maxName("http.server.requests", "ms", false, true)) + .isEqualTo("http_server_requests_max_milliseconds"); + // A native OTel histogram records a maximum, but only a bridged timer publishes a .max meter for it. + assertThat(naming.maxName("probe.native.duration", "s", true, false)).isNull(); + } + + @Test + public void unknownUnitsAndCharactersAreLeftAsPrometheusLeavesThem() { + assertThat(PrometheusNaming.OTLP.baseName("weird name/with:chars", "widgets", true)) + .isEqualTo("weird_name_with:chars_widgets"); + } + + @Test + public void theNamingIsChosenByItsBuildTimeId() { + assertThat(PrometheusNaming.fromId(PrometheusNamingId.OTLP)).isEqualTo(PrometheusNaming.OTLP); + assertThat(PrometheusNaming.fromId(PrometheusNamingId.MICROMETER_PROMETHEUS)) + .isEqualTo(PrometheusNaming.MICROMETER_PROMETHEUS); + } + + @Test + public void anUnknownOrMissingIdIsNoNamingAtAll() { + // Not a silent fallback to Micrometer: names worked out for the wrong route query nothing. + assertThat(PrometheusNaming.fromId(null)).isNull(); + assertThat(PrometheusNaming.fromId("statsd")).isNull(); + } +} diff --git a/extensions/micrometer/deployment/src/main/java/io/quarkus/micrometer/deployment/devui/MicrometerMetricsDevUIProcessor.java b/extensions/micrometer/deployment/src/main/java/io/quarkus/micrometer/deployment/devui/MicrometerMetricsDevUIProcessor.java index e1b8c734287eb..f765314176404 100644 --- a/extensions/micrometer/deployment/src/main/java/io/quarkus/micrometer/deployment/devui/MicrometerMetricsDevUIProcessor.java +++ b/extensions/micrometer/deployment/src/main/java/io/quarkus/micrometer/deployment/devui/MicrometerMetricsDevUIProcessor.java @@ -8,8 +8,11 @@ import io.quarkus.deployment.IsLocalDevelopment; import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.devui.observability.store.metrics.PrometheusNamingId; import io.quarkus.devui.spi.observability.MetricsBackendBuildItem; import io.quarkus.micrometer.deployment.MicrometerProcessor; +import io.quarkus.micrometer.deployment.export.PrometheusRegistryProcessor; +import io.quarkus.micrometer.runtime.config.MicrometerConfig; import io.quarkus.micrometer.runtime.devui.DevUiMetricsSampler; /** @@ -26,7 +29,8 @@ public class MicrometerMetricsDevUIProcessor { private static final String BRIDGE_RECORDER = "io.quarkus.micrometer.opentelemetry.runtime.MicrometerOtelBridgeRecorder"; @BuildStep(onlyIf = { IsLocalDevelopment.class, MicrometerProcessor.MicrometerEnabled.class }) - void registerMicrometerMetricsCapture(BuildProducer additionalBeans, + void registerMicrometerMetricsCapture(MicrometerConfig micrometerConfig, + BuildProducer additionalBeans, BuildProducer backends) { // The Dev UI metrics view is active whenever a metrics backend and the Dev UI are present // in dev mode — there is no separate build-time enable flag. It is dev-only via @@ -49,6 +53,11 @@ void registerMicrometerMetricsCapture(BuildProducer add .setDefaultScope(DotNames.SINGLETON) .setUnremovable() .build()); - backends.produce(new MetricsBackendBuildItem("micrometer")); + // Only the Prometheus registry gives an export whose naming is known here; with any other registry + // the meters may reach Prometheus under different names, so no Grafana dashboard is offered. The + // check is the registry's own, so that registry-enabled-default is honoured as it is there. + boolean prometheusRegistry = PrometheusRegistryProcessor.PrometheusEnabled.isEnabled(micrometerConfig); + backends.produce(new MetricsBackendBuildItem("micrometer", + prometheusRegistry ? PrometheusNamingId.MICROMETER_PROMETHEUS : null)); } } diff --git a/extensions/micrometer/deployment/src/main/java/io/quarkus/micrometer/deployment/export/PrometheusRegistryProcessor.java b/extensions/micrometer/deployment/src/main/java/io/quarkus/micrometer/deployment/export/PrometheusRegistryProcessor.java index c761c9f857246..ba38d82bd9a55 100644 --- a/extensions/micrometer/deployment/src/main/java/io/quarkus/micrometer/deployment/export/PrometheusRegistryProcessor.java +++ b/extensions/micrometer/deployment/src/main/java/io/quarkus/micrometer/deployment/export/PrometheusRegistryProcessor.java @@ -42,6 +42,11 @@ public static class PrometheusEnabled implements BooleanSupplier { MicrometerConfig mConfig; public boolean getAsBoolean() { + return isEnabled(mConfig); + } + + /** The same check, for a build step that needs to know without being skipped. */ + public static boolean isEnabled(MicrometerConfig mConfig) { return (REGISTRY_CLASS != null) && QuarkusClassLoader.isClassPresentAtRuntime(REGISTRY_CLASS_NAME) && mConfig.checkRegistryEnabledWithDefault(mConfig.export().prometheus()); } diff --git a/extensions/micrometer/runtime-dev/src/main/java/io/quarkus/micrometer/runtime/devui/DevUiMetricsSampler.java b/extensions/micrometer/runtime-dev/src/main/java/io/quarkus/micrometer/runtime/devui/DevUiMetricsSampler.java index 0298cea3430db..644c9e10ea6a1 100644 --- a/extensions/micrometer/runtime-dev/src/main/java/io/quarkus/micrometer/runtime/devui/DevUiMetricsSampler.java +++ b/extensions/micrometer/runtime-dev/src/main/java/io/quarkus/micrometer/runtime/devui/DevUiMetricsSampler.java @@ -1,6 +1,7 @@ package io.quarkus.micrometer.runtime.devui; import java.util.LinkedHashMap; +import java.util.Locale; import java.util.Map; import java.util.concurrent.TimeUnit; @@ -97,8 +98,10 @@ SECONDS_UNIT, timerDistribution(timer.takeSnapshot())), summaryDistribution(summary.takeSnapshot())), longTaskTimer -> new MetricSample(name, tags, "LONG_TASK_TIMER", false, longTaskTimer.activeTasks(), now, "micrometer", "tasks", null), + // value() is in the gauge's base time unit, and the id carries no unit of its own; name it + // so that the reading, and the exported Prometheus name, both say which unit it is in. timeGauge -> new MetricSample(name, tags, "GAUGE", false, timeGauge.value(), now, "micrometer", - unit, null), + unit == null ? timeGauge.baseTimeUnit().name().toLowerCase(Locale.ROOT) : unit, null), functionCounter -> new MetricSample(name, tags, "COUNTER", true, functionCounter.count(), now, "micrometer", unit, null), // A FunctionTimer tracks totals only: no max, no percentiles, no histogram. diff --git a/extensions/opentelemetry/deployment/src/main/java/io/quarkus/opentelemetry/deployment/devui/OpenTelemetryMetricsDevUIProcessor.java b/extensions/opentelemetry/deployment/src/main/java/io/quarkus/opentelemetry/deployment/devui/OpenTelemetryMetricsDevUIProcessor.java index 41f1464c3a962..0602b610ab32e 100644 --- a/extensions/opentelemetry/deployment/src/main/java/io/quarkus/opentelemetry/deployment/devui/OpenTelemetryMetricsDevUIProcessor.java +++ b/extensions/opentelemetry/deployment/src/main/java/io/quarkus/opentelemetry/deployment/devui/OpenTelemetryMetricsDevUIProcessor.java @@ -1,14 +1,21 @@ package io.quarkus.opentelemetry.deployment.devui; +import static io.quarkus.opentelemetry.runtime.config.build.ExporterType.Constants.CDI_VALUE; +import static io.quarkus.opentelemetry.runtime.config.build.ExporterType.Constants.OTLP_VALUE; + +import java.util.List; + import io.quarkus.arc.deployment.AdditionalBeanBuildItem; import io.quarkus.arc.processor.DotNames; import io.quarkus.deployment.IsLocalDevelopment; import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; import io.quarkus.deployment.annotations.BuildSteps; +import io.quarkus.devui.observability.store.metrics.PrometheusNamingId; import io.quarkus.devui.spi.observability.MetricsBackendBuildItem; import io.quarkus.opentelemetry.deployment.OpenTelemetryEnabled; import io.quarkus.opentelemetry.runtime.config.build.OTelBuildConfig; +import io.quarkus.opentelemetry.runtime.config.build.exporter.OtlpExporterBuildConfig; import io.quarkus.opentelemetry.runtime.devui.DevUiMetricsSdkBuilderCustomizer; /** @@ -23,6 +30,7 @@ public class OpenTelemetryMetricsDevUIProcessor { @BuildStep void registerOtelMetricsCapture(OTelBuildConfig oTelBuildConfig, + OtlpExporterBuildConfig otlpExporterBuildConfig, BuildProducer additionalBeans, BuildProducer backends) { if (!oTelBuildConfig.metrics().enabled().orElse(Boolean.TRUE)) { @@ -33,6 +41,20 @@ void registerOtelMetricsCapture(OTelBuildConfig oTelBuildConfig, .setDefaultScope(DotNames.SINGLETON) .setUnremovable() .build()); - backends.produce(new MetricsBackendBuildItem("otel")); + // Captured from the SDK. Only metrics exported over OTLP are named the way Prometheus renames OTLP on + // receipt; with no exporter, or another one, how they reach Prometheus (if at all) is not known here. + backends.produce(new MetricsBackendBuildItem("otel", + exportedOverOtlp(oTelBuildConfig.metrics().exporter(), otlpExporterBuildConfig) + ? PrometheusNamingId.OTLP + : null)); + } + + /** + * Whether the metrics leave over OTLP: through the Quarkus managed exporter ({@code cdi}, the default, as + * long as it is not disabled), or through the upstream OTLP exporter ({@code otlp}). + */ + private static boolean exportedOverOtlp(List exporters, OtlpExporterBuildConfig otlpExporterBuildConfig) { + return exporters.contains(OTLP_VALUE) + || (exporters.contains(CDI_VALUE) && otlpExporterBuildConfig.enabled()); } } diff --git a/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/devui/GrafanaDashboardExportDevUITest.java b/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/devui/GrafanaDashboardExportDevUITest.java new file mode 100644 index 0000000000000..5ddb19e733809 --- /dev/null +++ b/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/devui/GrafanaDashboardExportDevUITest.java @@ -0,0 +1,72 @@ +package io.quarkus.opentelemetry.deployment.devui; + +import static io.opentelemetry.semconv.ServiceAttributes.SERVICE_NAME; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; + +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.devui.observability.store.metrics.PrometheusNamingId; +import io.quarkus.devui.tests.DevUIJsonRPCTest; +import io.quarkus.test.QuarkusDevModeTest; +import tools.jackson.databind.JsonNode; + +/** + * The Grafana export over JSON-RPC, for an application whose metrics leave over OTLP: the queries have to + * use the names Prometheus gives them on receipt, not the meter names the dashboard shows. + */ +public class GrafanaDashboardExportDevUITest extends DevUIJsonRPCTest { + + @RegisterExtension + static final QuarkusDevModeTest test = new QuarkusDevModeTest() + .withApplicationRoot((JavaArchive jar) -> jar + .addClasses(CustomMetricResource.class) + .addAsResource(new StringAsset( + "quarkus.application.name=orders\n" + + "quarkus.otel.metrics.enabled=true\n" + + "quarkus.otel.traces.enabled=false\n" + + "quarkus.devservices.enabled=false\n"), + "application.properties")); + + public GrafanaDashboardExportDevUITest() { + super("devui-observability"); + } + + @Test + public void exportsTheCardsAsAGrafanaDashboard() throws Exception { + JsonNode dashboard = super.executeJsonRPCMethod("exportGrafanaDashboard", Map.of( + "cards", List.of( + Map.of("kind", "metric", "name", "custom.otel.hits", "plot", "rate", "unit", "{hit}"), + Map.of("kind", "signal", "id", "traces", "title", "Traces")), + "naming", PrometheusNamingId.OTLP, + "title", "Orders")); + + assertThat(dashboard).isNotNull(); + assertThat(dashboard.get("title").asText()).isEqualTo("Orders"); + + JsonNode panels = dashboard.get("panels"); + assertThat(panels.size()).isEqualTo(2); + assertThat(panels.get(0).get("targets").get(0).get("expr").asText()) + .isEqualTo("rate(custom_otel_hits_total[$__rate_interval])"); + assertThat(panels.get(1).get("datasource").get("type").asText()).isEqualTo("tempo"); + assertThat(panels.get(1).get("targets").get(0).get("datasource").get("type").asText()).isEqualTo("tempo"); + // The application name, not the artifact name, is what the traces carry as their service name. The key + // comes from semconv: the Dev UI has no OpenTelemetry dependency and spells it out, so this keeps it in step. + assertThat(panels.get(1).get("targets").get(0).get("query").asText()) + .isEqualTo("{ resource." + SERVICE_NAME.getKey() + " = \"orders\" } | select(name)"); + } + + @Test + public void exportsAnEmptyDashboardWhenNoCardsAreSent() throws Exception { + JsonNode dashboard = super.executeJsonRPCMethod("exportGrafanaDashboard", Map.of( + "cards", List.of(), "naming", PrometheusNamingId.OTLP, "title", "")); + + assertThat(dashboard.get("panels").isEmpty()).isTrue(); + assertThat(dashboard.get("title").asText()).isEqualTo("Quarkus Dev UI observability"); + } +} diff --git a/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/devui/ObservabilitySectionDevUITest.java b/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/devui/ObservabilitySectionDevUITest.java index 57681377672df..0614c92524a38 100644 --- a/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/devui/ObservabilitySectionDevUITest.java +++ b/extensions/opentelemetry/deployment/src/test/java/io/quarkus/opentelemetry/deployment/devui/ObservabilitySectionDevUITest.java @@ -50,4 +50,11 @@ public void contributesTracesSignalToObservabilitySection() throws Exception { .as("OpenTelemetry should contribute a 'traces' signal to the Observability section") .isTrue(); } + + @Test + public void offersNoMetricNamingWhenMetricsAreNotExportedOverOtlp() throws Exception { + // quarkus.otel.metrics.exporter=none: the metrics never reach Prometheus, so there are no names to query. + JsonNode naming = super.getBuildTimeData("prometheusNaming"); + assertThat(naming == null || naming.isNull()).isTrue(); + } } From 037dd8a72331b043ec250112e407dcf72ca5fd8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20=C3=89pardaud?= Date: Fri, 16 Oct 2026 16:00:27 +0200 Subject: [PATCH 248/254] Update docs/src/main/asciidoc/writing-extensions.adoc --- docs/src/main/asciidoc/writing-extensions.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/main/asciidoc/writing-extensions.adoc b/docs/src/main/asciidoc/writing-extensions.adoc index 5155f2d3fe590..25f60c20833fd 100644 --- a/docs/src/main/asciidoc/writing-extensions.adoc +++ b/docs/src/main/asciidoc/writing-extensions.adoc @@ -2876,7 +2876,7 @@ An extension may provide multiple capabilities, but only a single provider of a By default, capabilities are not displayed to users. Capabilities should be used when checking for the presence of an extension rather than class path based checks. -The preferred way to declare the capabilities an extension provides (or requires) is in the extension descriptor, by configuring the `quarkus-extension-maven-plugin` in the runtime module's `pom.xml`: +The preferred way to declare the capabilities an extension provides (or requires) is by configuring the `quarkus-extension-maven-plugin` in the runtime module's `pom.xml`: [source,xml] ---- From f0533430fcea0206d868c744ffb9ced158fc2a31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Vav=C5=99=C3=ADk?= Date: Fri, 16 Oct 2026 11:23:15 +0200 Subject: [PATCH 249/254] fix(oidc-client): cache token requests based on additional params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closes: https://github.com/quarkusio/quarkus/issues/57231 Signed-off-by: Michal Vavřík --- ...urity-openid-connect-client-reference.adoc | 22 + .../KeycloakRealmUserPasswordManager.java | 7 + .../oidc/client/OidcClientResource.java | 16 + .../OidcClientUserPasswordTestCase.java | 65 ++ ...ation-oidc-client-user-password.properties | 11 +- extensions/oidc-client/runtime/pom.xml | 5 + .../oidc/client/OidcClientConfigBuilder.java | 18 + .../runtime/AbstractTokensProducer.java | 15 +- .../oidc/client/runtime/OidcClientConfig.java | 13 + .../oidc/client/runtime/TokensHelper.java | 133 ++- .../client/OidcClientConfigBuilderTest.java | 7 + .../runtime/InMemoryOidcTestClient.java | 206 +++++ .../oidc/client/runtime/TokensHelperTest.java | 781 ++++++++++++++++++ 13 files changed, 1277 insertions(+), 22 deletions(-) create mode 100644 extensions/oidc-client/runtime/src/test/java/io/quarkus/oidc/client/runtime/InMemoryOidcTestClient.java create mode 100644 extensions/oidc-client/runtime/src/test/java/io/quarkus/oidc/client/runtime/TokensHelperTest.java diff --git a/docs/src/main/asciidoc/security-openid-connect-client-reference.adoc b/docs/src/main/asciidoc/security-openid-connect-client-reference.adoc index 7f34354af0810..cfaa94e60c1bf 100644 --- a/docs/src/main/asciidoc/security-openid-connect-client-reference.adoc +++ b/docs/src/main/asciidoc/security-openid-connect-client-reference.adoc @@ -276,6 +276,9 @@ public class OidcClientResource { You can also inject a `TokenProvider` that is bound to a named OIDC client, see <>. +`TokenProvider` can also acquire the access token with additional grant parameters, for example, `tokenProvider.getAccessToken(Map.of("scope", "openid"))`. +The tokens acquired with different additional grant parameters are cached separately, see <>. + When a Quarkus extension must acquire OIDC tokens by using one of the token grants supported by the OIDC client, it can also use `TokenProvider` while depending only on the OIDC Client SPI: . Add the following dependency: @@ -1001,6 +1004,22 @@ Some OpenID Connect providers do not return a refresh token in a `client_credent For example, starting with Keycloak 12, a refresh token is not returned by default for `client_credentials`. Providers might also restrict how many times a refresh token can be used. +[[additional-grant-parameters-token-cache]] +=== Caching tokens acquired with additional grant parameters + +`OidcClientRequestReactiveFilter`, `OidcClientRequestFilter`, `TokenProvider`, and `Tokens` producers always cache the tokens they acquire with the configured grant. +Tokens acquired with additional grant parameters, for example, with `tokenProvider.getAccessToken(Map.of("scope", "openid"))` or by a custom filter, are cached per set of such parameters, because the parameters, for example, a requested `scope` or a subject token which has to be exchanged, can affect which tokens are issued. +By default, each filter and each token producer caches the tokens of up to 15 sets of additional grant parameters. +This limit is approximate, the cache can briefly exceed it when tokens are requested concurrently. +When the limit is reached, the tokens which are least likely to be needed again, for example, expired or least recently used tokens, are evicted. +The tokens acquired with the configured grant only, without additional grant parameters, are not counted toward this limit and are never evicted. +Use the `quarkus.oidc-client.token-cache-max-size` property to change the limit per producer, for example, when many different subject tokens are exchanged: + +[source,properties] +---- +quarkus.oidc-client.token-cache-max-size=30 +---- + [[revoke-access-tokens]] === Revoking access tokens @@ -1231,6 +1250,9 @@ public class OidcClientRequestCustomFilter extends AbstractOidcClientRequestFilt } ---- +TIP: To make the most of the <>, let Quarkus load the client assertion with the `quarkus.oidc-client.credentials.jwt.token-path` property instead of using a custom filter. +Quarkus then renews the client assertion without affecting the cached tokens. + ==== SPIFFE JWT-SVID link:https://spiffe.io[SPIFFE] (Secure Production Identity Framework for Everyone) JWT-SVID tokens can be used as client assertions for client authentication. diff --git a/extensions/oidc-client/deployment/src/test/java/io/quarkus/oidc/client/KeycloakRealmUserPasswordManager.java b/extensions/oidc-client/deployment/src/test/java/io/quarkus/oidc/client/KeycloakRealmUserPasswordManager.java index a2d79363f6c36..a5d4102078439 100644 --- a/extensions/oidc-client/deployment/src/test/java/io/quarkus/oidc/client/KeycloakRealmUserPasswordManager.java +++ b/extensions/oidc-client/deployment/src/test/java/io/quarkus/oidc/client/KeycloakRealmUserPasswordManager.java @@ -30,6 +30,7 @@ public Map start() { RealmRepresentation realm = createRealm(KEYCLOAK_REALM); realm.getClients().add(createClient("quarkus-app")); + realm.getClients().add(createClient("quarkus-app-long-lived-tokens", 60)); realm.getClients().add(createPublicClient("quarkus-public-app")); realm.getUsers().add(createUser("alice", "user")); realm.getUsers().add(createUser("bob", "user")); @@ -97,6 +98,12 @@ private static ClientRepresentation createClient(String clientId) { return client; } + private static ClientRepresentation createClient(String clientId, int accessTokenLifespanSeconds) { + ClientRepresentation client = createClient(clientId); + client.setAttributes(Map.of("access.token.lifespan", String.valueOf(accessTokenLifespanSeconds))); + return client; + } + private static ClientRepresentation createPublicClient(String clientId) { ClientRepresentation client = new ClientRepresentation(); diff --git a/extensions/oidc-client/deployment/src/test/java/io/quarkus/oidc/client/OidcClientResource.java b/extensions/oidc-client/deployment/src/test/java/io/quarkus/oidc/client/OidcClientResource.java index b94d2399d2d30..8db3dc50b65be 100644 --- a/extensions/oidc-client/deployment/src/test/java/io/quarkus/oidc/client/OidcClientResource.java +++ b/extensions/oidc-client/deployment/src/test/java/io/quarkus/oidc/client/OidcClientResource.java @@ -28,6 +28,10 @@ public class OidcClientResource { @NamedOidcClient("scopes-as-additional-params") TokenProvider scopesAsAdditionalParamsTokenProvider; + @Inject + @NamedOidcClient("single-token-cache-entry") + TokenProvider singleTokenCacheEntryTokenProvider; + @Inject @NamedOidcClient("key") OidcClient keyClient; @@ -56,6 +60,18 @@ public Uni tokenProviderWithParams() { return scopesAsAdditionalParamsTokenProvider.getAccessToken(Map.of("scope", "openid")); } + @GET + @Path("tokenprovider-with-scope") + public Uni tokenProviderWithScope(@QueryParam("scope") String scope) { + return scopesAsAdditionalParamsTokenProvider.getAccessToken(Map.of("scope", scope)); + } + + @GET + @Path("tokenprovider-single-token-cache-entry") + public Uni tokenProviderWithSingleTokenCacheEntry(@QueryParam("scope") String scope) { + return singleTokenCacheEntryTokenProvider.getAccessToken(Map.of("scope", scope)); + } + @GET @Path("public-tokenprovider") public Uni publicTokenProvider() { diff --git a/extensions/oidc-client/deployment/src/test/java/io/quarkus/oidc/client/OidcClientUserPasswordTestCase.java b/extensions/oidc-client/deployment/src/test/java/io/quarkus/oidc/client/OidcClientUserPasswordTestCase.java index 2b63467924768..bcdc8b23d6e3e 100644 --- a/extensions/oidc-client/deployment/src/test/java/io/quarkus/oidc/client/OidcClientUserPasswordTestCase.java +++ b/extensions/oidc-client/deployment/src/test/java/io/quarkus/oidc/client/OidcClientUserPasswordTestCase.java @@ -26,6 +26,7 @@ import io.quarkus.test.common.QuarkusTestResource; import io.restassured.RestAssured; import io.restassured.response.Response; +import io.vertx.core.json.JsonObject; @QuarkusTestResource(KeycloakRealmUserPasswordManager.class) public class OidcClientUserPasswordTestCase { @@ -79,6 +80,41 @@ public void testPasswordGrantTokenProviderWithAdditionalParams() { assertTrue(OidcCommonUtils.decodeJwtContent(token).getString("scope").contains("openid")); } + @Test + public void testPasswordGrantTokenProviderWithDifferentAdditionalParams() { + String path = "/client/tokenprovider-with-scope"; + String openidToken = getTokenWithScope(path, "openid"); + assertScope(openidToken, "openid", "phone"); + assertLongLived(openidToken); + + assertEquals(openidToken, getTokenWithScope(path, "openid")); + + String phoneToken = getTokenWithScope(path, "openid phone"); + assertScope(phoneToken, "phone", null); + assertNotEquals(openidToken, phoneToken); + + assertEquals(phoneToken, getTokenWithScope(path, "openid phone")); + + // the tokens acquired with the other scope are still cached + assertEquals(openidToken, getTokenWithScope(path, "openid")); + } + + @Test + public void testTokenCacheMaxSizeLimitsCachedAdditionalParams() { + String path = "/client/tokenprovider-single-token-cache-entry"; + String openidToken = getTokenWithScope(path, "openid"); + assertScope(openidToken, "openid", "phone"); + assertLongLived(openidToken); + assertEquals(openidToken, getTokenWithScope(path, "openid")); + + // the cache holds the tokens of a single set of additional parameters, the openid tokens are evicted + String phoneToken = getTokenWithScope(path, "openid phone"); + assertScope(phoneToken, "phone", null); + + String newOpenidToken = getTokenWithScope(path, "openid"); + assertScope(newOpenidToken, "openid", "phone"); + } + @Test public void testNamedPasswordGrantTokenProvider() { String token = RestAssured.when().get("/client/public-tokenprovider") @@ -149,6 +185,35 @@ public Boolean call() throws Exception { .body(equalTo("alice")); } + private static String getTokenWithScope(String path, String scope) { + String token = RestAssured.given().queryParam("scope", scope) + .when().get(path) + .then() + .statusCode(200) + .extract().body().asString(); + RestAssured.given().auth().oauth2(token) + .when().get("/protected") + .then() + .statusCode(200) + .body(equalTo("alice")); + return token; + } + + private static void assertLongLived(String token) { + JsonObject claims = OidcCommonUtils.decodeJwtContent(token); + assertTrue(claims.getLong("exp") - claims.getLong("iat") > 10, + "The test compares cached access tokens, the client must issue tokens which do not expire between the requests"); + } + + private static void assertScope(String token, String expectedScope, String unexpectedScope) { + String scope = OidcCommonUtils.decodeJwtContent(token).getString("scope"); + assertTrue(scope.contains(expectedScope), "Expected scope '" + expectedScope + "' but got '" + scope + "'"); + if (unexpectedScope != null) { + assertFalse(scope.contains(unexpectedScope), + "Unexpected scope '" + unexpectedScope + "' in '" + scope + "'"); + } + } + private static void assertTokensNotNull(String[] tokens) { assertEquals(2, tokens.length); assertNotNull(tokens[0]); diff --git a/extensions/oidc-client/deployment/src/test/resources/application-oidc-client-user-password.properties b/extensions/oidc-client/deployment/src/test/resources/application-oidc-client-user-password.properties index 5c95f85dd6ff8..ff485a8a2572c 100644 --- a/extensions/oidc-client/deployment/src/test/resources/application-oidc-client-user-password.properties +++ b/extensions/oidc-client/deployment/src/test/resources/application-oidc-client-user-password.properties @@ -17,11 +17,20 @@ quarkus.oidc-client.public.grant-options.password.username=alice quarkus.oidc-client.public.grant-options.password.password=alice quarkus.oidc-client.scopes-as-additional-params.auth-server-url=${quarkus.oidc.auth-server-url} -quarkus.oidc-client.scopes-as-additional-params.client-id=quarkus-app +quarkus.oidc-client.scopes-as-additional-params.client-id=quarkus-app-long-lived-tokens quarkus.oidc-client.scopes-as-additional-params.credentials.secret=secret quarkus.oidc-client.scopes-as-additional-params.grant.type=password quarkus.oidc-client.scopes-as-additional-params.grant-options.password.username=alice quarkus.oidc-client.scopes-as-additional-params.grant-options.password.password=alice quarkus.oidc-client.scopes-as-additional-params.early-tokens-acquisition=false +quarkus.oidc-client.single-token-cache-entry.auth-server-url=${quarkus.oidc.auth-server-url} +quarkus.oidc-client.single-token-cache-entry.client-id=quarkus-app-long-lived-tokens +quarkus.oidc-client.single-token-cache-entry.credentials.secret=secret +quarkus.oidc-client.single-token-cache-entry.grant.type=password +quarkus.oidc-client.single-token-cache-entry.grant-options.password.username=alice +quarkus.oidc-client.single-token-cache-entry.grant-options.password.password=alice +quarkus.oidc-client.single-token-cache-entry.early-tokens-acquisition=false +quarkus.oidc-client.single-token-cache-entry.token-cache-max-size=1 + quarkus.log.category."io.quarkus.oidc.client.runtime.OidcClientImpl".level=DEBUG \ No newline at end of file diff --git a/extensions/oidc-client/runtime/pom.xml b/extensions/oidc-client/runtime/pom.xml index f09beced53dfe..87b68dff16eae 100644 --- a/extensions/oidc-client/runtime/pom.xml +++ b/extensions/oidc-client/runtime/pom.xml @@ -44,6 +44,11 @@ quarkus-junit-internal test + + org.assertj + assertj-core + test + diff --git a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/OidcClientConfigBuilder.java b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/OidcClientConfigBuilder.java index e7fe50f398a2f..dec8773ee49b9 100644 --- a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/OidcClientConfigBuilder.java +++ b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/OidcClientConfigBuilder.java @@ -35,6 +35,7 @@ private static class OidcClientConfigImpl extends OidcClientCommonConfigImpl imp private final boolean clientEnabled; private final Optional id; private final Optional refreshInterval;; + private final int tokenCacheMaxSize; private OidcClientConfigImpl(OidcClientConfigBuilder builder) { super(builder); @@ -51,6 +52,7 @@ private OidcClientConfigImpl(OidcClientConfigBuilder builder) { this.clientEnabled = builder.clientEnabled; this.id = builder.id; this.refreshInterval = builder.refreshInterval; + this.tokenCacheMaxSize = builder.tokenCacheMaxSize; } @Override @@ -117,6 +119,11 @@ public Map headers() { public Optional refreshInterval() { return refreshInterval; } + + @Override + public int tokenCacheMaxSize() { + return tokenCacheMaxSize; + } } /** @@ -139,6 +146,7 @@ public Optional refreshInterval() { private boolean clientEnabled; private Optional id; private Optional refreshInterval; + private int tokenCacheMaxSize; /** * Creates {@link OidcClientConfigBuilder} builder populated with documented default values. @@ -163,6 +171,7 @@ public OidcClientConfigBuilder(OidcClientConfig config) { this.clientEnabled = config.clientEnabled(); this.id = config.id(); this.refreshInterval = config.refreshInterval(); + this.tokenCacheMaxSize = config.tokenCacheMaxSize(); if (config.scopes().isPresent()) { this.scopes.addAll(config.scopes().get()); } @@ -379,6 +388,15 @@ public OidcClientConfigBuilder refreshInterval(Duration refreshInterval) { return this; } + /** + * @param tokenCacheMaxSize {@link OidcClientConfig#tokenCacheMaxSize()} + * @return this builder + */ + public OidcClientConfigBuilder tokenCacheMaxSize(int tokenCacheMaxSize) { + this.tokenCacheMaxSize = tokenCacheMaxSize; + return this; + } + /** * @return OidcClientConfig */ diff --git a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/AbstractTokensProducer.java b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/AbstractTokensProducer.java index 10ff7b72a6c12..f0612a603c814 100644 --- a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/AbstractTokensProducer.java +++ b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/AbstractTokensProducer.java @@ -11,6 +11,7 @@ import org.jboss.logging.Logger; +import io.quarkus.arc.ClientProxy; import io.quarkus.oidc.client.OidcClient; import io.quarkus.oidc.client.Tokens; import io.smallrye.mutiny.Uni; @@ -29,7 +30,7 @@ public abstract class AbstractTokensProducer { @Inject public Instance oidcClientsInstance; - final TokensHelper tokensHelper = new TokensHelper(); + private TokensHelper tokensHelper; @PostConstruct public void init() { @@ -40,20 +41,27 @@ public void init() { } final OidcClientsImpl oidcClients = oidcClientsInstance.get(); Optional initializedClient = client(); + final OidcClientConfig oidcClientConfig; if (initializedClient.isEmpty()) { Optional clientId = Objects.requireNonNull(clientId(), "clientId must not be null"); if (clientId.isPresent()) { // static named OidcClient oidcClient = Objects.requireNonNull(oidcClients.getClient(clientId.get()), "Unknown client"); - earlyTokenAcquisition = oidcClientsConfig.namedClients().get(clientId.get()).earlyTokensAcquisition(); + oidcClientConfig = oidcClientsConfig.namedClients().get(clientId.get()); } else { // default OidcClient - earlyTokenAcquisition = OidcClientsConfig.getDefaultClient(oidcClientsConfig).earlyTokensAcquisition(); + oidcClientConfig = OidcClientsConfig.getDefaultClient(oidcClientsConfig); oidcClient = oidcClients.getClient(); } + earlyTokenAcquisition = oidcClientConfig.earlyTokensAcquisition(); } else { oidcClient = initializedClient.get(); + oidcClientConfig = ClientProxy.unwrap(oidcClient) instanceof OidcClientImpl oidcClientImpl + ? oidcClientImpl.getConfig() + : null; } + tokensHelper = new TokensHelper(oidcClientConfig != null ? oidcClientConfig.tokenCacheMaxSize() + : TokensHelper.DEFAULT_TOKEN_CACHE_MAX_SIZE); initTokens(); if (!isForceNewTokens()) { @@ -135,6 +143,7 @@ protected boolean isForceNewTokens() { /** * @return Additional parameters which will be used during the token acquisition or refresh methods. + * The tokens are cached per set of additional parameters. */ protected Map additionalParameters() { return Map.of(); diff --git a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/OidcClientConfig.java b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/OidcClientConfig.java index abe567e186387..68cab895b117a 100644 --- a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/OidcClientConfig.java +++ b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/OidcClientConfig.java @@ -193,6 +193,19 @@ public String getGrantType() { */ Optional refreshInterval(); + /** + * Maximum number of sets of additional grant parameters for which an OIDC client filter or a token producer, + * such as {@code TokenProvider}, caches the tokens. + * Tokens requested with additional grant parameters, for example, with different scopes or subject tokens + * passed to {@code TokenProvider#getAccessToken(Map)}, are cached per set of parameters. + * This number is approximate, the cache can briefly exceed it when tokens are requested concurrently. + * This property is not relevant when tokens are acquired with the configured grant only, without additional + * grant parameters, because such tokens are always cached. + * Neither does it apply when the {@link OidcClient} API is used directly. + */ + @WithDefault("15") + int tokenCacheMaxSize(); + /** * Creates {@link OidcClientConfigBuilder} builder populated with documented default values. * diff --git a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/TokensHelper.java b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/TokensHelper.java index c29fccbdd3b95..12ba5a44e57f0 100644 --- a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/TokensHelper.java +++ b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/TokensHelper.java @@ -1,6 +1,10 @@ package io.quarkus.oidc.client.runtime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicReferenceFieldUpdater; import java.util.function.BiConsumer; @@ -13,11 +17,41 @@ public class TokensHelper { private static final Logger LOG = Logger.getLogger(TokensHelper.class); - @SuppressWarnings("unused") - private volatile TokenRequestState tokenRequestState; + static final int DEFAULT_TOKEN_CACHE_MAX_SIZE = 15; - private static final AtomicReferenceFieldUpdater tokenRequestStateUpdater = AtomicReferenceFieldUpdater - .newUpdater(TokensHelper.class, TokenRequestState.class, "tokenRequestState"); + private static final AtomicReferenceFieldUpdater tokenRequestStateUpdater = AtomicReferenceFieldUpdater + .newUpdater(TokenRequestStateHolder.class, TokenRequestState.class, "tokenRequestState"); + + // the tokens whose access token has expired are evicted first, then the least recently used tokens + private static final Comparator EVICTION_ORDER = new Comparator<>() { + @Override + public int compare(EvictionCandidate candidate, EvictionCandidate other) { + if (candidate.accessTokenExpired() != other.accessTokenExpired()) { + return candidate.accessTokenExpired() ? -1 : 1; + } + return Long.compare(candidate.lastAccessed(), other.lastAccessed()); + } + }; + + // the tokens acquired without additional parameters are the typical case, they are kept in a dedicated holder + // which is never evicted and does not count towards the cache size limit + private final TokenRequestStateHolder noParametersHolder = new TokenRequestStateHolder(Map.of()); + // the tokens are cached per set of additional parameters as the parameters can affect which tokens are issued, + // each holder manages the token request state of one set of parameters + private final ConcurrentHashMap, TokenRequestStateHolder> tokenRequestStateHolders = new ConcurrentHashMap<>(); + private final int tokenCacheMaxSize; + + public TokensHelper() { + this(DEFAULT_TOKEN_CACHE_MAX_SIZE); + } + + public TokensHelper(int tokenCacheMaxSize) { + if (tokenCacheMaxSize < 1) { + throw new IllegalArgumentException( + "The maximum number of sets of additional parameters the tokens are cached for must be at least 1"); + } + this.tokenCacheMaxSize = tokenCacheMaxSize; + } public void initTokens(OidcClient oidcClient) { initTokens(oidcClient, Map.of()); @@ -25,8 +59,8 @@ public void initTokens(OidcClient oidcClient) { public void initTokens(OidcClient oidcClient, Map additionalParameters) { //init the tokens, this just happens in a blocking manner for now - tokenRequestStateUpdater.set(this, - new TokenRequestState(oidcClient.getTokens(additionalParameters).await().indefinitely())); + Tokens tokens = oidcClient.getTokens(additionalParameters).await().indefinitely(); + tokenRequestStateUpdater.set(tokenRequestStateHolder(additionalParameters), new TokenRequestState(tokens)); } public Uni getTokens(OidcClient oidcClient) { @@ -34,17 +68,22 @@ public Uni getTokens(OidcClient oidcClient) { } public Uni getTokens(OidcClient oidcClient, Map additionalParameters, boolean forceNewTokens) { + return getTokens(oidcClient, additionalParameters, forceNewTokens, tokenRequestStateHolder(additionalParameters)); + } + + private Uni getTokens(OidcClient oidcClient, Map additionalParameters, + boolean forceNewTokens, TokenRequestStateHolder holder) { TokenRequestState currentState = null; TokenRequestState newState = null; //if the tokens are expired we refresh them in an async manner //we use CAS to make sure we only make a single request for (;;) { - currentState = tokenRequestStateUpdater.get(this); + currentState = tokenRequestStateUpdater.get(holder); if (currentState == null) { //init the initial state //note that this can still happen at runtime as if there is an error then the state will be null - newState = new TokenRequestState(prepareUni(oidcClient.getTokens(additionalParameters))); - if (tokenRequestStateUpdater.compareAndSet(this, currentState, newState)) { + newState = new TokenRequestState(prepareUni(oidcClient.getTokens(additionalParameters), holder)); + if (tokenRequestStateUpdater.compareAndSet(holder, currentState, newState)) { return newState.tokenUni; } //rerun the CAS loop @@ -53,8 +92,8 @@ public Uni getTokens(OidcClient oidcClient, Map addition } else if (forceNewTokens) { LOG.debugf("Forcing acquisition of new tokens for client %s", currentState.tokens.getClientId()); - newState = new TokenRequestState(prepareUni(oidcClient.getTokens(additionalParameters))); - if (tokenRequestStateUpdater.compareAndSet(this, currentState, newState)) { + newState = new TokenRequestState(prepareUni(oidcClient.getTokens(additionalParameters), holder)); + if (tokenRequestStateUpdater.compareAndSet(holder, currentState, newState)) { return newState.tokenUni; } //rerun the CAS loop @@ -71,8 +110,8 @@ public Uni getTokens(OidcClient oidcClient, Map addition newState = new TokenRequestState( prepareUni(refreshTokenValid ? oidcClient.refreshTokens(tokens.getRefreshToken(), additionalParameters) - : oidcClient.getTokens(additionalParameters))); - if (tokenRequestStateUpdater.compareAndSet(this, currentState, newState)) { + : oidcClient.getTokens(additionalParameters), holder)); + if (tokenRequestStateUpdater.compareAndSet(holder, currentState, newState)) { return newState.tokenUni; } //rerun the CAS loop @@ -83,16 +122,19 @@ public Uni getTokens(OidcClient oidcClient, Map addition } } - private Uni prepareUni(Uni tokens) { + private Uni prepareUni(Uni tokens, TokenRequestStateHolder holder) { return tokens.onItemOrFailure().invoke(new BiConsumer() { @Override public void accept(Tokens tokens, Throwable throwable) { - //we only have a single outstanding request + //we only have a single outstanding request per set of additional parameters //so we don't need to CAS if (tokens != null) { - tokenRequestStateUpdater.set(TokensHelper.this, new TokenRequestState(tokens)); + tokenRequestStateUpdater.set(holder, new TokenRequestState(tokens)); } else { - tokenRequestStateUpdater.set(TokensHelper.this, null); + tokenRequestStateUpdater.set(holder, null); + if (holder != noParametersHolder) { + tokenRequestStateHolders.remove(holder.key, holder); + } } } }) @@ -100,7 +142,59 @@ public void accept(Tokens tokens, Throwable throwable) { .memoize().indefinitely(); } - static class TokenRequestState { + private TokenRequestStateHolder tokenRequestStateHolder(Map additionalParameters) { + if (additionalParameters.isEmpty()) { + return noParametersHolder; + } + Map key = Map.copyOf(additionalParameters); + TokenRequestStateHolder holder = tokenRequestStateHolders.get(key); + if (holder != null) { + holder.lastAccessed = System.nanoTime(); + return holder; + } + // the size check and the eviction are not synchronized with concurrent cache misses, so the cache can + // temporarily exceed its maximum size by the number of such misses, the next cache miss evicts the excess too + if (tokenRequestStateHolders.mappingCount() >= tokenCacheMaxSize) { + evictTokenRequestStateHolders(); + } + return tokenRequestStateHolders.computeIfAbsent(key, TokenRequestStateHolder::new); + } + + private void evictTokenRequestStateHolders() { + final long nowSecs = System.currentTimeMillis() / 1000; + List candidates = new ArrayList<>(); + for (TokenRequestStateHolder holder : tokenRequestStateHolders.values()) { + TokenRequestState state = tokenRequestStateUpdater.get(holder); + Long accessTokenExpiresAt = state == null || state.tokens == null ? null : state.tokens.getAccessTokenExpiresAt(); + boolean accessTokenExpired = accessTokenExpiresAt != null && nowSecs > accessTokenExpiresAt; + candidates.add(new EvictionCandidate(holder, accessTokenExpired, holder.lastAccessed)); + } + candidates.sort(EVICTION_ORDER); + int holdersToEvict = candidates.size() - tokenCacheMaxSize + 1; + if (holdersToEvict > 0) { + LOG.debugf("Token cache is full, evicting the tokens of %d sets of additional parameters", holdersToEvict); + } + // concurrent evictions select the same holders, so the number of evicted holders stays bounded by the + // computed count, a holder which has already been removed concurrently is not replaced by another one + for (int i = 0; i < holdersToEvict; i++) { + TokenRequestStateHolder holder = candidates.get(i).holder(); + tokenRequestStateHolders.remove(holder.key, holder); + } + } + + static final class TokenRequestStateHolder { + private final Map key; + @SuppressWarnings("unused") + private volatile TokenRequestState tokenRequestState; + private volatile long lastAccessed; + + TokenRequestStateHolder(Map key) { + this.key = key; + this.lastAccessed = System.nanoTime(); + } + } + + static final class TokenRequestState { final Tokens tokens; final Uni tokenUni; @@ -114,4 +208,7 @@ static class TokenRequestState { this.tokenUni = tokensUni; } } + + private record EvictionCandidate(TokenRequestStateHolder holder, boolean accessTokenExpired, long lastAccessed) { + } } diff --git a/extensions/oidc-client/runtime/src/test/java/io/quarkus/oidc/client/OidcClientConfigBuilderTest.java b/extensions/oidc-client/runtime/src/test/java/io/quarkus/oidc/client/OidcClientConfigBuilderTest.java index 0c8e23374a8f3..728ef4057f8ea 100644 --- a/extensions/oidc-client/runtime/src/test/java/io/quarkus/oidc/client/OidcClientConfigBuilderTest.java +++ b/extensions/oidc-client/runtime/src/test/java/io/quarkus/oidc/client/OidcClientConfigBuilderTest.java @@ -41,6 +41,7 @@ private static void testDefaultValues(OidcClientConfig config) { assertFalse(config.absoluteExpiresIn()); assertTrue(config.grantOptions().isEmpty()); assertTrue(config.earlyTokensAcquisition()); + assertEquals(15, config.tokenCacheMaxSize()); assertTrue(config.headers().isEmpty()); var grant = config.grant(); assertNotNull(grant); @@ -130,6 +131,7 @@ public void testSetEveryProperty() { .grantOptions("four", Map.of("five", "six")) .grantOptions(Map.of("seven", Map.of("eight", "nine"))) .earlyTokensAcquisition(false) + .tokenCacheMaxSize(5) .headers("one", "two") .headers(Map.of("three", "four")) // OidcClientCommonConfig methods @@ -215,6 +217,7 @@ public void testSetEveryProperty() { assertTrue(grantOptions.containsKey("seven")); assertEquals("nine", grantOptions.get("seven").get("eight")); assertFalse(config.earlyTokensAcquisition()); + assertEquals(5, config.tokenCacheMaxSize()); var headers = config.headers(); assertNotNull(headers); assertEquals(2, headers.size()); @@ -310,6 +313,7 @@ public void testCopyOidcClientConfigProperties() { .grantOptions("four", Map.of("five", "six")) .grantOptions(Map.of("seven", Map.of("eight", "nine"))) .earlyTokensAcquisition(false) + .tokenCacheMaxSize(5) .headers("one", "two") .headers(Map.of("three", "four")) .build(); @@ -344,6 +348,7 @@ public void testCopyOidcClientConfigProperties() { assertTrue(grantOptions.containsKey("seven")); assertEquals("nine", grantOptions.get("seven").get("eight")); assertFalse(existingConfig.earlyTokensAcquisition()); + assertEquals(5, existingConfig.tokenCacheMaxSize()); var headers = existingConfig.headers(); assertNotNull(headers); assertEquals(2, headers.size()); @@ -362,6 +367,7 @@ public void testCopyOidcClientConfigProperties() { .expiresInProperty("expires_in_test-CHANGED") .end() .earlyTokensAcquisition(true) + .tokenCacheMaxSize(7) .build(); // OidcClientConfig methods @@ -396,6 +402,7 @@ public void testCopyOidcClientConfigProperties() { assertTrue(grantOptions.containsKey("seven")); assertEquals("nine", grantOptions.get("seven").get("eight")); assertTrue(newConfig.earlyTokensAcquisition()); + assertEquals(7, newConfig.tokenCacheMaxSize()); headers = newConfig.headers(); assertNotNull(headers); assertEquals(2, headers.size()); diff --git a/extensions/oidc-client/runtime/src/test/java/io/quarkus/oidc/client/runtime/InMemoryOidcTestClient.java b/extensions/oidc-client/runtime/src/test/java/io/quarkus/oidc/client/runtime/InMemoryOidcTestClient.java new file mode 100644 index 0000000000000..361c5c7b8a0a3 --- /dev/null +++ b/extensions/oidc-client/runtime/src/test/java/io/quarkus/oidc/client/runtime/InMemoryOidcTestClient.java @@ -0,0 +1,206 @@ +package io.quarkus.oidc.client.runtime; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Predicate; +import java.util.function.Supplier; + +import io.quarkus.oidc.client.OidcClient; +import io.quarkus.oidc.client.Tokens; +import io.smallrye.mutiny.Uni; +import io.vertx.core.json.JsonObject; + +final class InMemoryOidcTestClient implements OidcClient { + + static final String CLIENT_ID = "test-client"; + + enum Expiry { + VALID, + ACCESS_TOKEN_EXPIRED, + ACCESS_TOKEN_WITHIN_REFRESH_INTERVAL, + ACCESS_AND_REFRESH_TOKENS_EXPIRED, + ACCESS_TOKEN_EXPIRED_NO_REFRESH_TOKEN + } + + record Call(Operation operation, String refreshToken, Map params) { + + enum Operation { + GRANT, + REFRESH + } + + boolean isGrant() { + return operation == Operation.GRANT; + } + + boolean isRefresh() { + return operation == Operation.REFRESH; + } + } + + static final class PendingRequest { + final Call call; + final CompletableFuture future = new CompletableFuture<>(); + + PendingRequest(Call call) { + this.call = call; + } + } + + private final List calls = new CopyOnWriteArrayList<>(); + private final List requests = new CopyOnWriteArrayList<>(); + private final List pendingRequests = new CopyOnWriteArrayList<>(); + private final Map> issuedRefreshTokens = new ConcurrentHashMap<>(); + private final AtomicInteger sequence = new AtomicInteger(); + private volatile boolean respondLater; + private volatile Expiry expiry = Expiry.VALID; + private volatile Supplier failure; + + @Override + public Uni getTokens(Map additionalGrantParameters) { + return record(new Call(Call.Operation.GRANT, null, Map.copyOf(additionalGrantParameters))); + } + + @Override + public Uni refreshTokens(String refreshToken, Map additionalGrantParameters) { + return record(new Call(Call.Operation.REFRESH, refreshToken, Map.copyOf(additionalGrantParameters))); + } + + @Override + public Uni revokeAccessToken(String accessToken, Map additionalParameters) { + return Uni.createFrom().item(true); + } + + @Override + public void close() { + } + + void issue(Expiry expiry) { + this.expiry = expiry; + } + + void respondLater() { + this.respondLater = true; + } + + void respondImmediately() { + this.respondLater = false; + } + + void failRequests(Supplier failure) { + this.failure = failure; + } + + void succeedRequests() { + this.failure = null; + } + + void completePendingRequests() { + completePendingRequests(call -> true); + } + + void completePendingRequests(Predicate callFilter) { + for (PendingRequest pendingRequest : pendingRequests) { + if (!pendingRequest.future.isDone() && callFilter.test(pendingRequest.call)) { + pendingRequest.future.complete(issue(pendingRequest.call)); + } + } + } + + void failPendingRequests(Throwable failure) { + for (PendingRequest pendingRequest : pendingRequests) { + if (!pendingRequest.future.isDone()) { + pendingRequest.future.completeExceptionally(failure); + } + } + } + + List calls() { + return List.copyOf(calls); + } + + List requests() { + return List.copyOf(requests); + } + + List pendingRequests() { + return List.copyOf(pendingRequests); + } + + static String accessTokenPrefix(Map params) { + return "access-" + key(params) + "#"; + } + + private static String key(Map params) { + return new TreeMap<>(params).toString(); + } + + private Uni record(Call call) { + calls.add(call); + return Uni.createFrom().deferred(new Supplier>() { + @Override + public Uni get() { + requests.add(call); + if (respondLater) { + PendingRequest pendingRequest = new PendingRequest(call); + pendingRequests.add(pendingRequest); + return Uni.createFrom().completionStage(pendingRequest.future); + } + Supplier currentFailure = failure; + if (currentFailure != null) { + return Uni.createFrom().failure(currentFailure.get()); + } + return Uni.createFrom().item(issue(call)); + } + }); + } + + private Tokens issue(Call call) { + Map params = call.isGrant() ? call.params() : issuedRefreshTokens.get(call.refreshToken()); + if (params == null) { + throw new IllegalStateException("Unknown refresh token: " + call.refreshToken()); + } + int seq = sequence.incrementAndGet(); + String accessToken = accessTokenPrefix(params) + seq; + String refreshToken = "refresh-" + key(params) + "#" + seq; + long nowSecs = System.currentTimeMillis() / 1000; + long accessTokenExpiresAt = nowSecs + 3600 + seq; + Long refreshTokenExpiresAt = nowSecs + 7200 + seq; + Duration refreshTokenTimeSkew = null; + switch (expiry) { + case VALID: + break; + case ACCESS_TOKEN_EXPIRED: + accessTokenExpiresAt = nowSecs - 10; + break; + case ACCESS_TOKEN_WITHIN_REFRESH_INTERVAL: + accessTokenExpiresAt = nowSecs + 5; + refreshTokenTimeSkew = Duration.ofSeconds(60); + break; + case ACCESS_AND_REFRESH_TOKENS_EXPIRED: + accessTokenExpiresAt = nowSecs - 10; + refreshTokenExpiresAt = nowSecs - 10; + break; + case ACCESS_TOKEN_EXPIRED_NO_REFRESH_TOKEN: + accessTokenExpiresAt = nowSecs - 10; + refreshToken = null; + refreshTokenExpiresAt = null; + break; + } + if (refreshToken != null) { + issuedRefreshTokens.put(refreshToken, params); + } + JsonObject grantResponse = new JsonObject().put("access_token", accessToken); + for (Map.Entry param : params.entrySet()) { + grantResponse.put(param.getKey(), param.getValue()); + } + return new Tokens(accessToken, accessTokenExpiresAt, refreshTokenTimeSkew, refreshToken, refreshTokenExpiresAt, + grantResponse, CLIENT_ID); + } +} diff --git a/extensions/oidc-client/runtime/src/test/java/io/quarkus/oidc/client/runtime/TokensHelperTest.java b/extensions/oidc-client/runtime/src/test/java/io/quarkus/oidc/client/runtime/TokensHelperTest.java new file mode 100644 index 0000000000000..10927a641800e --- /dev/null +++ b/extensions/oidc-client/runtime/src/test/java/io/quarkus/oidc/client/runtime/TokensHelperTest.java @@ -0,0 +1,781 @@ +package io.quarkus.oidc.client.runtime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import org.junit.jupiter.api.Test; + +import io.quarkus.oidc.client.Tokens; +import io.quarkus.oidc.client.runtime.InMemoryOidcTestClient.Call; +import io.quarkus.oidc.client.runtime.InMemoryOidcTestClient.Expiry; +import io.smallrye.mutiny.Uni; + +class TokensHelperTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + private static final Map NO_PARAMS = Map.of(); + private static final Map SCOPE_A = Map.of("scope", "a"); + private static final Map SCOPE_B = Map.of("scope", "b"); + private static final Map SCOPE_C = Map.of("scope", "c"); + private static final Map SCOPE_D = Map.of("scope", "d"); + + private final InMemoryOidcTestClient client = new InMemoryOidcTestClient(); + private TokensHelper helper = new TokensHelper(); + + @Test + void testFirstCallAcquiresTokensAndSecondCallIsCacheHit() { + Tokens first = getTokens(SCOPE_A); + Tokens second = getTokens(SCOPE_A); + + assertIssuedFor(first, SCOPE_A); + assertThat(second.getAccessToken()).isEqualTo(first.getAccessToken()); + assertThat(client.requests()).hasSize(1); + assertThat(client.requests().get(0).isGrant()).isTrue(); + assertThat(client.requests().get(0).params()).isEqualTo(SCOPE_A); + } + + @Test + void testInitTokensPrePopulatesCache() { + helper.initTokens(client, SCOPE_A); + assertThat(client.requests()).hasSize(1); + + Tokens tokens = getTokens(SCOPE_A); + + assertIssuedFor(tokens, SCOPE_A); + assertThat(client.requests()).hasSize(1); + } + + @Test + void testInitTokensWithoutParametersPrePopulatesCacheForNoParameters() { + helper.initTokens(client); + assertThat(client.requests()).hasSize(1); + + Tokens tokens = helper.getTokens(client).await().atMost(TIMEOUT); + + assertIssuedFor(tokens, NO_PARAMS); + assertThat(client.requests()).hasSize(1); + } + + @Test + void testExpiredAccessTokenIsRefreshedWithRefreshTokenAndParameters() { + client.issue(Expiry.ACCESS_TOKEN_EXPIRED); + Tokens expired = getTokens(SCOPE_A); + client.issue(Expiry.VALID); + + Tokens refreshed = getTokens(SCOPE_A); + + assertIssuedFor(refreshed, SCOPE_A); + assertThat(refreshed.getAccessToken()).isNotEqualTo(expired.getAccessToken()); + assertThat(client.requests()).hasSize(2); + Call refresh = client.requests().get(1); + assertThat(refresh.isRefresh()).isTrue(); + assertThat(refresh.refreshToken()).isEqualTo(expired.getRefreshToken()); + assertThat(refresh.params()).isEqualTo(SCOPE_A); + + Tokens cached = getTokens(SCOPE_A); + assertThat(cached.getAccessToken()).isEqualTo(refreshed.getAccessToken()); + assertThat(client.requests()).hasSize(2); + } + + @Test + void testExpiredAccessTokenWithoutRefreshTokenLeadsToNewGrant() { + client.issue(Expiry.ACCESS_TOKEN_EXPIRED_NO_REFRESH_TOKEN); + Tokens expired = getTokens(SCOPE_A); + client.issue(Expiry.VALID); + + Tokens renewed = getTokens(SCOPE_A); + + assertIssuedFor(renewed, SCOPE_A); + assertThat(renewed.getAccessToken()).isNotEqualTo(expired.getAccessToken()); + assertThat(client.requests()).hasSize(2); + assertThat(client.requests().get(1).isGrant()).isTrue(); + assertThat(client.requests().get(1).params()).isEqualTo(SCOPE_A); + } + + @Test + void testExpiredAccessTokenWithExpiredRefreshTokenLeadsToNewGrant() { + client.issue(Expiry.ACCESS_AND_REFRESH_TOKENS_EXPIRED); + Tokens expired = getTokens(SCOPE_A); + client.issue(Expiry.VALID); + + Tokens renewed = getTokens(SCOPE_A); + + assertIssuedFor(renewed, SCOPE_A); + assertThat(renewed.getAccessToken()).isNotEqualTo(expired.getAccessToken()); + assertThat(client.requests()).hasSize(2); + assertThat(client.requests().get(1).isGrant()).isTrue(); + assertThat(client.requests().get(1).params()).isEqualTo(SCOPE_A); + } + + @Test + void testAccessTokenWithinRefreshIntervalIsRefreshedProactively() { + client.issue(Expiry.ACCESS_TOKEN_WITHIN_REFRESH_INTERVAL); + Tokens aboutToExpire = getTokens(SCOPE_A); + client.issue(Expiry.VALID); + + Tokens refreshed = getTokens(SCOPE_A); + + assertIssuedFor(refreshed, SCOPE_A); + assertThat(refreshed.getAccessToken()).isNotEqualTo(aboutToExpire.getAccessToken()); + assertThat(client.requests()).hasSize(2); + assertThat(client.requests().get(1).isRefresh()).isTrue(); + assertThat(client.requests().get(1).refreshToken()).isEqualTo(aboutToExpire.getRefreshToken()); + } + + @Test + void testForceNewTokensDiscardsValidCachedTokens() { + Tokens first = getTokens(SCOPE_A); + + Tokens forced = helper.getTokens(client, SCOPE_A, true).await().atMost(TIMEOUT); + + assertIssuedFor(forced, SCOPE_A); + assertThat(forced.getAccessToken()).isNotEqualTo(first.getAccessToken()); + assertThat(client.requests()).hasSize(2); + assertThat(client.requests().get(1).isGrant()).isTrue(); + assertThat(client.requests().get(1).params()).isEqualTo(SCOPE_A); + + Tokens cached = getTokens(SCOPE_A); + assertThat(cached.getAccessToken()).isEqualTo(forced.getAccessToken()); + assertThat(client.requests()).hasSize(2); + } + + @Test + void testCallsWithSameParametersJoinInFlightRequest() throws Exception { + client.respondLater(); + CompletableFuture first = subscribe(helper.getTokens(client, SCOPE_A, false)); + CompletableFuture second = subscribe(helper.getTokens(client, SCOPE_A, false)); + assertThat(client.requests()).hasSize(1); + + client.completePendingRequests(); + + assertIssuedFor(await(first), SCOPE_A); + assertThat(await(second).getAccessToken()).isEqualTo(await(first).getAccessToken()); + assertThat(client.requests()).hasSize(1); + } + + @Test + void testReturnedUniIsMemoized() throws Exception { + client.respondLater(); + Uni uni = helper.getTokens(client, SCOPE_A, false); + CompletableFuture first = subscribe(uni); + CompletableFuture second = subscribe(uni); + assertThat(client.requests()).hasSize(1); + + client.completePendingRequests(); + + assertThat(await(first).getAccessToken()).isEqualTo(await(second).getAccessToken()); + assertThat(client.requests()).hasSize(1); + } + + @Test + void testFailedRequestIsPropagatedAndNextCallRequestsTokensAgain() { + client.failRequests(() -> new IllegalStateException("token endpoint unavailable")); + assertThatThrownBy(() -> getTokens(SCOPE_A)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("token endpoint unavailable"); + client.succeedRequests(); + + Tokens tokens = getTokens(SCOPE_A); + + assertIssuedFor(tokens, SCOPE_A); + assertThat(client.requests()).hasSize(2); + assertThat(client.requests().get(1).params()).isEqualTo(SCOPE_A); + } + + @Test + void testFailedInFlightRequestFailsAllWaitersAndNextCallRequestsTokensAgain() throws Exception { + client.respondLater(); + CompletableFuture first = subscribe(helper.getTokens(client, SCOPE_A, false)); + CompletableFuture second = subscribe(helper.getTokens(client, SCOPE_A, false)); + assertThat(client.requests()).hasSize(1); + + client.failPendingRequests(new IllegalStateException("token endpoint unavailable")); + + assertThatThrownBy(() -> await(first)).hasCauseInstanceOf(IllegalStateException.class); + assertThatThrownBy(() -> await(second)).hasCauseInstanceOf(IllegalStateException.class); + client.respondImmediately(); + assertIssuedFor(getTokens(SCOPE_A), SCOPE_A); + assertThat(client.requests()).hasSize(2); + } + + @Test + void testConcurrentCallsWithSameParametersMakeSingleRequest() throws Exception { + client.respondLater(); + + List results = getTokensConcurrently(Collections.nCopies(50, SCOPE_A)); + + assertThat(client.requests()).hasSize(1); + for (Tokens tokens : results) { + assertIssuedFor(tokens, SCOPE_A); + assertThat(tokens.getAccessToken()).isEqualTo(results.get(0).getAccessToken()); + } + } + + @Test + void testCallWithDifferentParametersDoesNotReturnCachedTokens() { + Tokens forA = getTokens(SCOPE_A); + + Tokens forB = getTokens(SCOPE_B); + + assertIssuedFor(forA, SCOPE_A); + assertIssuedFor(forB, SCOPE_B); + assertThat(client.requests()).hasSize(2); + assertThat(client.requests().get(1).isGrant()).isTrue(); + assertThat(client.requests().get(1).params()).isEqualTo(SCOPE_B); + } + + @Test + void testTokensAreCachedPerParameters() { + Tokens forA = getTokens(SCOPE_A); + Tokens forB = getTokens(SCOPE_B); + assertIssuedFor(forA, SCOPE_A); + assertIssuedFor(forB, SCOPE_B); + assertThat(client.requests()).hasSize(2); + + Tokens forAAgain = getTokens(SCOPE_A); + Tokens forBAgain = getTokens(SCOPE_B); + + assertThat(forAAgain.getAccessToken()).isEqualTo(forA.getAccessToken()); + assertThat(forBAgain.getAccessToken()).isEqualTo(forB.getAccessToken()); + assertThat(client.requests()).hasSize(2); + } + + @Test + void testCallWithDifferentParametersDoesNotJoinInFlightRequest() throws Exception { + client.respondLater(); + CompletableFuture forA = subscribe(helper.getTokens(client, SCOPE_A, false)); + CompletableFuture forB = subscribe(helper.getTokens(client, SCOPE_B, false)); + + client.completePendingRequests(); + + assertIssuedFor(await(forA), SCOPE_A); + assertIssuedFor(await(forB), SCOPE_B); + assertThat(client.requests()).extracting(Call::params).containsExactly(SCOPE_A, SCOPE_B); + } + + @Test + void testForceNewTokensDoesNotJoinInFlightRequestWithDifferentParameters() throws Exception { + client.respondLater(); + CompletableFuture forA = subscribe(helper.getTokens(client, SCOPE_A, true)); + CompletableFuture forB = subscribe(helper.getTokens(client, SCOPE_B, true)); + + client.completePendingRequests(); + + assertIssuedFor(await(forA), SCOPE_A); + assertIssuedFor(await(forB), SCOPE_B); + assertThat(client.requests()).extracting(Call::params).containsExactly(SCOPE_A, SCOPE_B); + } + + @Test + void testExpiredTokensAreRefreshedWithTheirOwnRefreshTokenAndParameters() { + client.issue(Expiry.ACCESS_TOKEN_EXPIRED); + Tokens expiredForA = getTokens(SCOPE_A); + client.issue(Expiry.VALID); + Tokens forB = getTokens(SCOPE_B); + assertIssuedFor(forB, SCOPE_B); + assertThat(client.requests()).hasSize(2); + + Tokens refreshedForA = getTokens(SCOPE_A); + + assertIssuedFor(refreshedForA, SCOPE_A); + assertThat(refreshedForA.getAccessToken()).isNotEqualTo(expiredForA.getAccessToken()); + assertThat(client.requests()).hasSize(3); + Call refresh = client.requests().get(2); + assertThat(refresh.isRefresh()).isTrue(); + assertThat(refresh.refreshToken()).isEqualTo(expiredForA.getRefreshToken()); + assertThat(refresh.params()).isEqualTo(SCOPE_A); + + Tokens forBAgain = getTokens(SCOPE_B); + assertThat(forBAgain.getAccessToken()).isEqualTo(forB.getAccessToken()); + assertThat(client.requests()).hasSize(3); + } + + @Test + void testExpiredTokensAreNotRefreshedForDifferentParameters() { + client.issue(Expiry.ACCESS_TOKEN_EXPIRED); + Tokens expiredForA = getTokens(SCOPE_A); + client.issue(Expiry.VALID); + + Tokens forB = getTokens(SCOPE_B); + + assertIssuedFor(forB, SCOPE_B); + assertThat(client.requests()).hasSize(2); + Call request = client.requests().get(1); + assertThat(request.isGrant()) + .as("expected a new grant request for %s but got %s with the refresh token issued for %s", + SCOPE_B, request, SCOPE_A) + .isTrue(); + assertThat(request.params()).isEqualTo(SCOPE_B); + assertThat(client.calls()).extracting(Call::refreshToken).doesNotContain(expiredForA.getRefreshToken()); + } + + @Test + void testFailedRequestForDifferentParametersLeavesCachedTokensIntact() { + Tokens forA = getTokens(SCOPE_A); + client.failRequests(() -> new IllegalStateException("token endpoint unavailable")); + + assertThatThrownBy(() -> getTokens(SCOPE_B), + "expected the request for %s to fail instead of returning the tokens cached for %s", SCOPE_B, SCOPE_A) + .isInstanceOf(IllegalStateException.class); + + client.succeedRequests(); + Tokens forAAgain = getTokens(SCOPE_A); + assertThat(forAAgain.getAccessToken()).isEqualTo(forA.getAccessToken()); + assertThat(client.requests()).hasSize(2); + } + + @Test + void testForceNewTokensCarriesParameters() { + Tokens forA = helper.getTokens(client, SCOPE_A, true).await().atMost(TIMEOUT); + Tokens forB = helper.getTokens(client, SCOPE_B, true).await().atMost(TIMEOUT); + Tokens forAAgain = helper.getTokens(client, SCOPE_A, true).await().atMost(TIMEOUT); + + assertIssuedFor(forA, SCOPE_A); + assertIssuedFor(forB, SCOPE_B); + assertIssuedFor(forAAgain, SCOPE_A); + assertThat(forAAgain.getAccessToken()).isNotEqualTo(forA.getAccessToken()); + assertThat(client.requests()).extracting(Call::params).containsExactly(SCOPE_A, SCOPE_B, SCOPE_A); + assertThat(client.requests()).allMatch(Call::isGrant); + } + + @Test + void testParametersWithEqualContentShareCachedTokens() { + Map immutable = Map.of("scope", "a", "audience", "x"); + Map reversed = new LinkedHashMap<>(); + reversed.put("audience", "x"); + reversed.put("scope", "a"); + Map sorted = new TreeMap<>(immutable); + + Tokens first = getTokens(immutable); + Tokens second = getTokens(reversed); + Tokens third = getTokens(sorted); + + assertIssuedFor(first, immutable); + assertThat(second.getAccessToken()).isEqualTo(first.getAccessToken()); + assertThat(third.getAccessToken()).isEqualTo(first.getAccessToken()); + assertThat(client.requests()).hasSize(1); + } + + @Test + void testEmptyParameterVariantsShareCachedTokens() { + Tokens first = helper.getTokens(client).await().atMost(TIMEOUT); + Tokens second = getTokens(Map.of()); + Tokens third = getTokens(Collections.emptyMap()); + Tokens fourth = getTokens(new HashMap<>()); + + assertIssuedFor(first, NO_PARAMS); + assertThat(second.getAccessToken()).isEqualTo(first.getAccessToken()); + assertThat(third.getAccessToken()).isEqualTo(first.getAccessToken()); + assertThat(fourth.getAccessToken()).isEqualTo(first.getAccessToken()); + assertThat(client.requests()).hasSize(1); + } + + @Test + void testDifferentValueOrAdditionalParameterIsDifferentCacheEntry() { + Map scopeAWithAudience = Map.of("scope", "a", "audience", "x"); + + Tokens forA = getTokens(SCOPE_A); + Tokens forB = getTokens(SCOPE_B); + Tokens forAWithAudience = getTokens(scopeAWithAudience); + + assertIssuedFor(forA, SCOPE_A); + assertIssuedFor(forB, SCOPE_B); + assertIssuedFor(forAWithAudience, scopeAWithAudience); + assertThat(client.requests()).extracting(Call::params).containsExactly(SCOPE_A, SCOPE_B, scopeAWithAudience); + } + + @Test + void testCacheIsNotAffectedByCallerMutatingParametersAfterCall() { + Map mutable = new HashMap<>(SCOPE_A); + Tokens forA = getTokens(mutable); + mutable.put("scope", "b"); + + Tokens forAAgain = getTokens(SCOPE_A); + assertThat(forAAgain.getAccessToken()).isEqualTo(forA.getAccessToken()); + assertThat(client.requests()).hasSize(1); + + Tokens forB = getTokens(SCOPE_B); + assertIssuedFor(forB, SCOPE_B); + assertThat(client.requests()).hasSize(2); + assertThat(client.requests().get(1).params()).isEqualTo(SCOPE_B); + } + + @Test + void testInitTokensWithParametersDoesNotServeCallWithDifferentParameters() { + helper.initTokens(client, SCOPE_A); + + Tokens forB = getTokens(SCOPE_B); + + assertIssuedFor(forB, SCOPE_B); + assertThat(client.requests()).hasSize(2); + assertThat(client.requests().get(1).isGrant()).isTrue(); + assertThat(client.requests().get(1).params()).isEqualTo(SCOPE_B); + } + + @Test + void testConcurrentCallsWithDifferentParametersMakeOneRequestPerParameters() throws Exception { + client.respondLater(); + List> paramsPerThread = new ArrayList<>(); + for (int i = 0; i < 25; i++) { + paramsPerThread.add(SCOPE_A); + paramsPerThread.add(SCOPE_B); + } + + List results = getTokensConcurrently(paramsPerThread); + + for (int i = 0; i < paramsPerThread.size(); i++) { + assertIssuedFor(results.get(i), paramsPerThread.get(i)); + } + assertThat(client.requests()).extracting(Call::params).containsExactlyInAnyOrder(SCOPE_A, SCOPE_B); + } + + @Test + void testManyDifferentParametersGetTheirOwnTokens() { + for (int i = 0; i < 100; i++) { + Map params = Map.of("subject_token", "subject-" + i); + assertIssuedFor(getTokens(params), params); + } + assertThat(client.requests()).hasSize(100); + } + + @Test + void testTokensWithoutParametersAreNeverEvicted() { + helper = new TokensHelper(2); + Tokens noParams = getTokens(NO_PARAMS); + getTokens(SCOPE_A); + getTokens(SCOPE_B); + assertThat(client.requests()).hasSize(3); + + // the cache is full and the tokens without parameters are the least recently used ones, + // still, the tokens for SCOPE_A are evicted instead + assertIssuedFor(getTokens(SCOPE_C), SCOPE_C); + assertThat(client.requests()).hasSize(4); + assertThat(getTokens(NO_PARAMS).getAccessToken()).isEqualTo(noParams.getAccessToken()); + getTokens(SCOPE_B); + getTokens(SCOPE_C); + assertThat(client.requests()).hasSize(4); + assertIssuedFor(getTokens(SCOPE_A), SCOPE_A); + assertThat(client.requests()).hasSize(5); + } + + @Test + void testTokensWithoutParametersDoNotCountTowardsCacheSize() { + helper = new TokensHelper(2); + getTokens(NO_PARAMS); + getTokens(SCOPE_A); + getTokens(SCOPE_B); + assertThat(client.requests()).hasSize(3); + + // two sets of parameters fit into the cache next to the tokens without parameters, nothing is evicted + getTokens(NO_PARAMS); + getTokens(SCOPE_A); + getTokens(SCOPE_B); + assertThat(client.requests()).hasSize(3); + } + + @Test + void testFailedRequestWithoutParametersIsRepeatedByNextCall() { + client.failRequests(() -> new IllegalStateException("token endpoint unavailable")); + assertThatThrownBy(() -> getTokens(NO_PARAMS)).isInstanceOf(IllegalStateException.class); + client.succeedRequests(); + + assertIssuedFor(getTokens(NO_PARAMS), NO_PARAMS); + assertThat(client.requests()).hasSize(2); + } + + @Test + void testTokenCacheMaxSizeMustBePositive() { + assertThatThrownBy(() -> new TokensHelper(0)).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void testLeastRecentlyUsedTokensAreEvictedWhenCacheIsFull() { + helper = new TokensHelper(3); + getTokens(SCOPE_A); + getTokens(SCOPE_B); + getTokens(SCOPE_C); + assertThat(client.requests()).hasSize(3); + + // cache hits never evict + getTokens(SCOPE_A); + getTokens(SCOPE_B); + getTokens(SCOPE_C); + assertThat(client.requests()).hasSize(3); + + // the tokens for SCOPE_B are the least recently used now + getTokens(SCOPE_A); + assertIssuedFor(getTokens(SCOPE_D), SCOPE_D); + assertThat(client.requests()).hasSize(4); + + getTokens(SCOPE_A); + getTokens(SCOPE_C); + getTokens(SCOPE_D); + assertThat(client.requests()).hasSize(4); + assertIssuedFor(getTokens(SCOPE_B), SCOPE_B); + assertThat(client.requests()).hasSize(5); + assertThat(client.requests().get(4).isGrant()).isTrue(); + } + + @Test + void testCacheIsBoundedByDefaultSize() { + List> params = parameterSets(TokensHelper.DEFAULT_TOKEN_CACHE_MAX_SIZE); + for (Map p : params) { + assertIssuedFor(getTokens(p), p); + } + for (Map p : params) { + getTokens(p); + } + assertThat(client.requests()).hasSize(params.size()); + + Map oneMore = Map.of("scope", "one-more"); + assertIssuedFor(getTokens(oneMore), oneMore); + assertThat(client.requests()).hasSize(params.size() + 1); + + // the least recently used tokens, the ones of the first set of parameters, have been evicted + for (int i = 1; i < params.size(); i++) { + getTokens(params.get(i)); + } + getTokens(oneMore); + assertThat(client.requests()).hasSize(params.size() + 1); + assertIssuedFor(getTokens(params.get(0)), params.get(0)); + assertThat(client.requests()).hasSize(params.size() + 2); + } + + @Test + void testExpiredTokensAreEvictedBeforeLeastRecentlyUsedTokens() { + helper = new TokensHelper(3); + getTokens(SCOPE_A); + getTokens(SCOPE_B); + client.issue(Expiry.ACCESS_TOKEN_EXPIRED); + // the most recently used tokens, but their access token has expired + Tokens expiredForC = getTokens(SCOPE_C); + client.issue(Expiry.VALID); + assertThat(client.requests()).hasSize(3); + + assertIssuedFor(getTokens(SCOPE_D), SCOPE_D); + assertThat(client.requests()).hasSize(4); + + getTokens(SCOPE_A); + getTokens(SCOPE_B); + getTokens(SCOPE_D); + assertThat(client.requests()).hasSize(4); + // the expired tokens were evicted together with their refresh token, new tokens are acquired with a grant + Tokens forC = getTokens(SCOPE_C); + assertIssuedFor(forC, SCOPE_C); + assertThat(forC.getAccessToken()).isNotEqualTo(expiredForC.getAccessToken()); + assertThat(client.requests()).hasSize(5); + assertThat(client.requests().get(4).isGrant()).isTrue(); + assertThat(client.calls()).extracting(Call::refreshToken).doesNotContain(expiredForC.getRefreshToken()); + } + + @Test + void testFailedRequestDoesNotOccupyCache() { + helper = new TokensHelper(3); + getTokens(SCOPE_A); + getTokens(SCOPE_B); + Map failing = Map.of("scope", "failing"); + client.failRequests(() -> new IllegalStateException("token endpoint unavailable")); + assertThatThrownBy(() -> getTokens(failing)).isInstanceOf(IllegalStateException.class); + client.succeedRequests(); + + // there is room for one more set of tokens, nothing is evicted + assertIssuedFor(getTokens(SCOPE_C), SCOPE_C); + int requests = client.requests().size(); + getTokens(SCOPE_A); + getTokens(SCOPE_B); + getTokens(SCOPE_C); + assertThat(client.requests()).hasSize(requests); + } + + @Test + void testRequestInProgressIsNotEvictedBeforeLeastRecentlyUsedTokens() throws Exception { + helper = new TokensHelper(3); + getTokens(SCOPE_A); + getTokens(SCOPE_B); + client.respondLater(); + CompletableFuture inProgress = subscribe(helper.getTokens(client, SCOPE_C, false)); + CompletableFuture oneMore = subscribe(helper.getTokens(client, SCOPE_D, false)); + client.completePendingRequests(); + assertIssuedFor(await(inProgress), SCOPE_C); + assertIssuedFor(await(oneMore), SCOPE_D); + client.respondImmediately(); + + int requests = client.requests().size(); + getTokens(SCOPE_B); + getTokens(SCOPE_C); + getTokens(SCOPE_D); + assertThat(client.requests()).hasSize(requests); + assertIssuedFor(getTokens(SCOPE_A), SCOPE_A); + assertThat(client.requests()).hasSize(requests + 1); + } + + @Test + void testEvictedRequestInProgressStillDeliversItsTokens() throws Exception { + helper = new TokensHelper(2); + client.respondLater(); + CompletableFuture forA = subscribe(helper.getTokens(client, SCOPE_A, false)); + CompletableFuture forB = subscribe(helper.getTokens(client, SCOPE_B, false)); + // the least recently used request in progress, the one for SCOPE_A, is evicted + CompletableFuture forC = subscribe(helper.getTokens(client, SCOPE_C, false)); + assertThat(client.requests()).hasSize(3); + + client.completePendingRequests(); + assertIssuedFor(await(forA), SCOPE_A); + assertIssuedFor(await(forB), SCOPE_B); + assertIssuedFor(await(forC), SCOPE_C); + + client.respondImmediately(); + getTokens(SCOPE_B); + getTokens(SCOPE_C); + assertThat(client.requests()).hasSize(3); + assertIssuedFor(getTokens(SCOPE_A), SCOPE_A); + assertThat(client.requests()).hasSize(4); + } + + @Test + void testBurstOfConcurrentMissesEvictsOnlyLeastRecentlyUsedTokens() throws Exception { + helper = new TokensHelper(5); + Map anchor = Map.of("scope", "anchor"); + getTokens(anchor); + List> previousBurst = parameterSets(4); + for (Map p : previousBurst) { + getTokens(p); + } + for (int round = 0; round < 50; round++) { + // the anchor tokens become the most recently used ones and must survive the burst of misses which + // must evict exactly the tokens of the previous burst, the least recently used ones + int requests = client.requests().size(); + getTokens(anchor); + assertThat(client.requests()).as("round %d: the anchor tokens must still be cached", round) + .hasSize(requests); + List> burst = new ArrayList<>(); + for (int i = 0; i < 4; i++) { + burst.add(Map.of("scope", "burst-" + round + "-" + i)); + } + client.respondLater(); + + List results = getTokensConcurrently(burst); + + client.respondImmediately(); + for (int i = 0; i < burst.size(); i++) { + assertIssuedFor(results.get(i), burst.get(i)); + } + assertThat(client.requests()).hasSize(requests + 4); + previousBurst = burst; + } + + int requests = client.requests().size(); + getTokens(anchor); + for (Map p : previousBurst) { + getTokens(p); + } + assertThat(client.requests()).hasSize(requests); + Map evicted = Map.of("scope", "burst-0-0"); + assertIssuedFor(getTokens(evicted), evicted); + assertThat(client.requests()).hasSize(requests + 1); + assertThat(client.requests().get(requests).isGrant()).isTrue(); + } + + @Test + void testConcurrentCallsWithMoreParametersThanCacheSizeGetTheirOwnTokens() throws Exception { + helper = new TokensHelper(3); + + List results = getTokensConcurrently(subjectTokensPerThread(100, 10)); + + for (int i = 0; i < results.size(); i++) { + assertIssuedFor(results.get(i), subjectTokensPerThread(100, 10).get(i)); + } + } + + @Test + void testConcurrentCallsWithMoreRequestsInProgressThanCacheSizeGetTheirOwnTokens() throws Exception { + helper = new TokensHelper(3); + client.respondLater(); + + List results = getTokensConcurrently(subjectTokensPerThread(100, 10)); + + for (int i = 0; i < results.size(); i++) { + assertIssuedFor(results.get(i), subjectTokensPerThread(100, 10).get(i)); + } + } + + private static List> parameterSets(int count) { + List> params = new ArrayList<>(); + for (int i = 0; i < count; i++) { + params.add(Map.of("scope", "scope-" + i)); + } + return params; + } + + private static List> subjectTokensPerThread(int threads, int subjectTokens) { + List> params = new ArrayList<>(); + for (int i = 0; i < threads; i++) { + params.add(Map.of("subject_token", "subject-" + (i % subjectTokens))); + } + return params; + } + + private Tokens getTokens(Map params) { + return helper.getTokens(client, params, false).await().atMost(TIMEOUT); + } + + private static CompletableFuture subscribe(Uni uni) { + return uni.subscribe().asCompletionStage(); + } + + private static Tokens await(CompletableFuture future) + throws InterruptedException, ExecutionException, TimeoutException { + return future.get(TIMEOUT.toSeconds(), TimeUnit.SECONDS); + } + + private List getTokensConcurrently(List> paramsPerThread) throws Exception { + try (ExecutorService executor = Executors.newFixedThreadPool(paramsPerThread.size())) { + CountDownLatch start = new CountDownLatch(1); + CountDownLatch requested = new CountDownLatch(paramsPerThread.size()); + List> futures = new ArrayList<>(); + for (Map params : paramsPerThread) { + futures.add(executor.submit(new Callable() { + @Override + public Tokens call() throws Exception { + start.await(); + CompletableFuture result = subscribe(helper.getTokens(client, params, false)); + requested.countDown(); + return await(result); + } + })); + } + start.countDown(); + assertThat(requested.await(TIMEOUT.toSeconds(), TimeUnit.SECONDS)).isTrue(); + client.completePendingRequests(); + List results = new ArrayList<>(); + for (Future future : futures) { + results.add(future.get(TIMEOUT.toSeconds(), TimeUnit.SECONDS)); + } + return results; + } + } + + private static void assertIssuedFor(Tokens tokens, Map params) { + assertThat(tokens.getAccessToken()) + .as("access token must have been issued for the parameters %s", params) + .startsWith(InMemoryOidcTestClient.accessTokenPrefix(params)); + } +} From 3543ec5458d2aff31418db22266daaf6e3a49335 Mon Sep 17 00:00:00 2001 From: Ignacio Vidal Date: Sun, 27 Sep 2026 18:01:21 +0100 Subject: [PATCH 250/254] OIDC: Serve still-valid access tokens while a token refresh is in flight TokensHelper replaces the cached tokens with the in-flight Uni for the duration of a refresh, so every request arriving in that window subscribes to that Uni and waits for the token endpoint to respond. The tokens being replaced are normally still valid at that point, because refresh-token-time-skew starts the refresh before they expire, so that wait is avoidable. Retain those tokens in TokenRequestState and serve them to callers arriving while the refresh is in flight, as long as they have not expired -- the same rule already applied when no refresh is running. The only difference is that the caller waits on a refresh someone else started rather than one it starts itself. This is opt-in via the new quarkus.oidc-client.min-remaining-access-token-lifespan property, which sets how much of the access token's lifespan must be left for it to still be worth returning, so that it does not expire in transit or while the target service is processing the request. When not configured, callers wait for the refresh, as before. A refresh only starts once the remaining lifespan has dropped below refresh-token-time-skew, so a minimum which is not less than the skew could never be satisfied and would silently disable the optimisation. This combination, a minimum below one second (the access token expiry is tracked in whole seconds, so it would truncate to zero) and a missing skew are rejected with a ConfigurationException when the OIDC client is created. --- ...urity-openid-connect-client-reference.adoc | 10 + ...ssTokenLifespanBelowOneSecondTestCase.java | 48 ++ ...sTokenLifespanNotLessThanSkewTestCase.java | 58 +++ ...ccessTokenLifespanNotPositiveTestCase.java | 48 ++ ...ccessTokenLifespanWithoutSkewTestCase.java | 47 ++ .../oidc/client/OidcClientConfigBuilder.java | 18 + .../java/io/quarkus/oidc/client/Tokens.java | 26 +- .../oidc/client/runtime/OidcClientConfig.java | 7 + .../oidc/client/runtime/OidcClientImpl.java | 3 +- .../client/runtime/OidcClientRecorder.java | 30 ++ .../oidc/client/runtime/TokensHelper.java | 32 +- .../runtime/InMemoryOidcTestClient.java | 2 +- .../oidc/client/runtime/TokensHelperTest.java | 425 ++++++++++++++++++ 13 files changed, 745 insertions(+), 9 deletions(-) create mode 100644 extensions/oidc-client/deployment/src/test/java/io/quarkus/oidc/client/OidcClientMinRemainingAccessTokenLifespanBelowOneSecondTestCase.java create mode 100644 extensions/oidc-client/deployment/src/test/java/io/quarkus/oidc/client/OidcClientMinRemainingAccessTokenLifespanNotLessThanSkewTestCase.java create mode 100644 extensions/oidc-client/deployment/src/test/java/io/quarkus/oidc/client/OidcClientMinRemainingAccessTokenLifespanNotPositiveTestCase.java create mode 100644 extensions/oidc-client/deployment/src/test/java/io/quarkus/oidc/client/OidcClientMinRemainingAccessTokenLifespanWithoutSkewTestCase.java diff --git a/docs/src/main/asciidoc/security-openid-connect-client-reference.adoc b/docs/src/main/asciidoc/security-openid-connect-client-reference.adoc index cfaa94e60c1bf..9a60a466e11ba 100644 --- a/docs/src/main/asciidoc/security-openid-connect-client-reference.adoc +++ b/docs/src/main/asciidoc/security-openid-connect-client-reference.adoc @@ -987,6 +987,16 @@ Additionally, you can use the `quarkus.oidc-client.refresh-token-time-skew` prop For example, if this property is set to `3S` and the access token will expire in less than 3 seconds, the token is refreshed automatically. +While such a preemptive refresh is in progress, the access token which is being refreshed may not have expired yet by the time the next request arrives. + +To allow such a token to be returned to the requests arriving in the meantime instead of making them wait for the refresh token endpoint to reply, configure the `quarkus.oidc-client.min-remaining-access-token-lifespan` property, which sets how much of the access token lifespan must be left for it to still be returned while it is being refreshed. Its value must be less than `quarkus.oidc-client.refresh-token-time-skew`. + +Using both `quarkus.oidc-client.refresh-token-time-skew` and `quarkus.oidc-client.min-remaining-access-token-lifespan` can be recommended for high-throughput applications, to minimize the risk of latency spikes while requests wait for a token refresh to complete, while also finding an optimal refresh skew period so that the token is not refreshed too early, does not expire in transit, and remains valid while the target service uses it. + +Using `quarkus.oidc-client.min-remaining-access-token-lifespan` is only recommended after you have proven its effectiveness with your own application throughput measurement tests. + +For example, with `quarkus.oidc-client.refresh-token-time-skew=60S` the refresh starts while a minute of the access token lifespan is left, and `quarkus.oidc-client.min-remaining-access-token-lifespan=30S` returns that token to the requests arriving during the refresh for as long as at least 30 seconds of token duration are left. + By default, the OIDC client refreshes the token during the current request when it detects that the token has expired, or is nearly expired if the xref:#quarkus-oidc-client_quarkus-oidc-client-refresh-token-time-skew[refresh token time skew] is configured. Performance-critical applications might want to avoid waiting for a possible token refresh during incoming requests and configure an asynchronous token refresh instead. diff --git a/extensions/oidc-client/deployment/src/test/java/io/quarkus/oidc/client/OidcClientMinRemainingAccessTokenLifespanBelowOneSecondTestCase.java b/extensions/oidc-client/deployment/src/test/java/io/quarkus/oidc/client/OidcClientMinRemainingAccessTokenLifespanBelowOneSecondTestCase.java new file mode 100644 index 0000000000000..8db223787afd4 --- /dev/null +++ b/extensions/oidc-client/deployment/src/test/java/io/quarkus/oidc/client/OidcClientMinRemainingAccessTokenLifespanBelowOneSecondTestCase.java @@ -0,0 +1,48 @@ +package io.quarkus.oidc.client; + +import static io.quarkus.oidc.client.OidcClientMinRemainingAccessTokenLifespanNotLessThanSkewTestCase.configurationException; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.runtime.configuration.ConfigurationException; +import io.quarkus.test.QuarkusExtensionTest; + +/** + * The access token expiry is only tracked in whole seconds, so a minimum remaining lifespan below a second + * would be truncated to zero and silently reuse every token which has not expired yet. It is rejected instead, + * so that the minimum which is applied is always the one which was configured. + */ +public class OidcClientMinRemainingAccessTokenLifespanBelowOneSecondTestCase { + + @RegisterExtension + static final QuarkusExtensionTest test = new QuarkusExtensionTest() + .withApplicationRoot((jar) -> jar + .addAsResource(new StringAsset( + "# Disable Dev Services, Keycloak is started by a Maven plugin\n" + + "quarkus.keycloak.devservices.enabled=false\n" + + "quarkus.oidc-client.token-path=http://localhost:8180/oidc/tokens\n" + + "quarkus.oidc-client.client-id=quarkus\n" + + "quarkus.oidc-client.credentials.secret=secret\n" + + "quarkus.oidc-client.refresh-token-time-skew=10S\n" + + "quarkus.oidc-client.min-remaining-access-token-lifespan=500ms\n"), + "application.properties")) + .assertException(t -> { + ConfigurationException te = configurationException(t); + assertNotNull(te, "Expected ConfigurationException, but got: " + t); + assertTrue( + te.getMessage().contains( + "'quarkus.oidc-client.min-remaining-access-token-lifespan' must be greater than 0 seconds"), + te.getMessage()); + }); + + @Test + public void test() { + Assertions.fail(); + } + +} diff --git a/extensions/oidc-client/deployment/src/test/java/io/quarkus/oidc/client/OidcClientMinRemainingAccessTokenLifespanNotLessThanSkewTestCase.java b/extensions/oidc-client/deployment/src/test/java/io/quarkus/oidc/client/OidcClientMinRemainingAccessTokenLifespanNotLessThanSkewTestCase.java new file mode 100644 index 0000000000000..db2e7dff5419b --- /dev/null +++ b/extensions/oidc-client/deployment/src/test/java/io/quarkus/oidc/client/OidcClientMinRemainingAccessTokenLifespanNotLessThanSkewTestCase.java @@ -0,0 +1,58 @@ +package io.quarkus.oidc.client; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.runtime.configuration.ConfigurationException; +import io.quarkus.test.QuarkusExtensionTest; + +/** + * A refresh only starts once the remaining lifespan has dropped below the skew, so a minimum which is not + * less than the skew could never be satisfied and would silently disable the reuse of the token being refreshed. + */ +public class OidcClientMinRemainingAccessTokenLifespanNotLessThanSkewTestCase { + + @RegisterExtension + static final QuarkusExtensionTest test = new QuarkusExtensionTest() + .withApplicationRoot((jar) -> jar + .addAsResource(new StringAsset( + "# Disable Dev Services, Keycloak is started by a Maven plugin\n" + + "quarkus.keycloak.devservices.enabled=false\n" + + "quarkus.oidc-client.token-path=http://localhost:8180/oidc/tokens\n" + + "quarkus.oidc-client.client-id=quarkus\n" + + "quarkus.oidc-client.credentials.secret=secret\n" + + "quarkus.oidc-client.refresh-token-time-skew=10S\n" + + "quarkus.oidc-client.min-remaining-access-token-lifespan=10S\n"), + "application.properties")) + .assertException(t -> { + ConfigurationException te = configurationException(t); + assertNotNull(te, "Expected ConfigurationException, but got: " + t); + assertTrue( + te.getMessage().contains( + "'quarkus.oidc-client.min-remaining-access-token-lifespan' must be less than" + + " 'quarkus.oidc-client.refresh-token-time-skew'"), + te.getMessage()); + }); + + static ConfigurationException configurationException(Throwable t) { + Throwable e = t; + while (e != null) { + if (e instanceof ConfigurationException) { + return (ConfigurationException) e; + } + e = e.getCause(); + } + return null; + } + + @Test + public void test() { + Assertions.fail(); + } + +} diff --git a/extensions/oidc-client/deployment/src/test/java/io/quarkus/oidc/client/OidcClientMinRemainingAccessTokenLifespanNotPositiveTestCase.java b/extensions/oidc-client/deployment/src/test/java/io/quarkus/oidc/client/OidcClientMinRemainingAccessTokenLifespanNotPositiveTestCase.java new file mode 100644 index 0000000000000..4af0cb8da0408 --- /dev/null +++ b/extensions/oidc-client/deployment/src/test/java/io/quarkus/oidc/client/OidcClientMinRemainingAccessTokenLifespanNotPositiveTestCase.java @@ -0,0 +1,48 @@ +package io.quarkus.oidc.client; + +import static io.quarkus.oidc.client.OidcClientMinRemainingAccessTokenLifespanNotLessThanSkewTestCase.configurationException; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.runtime.configuration.ConfigurationException; +import io.quarkus.test.QuarkusExtensionTest; + +/** + * A zero minimum remaining lifespan would allow an access token with nothing left to be reused, which is what + * this feature exists to prevent, so it is rejected rather than treated as "no minimum". The minimum is compared + * in whole seconds, matching the access token expiry, so anything below a second is rejected the same way. + */ +public class OidcClientMinRemainingAccessTokenLifespanNotPositiveTestCase { + + @RegisterExtension + static final QuarkusExtensionTest test = new QuarkusExtensionTest() + .withApplicationRoot((jar) -> jar + .addAsResource(new StringAsset( + "# Disable Dev Services, Keycloak is started by a Maven plugin\n" + + "quarkus.keycloak.devservices.enabled=false\n" + + "quarkus.oidc-client.token-path=http://localhost:8180/oidc/tokens\n" + + "quarkus.oidc-client.client-id=quarkus\n" + + "quarkus.oidc-client.credentials.secret=secret\n" + + "quarkus.oidc-client.refresh-token-time-skew=10S\n" + + "quarkus.oidc-client.min-remaining-access-token-lifespan=0S\n"), + "application.properties")) + .assertException(t -> { + ConfigurationException te = configurationException(t); + assertNotNull(te, "Expected ConfigurationException, but got: " + t); + assertTrue( + te.getMessage().contains( + "'quarkus.oidc-client.min-remaining-access-token-lifespan' must be greater than 0 seconds"), + te.getMessage()); + }); + + @Test + public void test() { + Assertions.fail(); + } + +} diff --git a/extensions/oidc-client/deployment/src/test/java/io/quarkus/oidc/client/OidcClientMinRemainingAccessTokenLifespanWithoutSkewTestCase.java b/extensions/oidc-client/deployment/src/test/java/io/quarkus/oidc/client/OidcClientMinRemainingAccessTokenLifespanWithoutSkewTestCase.java new file mode 100644 index 0000000000000..4bc8ef0c5a83a --- /dev/null +++ b/extensions/oidc-client/deployment/src/test/java/io/quarkus/oidc/client/OidcClientMinRemainingAccessTokenLifespanWithoutSkewTestCase.java @@ -0,0 +1,47 @@ +package io.quarkus.oidc.client; + +import static io.quarkus.oidc.client.OidcClientMinRemainingAccessTokenLifespanNotLessThanSkewTestCase.configurationException; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.runtime.configuration.ConfigurationException; +import io.quarkus.test.QuarkusExtensionTest; + +/** + * Without a refresh token time skew no refresh starts before the access token has expired, so there is never a + * valid token to reuse and a minimum remaining lifespan on its own would have no effect. + */ +public class OidcClientMinRemainingAccessTokenLifespanWithoutSkewTestCase { + + @RegisterExtension + static final QuarkusExtensionTest test = new QuarkusExtensionTest() + .withApplicationRoot((jar) -> jar + .addAsResource(new StringAsset( + "# Disable Dev Services, Keycloak is started by a Maven plugin\n" + + "quarkus.keycloak.devservices.enabled=false\n" + + "quarkus.oidc-client.token-path=http://localhost:8180/oidc/tokens\n" + + "quarkus.oidc-client.client-id=quarkus\n" + + "quarkus.oidc-client.credentials.secret=secret\n" + + "quarkus.oidc-client.min-remaining-access-token-lifespan=5S\n"), + "application.properties")) + .assertException(t -> { + ConfigurationException te = configurationException(t); + assertNotNull(te, "Expected ConfigurationException, but got: " + t); + assertTrue( + te.getMessage().contains( + "'quarkus.oidc-client.min-remaining-access-token-lifespan' requires" + + " 'quarkus.oidc-client.refresh-token-time-skew' to be configured"), + te.getMessage()); + }); + + @Test + public void test() { + Assertions.fail(); + } + +} diff --git a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/OidcClientConfigBuilder.java b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/OidcClientConfigBuilder.java index dec8773ee49b9..a0ada26ce417d 100644 --- a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/OidcClientConfigBuilder.java +++ b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/OidcClientConfigBuilder.java @@ -30,6 +30,7 @@ private static class OidcClientConfigImpl extends OidcClientCommonConfigImpl imp private final Optional accessTokenExpiresIn; private final Optional accessTokenExpirySkew; private final Optional refreshTokenTimeSkew; + private final Optional minRemainingAccessTokenLifespan; private final Optional> scopes; private final Optional> audience; private final boolean clientEnabled; @@ -47,6 +48,7 @@ private OidcClientConfigImpl(OidcClientConfigBuilder builder) { this.accessTokenExpiresIn = builder.accessTokenExpiresIn; this.accessTokenExpirySkew = builder.accessTokenExpirySkew; this.refreshTokenTimeSkew = builder.refreshTokenTimeSkew; + this.minRemainingAccessTokenLifespan = builder.minRemainingAccessTokenLifespan; this.scopes = builder.scopes.isEmpty() ? Optional.empty() : Optional.of(List.copyOf(builder.scopes)); this.audience = builder.audience.isEmpty() ? Optional.empty() : Optional.of(List.copyOf(builder.audience)); this.clientEnabled = builder.clientEnabled; @@ -80,6 +82,11 @@ public Optional refreshTokenTimeSkew() { return refreshTokenTimeSkew; } + @Override + public Optional minRemainingAccessTokenLifespan() { + return minRemainingAccessTokenLifespan; + } + @Override public Optional accessTokenExpiresIn() { return accessTokenExpiresIn; @@ -143,6 +150,7 @@ public int tokenCacheMaxSize() { private Optional accessTokenExpiresIn; private Optional accessTokenExpirySkew; private Optional refreshTokenTimeSkew; + private Optional minRemainingAccessTokenLifespan; private boolean clientEnabled; private Optional id; private Optional refreshInterval; @@ -168,6 +176,7 @@ public OidcClientConfigBuilder(OidcClientConfig config) { this.accessTokenExpiresIn = config.accessTokenExpiresIn(); this.accessTokenExpirySkew = config.accessTokenExpirySkew(); this.refreshTokenTimeSkew = config.refreshTokenTimeSkew(); + this.minRemainingAccessTokenLifespan = config.minRemainingAccessTokenLifespan(); this.clientEnabled = config.clientEnabled(); this.id = config.id(); this.refreshInterval = config.refreshInterval(); @@ -291,6 +300,15 @@ public OidcClientConfigBuilder refreshTokenTimeSkew(Duration refreshTokenTimeSke return this; } + /** + * @param minRemainingAccessTokenLifespan {@link OidcClientConfig#minRemainingAccessTokenLifespan()} + * @return this builder + */ + public OidcClientConfigBuilder minRemainingAccessTokenLifespan(Duration minRemainingAccessTokenLifespan) { + this.minRemainingAccessTokenLifespan = Optional.ofNullable(minRemainingAccessTokenLifespan); + return this; + } + /** * Adds scopes to the {@link OidcClientConfig#scopes()}. * diff --git a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/Tokens.java b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/Tokens.java index 392f60cbf5e76..4e22b9c501260 100644 --- a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/Tokens.java +++ b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/Tokens.java @@ -15,13 +15,15 @@ public class Tokens { final private String accessToken; final private Long accessTokenExpiresAt; final private Long refreshTokenTimeSkew; + final private Long minRemainingAccessTokenLifespan; final private String refreshToken; final Long refreshTokenExpiresAt; final private JsonObject grantResponse; final private String clientId; public Tokens(String accessToken, Long accessTokenExpiresAt, Duration refreshTokenTimeSkewDuration, String refreshToken, - Long refreshTokenExpiresAt, JsonObject grantResponse, String clientId) { + Long refreshTokenExpiresAt, JsonObject grantResponse, String clientId, + Duration minRemainingAccessTokenLifespanDuration) { this.accessToken = accessToken; this.accessTokenExpiresAt = accessTokenExpiresAt; this.refreshTokenTimeSkew = refreshTokenTimeSkewDuration == null ? null : refreshTokenTimeSkewDuration.getSeconds(); @@ -29,6 +31,8 @@ public Tokens(String accessToken, Long accessTokenExpiresAt, Duration refreshTok this.refreshTokenExpiresAt = refreshTokenExpiresAt; this.grantResponse = grantResponse; this.clientId = clientId; + this.minRemainingAccessTokenLifespan = minRemainingAccessTokenLifespanDuration == null ? null + : minRemainingAccessTokenLifespanDuration.getSeconds(); } public String getClientId() { @@ -55,6 +59,18 @@ public Long getRefreshTokenTimeSkew() { return refreshTokenTimeSkew; } + /** + * Whether this access token has enough of its lifespan left to still be worth sending while it is + * being refreshed, so that it does not expire in transit or while the target service is processing + * the request. + */ + public boolean hasMinRemainingAccessTokenLifespan() { + if (minRemainingAccessTokenLifespan == null || accessTokenExpiresAt == null) { + return false; + } + return accessTokenExpiresAt - now() >= minRemainingAccessTokenLifespan; + } + public boolean isAccessTokenExpired() { return isExpired(accessTokenExpiresAt, true); } @@ -67,7 +83,7 @@ public boolean isAccessTokenWithinRefreshInterval() { if (accessTokenExpiresAt == null || refreshTokenTimeSkew == null) { return false; } - final long nowSecs = System.currentTimeMillis() / 1000; + final long nowSecs = now(); final boolean proactiveRefresh = nowSecs + refreshTokenTimeSkew > accessTokenExpiresAt; if (proactiveRefresh) { @@ -84,7 +100,7 @@ private boolean isExpired(Long expiresAt, boolean accessToken) { if (expiresAt == null) { return false; } - final long nowSecs = System.currentTimeMillis() / 1000; + final long nowSecs = now(); final boolean expired = nowSecs > expiresAt; if (expired) { @@ -104,4 +120,8 @@ private boolean isExpired(Long expiresAt, boolean accessToken) { return expired; } + + private static long now() { + return System.currentTimeMillis() / 1000; + } } diff --git a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/OidcClientConfig.java b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/OidcClientConfig.java index 68cab895b117a..6476b105a5a0f 100644 --- a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/OidcClientConfig.java +++ b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/OidcClientConfig.java @@ -45,6 +45,13 @@ public interface OidcClientConfig extends OidcClientCommonConfig { */ Optional refreshTokenTimeSkew(); + /** + * Minimum remaining lifespan required to reuse the access token being refreshed in the background, + * instead of waiting for the refresh to complete. Its value must be less than + * {@link #refreshTokenTimeSkew()}. + */ + Optional minRemainingAccessTokenLifespan(); + /** * Access token expiration period relative to the current time. * This property is only checked when an access token grant response diff --git a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/OidcClientImpl.java b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/OidcClientImpl.java index d23eaeb211d4a..b8099341fa091 100644 --- a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/OidcClientImpl.java +++ b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/OidcClientImpl.java @@ -377,7 +377,8 @@ private Uni emitGrantTokens(OidcRequestContextProperties requestProps, H var tokens = new Tokens(accessToken, accessTokenExpiresAt, oidcConfig.refreshTokenTimeSkew().orElse(null), refreshToken, - refreshTokenExpiresAt, json, oidcConfig.clientId().orElse(DEFAULT_OIDC_CLIENT_ID)); + refreshTokenExpiresAt, json, oidcConfig.clientId().orElse(DEFAULT_OIDC_CLIENT_ID), + oidcConfig.minRemainingAccessTokenLifespan().orElse(null)); return Uni.createFrom().item(tokens); } else { String errorMessage = buffer.toString(); diff --git a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/OidcClientRecorder.java b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/OidcClientRecorder.java index 894169fc6c025..a059c497e5545 100644 --- a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/OidcClientRecorder.java +++ b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/OidcClientRecorder.java @@ -111,6 +111,7 @@ protected static Uni createOidcClientUni(OidcClientConfig oidcConfig try { OidcCommonUtils.verifyCommonConfiguration(oidcConfig, false, false); OidcCommonUtils.validateCredentialsForAllEndpoints(oidcConfig.credentials()); + verifyMinRemainingAccessTokenLifespan(oidcConfig, oidcClientId); } catch (ConfigurationException e) { return Uni.createFrom().failure(e); } @@ -157,6 +158,35 @@ protected static Uni createOidcClientUni(OidcClientConfig oidcConfig } } + /** + * Reusing the access token which is being refreshed is only enabled when a minimum remaining lifespan is + * configured lower than the refresh token time skew. + */ + private static void verifyMinRemainingAccessTokenLifespan(OidcClientConfig oidcConfig, String oidcClientId) { + if (oidcConfig.minRemainingAccessTokenLifespan().isEmpty()) { + return; + } + final long minRemainingLifespan = oidcConfig.minRemainingAccessTokenLifespan().get().getSeconds(); + if (minRemainingLifespan <= 0) { + throw new ConfigurationException(String.format( + "'quarkus.oidc-client.min-remaining-access-token-lifespan' must be greater than 0 seconds" + + " for the '%s' client, consider setting it to at least 3 seconds", + oidcClientId)); + } + if (oidcConfig.refreshTokenTimeSkew().isEmpty()) { + throw new ConfigurationException(String.format( + "'quarkus.oidc-client.min-remaining-access-token-lifespan' requires" + + " 'quarkus.oidc-client.refresh-token-time-skew' to be configured for the '%s' client", + oidcClientId)); + } + if (minRemainingLifespan >= oidcConfig.refreshTokenTimeSkew().get().getSeconds()) { + throw new ConfigurationException(String.format( + "'quarkus.oidc-client.min-remaining-access-token-lifespan' must be less than" + + " 'quarkus.oidc-client.refresh-token-time-skew' for the '%s' client", + oidcClientId)); + } + } + private static Uni createOidcClientUniFromMetadata( Function> metadataResolver, OidcClientConfig oidcConfig, OidcWebClient client, diff --git a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/TokensHelper.java b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/TokensHelper.java index 12ba5a44e57f0..3b9ff6c3681fb 100644 --- a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/TokensHelper.java +++ b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/TokensHelper.java @@ -88,6 +88,16 @@ private Uni getTokens(OidcClient oidcClient, Map additio } //rerun the CAS loop } else if (currentState.tokenUni != null) { + // Serve token getting refreshed if it has enough lifespan left + final Tokens tokensBeingRefreshed = currentState.tokensBeingRefreshed; + if (tokensBeingRefreshed != null && !forceNewTokens) { + if (tokensBeingRefreshed.hasMinRemainingAccessTokenLifespan()) { + return Uni.createFrom().item(tokensBeingRefreshed); + } + LOG.debugf("Access token being refreshed for client %s will not be reused because too little" + + " of its lifespan is left, waiting for the refreshed tokens instead", + tokensBeingRefreshed.getClientId()); + } return currentState.tokenUni; } else if (forceNewTokens) { LOG.debugf("Forcing acquisition of new tokens for client %s", currentState.tokens.getClientId()); @@ -98,19 +108,21 @@ private Uni getTokens(OidcClient oidcClient, Map additio } //rerun the CAS loop } else { - Tokens tokens = currentState.tokens; - - if (tokens.isAccessTokenExpired() || tokens.isAccessTokenWithinRefreshInterval()) { + final Tokens tokens = currentState.tokens; + final boolean accessTokenExpired = tokens.isAccessTokenExpired(); + if (accessTokenExpired || tokens.isAccessTokenWithinRefreshInterval()) { LOG.debugf("Starting refreshing the tokens for client %s", tokens.getClientId()); final boolean refreshTokenValid = tokens.getRefreshToken() != null && !tokens.isRefreshTokenExpired(); if (!refreshTokenValid) { LOG.debugf("Refresh token is not available or has expired, " + "acquiring new tokens instead for client %s", tokens.getClientId()); } + final Tokens tokensBeingRefreshed = accessTokenExpired ? null : tokens; newState = new TokenRequestState( prepareUni(refreshTokenValid ? oidcClient.refreshTokens(tokens.getRefreshToken(), additionalParameters) - : oidcClient.getTokens(additionalParameters), holder)); + : oidcClient.getTokens(additionalParameters), holder), + tokensBeingRefreshed); if (tokenRequestStateUpdater.compareAndSet(holder, currentState, newState)) { return newState.tokenUni; } @@ -197,15 +209,27 @@ static final class TokenRequestStateHolder { static final class TokenRequestState { final Tokens tokens; final Uni tokenUni; + /** + * Tokens being refreshed which were still valid when {@link #tokenUni} was started due to the + * refresh token time skew, retained so that they can keep being served while the refresh is in + * progress. May be null when no tokens have been acquired yet, or when the ones being replaced had already expired. + */ + final Tokens tokensBeingRefreshed; TokenRequestState(Tokens tokens) { this.tokens = tokens; this.tokenUni = null; + this.tokensBeingRefreshed = null; } TokenRequestState(Uni tokensUni) { + this(tokensUni, null); + } + + TokenRequestState(Uni tokensUni, Tokens tokensBeingRefreshed) { this.tokens = null; this.tokenUni = tokensUni; + this.tokensBeingRefreshed = tokensBeingRefreshed; } } diff --git a/extensions/oidc-client/runtime/src/test/java/io/quarkus/oidc/client/runtime/InMemoryOidcTestClient.java b/extensions/oidc-client/runtime/src/test/java/io/quarkus/oidc/client/runtime/InMemoryOidcTestClient.java index 361c5c7b8a0a3..4d2701f615f2a 100644 --- a/extensions/oidc-client/runtime/src/test/java/io/quarkus/oidc/client/runtime/InMemoryOidcTestClient.java +++ b/extensions/oidc-client/runtime/src/test/java/io/quarkus/oidc/client/runtime/InMemoryOidcTestClient.java @@ -201,6 +201,6 @@ private Tokens issue(Call call) { grantResponse.put(param.getKey(), param.getValue()); } return new Tokens(accessToken, accessTokenExpiresAt, refreshTokenTimeSkew, refreshToken, refreshTokenExpiresAt, - grantResponse, CLIENT_ID); + grantResponse, CLIENT_ID, null); } } diff --git a/extensions/oidc-client/runtime/src/test/java/io/quarkus/oidc/client/runtime/TokensHelperTest.java b/extensions/oidc-client/runtime/src/test/java/io/quarkus/oidc/client/runtime/TokensHelperTest.java index 10927a641800e..3066a1a8d8f8e 100644 --- a/extensions/oidc-client/runtime/src/test/java/io/quarkus/oidc/client/runtime/TokensHelperTest.java +++ b/extensions/oidc-client/runtime/src/test/java/io/quarkus/oidc/client/runtime/TokensHelperTest.java @@ -2,6 +2,10 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.time.Duration; import java.util.ArrayList; @@ -20,13 +24,17 @@ import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; +import io.quarkus.oidc.client.OidcClient; import io.quarkus.oidc.client.Tokens; import io.quarkus.oidc.client.runtime.InMemoryOidcTestClient.Call; import io.quarkus.oidc.client.runtime.InMemoryOidcTestClient.Expiry; import io.smallrye.mutiny.Uni; +import io.vertx.core.json.JsonObject; class TokensHelperTest { @@ -778,4 +786,421 @@ private static void assertIssuedFor(Tokens tokens, Map params) { .as("access token must have been issued for the parameters %s", params) .startsWith(InMemoryOidcTestClient.accessTokenPrefix(params)); } + + private static final Duration TIME_SKEW = Duration.ofSeconds(10); + /** Reusing the tokens being refreshed is opt-in, so the tests which exercise it configure a minimum. */ + private static final Duration MIN_REMAINING_LIFESPAN = Duration.ofSeconds(2); + + private static Tokens tokens(String accessToken, long expiresInSecs) { + return tokens(accessToken, expiresInSecs, TIME_SKEW, MIN_REMAINING_LIFESPAN); + } + + private static Tokens tokens(String accessToken, long expiresInSecs, Duration skew, Duration minRemaining) { + final long nowSecs = System.currentTimeMillis() / 1000; + return new Tokens(accessToken, nowSecs + expiresInSecs, skew, null, null, new JsonObject(), "client", + minRemaining); + } + + /** + * While a proactive refresh is in flight, callers must keep receiving the token being replaced + * rather than blocking on the refresh: it is still valid, because the refresh was started + * early by the refresh token time skew. + */ + @Test + void testStillValidTokensServedWhileRefreshIsInFlight() throws Exception { + // Expires in 8s: inside the 10s skew, so it is marked for refresh immediately, yet with + // comfortably more life left than the margin required to keep serving it. That is exactly + // the situation this covers, and it keeps the test clear of the boundary in both + // directions. + Tokens current = tokens("current", 8); + assertFalse(current.isAccessTokenExpired()); + assertTrue(current.isAccessTokenWithinRefreshInterval()); + + BlockingOidcClient client = new BlockingOidcClient(tokens("refreshed", 300)); + TokensHelper helper = new TokensHelper(); + helper.initTokens(new ImmediateOidcClient(current), Map.of()); + + // Triggers the refresh, which then blocks. + Uni refreshing = helper.getTokens(client, Map.of(), false); + refreshing.subscribe().with(t -> { + }, t -> { + }); + + // Wait until the refresh is genuinely in flight, rather than assuming it is by now. + client.awaitInFlight(); + + // A second caller arriving during that refresh must not wait for it. + Tokens served = helper.getTokens(client, Map.of(), false).await().atMost(Duration.ofSeconds(2)); + assertSame(current, served, "the still-valid previous tokens should have been served"); + + // And the refresh itself must not have been duplicated. + assertEquals(1, client.acquisitions.get()); + + client.release.countDown(); + } + + /** + * Expired tokens are never served, even though a refresh is already in flight: the caller + * waits for it, exactly as a caller waits when it starts a refresh itself. This is the same + * rule applied when no refresh is running -- an expired access token is not usable. + */ + @Test + void testExpiredTokensNotServedWhileRefreshIsInFlight() throws Exception { + // Already expired, so there is nothing safe to reuse. + Tokens current = tokens("current", -1); + assertTrue(current.isAccessTokenExpired()); + + BlockingOidcClient client = new BlockingOidcClient(tokens("refreshed", 300)); + TokensHelper helper = new TokensHelper(); + helper.initTokens(new ImmediateOidcClient(current), Map.of()); + + helper.getTokens(client, Map.of(), false).subscribe().with(t -> { + }, t -> { + }); + client.awaitInFlight(); + + // Release the refresh so the waiting caller can complete, then confirm it waited for the + // new tokens rather than being handed the expired ones. + client.release.countDown(); + Tokens served = helper.getTokens(client, Map.of(), false).await().atMost(Duration.ofSeconds(5)); + assertEquals("refreshed", served.getAccessToken(), + "expired tokens must not be served; the refreshed ones should be"); + } + + /** + * Short-lived tokens are reused on the same terms as any other, so the optimisation still + * applies when the skew -- and therefore the remaining lifetime once a refresh starts -- is + * small. An extra safety margin here would have silently excluded exactly these deployments. + */ + @Test + void testShortLivedTokensStillServedWhileRefreshIsInFlight() throws Exception { + // A 3s skew means a refresh starts once fewer than 3s of life remain, so 2s remaining + // both triggers the refresh and leaves the tokens usable while it runs. + final long nowSecs = System.currentTimeMillis() / 1000; + Tokens current = new Tokens("current", nowSecs + 2, Duration.ofSeconds(3), null, null, + new JsonObject(), "client", MIN_REMAINING_LIFESPAN); + assertFalse(current.isAccessTokenExpired()); + assertTrue(current.isAccessTokenWithinRefreshInterval()); + + BlockingOidcClient client = new BlockingOidcClient(tokens("refreshed", 300)); + TokensHelper helper = new TokensHelper(); + helper.initTokens(new ImmediateOidcClient(current), Map.of()); + + helper.getTokens(client, Map.of(), false).subscribe().with(t -> { + }, t -> { + }); + client.awaitInFlight(); + + Tokens served = helper.getTokens(client, Map.of(), false).await().atMost(Duration.ofSeconds(2)); + assertSame(current, served, "short-lived tokens with life left should still be served"); + + client.release.countDown(); + } + + /** + * A caller which forces the acquisition of new tokens does so because the tokens it was given + * were rejected, typically after a 401 when 'refresh-on-unauthorized' is enabled. Handing it + * the tokens being replaced would just fail again, so it waits for the refresh instead, even + * though those tokens are still valid and would be served to any other caller. + */ + @Test + void testForcedAcquisitionNotServedPreviousTokensWhileRefreshIsInFlight() throws Exception { + // Still valid, so this is served to ordinary callers: only the forcing is what differs. + Tokens current = tokens("current", 8); + assertFalse(current.isAccessTokenExpired()); + assertTrue(current.isAccessTokenWithinRefreshInterval()); + + BlockingOidcClient client = new BlockingOidcClient(tokens("refreshed", 300)); + TokensHelper helper = new TokensHelper(); + helper.initTokens(new ImmediateOidcClient(current), Map.of()); + + helper.getTokens(client, Map.of(), false).subscribe().with(t -> { + }, t -> { + }); + client.awaitInFlight(); + + final AtomicReference forced = new AtomicReference<>(); + final CountDownLatch forcedCompleted = new CountDownLatch(1); + helper.getTokens(client, Map.of(), true).subscribe().with(t -> { + forced.set(t); + forcedCompleted.countDown(); + }, t -> forcedCompleted.countDown()); + + // The refresh is still held, so the forcing caller must not have been given anything yet. + // Without this the test would also pass while the rejected tokens are handed back, since + // both paths eventually produce a value. + assertFalse(forcedCompleted.await(1, TimeUnit.SECONDS), + "tokens which have been rejected must not be served to a caller forcing new ones"); + + client.release.countDown(); + assertTrue(forcedCompleted.await(10, TimeUnit.SECONDS), "the forcing caller should have completed"); + assertEquals("refreshed", forced.get().getAccessToken(), + "the forcing caller should have waited for the refreshed tokens"); + } + + /** + * Tokens which are still valid but too close to expiry are not served while a refresh is in + * flight: they could expire in transit, or while the target service handles the request. Such a + * caller waits for the refreshed tokens instead. + */ + @Test + void testTokensBelowMinRemainingLifespanNotServedWhileRefreshIsInFlight() throws Exception { + // 30s skew so the refresh starts early, but only 5s of life left against a 10s minimum: + // still valid, yet not worth sending. + Tokens current = tokens("current", 5, Duration.ofSeconds(30), Duration.ofSeconds(10)); + assertFalse(current.isAccessTokenExpired()); + assertTrue(current.isAccessTokenWithinRefreshInterval()); + assertFalse(current.hasMinRemainingAccessTokenLifespan()); + + BlockingOidcClient client = new BlockingOidcClient(tokens("refreshed", 300)); + TokensHelper helper = new TokensHelper(); + helper.initTokens(new ImmediateOidcClient(current), Map.of()); + + helper.getTokens(client, Map.of(), false).subscribe().with(t -> { + }, t -> { + }); + client.awaitInFlight(); + + final AtomicReference served = new AtomicReference<>(); + final CountDownLatch completed = new CountDownLatch(1); + helper.getTokens(client, Map.of(), false).subscribe().with(t -> { + served.set(t); + completed.countDown(); + }, t -> completed.countDown()); + + // The refresh is still held, so a caller which must not be served the nearly expired tokens + // has nothing to receive yet. + assertFalse(completed.await(1, TimeUnit.SECONDS), + "tokens below the minimum remaining lifespan must not be served"); + + client.release.countDown(); + assertTrue(completed.await(10, TimeUnit.SECONDS), "the caller should have completed"); + assertEquals("refreshed", served.get().getAccessToken(), + "the caller should have waited for the refreshed tokens"); + } + + /** + * Tokens with more than the minimum remaining lifespan left are served, so the margin only + * excludes the tokens it is meant to. + */ + @Test + void testTokensAboveMinRemainingLifespanServedWhileRefreshIsInFlight() throws Exception { + // 20s left against a 10s minimum, inside a 30s skew. + Tokens current = tokens("current", 20, Duration.ofSeconds(30), Duration.ofSeconds(10)); + assertTrue(current.isAccessTokenWithinRefreshInterval()); + assertTrue(current.hasMinRemainingAccessTokenLifespan()); + + BlockingOidcClient client = new BlockingOidcClient(tokens("refreshed", 300)); + TokensHelper helper = new TokensHelper(); + helper.initTokens(new ImmediateOidcClient(current), Map.of()); + + helper.getTokens(client, Map.of(), false).subscribe().with(t -> { + }, t -> { + }); + client.awaitInFlight(); + + Tokens served = helper.getTokens(client, Map.of(), false).await().atMost(Duration.ofSeconds(2)); + assertSame(current, served, "tokens above the minimum remaining lifespan should be served"); + + client.release.countDown(); + } + + /** + * A refresh only starts once the remaining lifespan has dropped below the refresh token time skew, so a + * minimum which is not less than the skew can never be satisfied. That combination is rejected when the + * OIDC client is created, rather than adjusted here, so a {@link Tokens} built directly with it simply + * never reuses the token being refreshed instead of silently applying a different minimum. + */ + @Test + void testTokensNotReusedWhenMinRemainingLifespanNotLessThanSkew() throws Exception { + // A 3s skew with a 10s minimum: a refresh cannot start while more than 3s remain, so the minimum + // is unreachable. + Tokens current = tokens("current", 2, Duration.ofSeconds(3), Duration.ofSeconds(10)); + assertFalse(current.isAccessTokenExpired()); + assertTrue(current.isAccessTokenWithinRefreshInterval()); + assertFalse(current.hasMinRemainingAccessTokenLifespan(), + "a minimum which is not less than the skew can never be satisfied"); + + BlockingOidcClient client = new BlockingOidcClient(tokens("refreshed", 300)); + TokensHelper helper = new TokensHelper(); + helper.initTokens(new ImmediateOidcClient(current), Map.of()); + + helper.getTokens(client, Map.of(), false).subscribe().with(t -> { + }, t -> { + }); + client.awaitInFlight(); + + final AtomicReference served = new AtomicReference<>(); + final CountDownLatch completed = new CountDownLatch(1); + helper.getTokens(client, Map.of(), false).subscribe().with(t -> { + served.set(t); + completed.countDown(); + }, t -> completed.countDown()); + + assertFalse(completed.await(1, TimeUnit.SECONDS), + "tokens must not be reused when the minimum can never be satisfied"); + + client.release.countDown(); + assertTrue(completed.await(10, TimeUnit.SECONDS), "the caller should have completed"); + assertEquals("refreshed", served.get().getAccessToken(), + "the caller should have waited for the refreshed tokens"); + } + + /** + * Reusing the tokens being refreshed is opt-in: when no minimum remaining lifespan is configured + * the callers wait for the refresh, which is the behaviour of an OIDC client that has not enabled + * this. Without it, upgrading would change how every existing deployment behaves. + */ + @Test + void testTokensNotReusedWhenMinRemainingLifespanNotConfigured() throws Exception { + // Still valid and inside the skew, so the only reason not to serve it is the absent minimum. + Tokens current = tokens("current", 8, TIME_SKEW, null); + assertFalse(current.isAccessTokenExpired()); + assertTrue(current.isAccessTokenWithinRefreshInterval()); + assertFalse(current.hasMinRemainingAccessTokenLifespan()); + + BlockingOidcClient client = new BlockingOidcClient(tokens("refreshed", 300)); + TokensHelper helper = new TokensHelper(); + helper.initTokens(new ImmediateOidcClient(current), Map.of()); + + helper.getTokens(client, Map.of(), false).subscribe().with(t -> { + }, t -> { + }); + client.awaitInFlight(); + + final AtomicReference served = new AtomicReference<>(); + final CountDownLatch completed = new CountDownLatch(1); + helper.getTokens(client, Map.of(), false).subscribe().with(t -> { + served.set(t); + completed.countDown(); + }, t -> completed.countDown()); + + assertFalse(completed.await(1, TimeUnit.SECONDS), + "tokens must not be reused when no minimum remaining lifespan is configured"); + + client.release.countDown(); + assertTrue(completed.await(10, TimeUnit.SECONDS), "the caller should have completed"); + assertEquals("refreshed", served.get().getAccessToken(), + "the caller should have waited for the refreshed tokens"); + } + + /** + * Tokens whose expiry the provider did not report cannot be shown to have the required lifespan left, + * so they are not reused while a refresh is in flight. + */ + @Test + void testTokensWithUnknownExpiryAreNotReused() { + Tokens noExpiry = new Tokens("current", null, TIME_SKEW, null, null, new JsonObject(), "client", + MIN_REMAINING_LIFESPAN); + assertFalse(noExpiry.hasMinRemainingAccessTokenLifespan(), + "tokens with an unknown expiry must not be reused while they are being refreshed"); + } + + /** + * An OidcClient whose token acquisition blocks until released, so that the in-flight refresh + * window can be observed deterministically. + */ + private static final class BlockingOidcClient implements OidcClient { + + private final CountDownLatch release = new CountDownLatch(1); + /** Counts down once acquisition has actually begun, so tests never guess at timing. */ + private final CountDownLatch started = new CountDownLatch(1); + private final AtomicInteger acquisitions = new AtomicInteger(); + private final Tokens newTokens; + + BlockingOidcClient(Tokens newTokens) { + this.newTokens = newTokens; + } + + /** Blocks until a token request is genuinely in flight. */ + void awaitInFlight() throws InterruptedException { + assertTrue(started.await(10, TimeUnit.SECONDS), "token acquisition should have started"); + } + + @Override + public Uni getTokens() { + return getTokens(Map.of()); + } + + @Override + public Uni getTokens(Map additionalGrantParameters) { + acquisitions.incrementAndGet(); + // Blocks on a separate thread so the refresh stays genuinely in flight while the + // test thread makes its second call; running it on the subscribing thread would + // complete the refresh before that call happens. + return Uni.createFrom().item(() -> { + try { + started.countDown(); + release.await(10, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return newTokens; + }).runSubscriptionOn(Executors.newSingleThreadExecutor()); + } + + @Override + public Uni refreshTokens(String refreshToken) { + return refreshTokens(refreshToken, Map.of()); + } + + @Override + public Uni refreshTokens(String refreshToken, Map additionalGrantParameters) { + return getTokens(additionalGrantParameters); + } + + @Override + public Uni revokeAccessToken(String accessToken) { + return Uni.createFrom().item(true); + } + + @Override + public Uni revokeAccessToken(String accessToken, Map additionalParameters) { + return Uni.createFrom().item(true); + } + + @Override + public void close() { + // no-op in the tests + } + } + + /** An OidcClient that returns a fixed set of tokens without blocking. */ + private record ImmediateOidcClient(Tokens tokens) implements OidcClient { + + @Override + public Uni getTokens() { + return Uni.createFrom().item(tokens); + } + + @Override + public Uni getTokens(Map additionalGrantParameters) { + return Uni.createFrom().item(tokens); + } + + @Override + public Uni refreshTokens(String refreshToken) { + return Uni.createFrom().item(tokens); + } + + @Override + public Uni refreshTokens(String refreshToken, Map additionalGrantParameters) { + return Uni.createFrom().item(tokens); + } + + @Override + public Uni revokeAccessToken(String accessToken) { + return Uni.createFrom().item(true); + } + + @Override + public Uni revokeAccessToken(String accessToken, Map additionalParameters) { + return Uni.createFrom().item(true); + } + + @Override + public void close() { + // no-op in the test + } + } } From 19fee8c53ce83469325d1977461868ff5d51b916 Mon Sep 17 00:00:00 2001 From: Ignacio Vidal Date: Wed, 21 Oct 2026 22:12:18 +0100 Subject: [PATCH 251/254] Keep the previous Tokens constructor, deprecated for removal Tokens is public API, so replacing its 7-arg constructor broke existing callers. Restore it, delegating with no minimum remaining access token lifespan so tokens created with it keep the previous behaviour, and deprecate it for removal in favour of the 8-arg constructor. --- .../main/java/io/quarkus/oidc/client/Tokens.java | 11 +++++++++++ .../oidc/client/runtime/TokensHelperTest.java | 15 +++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/Tokens.java b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/Tokens.java index 4e22b9c501260..1f6325583cd9a 100644 --- a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/Tokens.java +++ b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/Tokens.java @@ -21,6 +21,17 @@ public class Tokens { final private JsonObject grantResponse; final private String clientId; + /** + * @deprecated Use {@link #Tokens(String, Long, Duration, String, Long, JsonObject, String, Duration)} instead. + * Tokens created with this constructor are never reused while they are being refreshed. + */ + @Deprecated(since = "4.0", forRemoval = true) + public Tokens(String accessToken, Long accessTokenExpiresAt, Duration refreshTokenTimeSkewDuration, String refreshToken, + Long refreshTokenExpiresAt, JsonObject grantResponse, String clientId) { + this(accessToken, accessTokenExpiresAt, refreshTokenTimeSkewDuration, refreshToken, refreshTokenExpiresAt, + grantResponse, clientId, null); + } + public Tokens(String accessToken, Long accessTokenExpiresAt, Duration refreshTokenTimeSkewDuration, String refreshToken, Long refreshTokenExpiresAt, JsonObject grantResponse, String clientId, Duration minRemainingAccessTokenLifespanDuration) { diff --git a/extensions/oidc-client/runtime/src/test/java/io/quarkus/oidc/client/runtime/TokensHelperTest.java b/extensions/oidc-client/runtime/src/test/java/io/quarkus/oidc/client/runtime/TokensHelperTest.java index 3066a1a8d8f8e..f8ce47d8dafa4 100644 --- a/extensions/oidc-client/runtime/src/test/java/io/quarkus/oidc/client/runtime/TokensHelperTest.java +++ b/extensions/oidc-client/runtime/src/test/java/io/quarkus/oidc/client/runtime/TokensHelperTest.java @@ -1046,6 +1046,21 @@ void testTokensNotReusedWhenMinRemainingLifespanNotLessThanSkew() throws Excepti "the caller should have waited for the refreshed tokens"); } + /** + * The deprecated constructor predates the minimum remaining lifespan, so tokens created with it + * must keep the previous behaviour and never be reused while they are being refreshed. + */ + @Test + @SuppressWarnings("removal") + void testDeprecatedConstructorDoesNotConfigureMinRemainingLifespan() { + final long nowSecs = System.currentTimeMillis() / 1000; + Tokens tokens = new Tokens("current", nowSecs + 8, TIME_SKEW, "refresh", nowSecs + 300, new JsonObject(), + "client"); + assertThat(tokens.isAccessTokenExpired()).isFalse(); + assertThat(tokens.isAccessTokenWithinRefreshInterval()).isTrue(); + assertThat(tokens.hasMinRemainingAccessTokenLifespan()).isFalse(); + } + /** * Reusing the tokens being refreshed is opt-in: when no minimum remaining lifespan is configured * the callers wait for the refresh, which is the behaviour of an OIDC client that has not enabled From 85e6ad6033552d2ec780adbb6a9856b0268c83b8 Mon Sep 17 00:00:00 2001 From: Ignacio Vidal Date: Wed, 21 Oct 2026 22:31:11 +0100 Subject: [PATCH 252/254] Stop serving the tokens being refreshed once new tokens are forced A caller forces the acquisition of new tokens because the ones it was given were rejected, typically after a 401 when refresh on unauthorized is enabled. If a refresh is already in flight, the tokens being refreshed kept being served to the other callers, even though they are now known to have been rejected. When new tokens are forced during a refresh, drop the tokens being refreshed from the state so that every caller waits for the refresh in flight. --- .../oidc/client/runtime/TokensHelper.java | 9 +++- .../oidc/client/runtime/TokensHelperTest.java | 44 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/TokensHelper.java b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/TokensHelper.java index 3b9ff6c3681fb..684a17b874dcd 100644 --- a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/TokensHelper.java +++ b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/TokensHelper.java @@ -87,10 +87,17 @@ private Uni getTokens(OidcClient oidcClient, Map additio return newState.tokenUni; } //rerun the CAS loop + } else if (currentState.tokenUni != null && currentState.tokensBeingRefreshed != null && forceNewTokens) { + // Stop serving the old tokensBeingRefreshed and make clients wait for the refresh in flight + newState = new TokenRequestState(currentState.tokenUni); + if (tokenRequestStateUpdater.compareAndSet(holder, currentState, newState)) { + return newState.tokenUni; + } + //rerun the CAS loop } else if (currentState.tokenUni != null) { // Serve token getting refreshed if it has enough lifespan left final Tokens tokensBeingRefreshed = currentState.tokensBeingRefreshed; - if (tokensBeingRefreshed != null && !forceNewTokens) { + if (tokensBeingRefreshed != null) { if (tokensBeingRefreshed.hasMinRemainingAccessTokenLifespan()) { return Uni.createFrom().item(tokensBeingRefreshed); } diff --git a/extensions/oidc-client/runtime/src/test/java/io/quarkus/oidc/client/runtime/TokensHelperTest.java b/extensions/oidc-client/runtime/src/test/java/io/quarkus/oidc/client/runtime/TokensHelperTest.java index f8ce47d8dafa4..9104205d9c0d2 100644 --- a/extensions/oidc-client/runtime/src/test/java/io/quarkus/oidc/client/runtime/TokensHelperTest.java +++ b/extensions/oidc-client/runtime/src/test/java/io/quarkus/oidc/client/runtime/TokensHelperTest.java @@ -938,6 +938,50 @@ void testForcedAcquisitionNotServedPreviousTokensWhileRefreshIsInFlight() throws "the forcing caller should have waited for the refreshed tokens"); } + /** + * Once a caller has forced the acquisition of new tokens while a refresh is in flight, the tokens + * being refreshed are known to have been rejected, so they must not be served to anyone else + * either: every caller arriving afterwards waits for the refresh too. + */ + @Test + void testTokensBeingRefreshedNotServedAfterForcedAcquisition() throws Exception { + Tokens current = tokens("current", 8); + BlockingOidcClient client = new BlockingOidcClient(tokens("refreshed", 300)); + TokensHelper helper = new TokensHelper(); + helper.initTokens(new ImmediateOidcClient(current), Map.of()); + + helper.getTokens(client, Map.of(), false).subscribe().with(t -> { + }, t -> { + }); + client.awaitInFlight(); + + // Before the forcing, the tokens being refreshed are still served, so what follows is down to it. + assertThat(helper.getTokens(client, Map.of(), false).await().atMost(Duration.ofSeconds(2))).isSameAs(current); + + final CountDownLatch forcedCompleted = new CountDownLatch(1); + helper.getTokens(client, Map.of(), true).subscribe().with(t -> forcedCompleted.countDown(), + t -> forcedCompleted.countDown()); + + final AtomicReference served = new AtomicReference<>(); + final CountDownLatch completed = new CountDownLatch(1); + helper.getTokens(client, Map.of(), false).subscribe().with(t -> { + served.set(t); + completed.countDown(); + }, t -> completed.countDown()); + + assertThat(completed.await(1, TimeUnit.SECONDS)) + .as("rejected tokens must not be served to callers arriving after the forced acquisition") + .isFalse(); + + client.release.countDown(); + assertThat(forcedCompleted.await(10, TimeUnit.SECONDS)).as("the forcing caller should have completed").isTrue(); + assertThat(completed.await(10, TimeUnit.SECONDS)).as("the caller should have completed").isTrue(); + assertThat(served.get().getAccessToken()).isEqualTo("refreshed"); + assertThat(client.acquisitions.get()) + .as("the forced acquisition should have joined the refresh in flight rather than start another") + .isEqualTo(1); + } + /** * Tokens which are still valid but too close to expiry are not served while a refresh is in * flight: they could expire in transit, or while the target service handles the request. Such a From b5b4032ac33795cebe6d24c8b159fa3031f06820 Mon Sep 17 00:00:00 2001 From: Ignacio Vidal Date: Wed, 21 Oct 2026 22:42:32 +0100 Subject: [PATCH 253/254] Give the short-lived tokens test headroom over the second boundary The access token expiry is tracked in whole seconds, and the test created tokens expiring in 2s with a 2s minimum remaining lifespan, so crossing a second boundary before they were requested was enough for them not to be served. Use a 6s skew, 5s remaining and a 1s minimum, which leaves 4s for the test to run. --- .../quarkus/oidc/client/runtime/TokensHelperTest.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/extensions/oidc-client/runtime/src/test/java/io/quarkus/oidc/client/runtime/TokensHelperTest.java b/extensions/oidc-client/runtime/src/test/java/io/quarkus/oidc/client/runtime/TokensHelperTest.java index 9104205d9c0d2..c883c56f125ac 100644 --- a/extensions/oidc-client/runtime/src/test/java/io/quarkus/oidc/client/runtime/TokensHelperTest.java +++ b/extensions/oidc-client/runtime/src/test/java/io/quarkus/oidc/client/runtime/TokensHelperTest.java @@ -874,11 +874,11 @@ void testExpiredTokensNotServedWhileRefreshIsInFlight() throws Exception { */ @Test void testShortLivedTokensStillServedWhileRefreshIsInFlight() throws Exception { - // A 3s skew means a refresh starts once fewer than 3s of life remain, so 2s remaining - // both triggers the refresh and leaves the tokens usable while it runs. - final long nowSecs = System.currentTimeMillis() / 1000; - Tokens current = new Tokens("current", nowSecs + 2, Duration.ofSeconds(3), null, null, - new JsonObject(), "client", MIN_REMAINING_LIFESPAN); + // A 6s skew means a refresh starts once fewer than 6s of life remain, so 5s remaining + // both triggers the refresh and leaves the tokens usable while it runs. The expiry is + // tracked in whole seconds, so the 1s minimum leaves 4s for the test to run before the + // tokens stop being served. + Tokens current = tokens("current", 5, Duration.ofSeconds(6), Duration.ofSeconds(1)); assertFalse(current.isAccessTokenExpired()); assertTrue(current.isAccessTokenWithinRefreshInterval()); From cf54e07f9ce89561e3a81e6bef4ac96d7a321e97 Mon Sep 17 00:00:00 2001 From: Ignacio Vidal Date: Wed, 21 Oct 2026 22:57:07 +0100 Subject: [PATCH 254/254] Only log why the tokens being refreshed are not reused when a minimum is set The debug message explaining that the tokens being refreshed would not be reused because too little of their lifespan was left was also logged when no minimum remaining access token lifespan was configured, where it does not apply. Log it only when a minimum is configured, and include it in the message. --- .../src/main/java/io/quarkus/oidc/client/Tokens.java | 4 ++++ .../java/io/quarkus/oidc/client/runtime/TokensHelper.java | 8 +++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/Tokens.java b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/Tokens.java index 1f6325583cd9a..47b9f03eaa9f5 100644 --- a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/Tokens.java +++ b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/Tokens.java @@ -70,6 +70,10 @@ public Long getRefreshTokenTimeSkew() { return refreshTokenTimeSkew; } + public Long getMinRemainingAccessTokenLifespan() { + return minRemainingAccessTokenLifespan; + } + /** * Whether this access token has enough of its lifespan left to still be worth sending while it is * being refreshed, so that it does not expire in transit or while the target service is processing diff --git a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/TokensHelper.java b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/TokensHelper.java index 684a17b874dcd..e11cf7ae7fba0 100644 --- a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/TokensHelper.java +++ b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/TokensHelper.java @@ -101,9 +101,11 @@ private Uni getTokens(OidcClient oidcClient, Map additio if (tokensBeingRefreshed.hasMinRemainingAccessTokenLifespan()) { return Uni.createFrom().item(tokensBeingRefreshed); } - LOG.debugf("Access token being refreshed for client %s will not be reused because too little" - + " of its lifespan is left, waiting for the refreshed tokens instead", - tokensBeingRefreshed.getClientId()); + if (tokensBeingRefreshed.getMinRemainingAccessTokenLifespan() != null) { + LOG.debugf("Access token being refreshed for client %s will not be reused because less than" + + " %d seconds of its lifespan is left, waiting for the refreshed tokens instead", + tokensBeingRefreshed.getClientId(), tokensBeingRefreshed.getMinRemainingAccessTokenLifespan()); + } } return currentState.tokenUni; } else if (forceNewTokens) {