diff --git a/core/src/main/java/google/registry/env/common/backend/WEB-INF/logging.properties b/core/src/main/java/google/registry/env/common/backend/WEB-INF/logging.properties index 064aa4905..487bddee4 100644 --- a/core/src/main/java/google/registry/env/common/backend/WEB-INF/logging.properties +++ b/core/src/main/java/google/registry/env/common/backend/WEB-INF/logging.properties @@ -11,3 +11,7 @@ # Set the default logging level for all loggers to INFO. .level = INFO + +# Turn off logging in Hibernate classes for misleading ERROR-level logs +org.hibernate.engine.jdbc.batch.internal.BatchingBatch.level=OFF +org.hibernate.engine.jdbc.spi.SqlExceptionHelper.level=OFF diff --git a/core/src/main/java/google/registry/env/common/default/WEB-INF/logging.properties b/core/src/main/java/google/registry/env/common/default/WEB-INF/logging.properties index 064aa4905..487bddee4 100644 --- a/core/src/main/java/google/registry/env/common/default/WEB-INF/logging.properties +++ b/core/src/main/java/google/registry/env/common/default/WEB-INF/logging.properties @@ -11,3 +11,7 @@ # Set the default logging level for all loggers to INFO. .level = INFO + +# Turn off logging in Hibernate classes for misleading ERROR-level logs +org.hibernate.engine.jdbc.batch.internal.BatchingBatch.level=OFF +org.hibernate.engine.jdbc.spi.SqlExceptionHelper.level=OFF diff --git a/core/src/main/java/google/registry/env/common/pubapi/WEB-INF/logging.properties b/core/src/main/java/google/registry/env/common/pubapi/WEB-INF/logging.properties index 064aa4905..487bddee4 100644 --- a/core/src/main/java/google/registry/env/common/pubapi/WEB-INF/logging.properties +++ b/core/src/main/java/google/registry/env/common/pubapi/WEB-INF/logging.properties @@ -11,3 +11,7 @@ # Set the default logging level for all loggers to INFO. .level = INFO + +# Turn off logging in Hibernate classes for misleading ERROR-level logs +org.hibernate.engine.jdbc.batch.internal.BatchingBatch.level=OFF +org.hibernate.engine.jdbc.spi.SqlExceptionHelper.level=OFF diff --git a/core/src/main/java/google/registry/env/common/tools/WEB-INF/logging.properties b/core/src/main/java/google/registry/env/common/tools/WEB-INF/logging.properties index 064aa4905..487bddee4 100644 --- a/core/src/main/java/google/registry/env/common/tools/WEB-INF/logging.properties +++ b/core/src/main/java/google/registry/env/common/tools/WEB-INF/logging.properties @@ -11,3 +11,7 @@ # Set the default logging level for all loggers to INFO. .level = INFO + +# Turn off logging in Hibernate classes for misleading ERROR-level logs +org.hibernate.engine.jdbc.batch.internal.BatchingBatch.level=OFF +org.hibernate.engine.jdbc.spi.SqlExceptionHelper.level=OFF diff --git a/core/src/main/java/google/registry/persistence/transaction/DatabaseException.java b/core/src/main/java/google/registry/persistence/transaction/DatabaseException.java new file mode 100644 index 000000000..90f9e7564 --- /dev/null +++ b/core/src/main/java/google/registry/persistence/transaction/DatabaseException.java @@ -0,0 +1,112 @@ +// Copyright 2023 The Nomulus Authors. All Rights Reserved. +// +// Licensed 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 google.registry.persistence.transaction; + +import autovalue.shaded.com.google.common.collect.ImmutableList; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Joiner; +import java.sql.SQLException; +import java.util.Optional; +import javax.persistence.PersistenceException; + +/** + * Wraps an exception thrown by the JPA framework and captures the SQL error details (state and + * status code) in the {@link #getMessage message}. + * + *
The {@link SQLException} class has its own chain of exceptions that describe multiple error + * conditions encontered during a transaction. A typical logger relying on the {@link + * Throwable#getCause() chain of causes} in {@code Throwable} instances cannot capture all details + * of errors thrown from the database drivers. This exception captures all error details in its + * message text. + * + *
The {@link TransactionManager} wraps every persistence exception in an instance of this class. + * This allows us to disable logging in specific Hibernate classes that logs at {@code ERROR}-level + * and rethrows. These {@code ERROR} logs are misleading, since the impacted transactions often + * succeeds on retries. + * + *
See the {@code logging.properties} files in the {@code env} package for the specific Hibernate + * classes that have logging suppressed. + */ +class DatabaseException extends PersistenceException { + + private transient String cachedMessage; + + @VisibleForTesting + DatabaseException(Throwable throwable) { + super(throwable); + } + + @Override + public String getMessage() { + if (cachedMessage == null) { + cachedMessage = getSqlError(getCause()); + } + return cachedMessage; + } + + /** + * Throws an unchecked exception on behalf of the {@code original} {@link Throwable}. + * + *
If the {@code original Throwable} has at least one {@link SQLException} in its chain of
+ * causes, a {@link DatabaseException} is thrown; otherwise this does nothing.
+ */
+ static void tryWrapAndThrow(Throwable original) {
+ Throwable t = original;
+ do {
+ if (t instanceof SQLException) {
+ throw new DatabaseException(original);
+ }
+ } while ((t = t.getCause()) != null);
+ }
+
+ /**
+ * Returns the SQL state and error code of every {@link SQLException} in the chain of causes, or
+ * empty string if such information is not found.
+ */
+ static String getSqlError(Throwable t) {
+ ImmutableList.Builder Please refer to the class javadoc of {@link DatabaseException} for more information.
+ */
+public class HibernateLoggingSuppressionTest {
+
+ private static final String LOG_SUPPRESSION_TARGET =
+ "org.hibernate.engine.jdbc.spi.SqlExceptionHelper";
+
+ // The line that should be added to the `logging.properties` file.
+ private static final String LOGGING_PROPERTIES_LINE = LOG_SUPPRESSION_TARGET + ".level=OFF\n";
+
+ @RegisterExtension
+ public final JpaUnitTestExtension jpa =
+ new JpaTestExtensions.Builder()
+ .withoutCannedData()
+ .withEntityClass(TestEntity.class)
+ .buildUnitTestExtension();
+
+ private Logger logger;
+ private TestLogHandler testLogHandler;
+
+ @BeforeEach
+ void setup() {
+ testLogHandler = new TestLogHandler();
+ logger = getLogger(LOG_SUPPRESSION_TARGET);
+ logger.addHandler(testLogHandler);
+ }
+
+ @AfterEach
+ void teardown() {
+ logger.removeHandler(testLogHandler);
+ }
+
+ // Updates logging configs to suppress logging. Call `revertSuppressionOfHibernateLogs` to revert.
+ void suppressHibernateLogs() throws IOException {
+ try (ByteArrayInputStream additionalProperties =
+ new ByteArrayInputStream(LOGGING_PROPERTIES_LINE.getBytes(UTF_8))) {
+ LogManager.getLogManager()
+ .updateConfiguration(
+ additionalProperties,
+ x ->
+ (o, n) -> {
+ if (!x.startsWith(LOG_SUPPRESSION_TARGET)) {
+ return o;
+ }
+ checkArgument(o == null, "Cannot override old value in this test");
+ return n;
+ });
+ }
+ }
+
+ void revertSuppressionOfHibernateLogs() throws IOException {
+ try (ByteArrayInputStream additionalProperties =
+ new ByteArrayInputStream(LOGGING_PROPERTIES_LINE.getBytes(UTF_8))) {
+ LogManager.getLogManager()
+ .updateConfiguration(
+ additionalProperties,
+ x ->
+ (o, n) -> {
+ if (!x.startsWith(LOG_SUPPRESSION_TARGET)) {
+ return o;
+ }
+ return null;
+ });
+ }
+ }
+
+ @Test
+ void noLoggingSuppression() {
+ try {
+ tm().transact(() -> tm().insert(new TestEntity(1, 1)));
+ tm().transact(() -> tm().insert(new TestEntity(1, 1)));
+ } catch (DatabaseException e) {
+ assertThat(e).hasMessageThat().contains("SQLState: ");
+ }
+ assertThat(
+ testLogHandler.getStoredLogRecords().stream()
+ .anyMatch(
+ logRecord ->
+ logRecord.getLevel().equals(Level.SEVERE)
+ && logRecord.getMessage().contains("duplicate key")))
+ .isTrue();
+ }
+
+ @Test
+ void withLoggingSuppression() throws Exception {
+ suppressHibernateLogs();
+ try {
+ tm().transact(() -> tm().insert(new TestEntity(1, 1)));
+ tm().transact(() -> tm().insert(new TestEntity(1, 1)));
+ } catch (DatabaseException e) {
+ assertThat(e).hasMessageThat().contains("SQLState: ");
+ }
+ assertThat(
+ testLogHandler.getStoredLogRecords().stream()
+ .anyMatch(
+ logRecord ->
+ logRecord.getLevel().equals(Level.SEVERE)
+ && logRecord.getMessage().contains("duplicate key")))
+ .isFalse();
+ revertSuppressionOfHibernateLogs();
+ }
+
+ @Entity
+ static class TestEntity {
+ @Id long id;
+ int value;
+
+ // For Hibernate
+ TestEntity() {}
+
+ TestEntity(long id, int value) {
+ this.id = id;
+ this.value = value;
+ }
+
+ TestEntity incValue() {
+ this.value++;
+ return this;
+ }
+ }
+}
diff --git a/core/src/test/java/google/registry/persistence/transaction/JpaTransactionManagerExtensionTest.java b/core/src/test/java/google/registry/persistence/transaction/JpaTransactionManagerExtensionTest.java
index c679276c2..1d47321f8 100644
--- a/core/src/test/java/google/registry/persistence/transaction/JpaTransactionManagerExtensionTest.java
+++ b/core/src/test/java/google/registry/persistence/transaction/JpaTransactionManagerExtensionTest.java
@@ -64,8 +64,9 @@ public class JpaTransactionManagerExtensionTest {
TestEntity testEntity = new TestEntity("foo", "bar");
assertThat(
assertThrows(
- PersistenceException.class,
- () -> replicaTm().transact(() -> replicaTm().put(testEntity))))
+ PersistenceException.class,
+ () -> replicaTm().transact(() -> replicaTm().put(testEntity)))
+ .getCause())
.hasMessageThat()
.isEqualTo("Error while committing the transaction");
}
diff --git a/core/src/test/java/google/registry/persistence/transaction/JpaTransactionManagerImplTest.java b/core/src/test/java/google/registry/persistence/transaction/JpaTransactionManagerImplTest.java
index 8d6346a64..454529965 100644
--- a/core/src/test/java/google/registry/persistence/transaction/JpaTransactionManagerImplTest.java
+++ b/core/src/test/java/google/registry/persistence/transaction/JpaTransactionManagerImplTest.java
@@ -49,6 +49,7 @@ import javax.persistence.EntityManager;
import javax.persistence.Id;
import javax.persistence.IdClass;
import javax.persistence.OptimisticLockException;
+import javax.persistence.PersistenceException;
import javax.persistence.RollbackException;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
@@ -353,7 +354,11 @@ class JpaTransactionManagerImplTest {
tm().transact(() -> tm().insert(theEntity));
assertThat(existsInDb(theEntity)).isTrue();
assertThat(loadByKey(theEntityKey)).isEqualTo(theEntity);
- assertThrows(RollbackException.class, () -> tm().transact(() -> tm().insert(theEntity)));
+ assertThat(
+ assertThrows(
+ PersistenceException.class, () -> tm().transact(() -> tm().insert(theEntity)))
+ .getCause())
+ .isInstanceOf(RollbackException.class);
}
@Test
@@ -389,7 +394,8 @@ class JpaTransactionManagerImplTest {
void saveAllNew_rollsBackWhenFailure() {
moreEntities.forEach(entity -> assertThat(tm().transact(() -> tm().exists(entity))).isFalse());
insertInDb(moreEntities.get(0));
- assertThrows(RollbackException.class, () -> insertInDb(moreEntities));
+ assertThat(assertThrows(PersistenceException.class, () -> insertInDb(moreEntities)).getCause())
+ .isInstanceOf(RollbackException.class);
assertThat(tm().transact(() -> tm().exists(moreEntities.get(0)))).isTrue();
assertThat(tm().transact(() -> tm().exists(moreEntities.get(1)))).isFalse();
assertThat(tm().transact(() -> tm().exists(moreEntities.get(2)))).isFalse();