This is an automated email from the ASF dual-hosted git repository.

adutra pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/polaris.git


The following commit(s) were added to refs/heads/main by this push:
     new 0c790e0ad Standardize logging libraries in tests (#2268)
0c790e0ad is described below

commit 0c790e0ad48d69d97033bdde08d3bf4bb415210f
Author: Alexandre Dutra <adu...@apache.org>
AuthorDate: Wed Aug 6 14:39:47 2025 +0200

    Standardize logging libraries in tests (#2268)
    
    This change enforces the following test logging patterns:
    
    - Non-Quarkus modules use Logback Classic, configured via logback-test.xml
    - Quarkus modules use JBoss Logging Manager, configured in Quarkus 
configuration files.
    
    This change also introduces a workaround for the "duplicate log messages" 
issues with Gradle + JBoss Logging Manager. See this issue for context:
    
    https://github.com/quarkusio/quarkus/issues/22844
    
    The workaround implemented in this PR is very similar to the one proposed 
in this comment:
    
    https://github.com/quarkusio/quarkus/issues/22844#issuecomment-1824720923
    
    Note: it's not entirely possible imho to suppress the following message on 
the console:
    
    ```
    The Agroal dependency is present but no JDBC datasources have been defined.
    ```
    
    This is because:
    
    1. The message happens during augmentation phase, not during tests
    2. And it suffers from the "duplicate message" issue with (it's actually 
Gradle that prints those messages).
---
 .../src/main/kotlin/polaris-java.gradle.kts        | 12 +--
 .../src/main/kotlin/polaris-runtime.gradle.kts     | 25 ++++++
 .../src/test/resources/logback-test.xml            | 11 +--
 plugins/spark/v3.5/integration/build.gradle.kts    |  5 +-
 .../src/test/resources/logback-test.xml}           | 22 ++---
 runtime/admin/build.gradle.kts                     |  2 -
 runtime/common/build.gradle.kts                    |  3 +-
 runtime/defaults/build.gradle.kts                  |  3 +-
 .../src/main/resources/application-it.properties   | 13 ++-
 .../src/main/resources/application-test.properties | 24 +++++-
 .../src/main/resources/application.properties      |  9 ++-
 runtime/service/build.gradle.kts                   |  6 +-
 .../service/exception/IcebergExceptionMapper.java  | 15 ++--
 .../IcebergJsonProcessingExceptionMapper.java      | 15 +++-
 .../service/exception/PolarisExceptionMapper.java  |  8 +-
 .../service/exception/ExceptionMapperTest.java     | 94 ++++++++++++++--------
 runtime/spark-tests/build.gradle.kts               |  2 +-
 .../junit/GradleDuplicateLoggingWorkaround.java    | 84 +++++++++++++++++++
 .../org.junit.jupiter.api.extension.Extension      | 20 +++++
 19 files changed, 277 insertions(+), 96 deletions(-)

diff --git a/build-logic/src/main/kotlin/polaris-java.gradle.kts 
b/build-logic/src/main/kotlin/polaris-java.gradle.kts
index 980a144b7..799234506 100644
--- a/build-logic/src/main/kotlin/polaris-java.gradle.kts
+++ b/build-logic/src/main/kotlin/polaris-java.gradle.kts
@@ -107,11 +107,13 @@ testing {
       dependencies {
         implementation(project())
         implementation(testFixtures(project()))
-        runtimeOnly(
-          libs.findLibrary("logback-classic").orElseThrow {
-            GradleException("logback-classic not declared in 
libs.versions.toml")
-          }
-        )
+        if (!plugins.hasPlugin("io.quarkus")) {
+          implementation(
+            libs.findLibrary("logback-classic").orElseThrow {
+              GradleException("logback-classic not declared in 
libs.versions.toml")
+            }
+          )
+        }
         implementation(
           libs.findLibrary("assertj-core").orElseThrow {
             GradleException("assertj-core not declared in libs.versions.toml")
diff --git a/build-logic/src/main/kotlin/polaris-runtime.gradle.kts 
b/build-logic/src/main/kotlin/polaris-runtime.gradle.kts
index ee3e547a6..f52b2f16f 100644
--- a/build-logic/src/main/kotlin/polaris-runtime.gradle.kts
+++ b/build-logic/src/main/kotlin/polaris-runtime.gradle.kts
@@ -24,6 +24,14 @@ testing {
   suites {
     withType<JvmTestSuite> {
       targets.all {
+        testTask.configure {
+          systemProperty("java.util.logging.manager", 
"org.jboss.logmanager.LogManager")
+          // Enable automatic extension detection to execute 
GradleDuplicateLoggingWorkaround
+          // automatically.
+          // See https://github.com/quarkusio/quarkus/issues/22844
+          systemProperty("junit.jupiter.extensions.autodetection.enabled", 
"true")
+        }
+
         if (testTask.name != "test") {
           testTask.configure {
             // For Quarkus...
@@ -47,6 +55,23 @@ testing {
   }
 }
 
+dependencies {
+  // All Quarkus projects should use JBoss LogManager with SLF4J, instead of 
Logback
+  implementation("org.jboss.slf4j:slf4j-jboss-logmanager")
+}
+
+configurations.all {
+  // Validate that Logback dependencies are not used in Quarkus modules.
+  dependencies.configureEach {
+    if (group == "ch.qos.logback") {
+      throw GradleException(
+        "Logback dependencies are not allowed in Quarkus modules. " +
+          "Found $group:$name in ${project.name}."
+      )
+    }
+  }
+}
+
 // Let the test's implementation config extend testImplementation, so it also 
inherits the
 // project's "main" implementation dependencies (not just the "api" 
configuration)
 configurations.named("intTestImplementation").configure {
diff --git a/runtime/service/src/test/resources/logback-test.xml 
b/persistence/relational-jdbc/src/test/resources/logback-test.xml
similarity index 70%
rename from runtime/service/src/test/resources/logback-test.xml
rename to persistence/relational-jdbc/src/test/resources/logback-test.xml
index ddad1db40..b7e97bb82 100644
--- a/runtime/service/src/test/resources/logback-test.xml
+++ b/persistence/relational-jdbc/src/test/resources/logback-test.xml
@@ -19,21 +19,14 @@
  under the License.
 
 -->
-<configuration>
+<configuration debug="false">
   <contextListener class="ch.qos.logback.classic.jul.LevelChangePropagator"/>
   <appender name="console" class="ch.qos.logback.core.ConsoleAppender">
     <encoder>
       <pattern>%date{ISO8601} [%thread] %-5level %logger{36} - %msg%n</pattern>
     </encoder>
   </appender>
-  <root level="${log.level.console:-INFO}">
+  <root level="${test.log.level:-ERROR}">
     <appender-ref ref="console"/>
   </root>
-
-  <!--
-  Prevent the 'The Agroal dependency is present but no JDBC datasources have 
been defined.' build-time warning.
-  See 
https://github.com/quarkusio/quarkus/blob/2fbc20f445fad43aaf4f3f984b9ac8319c7c7f0a/extensions/agroal/deployment/src/main/java/io/quarkus/agroal/deployment/AgroalProcessor.java#L111
-  THIS IS A TECHNICAL NECESSITY, because of the
-  -->
-  <logger name="io.quarkus.agroal.deployment" level="ERROR"/>
 </configuration>
diff --git a/plugins/spark/v3.5/integration/build.gradle.kts 
b/plugins/spark/v3.5/integration/build.gradle.kts
index 462523afe..bcfd7e15f 100644
--- a/plugins/spark/v3.5/integration/build.gradle.kts
+++ b/plugins/spark/v3.5/integration/build.gradle.kts
@@ -43,6 +43,7 @@ dependencies {
     exclude(group = "org.scala-lang", module = "scala-reflect")
   }
 
+  implementation(project(":polaris-runtime-defaults"))
   implementation(project(":polaris-runtime-service"))
 
   testImplementation(
@@ -52,6 +53,8 @@ dependencies {
 
   testImplementation(project(":polaris-api-management-model"))
 
+  testImplementation(project(":polaris-runtime-test-common"))
+
   
testImplementation("org.apache.spark:spark-sql_${scalaVersion}:${spark35Version}")
 {
     // exclude log4j dependencies. Explicit dependencies for the log4j 
libraries are
     // enforced below to ensure the version compatibility
@@ -63,7 +66,6 @@ dependencies {
   // enforce the usage of log4j 2.24.3. This is for the log4j-api compatibility
   // of spark-sql dependency
   testRuntimeOnly("org.apache.logging.log4j:log4j-core:2.25.1")
-  testRuntimeOnly("org.apache.logging.log4j:log4j-slf4j2-impl:2.25.1")
 
   testImplementation("io.delta:delta-spark_${scalaVersion}:3.3.1")
 
@@ -95,7 +97,6 @@ dependencies {
 
 tasks.named<Test>("intTest").configure {
   maxParallelForks = 1
-  systemProperty("java.util.logging.manager", 
"org.jboss.logmanager.LogManager")
   if (System.getenv("AWS_REGION") == null) {
     environment("AWS_REGION", "us-west-2")
   }
diff --git a/plugins/spark/v3.5/integration/src/intTest/resources/logback.xml 
b/plugins/spark/v3.5/spark/src/test/resources/logback-test.xml
similarity index 55%
rename from plugins/spark/v3.5/integration/src/intTest/resources/logback.xml
rename to plugins/spark/v3.5/spark/src/test/resources/logback-test.xml
index 5ec1efd5f..b7e97bb82 100644
--- a/plugins/spark/v3.5/integration/src/intTest/resources/logback.xml
+++ b/plugins/spark/v3.5/spark/src/test/resources/logback-test.xml
@@ -1,4 +1,4 @@
-<?xml version="1.0" encoding="UTF-8"?>
+<?xml version="1.0" encoding="UTF-8" ?>
 <!--
 
  Licensed to the Apache Software Foundation (ASF) under one
@@ -19,20 +19,14 @@
  under the License.
 
 -->
-<!--
-Spark by default set the logging at DEBUG level, which dumps a lot of code 
details in
-the Intelij console, which impacts the IDE performance significantly when 
running the
-tests. This configuration allows only log back the ERROR logs for the IDE, you 
can comment
-out the configuration if you would like ot see all spark debug log during the 
run.
--->
-<configuration>
-  <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
-    <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
-      <pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level - %msg%n</pattern>
+<configuration debug="false">
+  <contextListener class="ch.qos.logback.classic.jul.LevelChangePropagator"/>
+  <appender name="console" class="ch.qos.logback.core.ConsoleAppender">
+    <encoder>
+      <pattern>%date{ISO8601} [%thread] %-5level %logger{36} - %msg%n</pattern>
     </encoder>
   </appender>
-
-  <root level="ERROR">
-    <appender-ref ref="CONSOLE"/>
+  <root level="${test.log.level:-ERROR}">
+    <appender-ref ref="console"/>
   </root>
 </configuration>
diff --git a/runtime/admin/build.gradle.kts b/runtime/admin/build.gradle.kts
index f06895ba6..29ac2c856 100644
--- a/runtime/admin/build.gradle.kts
+++ b/runtime/admin/build.gradle.kts
@@ -43,7 +43,6 @@ dependencies {
   implementation("io.quarkus:quarkus-container-image-docker")
 
   implementation(project(":polaris-runtime-common"))
-  implementation("org.jboss.slf4j:slf4j-jboss-logmanager")
 
   testImplementation(project(":polaris-runtime-test-common"))
   testFixturesApi(project(":polaris-core"))
@@ -92,7 +91,6 @@ artifacts {
 tasks.withType(Test::class.java).configureEach {
   maxParallelForks = 4
   forkEvery = 1
-  systemProperty("java.util.logging.manager", 
"org.jboss.logmanager.LogManager")
 }
 
 tasks.named<Test>("test").configure {
diff --git a/runtime/common/build.gradle.kts b/runtime/common/build.gradle.kts
index 79452e80b..f1753882f 100644
--- a/runtime/common/build.gradle.kts
+++ b/runtime/common/build.gradle.kts
@@ -18,9 +18,8 @@
  */
 
 plugins {
-  alias(libs.plugins.quarkus)
   alias(libs.plugins.jandex)
-  id("polaris-runtime")
+  id("polaris-server")
 }
 
 dependencies {
diff --git a/runtime/defaults/build.gradle.kts 
b/runtime/defaults/build.gradle.kts
index 88134be6e..b61c2458b 100644
--- a/runtime/defaults/build.gradle.kts
+++ b/runtime/defaults/build.gradle.kts
@@ -18,9 +18,8 @@
  */
 
 plugins {
-  alias(libs.plugins.quarkus)
   alias(libs.plugins.jandex)
-  id("polaris-runtime")
+  id("polaris-server")
 }
 
 dependencies {
diff --git a/runtime/defaults/src/main/resources/application-it.properties 
b/runtime/defaults/src/main/resources/application-it.properties
index dc660f54a..7c3c70f3d 100644
--- a/runtime/defaults/src/main/resources/application-it.properties
+++ b/runtime/defaults/src/main/resources/application-it.properties
@@ -17,15 +17,22 @@
 # under the License.
 #
 
-# Configuration common to ALL integration tests (executed with "it" profile).
-# Note: Quarkus integration tests cannot use QuarkusTestProfile
+# Configuration common to ALL integration tests (executed with "it" profile – 
Gradle "intTest" tasks).
+# Note: Quarkus integration tests cannot use QuarkusTestProfile.
+
+# Note about logging during integration tests:
+# - Logging for test code is configured in the `application-test.properties` 
file (!!).
+#   By default, it is set to `ERROR` level.
+# - Logging for application code is configured in the main 
`application.properties` file.
+#   By default, it is set to `INFO` level.
+# Configuring logging levels or appenders in this file IS INEFFECTIVE!
 
 quarkus.http.limits.max-body-size=1000000
 quarkus.http.port=0
 
 quarkus.management.port=0
 
-# polaris.persistence.type=eclipse-link
+# polaris.persistence.type=jdbc
 # polaris.persistence.type=in-memory-atomic
 polaris.persistence.type=in-memory
 
diff --git a/runtime/defaults/src/main/resources/application-test.properties 
b/runtime/defaults/src/main/resources/application-test.properties
index ff0f576ca..3ba01dfb0 100644
--- a/runtime/defaults/src/main/resources/application-test.properties
+++ b/runtime/defaults/src/main/resources/application-test.properties
@@ -17,21 +17,37 @@
 # under the License.
 #
 
-# Configuration common to ALL unit tests (executed with "test" profile).
+# Configuration common to ALL unit tests (executed with "test" profile – 
Gradle "test" tasks).
 # Per-test specific configuration should use QuarkusTestProfile
 
-quarkus.log.file.enable=false
 quarkus.datasource.devservices.enabled=false
 
+quarkus.log.level=ERROR
+quarkus.log.file.enable=false
+quarkus.console.color=true
+
+# Useful loggers for debugging purposes.
+# quarkus.log.category."org.apache.polaris".level=INFO
+# quarkus.log.category."org.apache.iceberg".level=INFO
+# quarkus.log.category."io.quarkus.http.access-log".level=INFO
+# quarkus.log.category."org.apache.hc.client5.http".level=INFO
+
+# Silence a few verbose loggers that are not useful for unit tests.
+quarkus.log.category."org.apache.polaris.core.persistence.transactional.TransactionalMetaStoreManagerImpl".level=ERROR
+quarkus.log.category."org.apache.iceberg.rest.auth.AuthManagers".level=ERROR
+quarkus.log.category."org.apache.iceberg.rest.auth.OAuth2Manager".level=ERROR
+
 # Prevent the 'Hibernate Validator does not support constraints on static 
methods yet.' warning log messages on
 # code generated by the OpenAPI generator. The warning can only be silenced 
via a log level setting.
 # See 
https://github.com/quarkusio/quarkus/blob/15f6b4426512c04a54e7a0bd87f894fabe068c07/extensions/hibernate-validator/deployment/src/main/java/io/quarkus/hibernate/validator/deployment/MethodValidatedAnnotationsTransformer.java#L51-L53
-# and how the 'LOGGER' is setup.
+# and how the 'LOGGER' is set up.
 quarkus.log.category."io.quarkus.hibernate.validator.deployment".level=ERROR
+
 # Silence the FJP warning, nothing we can do about it.
 # See 
https://github.com/quarkusio/quarkus/blob/15f6b4426512c04a54e7a0bd87f894fabe068c07/core/deployment/src/main/java/io/quarkus/runner/bootstrap/ForkJoinClassLoading.java#L41-L42
 
quarkus.log.category."io.quarkus.runner.bootstrap.ForkJoinClassLoading".level=FATAL
+
 # Prevent the 'The Agroal dependency is present but no JDBC datasources have 
been defined.' build-time warning.
 # See 
https://github.com/quarkusio/quarkus/blob/2fbc20f445fad43aaf4f3f984b9ac8319c7c7f0a/extensions/agroal/deployment/src/main/java/io/quarkus/agroal/deployment/AgroalProcessor.java#L111
-# THIS IS ALSO CONFIGURED IN 
polaris-runtime-service/src/test/resources/logback-test.xml
 quarkus.log.category."io.quarkus.agroal.deployment".level=ERROR
+
diff --git a/runtime/defaults/src/main/resources/application.properties 
b/runtime/defaults/src/main/resources/application.properties
index a48c4bcbd..794ea1337 100644
--- a/runtime/defaults/src/main/resources/application.properties
+++ b/runtime/defaults/src/main/resources/application.properties
@@ -68,9 +68,11 @@ quarkus.log.file.path=./logs/polaris.log
 quarkus.log.file.rotation.file-suffix=.yyyy-MM-dd.gz
 quarkus.log.file.rotation.max-file-size=10M
 quarkus.log.file.rotation.max-backup-index=14
-quarkus.log.category."org.apache.polaris".level=INFO
-quarkus.log.category."org.apache.iceberg.rest".level=INFO
-quarkus.log.category."io.smallrye.config".level=INFO
+
+# Useful loggers. Set "io.smallrye.config" to DEBUG to print the resolved 
configuration properties.
+# quarkus.log.category."io.smallrye.config".level=INFO
+# quarkus.log.category."org.apache.polaris".level=INFO
+# quarkus.log.category."org.apache.iceberg.rest".level=INFO
 
 quarkus.management.port=8182
 quarkus.management.test-port=0
@@ -97,6 +99,7 @@ quarkus.otel.sdk.disabled=true
 # quarkus.otel.traces.sampler=parentbased_always_on
 # quarkus.otel.traces.sampler.arg=1.0d
 
+# This setting is a build-time setting and MUST be specified in this file.
 quarkus.test.integration-test-profile=it
 
 quarkus.fault-tolerance.global.timeout.enabled=false
diff --git a/runtime/service/build.gradle.kts b/runtime/service/build.gradle.kts
index b7a31c753..037776c40 100644
--- a/runtime/service/build.gradle.kts
+++ b/runtime/service/build.gradle.kts
@@ -67,8 +67,6 @@ dependencies {
   implementation(libs.guava)
   implementation(libs.slf4j.api)
 
-  implementation("org.jboss.slf4j:slf4j-jboss-logmanager")
-
   implementation(libs.hadoop.client.api)
   implementation(libs.hadoop.client.runtime)
 
@@ -148,9 +146,8 @@ dependencies {
   testImplementation(libs.threeten.extra)
   testImplementation(libs.hawkular.agent.prometheus.scraper)
 
-  testImplementation(libs.logback.classic)
-
   testImplementation(project(":polaris-runtime-test-common"))
+
   testImplementation("io.quarkus:quarkus-junit5")
   testImplementation(libs.awaitility)
   testImplementation(platform(libs.testcontainers.bom))
@@ -194,7 +191,6 @@ tasks.named("javadoc") { dependsOn("jandex") }
 
 tasks.withType(Test::class.java).configureEach {
   forkEvery = 1
-  systemProperty("java.util.logging.manager", 
"org.jboss.logmanager.LogManager")
   if (System.getenv("AWS_REGION") == null) {
     environment("AWS_REGION", "us-west-2")
   }
diff --git 
a/runtime/service/src/main/java/org/apache/polaris/service/exception/IcebergExceptionMapper.java
 
b/runtime/service/src/main/java/org/apache/polaris/service/exception/IcebergExceptionMapper.java
index 048da3f79..fff99e1f2 100644
--- 
a/runtime/service/src/main/java/org/apache/polaris/service/exception/IcebergExceptionMapper.java
+++ 
b/runtime/service/src/main/java/org/apache/polaris/service/exception/IcebergExceptionMapper.java
@@ -87,19 +87,17 @@ public class IcebergExceptionMapper implements 
ExceptionMapper<RuntimeException>
   private static final Set<String> ACCESS_DENIED_HINTS =
       Set.of("access denied", "not authorized", "forbidden");
 
-  public IcebergExceptionMapper() {}
-
   @Override
   public Response toResponse(RuntimeException runtimeException) {
-    LOGGER.info("Handling runtimeException {}", runtimeException.getMessage());
+    getLogger().info("Handling runtimeException {}", 
runtimeException.getMessage());
 
     int responseCode = mapExceptionToResponseCode(runtimeException);
-    LOGGER
+    getLogger()
         .atLevel(responseCode >= 500 ? Level.INFO : Level.DEBUG)
         .log("Full RuntimeException", runtimeException);
 
     if (responseCode == Response.Status.INTERNAL_SERVER_ERROR.getStatusCode()) 
{
-      LOGGER.error("Unhandled exception returning INTERNAL_SERVER_ERROR", 
runtimeException);
+      getLogger().error("Unhandled exception returning INTERNAL_SERVER_ERROR", 
runtimeException);
     }
 
     ErrorResponse icebergErrorResponse =
@@ -113,7 +111,7 @@ public class IcebergExceptionMapper implements 
ExceptionMapper<RuntimeException>
             .entity(icebergErrorResponse)
             .type(MediaType.APPLICATION_JSON_TYPE)
             .build();
-    LOGGER.debug("Mapped exception to errorResp: {}", errorResp);
+    getLogger().debug("Mapped exception to errorResp: {}", errorResp);
     return errorResp;
   }
 
@@ -262,4 +260,9 @@ public class IcebergExceptionMapper implements 
ExceptionMapper<RuntimeException>
 
     return Optional.of(Status.INTERNAL_SERVER_ERROR.getStatusCode());
   }
+
+  @VisibleForTesting
+  Logger getLogger() {
+    return LOGGER;
+  }
 }
diff --git 
a/runtime/service/src/main/java/org/apache/polaris/service/exception/IcebergJsonProcessingExceptionMapper.java
 
b/runtime/service/src/main/java/org/apache/polaris/service/exception/IcebergJsonProcessingExceptionMapper.java
index 57dcba32f..10130700c 100644
--- 
a/runtime/service/src/main/java/org/apache/polaris/service/exception/IcebergJsonProcessingExceptionMapper.java
+++ 
b/runtime/service/src/main/java/org/apache/polaris/service/exception/IcebergJsonProcessingExceptionMapper.java
@@ -26,6 +26,7 @@ import com.fasterxml.jackson.core.JsonParseException;
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.databind.exc.InvalidDefinitionException;
 import com.fasterxml.jackson.databind.exc.ValueInstantiationException;
+import com.google.common.annotations.VisibleForTesting;
 import jakarta.annotation.Nullable;
 import jakarta.ws.rs.core.MediaType;
 import jakarta.ws.rs.core.Response;
@@ -40,7 +41,7 @@ import org.slf4j.LoggerFactory;
 
 /** See Dropwizard's {@code 
io.dropwizard.jersey.jackson.JsonProcessingExceptionMapper} */
 @Provider
-public final class IcebergJsonProcessingExceptionMapper
+public class IcebergJsonProcessingExceptionMapper
     implements ExceptionMapper<JsonProcessingException> {
 
   private static final Logger LOGGER =
@@ -60,7 +61,8 @@ public final class IcebergJsonProcessingExceptionMapper
     if (exception instanceof JsonGenerationException
         || exception instanceof InvalidDefinitionException) {
       long id = ThreadLocalRandom.current().nextLong();
-      LOGGER.error(String.format(Locale.ROOT, "Error handling a request: 
%016x", id), exception);
+      getLogger()
+          .error(String.format(Locale.ROOT, "Error handling a request: %016x", 
id), exception);
       String message =
           String.format(
               Locale.ROOT,
@@ -75,8 +77,8 @@ public final class IcebergJsonProcessingExceptionMapper
     /*
      * Otherwise, it's those pesky users.
      */
-    LOGGER.info("Unable to process JSON: {}", exception.getMessage());
-    LOGGER.debug("Full JsonProcessingException", exception);
+    getLogger().info("Unable to process JSON: {}", exception.getMessage());
+    getLogger().debug("Full JsonProcessingException", exception);
 
     String messagePrefix =
         switch (exception) {
@@ -96,4 +98,9 @@ public final class IcebergJsonProcessingExceptionMapper
         .entity(icebergErrorResponse)
         .build();
   }
+
+  @VisibleForTesting
+  Logger getLogger() {
+    return LOGGER;
+  }
 }
diff --git 
a/runtime/service/src/main/java/org/apache/polaris/service/exception/PolarisExceptionMapper.java
 
b/runtime/service/src/main/java/org/apache/polaris/service/exception/PolarisExceptionMapper.java
index 8b68d3890..7d0387f1d 100644
--- 
a/runtime/service/src/main/java/org/apache/polaris/service/exception/PolarisExceptionMapper.java
+++ 
b/runtime/service/src/main/java/org/apache/polaris/service/exception/PolarisExceptionMapper.java
@@ -18,6 +18,7 @@
  */
 package org.apache.polaris.service.exception;
 
+import com.google.common.annotations.VisibleForTesting;
 import jakarta.ws.rs.core.MediaType;
 import jakarta.ws.rs.core.Response;
 import jakarta.ws.rs.ext.ExceptionMapper;
@@ -64,7 +65,7 @@ public class PolarisExceptionMapper implements 
ExceptionMapper<PolarisException>
   @Override
   public Response toResponse(PolarisException exception) {
     Response.Status status = getStatus(exception);
-    LOGGER
+    getLogger()
         .atLevel(
             status.getFamily() == Response.Status.Family.SERVER_ERROR ? 
Level.INFO : Level.DEBUG)
         .log("Full PolarisException", exception);
@@ -80,4 +81,9 @@ public class PolarisExceptionMapper implements 
ExceptionMapper<PolarisException>
         .type(MediaType.APPLICATION_JSON_TYPE)
         .build();
   }
+
+  @VisibleForTesting
+  Logger getLogger() {
+    return LOGGER;
+  }
 }
diff --git 
a/runtime/service/src/test/java/org/apache/polaris/service/exception/ExceptionMapperTest.java
 
b/runtime/service/src/test/java/org/apache/polaris/service/exception/ExceptionMapperTest.java
index d0da97fc3..7ba2bd3e6 100644
--- 
a/runtime/service/src/test/java/org/apache/polaris/service/exception/ExceptionMapperTest.java
+++ 
b/runtime/service/src/test/java/org/apache/polaris/service/exception/ExceptionMapperTest.java
@@ -18,11 +18,12 @@
  */
 package org.apache.polaris.service.exception;
 
-import ch.qos.logback.classic.Level;
-import ch.qos.logback.classic.Logger;
-import ch.qos.logback.classic.spi.ILoggingEvent;
-import ch.qos.logback.classic.spi.IThrowableProxy;
-import ch.qos.logback.core.read.ListAppender;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
 import com.fasterxml.jackson.core.JsonLocation;
 import com.fasterxml.jackson.core.JsonProcessingException;
 import jakarta.ws.rs.core.Response;
@@ -32,50 +33,62 @@ import java.util.stream.Stream;
 import org.apache.polaris.core.exceptions.AlreadyExistsException;
 import org.apache.polaris.core.exceptions.CommitConflictException;
 import org.assertj.core.api.Assertions;
+import org.jboss.logmanager.Level;
+import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.Arguments;
 import org.junit.jupiter.params.provider.MethodSource;
-import org.slf4j.LoggerFactory;
+import org.mockito.Mockito;
+import org.slf4j.Logger;
+import org.slf4j.impl.Slf4jLogger;
 
 /** Unit tests for exception mappers */
+@SuppressWarnings("resource")
 public class ExceptionMapperTest {
   private static final String MESSAGE = "this is the exception message";
   private static final String CAUSE = "this is the exception cause";
 
+  private static final org.jboss.logmanager.Logger JBOSS_LOGGER =
+      Mockito.mock(org.jboss.logmanager.Logger.class);
+
+  @BeforeEach
+  void setUp() {
+    reset(JBOSS_LOGGER);
+    when(JBOSS_LOGGER.isLoggable(any())).thenReturn(true);
+  }
+
   @ParameterizedTest
   @MethodSource("testFullExceptionIsLogged")
   public void testFullExceptionIsLogged(
       ExceptionMapper<Exception> mapper, Exception exception, Level level) {
-    Logger logger = (Logger) LoggerFactory.getLogger(mapper.getClass());
-    logger.setLevel(level);
-    ListAppender<ILoggingEvent> listAppender = new ListAppender<>();
-    listAppender.start();
-    logger.addAppender(listAppender);
 
     mapper.toResponse(exception);
 
-    Assertions.assertThat(
-            listAppender.list.stream()
-                .anyMatch(
-                    log -> {
-                      if (log.getLevel() != level) {
-                        return false;
-                      }
+    verify(JBOSS_LOGGER)
+        .logRaw(
+            argThat(
+                record -> {
+                  if (record.getLevel() != level) {
+                    return false;
+                  }
+
+                  String message = record.getMessage();
+                  if (message == null) {
+                    return false;
+                  }
 
-                      IThrowableProxy proxy = log.getThrowableProxy();
-                      if (proxy == null) {
-                        return false;
-                      }
+                  Throwable error = record.getThrown();
+                  if (error == null) {
+                    return false;
+                  }
 
-                      return proxy.getMessage().contains(CAUSE)
-                          || Optional.ofNullable(proxy.getCause())
-                              .map(IThrowableProxy::getMessage)
-                              .orElse("")
-                              .contains(CAUSE);
-                    }))
-        .as("The exception cause should be logged")
-        .isTrue();
+                  return message.contains(CAUSE)
+                      || Optional.ofNullable(error.getCause())
+                          .map(Throwable::getMessage)
+                          .orElse("")
+                          .contains(CAUSE);
+                }));
   }
 
   @Test
@@ -90,15 +103,30 @@ public class ExceptionMapperTest {
     // inherited Exception
     return Stream.of(
         Arguments.of(
-            new IcebergExceptionMapper(),
+            new IcebergExceptionMapper() {
+              @Override
+              Logger getLogger() {
+                return new Slf4jLogger(JBOSS_LOGGER);
+              }
+            },
             new RuntimeException(MESSAGE, new RuntimeException(CAUSE)),
             Level.ERROR),
         Arguments.of(
-            new IcebergJsonProcessingExceptionMapper(),
+            new IcebergJsonProcessingExceptionMapper() {
+              @Override
+              Logger getLogger() {
+                return new Slf4jLogger(JBOSS_LOGGER);
+              }
+            },
             new TestJsonProcessingException(MESSAGE, null, new 
RuntimeException(CAUSE)),
             Level.DEBUG),
         Arguments.of(
-            new PolarisExceptionMapper(),
+            new PolarisExceptionMapper() {
+              @Override
+              Logger getLogger() {
+                return new Slf4jLogger(JBOSS_LOGGER);
+              }
+            },
             new AlreadyExistsException(MESSAGE, new RuntimeException(CAUSE)),
             Level.DEBUG));
   }
diff --git a/runtime/spark-tests/build.gradle.kts 
b/runtime/spark-tests/build.gradle.kts
index fd130a6c5..57b778f53 100644
--- a/runtime/spark-tests/build.gradle.kts
+++ b/runtime/spark-tests/build.gradle.kts
@@ -36,6 +36,7 @@ dependencies {
 
   testImplementation(project(":polaris-tests"))
   testImplementation(testFixtures(project(":polaris-runtime-service")))
+  testImplementation(project(":polaris-runtime-test-common"))
 
   testImplementation(platform(libs.quarkus.bom))
   testImplementation("io.quarkus:quarkus-junit5")
@@ -60,7 +61,6 @@ dependencies {
 
 tasks.named<Test>("intTest").configure {
   maxParallelForks = 1
-  systemProperty("java.util.logging.manager", 
"org.jboss.logmanager.LogManager")
   if (System.getenv("AWS_REGION") == null) {
     environment("AWS_REGION", "us-west-2")
   }
diff --git 
a/runtime/test-common/src/main/java/org/apache/polaris/test/commons/junit/GradleDuplicateLoggingWorkaround.java
 
b/runtime/test-common/src/main/java/org/apache/polaris/test/commons/junit/GradleDuplicateLoggingWorkaround.java
new file mode 100644
index 000000000..97e9e0a09
--- /dev/null
+++ 
b/runtime/test-common/src/main/java/org/apache/polaris/test/commons/junit/GradleDuplicateLoggingWorkaround.java
@@ -0,0 +1,84 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.polaris.test.commons.junit;
+
+import jakarta.annotation.Priority;
+import jakarta.enterprise.event.Observes;
+import jakarta.enterprise.event.Startup;
+import jakarta.inject.Singleton;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.junit.jupiter.api.extension.BeforeAllCallback;
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * JUnit extension + Singleton CDI bean that "fixes" test logging 
configuration.
+ *
+ * <p>This component removes the SLF4JBridgeHandler installed by Gradle, to 
prevent duplicate
+ * logging messages in tests. Without this fix, Gradle tests using JBoss 
LogManager (which is the
+ * default in Quarkus) will log each message twice: once via the 
SLF4JBridgeHandler, without any
+ * formatting, and once via the JBoss LogManager (with formatting). This is 
annoying because the
+ * non-formatted messages appear on the console, regardless of the log level.
+ *
+ * <p>Note: this issue has been reported to Quarkus and appears fixed, but 
only the formatting part
+ * is fixed, not the duplicate messages.
+ *
+ * @see <a href="https://github.com/quarkusio/quarkus/issues/22844";>Gradle 
tests (with JBoss
+ *     LogManager setup) output duplicate unformatted messages</a>
+ * @see <a href="https://github.com/quarkusio/quarkus/issues/48763";>Gradle
+ *     testLogging.showStandardStreams = false ignored by Quarkus tests with 
JBoss LogManager</a>
+ */
+@Singleton
+public class GradleDuplicateLoggingWorkaround implements BeforeAllCallback {
+
+  private static final Logger LOGGER =
+      LoggerFactory.getLogger(GradleDuplicateLoggingWorkaround.class);
+
+  private static final AtomicBoolean DONE = new AtomicBoolean(false);
+
+  public void onStartup(@Observes @Priority(1) Startup event) {
+    // Sometimes the application is started before the test extension is 
invoked,
+    // so we need to ensure the SLF4JBridgeHandler is removed at startup as 
well.
+    removeGradleHandlers();
+  }
+
+  @Override
+  public void beforeAll(ExtensionContext context) {
+    removeGradleHandlers();
+  }
+
+  private static void removeGradleHandlers() {
+    if (!DONE.getAndSet(true)) {
+      var logger = 
org.jboss.logmanager.LogManager.getLogManager().getLogger("");
+      var found = false;
+      for (var handler : logger.getHandlers()) {
+        if (handler.getClass().getSimpleName().equals("SLF4JBridgeHandler")) {
+          logger.removeHandler(handler);
+          found = true;
+        }
+      }
+      if (found) {
+        LOGGER.warn(
+            "Removed SLF4JBridgeHandler to fix duplicate logging messages in 
Gradle tests.");
+      }
+    }
+  }
+}
diff --git 
a/runtime/test-common/src/main/resources/META-INF/services/org.junit.jupiter.api.extension.Extension
 
b/runtime/test-common/src/main/resources/META-INF/services/org.junit.jupiter.api.extension.Extension
new file mode 100644
index 000000000..0214d2dd3
--- /dev/null
+++ 
b/runtime/test-common/src/main/resources/META-INF/services/org.junit.jupiter.api.extension.Extension
@@ -0,0 +1,20 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+
+org.apache.polaris.test.commons.junit.GradleDuplicateLoggingWorkaround
\ No newline at end of file


Reply via email to