diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/TransientClientSessionException.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/TransientClientSessionException.java new file mode 100644 index 0000000000..fd16ae97ec --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/TransientClientSessionException.java @@ -0,0 +1,39 @@ +/* + * Copyright 2018 the original author or authors. + * + * 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 + * + * https://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.springframework.data.mongodb; + +import org.springframework.dao.TransientDataAccessException; +import org.springframework.lang.Nullable; + +/** + * {@link TransientDataAccessException} specific to MongoDB {@link com.mongodb.session.ClientSession} related data + * access failures such as reading data using an already closed session. + * + * @author Christoph Strobl + * @since 3.3 + */ +public class TransientClientSessionException extends TransientMongoDbException { + + /** + * Constructor for {@link TransientClientSessionException}. + * + * @param msg the detail message. Can be {@literal null}. + * @param cause the root cause. Can be {@literal null}. + */ + public TransientClientSessionException(@Nullable String msg, @Nullable Throwable cause) { + super(msg, cause); + } +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/TransientMongoDbException.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/TransientMongoDbException.java new file mode 100644 index 0000000000..2a253c2b37 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/TransientMongoDbException.java @@ -0,0 +1,40 @@ +/* + * Copyright 2018 the original author or authors. + * + * 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 + * + * https://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.springframework.data.mongodb; + +import org.springframework.dao.TransientDataAccessException; +import org.springframework.lang.Nullable; + +/** + * Root of the hierarchy of MongoDB specific data access exceptions that are considered transient such as + * {@link com.mongodb.MongoException MongoExceptions} carrying {@link com.mongodb.MongoException#hasErrorLabel(String) + * specific labels}. + * + * @author Christoph Strobl + * @since 3.3 + */ +public class TransientMongoDbException extends TransientDataAccessException { + + /** + * Constructor for {@link TransientMongoDbException}. + * + * @param msg the detail message. Can be {@literal null}. + * @param cause the root cause. Can be {@literal null}. + */ + public TransientMongoDbException(String msg, @Nullable Throwable cause) { + super(msg, cause); + } +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoExceptionTranslator.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoExceptionTranslator.java index 4775a4a4d2..2d570c06fb 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoExceptionTranslator.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoExceptionTranslator.java @@ -25,9 +25,11 @@ import org.springframework.dao.InvalidDataAccessApiUsageException; import org.springframework.dao.InvalidDataAccessResourceUsageException; import org.springframework.dao.PermissionDeniedDataAccessException; +import org.springframework.dao.TransientDataAccessException; import org.springframework.dao.support.PersistenceExceptionTranslator; import org.springframework.data.mongodb.ClientSessionException; -import org.springframework.data.mongodb.MongoTransactionException; +import org.springframework.data.mongodb.TransientClientSessionException; +import org.springframework.data.mongodb.TransientMongoDbException; import org.springframework.data.mongodb.UncategorizedMongoDbException; import org.springframework.data.mongodb.util.MongoDbErrorCodes; import org.springframework.lang.Nullable; @@ -65,9 +67,26 @@ public class MongoExceptionTranslator implements PersistenceExceptionTranslator private static final Set SECURITY_EXCEPTIONS = Set.of("MongoCryptException"); + @Override @Nullable public DataAccessException translateExceptionIfPossible(RuntimeException ex) { + DataAccessException translatedException = doTranslateException(ex); + if (translatedException == null) { + return null; + } + + // Translated exceptions that per se are not be recoverable (eg. WriteConflicts), might still be transient inside a + // transaction. Let's wrap those. + return (isTransientFailure(ex) && !(translatedException instanceof TransientDataAccessException)) + ? new TransientMongoDbException(ex.getMessage(), translatedException) + : translatedException; + + } + + @Nullable + DataAccessException doTranslateException(RuntimeException ex) { + // Check for well-known MongoException subclasses. if (ex instanceof BsonInvalidOperationException) { @@ -94,13 +113,13 @@ public DataAccessException translateExceptionIfPossible(RuntimeException ex) { if (DATA_INTEGRITY_EXCEPTIONS.contains(exception)) { - if (ex instanceof MongoServerException mse) { - if (mse.getCode() == 11000) { + if (ex instanceof MongoServerException) { + if (MongoDbErrorCodes.isDataDuplicateKeyError(ex)) { return new DuplicateKeyException(ex.getMessage(), ex); } if (ex instanceof MongoBulkWriteException bulkException) { - for (BulkWriteError x : bulkException.getWriteErrors()) { - if (x.getCode() == 11000) { + for (BulkWriteError writeError : bulkException.getWriteErrors()) { + if (MongoDbErrorCodes.isDuplicateKeyCode(writeError.getCode())) { return new DuplicateKeyException(ex.getMessage(), ex); } } @@ -115,20 +134,27 @@ public DataAccessException translateExceptionIfPossible(RuntimeException ex) { int code = mongoException.getCode(); - if (MongoDbErrorCodes.isDuplicateKeyCode(code)) { + if (MongoDbErrorCodes.isDuplicateKeyError(mongoException)) { return new DuplicateKeyException(ex.getMessage(), ex); - } else if (MongoDbErrorCodes.isDataAccessResourceFailureCode(code)) { + } + if (MongoDbErrorCodes.isDataAccessResourceError(mongoException)) { return new DataAccessResourceFailureException(ex.getMessage(), ex); - } else if (MongoDbErrorCodes.isInvalidDataAccessApiUsageCode(code) || code == 10003 || code == 12001 - || code == 12010 || code == 12011 || code == 12012) { + } + if (MongoDbErrorCodes.isInvalidDataAccessApiUsageError(mongoException) || code == 12001 || code == 12010 + || code == 12011 || code == 12012) { return new InvalidDataAccessApiUsageException(ex.getMessage(), ex); - } else if (MongoDbErrorCodes.isPermissionDeniedCode(code)) { + } + if (MongoDbErrorCodes.isPermissionDeniedError(mongoException)) { return new PermissionDeniedDataAccessException(ex.getMessage(), ex); - } else if (MongoDbErrorCodes.isClientSessionFailureCode(code)) { - return new ClientSessionException(ex.getMessage(), ex); - } else if (MongoDbErrorCodes.isTransactionFailureCode(code)) { - return new MongoTransactionException(ex.getMessage(), ex); - } else if(ex.getCause() != null && SECURITY_EXCEPTIONS.contains(ClassUtils.getShortName(ex.getCause().getClass()))) { + } + if (MongoDbErrorCodes.isDataIntegrityViolationError(mongoException)) { + return new DataIntegrityViolationException(mongoException.getMessage(), mongoException); + } + if (MongoDbErrorCodes.isClientSessionFailure(mongoException)) { + return isTransientFailure(mongoException) ? new TransientClientSessionException(ex.getMessage(), ex) + : new ClientSessionException(ex.getMessage(), ex); + } + if (ex.getCause() != null && SECURITY_EXCEPTIONS.contains(ClassUtils.getShortName(ex.getCause().getClass()))) { return new PermissionDeniedDataAccessException(ex.getMessage(), ex); } @@ -150,4 +176,25 @@ public DataAccessException translateExceptionIfPossible(RuntimeException ex) { // that translation should not occur. return null; } + + /** + * Check if a given exception holds an error label indicating a transient failure. + * + * @param e + * @return {@literal true} if the given {@link Exception} is a {@link MongoException} holding one of the transient + * exception error labels. + * @see MongoException#hasErrorLabel(String) + * @since 3.3 + */ + public static boolean isTransientFailure(Exception e) { + + if (!(e instanceof MongoException)) { + return false; + } + + MongoException mongoException = (MongoException) e; + + return mongoException.hasErrorLabel(MongoException.TRANSIENT_TRANSACTION_ERROR_LABEL) + || mongoException.hasErrorLabel(MongoException.UNKNOWN_TRANSACTION_COMMIT_RESULT_LABEL); + } } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/MongoPersistentEntityIndexCreator.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/MongoPersistentEntityIndexCreator.java index fa201d40ea..4f4b9b72e5 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/MongoPersistentEntityIndexCreator.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/index/MongoPersistentEntityIndexCreator.java @@ -28,7 +28,6 @@ import org.springframework.data.mapping.context.MappingContext; import org.springframework.data.mapping.context.MappingContextEvent; import org.springframework.data.mongodb.MongoDatabaseFactory; -import org.springframework.data.mongodb.UncategorizedMongoDbException; import org.springframework.data.mongodb.core.index.MongoPersistentEntityIndexResolver.IndexDefinitionHolder; import org.springframework.data.mongodb.core.mapping.Document; import org.springframework.data.mongodb.core.mapping.MongoMappingContext; @@ -152,7 +151,7 @@ void createIndex(IndexDefinitionHolder indexDefinition) { IndexOperations indexOperations = indexOperationsProvider.indexOps(indexDefinition.getCollection()); indexOperations.ensureIndex(indexDefinition); - } catch (UncategorizedMongoDbException ex) { + } catch (DataIntegrityViolationException ex) { if (ex.getCause() instanceof MongoException mongoException && MongoDbErrorCodes.isDataIntegrityViolationCode(mongoException.getCode())) { diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/support/IndexEnsuringQueryCreationListener.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/support/IndexEnsuringQueryCreationListener.java index 8683ba2439..9f642c1b64 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/support/IndexEnsuringQueryCreationListener.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/support/IndexEnsuringQueryCreationListener.java @@ -21,10 +21,10 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort.Direction; import org.springframework.data.domain.Sort.Order; -import org.springframework.data.mongodb.UncategorizedMongoDbException; import org.springframework.data.mongodb.core.MongoOperations; import org.springframework.data.mongodb.core.index.Index; import org.springframework.data.mongodb.core.index.IndexOperationsProvider; @@ -111,7 +111,7 @@ public void onCreation(PartTreeMongoQuery query) { MongoEntityMetadata metadata = query.getQueryMethod().getEntityInformation(); try { indexOperationsProvider.indexOps(metadata.getCollectionName(), metadata.getJavaType()).ensureIndex(index); - } catch (UncategorizedMongoDbException e) { + } catch (DataIntegrityViolationException e) { if (e.getCause() instanceof MongoException mongoException) { diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/MongoDbErrorCodes.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/MongoDbErrorCodes.java index f2e02ae7b9..2b7ccde4d5 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/MongoDbErrorCodes.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/MongoDbErrorCodes.java @@ -19,6 +19,8 @@ import org.springframework.lang.Nullable; +import com.mongodb.MongoException; + /** * {@link MongoDbErrorCodes} holds MongoDB specific error codes outlined in {@literal mongo/base/error_codes.yml}. * @@ -97,6 +99,7 @@ public final class MongoDbErrorCodes { invalidDataAccessApiUsageException.put(72, "InvalidOptions"); invalidDataAccessApiUsageException.put(115, "CommandNotSupported"); invalidDataAccessApiUsageException.put(116, "DocTooLargeForCapped"); + invalidDataAccessApiUsageException.put(10003, "CannotGrowDocumentInCappedNamespace"); invalidDataAccessApiUsageException.put(130, "SymbolNotFound"); invalidDataAccessApiUsageException.put(17280, "KeyTooLong"); invalidDataAccessApiUsageException.put(13334, "ShardKeyTooBig"); @@ -114,19 +117,17 @@ public final class MongoDbErrorCodes { clientSessionCodes = new HashMap<>(4, 1f); clientSessionCodes.put(206, "NoSuchSession"); clientSessionCodes.put(213, "DuplicateSession"); + clientSessionCodes.put(217, "IncompleteTransactionHistory"); + clientSessionCodes.put(225, "TransactionTooOld"); clientSessionCodes.put(228, "SessionTransferIncomplete"); + clientSessionCodes.put(244, "TransactionAborted"); + clientSessionCodes.put(251, "NoSuchTransaction"); + clientSessionCodes.put(256, "TransactionCommitted"); + clientSessionCodes.put(257, "TransactionToLarge"); + clientSessionCodes.put(261, "TooManyLogicalSessions"); + clientSessionCodes.put(263, "OperationNotSupportedInTransaction"); clientSessionCodes.put(264, "TooManyLogicalSessions"); - transactionCodes = new HashMap<>(8, 1f); - transactionCodes.put(217, "IncompleteTransactionHistory"); - transactionCodes.put(225, "TransactionTooOld"); - transactionCodes.put(244, "TransactionAborted"); - transactionCodes.put(251, "NoSuchTransaction"); - transactionCodes.put(256, "TransactionCommitted"); - transactionCodes.put(257, "TransactionToLarge"); - transactionCodes.put(263, "OperationNotSupportedInTransaction"); - transactionCodes.put(267, "PreparedTransactionInProgress"); - errorCodes = new HashMap<>(); errorCodes.putAll(dataAccessResourceFailureCodes); errorCodes.putAll(dataIntegrityViolationCodes); @@ -136,29 +137,107 @@ public final class MongoDbErrorCodes { errorCodes.putAll(clientSessionCodes); } + @Nullable + public static String getErrorDescription(@Nullable Integer errorCode) { + return errorCode == null ? null : errorCodes.get(errorCode); + } + public static boolean isDataIntegrityViolationCode(@Nullable Integer errorCode) { return errorCode != null && dataIntegrityViolationCodes.containsKey(errorCode); } + /** + * @param exception can be {@literal null}. + * @return + * @since 3.3 + */ + public static boolean isDataIntegrityViolationError(@Nullable Exception exception) { + + if(exception instanceof MongoException) { + return isDataIntegrityViolationCode(((MongoException) exception).getCode()); + } + return false; + } + public static boolean isDataAccessResourceFailureCode(@Nullable Integer errorCode) { return errorCode != null && dataAccessResourceFailureCodes.containsKey(errorCode); } + /** + * @param exception can be {@literal null}. + * @return + * @since 3.3 + */ + public static boolean isDataAccessResourceError(@Nullable Exception exception) { + + if(exception instanceof MongoException) { + return isDataAccessResourceFailureCode(((MongoException) exception).getCode()); + } + return false; + } + public static boolean isDuplicateKeyCode(@Nullable Integer errorCode) { return errorCode != null && duplicateKeyCodes.containsKey(errorCode); } + /** + * @param exception can be {@literal null}. + * @return + * @since 3.3 + */ + public static boolean isDuplicateKeyError(@Nullable Exception exception) { + + if(exception instanceof MongoException) { + return isDuplicateKeyCode(((MongoException) exception).getCode()); + } + return false; + } + + /** + * @param exception can be {@literal null}. + * @return + * @since 3.3 + */ + public static boolean isDataDuplicateKeyError(@Nullable Exception exception) { + + if(exception instanceof MongoException) { + return isDuplicateKeyCode(((MongoException) exception).getCode()); + } + return false; + } + public static boolean isPermissionDeniedCode(@Nullable Integer errorCode) { return errorCode != null && permissionDeniedCodes.containsKey(errorCode); } + /** + * @param exception can be {@literal null}. + * @return + * @since 3.3 + */ + public static boolean isPermissionDeniedError(@Nullable Exception exception) { + + if(exception instanceof MongoException) { + return isPermissionDeniedCode(((MongoException) exception).getCode()); + } + return false; + } + public static boolean isInvalidDataAccessApiUsageCode(@Nullable Integer errorCode) { return errorCode != null && invalidDataAccessApiUsageException.containsKey(errorCode); } - @Nullable - public static String getErrorDescription(@Nullable Integer errorCode) { - return errorCode == null ? null : errorCodes.get(errorCode); + /** + * @param exception can be {@literal null}. + * @return + * @since 3.3 + */ + public static boolean isInvalidDataAccessApiUsageError(@Nullable Exception exception) { + + if(exception instanceof MongoException) { + return isInvalidDataAccessApiUsageCode(((MongoException) exception).getCode()); + } + return false; } /** @@ -182,4 +261,17 @@ public static boolean isClientSessionFailureCode(@Nullable Integer errorCode) { public static boolean isTransactionFailureCode(@Nullable Integer errorCode) { return errorCode != null && transactionCodes.containsKey(errorCode); } + + /** + * @param exception can be {@literal null}. + * @return + * @since 3.3 + */ + public static boolean isClientSessionFailure(@Nullable Exception exception) { + + if(exception instanceof MongoException) { + return isClientSessionFailureCode(((MongoException) exception).getCode()); + } + return false; + } } diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoExceptionTranslatorUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoExceptionTranslatorUnitTests.java index ff74786cb4..bfffa4afa0 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoExceptionTranslatorUnitTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoExceptionTranslatorUnitTests.java @@ -25,11 +25,13 @@ import org.springframework.core.NestedRuntimeException; import org.springframework.dao.DataAccessException; import org.springframework.dao.DataAccessResourceFailureException; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.dao.DuplicateKeyException; import org.springframework.dao.InvalidDataAccessApiUsageException; import org.springframework.dao.InvalidDataAccessResourceUsageException; import org.springframework.data.mongodb.ClientSessionException; import org.springframework.data.mongodb.MongoTransactionException; +import org.springframework.data.mongodb.TransientMongoDbException; import org.springframework.data.mongodb.UncategorizedMongoDbException; import org.springframework.lang.Nullable; @@ -39,7 +41,9 @@ import com.mongodb.MongoSocketException; import com.mongodb.MongoSocketReadTimeoutException; import com.mongodb.MongoSocketWriteException; +import com.mongodb.MongoWriteException; import com.mongodb.ServerAddress; +import com.mongodb.WriteError; /** * Unit tests for {@link MongoExceptionTranslator}. @@ -80,15 +84,13 @@ void translateSocketException() { void translateSocketExceptionSubclasses() { expectExceptionWithCauseMessage( - translator.translateExceptionIfPossible( - new MongoSocketWriteException("intermediate message", new ServerAddress(), new Exception(EXCEPTION_MESSAGE)) - ), + translator.translateExceptionIfPossible(new MongoSocketWriteException("intermediate message", + new ServerAddress(), new Exception(EXCEPTION_MESSAGE))), DataAccessResourceFailureException.class, EXCEPTION_MESSAGE); expectExceptionWithCauseMessage( - translator.translateExceptionIfPossible( - new MongoSocketReadTimeoutException("intermediate message", new ServerAddress(), new Exception(EXCEPTION_MESSAGE)) - ), + translator.translateExceptionIfPossible(new MongoSocketReadTimeoutException("intermediate message", + new ServerAddress(), new Exception(EXCEPTION_MESSAGE))), DataAccessResourceFailureException.class, EXCEPTION_MESSAGE); } @@ -172,6 +174,38 @@ void translateTransactionExceptions() { checkTranslatedMongoException(MongoTransactionException.class, 267); } + @Test // DATAMONGO-2073 + public void translateTransientTransactionExceptions() { + + MongoException source = new MongoException(267, "PreparedTransactionInProgress"); + source.addLabel(MongoException.TRANSIENT_TRANSACTION_ERROR_LABEL); + + expectExceptionWithCauseMessage(translator.translateExceptionIfPossible(source), TransientMongoDbException.class, + "PreparedTransactionInProgress"); + } + + @Test // DATAMONGO-2073 + public void translateMongoExceptionWithTransientLabelToTransientMongoDbException() { + + MongoException exception = new MongoException(0, ""); + exception.addLabel(MongoException.UNKNOWN_TRANSACTION_COMMIT_RESULT_LABEL); + DataAccessException translatedException = translator.translateExceptionIfPossible(exception); + + expectExceptionWithCauseMessage(translatedException, TransientMongoDbException.class); + } + + @Test // DATAMONGO-2073 + public void wrapsTranslatedExceptionsWhenTransientLabelPresent() { + + MongoException exception = new MongoWriteException(new WriteError(112, "WriteConflict", new BsonDocument()), null); + exception.addLabel(MongoException.UNKNOWN_TRANSACTION_COMMIT_RESULT_LABEL); + + DataAccessException translatedException = translator.translateExceptionIfPossible(exception); + + assertThat(translatedException).isInstanceOf(TransientMongoDbException.class); + assertThat(translatedException.getCause()).isInstanceOf(DataIntegrityViolationException.class); + } + private void checkTranslatedMongoException(Class clazz, int code) { DataAccessException translated = translator.translateExceptionIfPossible(new MongoException(code, ""));