From 9be188dd7a5d0507a94e2ce321d3f3390d195a4e Mon Sep 17 00:00:00 2001 From: Bruno Sakoto Date: Wed, 16 Jun 2021 11:47:54 -0400 Subject: Support concurrent requests to create schema sets Issue-ID: CPS-466 Signed-off-by: Bruno Sakoto Change-Id: I2ecf98b9aa5a6097518e616c08f8bb2a2182a613 --- cps-rest/pom.xml | 8 + .../main/java/org/onap/cps/config/CpsConfig.java | 5 +- cps-ri/pom.xml | 14 +- .../spi/impl/CpsModulePersistenceServiceImpl.java | 95 +++++++++- ...sModulePersistenceServiceIntegrationSpec.groovy | 192 +++++++++++++++++++++ .../impl/CpsModulePersistenceServiceSpec.groovy | 191 -------------------- .../CpsModulePersistenceServiceUnitSpec.groovy | 89 ++++++++++ .../DuplicatedYangResourceException.java | 47 +++++ 8 files changed, 446 insertions(+), 195 deletions(-) create mode 100644 cps-ri/src/test/groovy/org/onap/cps/spi/impl/CpsModulePersistenceServiceIntegrationSpec.groovy delete mode 100644 cps-ri/src/test/groovy/org/onap/cps/spi/impl/CpsModulePersistenceServiceSpec.groovy create mode 100644 cps-ri/src/test/groovy/org/onap/cps/spi/impl/CpsModulePersistenceServiceUnitSpec.groovy create mode 100644 cps-service/src/main/java/org/onap/cps/spi/exceptions/DuplicatedYangResourceException.java diff --git a/cps-rest/pom.xml b/cps-rest/pom.xml index 22c1b7960b..4b42656e89 100755 --- a/cps-rest/pom.xml +++ b/cps-rest/pom.xml @@ -54,6 +54,14 @@ org.springframework.boot spring-boot-starter-jetty + + org.springframework.retry + spring-retry + + + org.springframework + spring-aspects + io.swagger.core.v3 swagger-annotations diff --git a/cps-rest/src/main/java/org/onap/cps/config/CpsConfig.java b/cps-rest/src/main/java/org/onap/cps/config/CpsConfig.java index 419a218930..35a1bedfdc 100755 --- a/cps-rest/src/main/java/org/onap/cps/config/CpsConfig.java +++ b/cps-rest/src/main/java/org/onap/cps/config/CpsConfig.java @@ -1,6 +1,7 @@ /*- * ============LICENSE_START======================================================= - * Copyright (C) 2020 Nordix Foundation. All rights reserved. + * Copyright (C) 2020 Nordix Foundation. All rights reserved. + * Modifications Copyright (C) 2021 Bell Canada. * ================================================================================ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -23,12 +24,14 @@ package org.onap.cps.config; import org.modelmapper.ModelMapper; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.retry.annotation.EnableRetry; import springfox.documentation.builders.PathSelectors; import springfox.documentation.builders.RequestHandlerSelectors; import springfox.documentation.spi.DocumentationType; import springfox.documentation.spring.web.plugins.Docket; @Configuration +@EnableRetry public class CpsConfig { /** diff --git a/cps-ri/pom.xml b/cps-ri/pom.xml index b2cf6917e6..c89882f392 100644 --- a/cps-ri/pom.xml +++ b/cps-ri/pom.xml @@ -1,7 +1,7 @@ org.codehaus.groovy diff --git a/cps-ri/src/main/java/org/onap/cps/spi/impl/CpsModulePersistenceServiceImpl.java b/cps-ri/src/main/java/org/onap/cps/spi/impl/CpsModulePersistenceServiceImpl.java index ae6543dabe..eae1ef1423 100755 --- a/cps-ri/src/main/java/org/onap/cps/spi/impl/CpsModulePersistenceServiceImpl.java +++ b/cps-ri/src/main/java/org/onap/cps/spi/impl/CpsModulePersistenceServiceImpl.java @@ -1,7 +1,7 @@ /* * ============LICENSE_START======================================================= * Copyright (C) 2020 Nordix Foundation - * Modifications Copyright (C) 2020 Bell Canada. All rights reserved. + * Modifications Copyright (C) 2020-2021 Bell Canada. * ================================================================================ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -25,10 +25,16 @@ import java.nio.charset.StandardCharsets; import java.util.Collection; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import java.util.stream.Collectors; import javax.transaction.Transactional; +import lombok.extern.slf4j.Slf4j; import org.apache.commons.codec.digest.DigestUtils; +import org.apache.commons.lang3.StringUtils; +import org.hibernate.exception.ConstraintViolationException; import org.onap.cps.spi.CascadeDeleteAllowed; import org.onap.cps.spi.CpsAdminPersistenceService; import org.onap.cps.spi.CpsModulePersistenceService; @@ -36,6 +42,7 @@ import org.onap.cps.spi.entities.AnchorEntity; import org.onap.cps.spi.entities.SchemaSetEntity; import org.onap.cps.spi.entities.YangResourceEntity; import org.onap.cps.spi.exceptions.AlreadyDefinedException; +import org.onap.cps.spi.exceptions.DuplicatedYangResourceException; import org.onap.cps.spi.exceptions.SchemaSetInUseException; import org.onap.cps.spi.repository.AnchorRepository; import org.onap.cps.spi.repository.DataspaceRepository; @@ -44,12 +51,18 @@ import org.onap.cps.spi.repository.SchemaSetRepository; import org.onap.cps.spi.repository.YangResourceRepository; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Retryable; import org.springframework.stereotype.Component; @Component +@Slf4j public class CpsModulePersistenceServiceImpl implements CpsModulePersistenceService { + private static final String YANG_RESOURCE_CHECKSUM_CONSTRAINT_NAME = "yang_resource_checksum_key"; + private static final Pattern CHECKSUM_EXCEPTION_PATTERN = Pattern.compile(".*\\(checksum\\)=\\((\\w+)\\).*"); + @Autowired private YangResourceRepository yangResourceRepository; @@ -70,6 +83,9 @@ public class CpsModulePersistenceServiceImpl implements CpsModulePersistenceServ @Override @Transactional + // A retry is made to store the schema set if it fails because of duplicated yang resource exception that + // can occur in case of specific concurrent requests. + @Retryable(value = DuplicatedYangResourceException.class, maxAttempts = 2, backoff = @Backoff(delay = 500)) public void storeSchemaSet(final String dataspaceName, final String schemaSetName, final Map yangResourcesNameToContentMap) { @@ -107,7 +123,21 @@ public class CpsModulePersistenceServiceImpl implements CpsModulePersistenceServ final Collection newYangResourceEntities = checksumToEntityMap.values(); if (!newYangResourceEntities.isEmpty()) { - yangResourceRepository.saveAll(newYangResourceEntities); + try { + yangResourceRepository.saveAll(newYangResourceEntities); + } catch (final DataIntegrityViolationException dataIntegrityViolationException) { + // Throw a CPS duplicated Yang resource exception if the cause of the error is a yang checksum + // database constraint violation. + // If it is not, then throw the original exception + final Optional convertedException = + convertToDuplicatedYangResourceException( + dataIntegrityViolationException, newYangResourceEntities); + convertedException.ifPresent( + e -> log.warn( + "Cannot persist duplicated yang resource. " + + "A total of 2 attempts to store the schema set are planned.", e)); + throw convertedException.isPresent() ? convertedException.get() : dataIntegrityViolationException; + } } return ImmutableSet.builder() @@ -116,6 +146,67 @@ public class CpsModulePersistenceServiceImpl implements CpsModulePersistenceServ .build(); } + /** + * Convert the specified data integrity violation exception into a CPS duplicated Yang resource exception + * if the cause of the error is a yang checksum database constraint violation. + * @param originalException the original db exception. + * @param yangResourceEntities the collection of Yang resources involved in the db failure. + * @return an optional converted CPS duplicated Yang resource exception. The optional is empty if the original + * cause of the error is not a yang checksum database constraint violation. + */ + private Optional convertToDuplicatedYangResourceException( + final DataIntegrityViolationException originalException, + final Collection yangResourceEntities) { + + // The exception result + DuplicatedYangResourceException duplicatedYangResourceException = null; + + final Throwable cause = originalException.getCause(); + if (cause instanceof ConstraintViolationException) { + final ConstraintViolationException constraintException = (ConstraintViolationException) cause; + if (YANG_RESOURCE_CHECKSUM_CONSTRAINT_NAME.equals(constraintException.getConstraintName())) { + // Db constraint related to yang resource checksum uniqueness is not respected + final String checksumInError = getDuplicatedChecksumFromException(constraintException); + final String nameInError = getNameForChecksum(checksumInError, yangResourceEntities); + duplicatedYangResourceException = + new DuplicatedYangResourceException(nameInError, checksumInError, constraintException); + } + } + + return Optional.ofNullable(duplicatedYangResourceException); + + } + + /** + * Get the checksum that caused the constraint violation exception. + * @param exception the exception having the checksum in error. + * @return the checksum in error or null if not found. + */ + private String getDuplicatedChecksumFromException(final ConstraintViolationException exception) { + String checksum = null; + final Matcher matcher = CHECKSUM_EXCEPTION_PATTERN.matcher(exception.getSQLException().getMessage()); + if (matcher.find() && matcher.groupCount() == 1) { + checksum = matcher.group(1); + } + return checksum; + } + + /** + * Get the name of the yang resource having the specified checksum. + * @param checksum the checksum. Null is supported. + * @param yangResourceEntities the list of yang resources to search among. + * @return the name found or null if none. + */ + private String getNameForChecksum( + final String checksum, final Collection yangResourceEntities) { + return + yangResourceEntities.stream() + .filter(entity -> StringUtils.equals(checksum, (entity.getChecksum()))) + .findFirst() + .map(YangResourceEntity::getName) + .orElse(null); + } + @Override public Map getYangSchemaResources(final String dataspaceName, final String schemaSetName) { final var dataspaceEntity = dataspaceRepository.getByName(dataspaceName); diff --git a/cps-ri/src/test/groovy/org/onap/cps/spi/impl/CpsModulePersistenceServiceIntegrationSpec.groovy b/cps-ri/src/test/groovy/org/onap/cps/spi/impl/CpsModulePersistenceServiceIntegrationSpec.groovy new file mode 100644 index 0000000000..b4a4c7e17d --- /dev/null +++ b/cps-ri/src/test/groovy/org/onap/cps/spi/impl/CpsModulePersistenceServiceIntegrationSpec.groovy @@ -0,0 +1,192 @@ +/* + * ============LICENSE_START======================================================= + * Copyright (C) 2021 Nordix Foundation + * Modifications Copyright (C) 2021 Bell Canada. + * ================================================================================ + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + * ============LICENSE_END========================================================= + */ +package org.onap.cps.spi.impl + +import static org.onap.cps.spi.CascadeDeleteAllowed.CASCADE_DELETE_ALLOWED +import static org.onap.cps.spi.CascadeDeleteAllowed.CASCADE_DELETE_PROHIBITED + +import org.onap.cps.spi.CpsAdminPersistenceService +import org.onap.cps.spi.CpsModulePersistenceService +import org.onap.cps.spi.entities.YangResourceEntity +import org.onap.cps.spi.exceptions.DataspaceNotFoundException +import org.onap.cps.spi.exceptions.AlreadyDefinedException +import org.onap.cps.spi.exceptions.SchemaSetInUseException +import org.onap.cps.spi.exceptions.SchemaSetNotFoundException +import org.onap.cps.spi.repository.AnchorRepository +import org.onap.cps.spi.repository.SchemaSetRepository +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.test.context.jdbc.Sql + +class CpsModulePersistenceServiceIntegrationSpec extends CpsPersistenceSpecBase { + + @Autowired + CpsModulePersistenceService objectUnderTest + + @Autowired + AnchorRepository anchorRepository + + @Autowired + SchemaSetRepository schemaSetRepository + + @Autowired + CpsAdminPersistenceService cpsAdminPersistenceService + + static final String SET_DATA = '/data/schemaset.sql' + static final String EXISTING_SCHEMA_SET_NAME = SCHEMA_SET_NAME1 + static final String SCHEMA_SET_NAME_NO_ANCHORS = 'SCHEMA-SET-100' + static final String SCHEMA_SET_NAME_WITH_ANCHORS_AND_DATA = 'SCHEMA-SET-101' + static final String SCHEMA_SET_NAME_NEW = 'SCHEMA-SET-NEW' + + static final Long NEW_RESOURCE_ABSTRACT_ID = 0L + static final String NEW_RESOURCE_NAME = 'some new resource' + static final String NEW_RESOURCE_CONTENT = 'some resource content' + static final String NEW_RESOURCE_CHECKSUM = '09002da02ee2683898d2c81c67f9e22cdbf8577d8c2de16c84d724e4ae44a0a6' + + def newYangResourcesNameToContentMap = [(NEW_RESOURCE_NAME):NEW_RESOURCE_CONTENT] + def dataspaceEntity + + def setup() { + dataspaceEntity = dataspaceRepository.getByName(DATASPACE_NAME) + } + + @Sql([CLEAR_DATA, SET_DATA]) + def 'Store schema set error scenario: #scenario.'() { + when: 'attempt to store schema set #schemaSetName in dataspace #dataspaceName' + objectUnderTest.storeSchemaSet(dataspaceName, schemaSetName, newYangResourcesNameToContentMap) + then: 'an #expectedException is thrown' + thrown(expectedException) + where: 'the following data is used' + scenario | dataspaceName | schemaSetName || expectedException + 'dataspace does not exist' | 'unknown' | 'not-relevant' || DataspaceNotFoundException + 'schema set already exists' | DATASPACE_NAME | EXISTING_SCHEMA_SET_NAME || AlreadyDefinedException + } + + @Sql([CLEAR_DATA, SET_DATA]) + def 'Store new schema set.'() { + when: 'a new schemaset is stored' + objectUnderTest.storeSchemaSet(DATASPACE_NAME, SCHEMA_SET_NAME_NEW, newYangResourcesNameToContentMap) + then: 'the schema set is persisted correctly' + assertSchemaSetPersisted(DATASPACE_NAME, SCHEMA_SET_NAME_NEW, NEW_RESOURCE_ABSTRACT_ID, NEW_RESOURCE_NAME, + NEW_RESOURCE_CONTENT, NEW_RESOURCE_CHECKSUM) + } + + @Sql([CLEAR_DATA, SET_DATA]) + def 'Retrieving schema set (resources) by anchor.'() { + given: 'a new schema set is stored' + objectUnderTest.storeSchemaSet(DATASPACE_NAME, SCHEMA_SET_NAME_NEW, newYangResourcesNameToContentMap) + and: 'an anchor is created with that schema set' + cpsAdminPersistenceService.createAnchor(DATASPACE_NAME, SCHEMA_SET_NAME_NEW, ANCHOR_NAME1) + when: 'the schema set resources for that anchor is retrieved' + def result = objectUnderTest.getYangSchemaSetResources(DATASPACE_NAME, ANCHOR_NAME1) + then: 'the correct resources are returned' + result == newYangResourcesNameToContentMap + } + + @Sql([CLEAR_DATA, SET_DATA]) + def 'Storing duplicate schema content.'() { + given: 'a new schema set with a resource with the same content as an existing resource' + def existingResourceContent = 'CONTENT-001' + def newYangResourcesNameToContentMap = [(NEW_RESOURCE_NAME):existingResourceContent] + when: 'the schema set with duplicate resource is stored' + objectUnderTest.storeSchemaSet(DATASPACE_NAME, SCHEMA_SET_NAME_NEW, newYangResourcesNameToContentMap) + then: 'the schema persisted (re)uses the existing id, name and has the same checksum' + def existingResourceId = 3001L + def existingResourceName = 'module1@2020-02-02.yang' + def existingResourceChecksum = 'e8bdda931099310de66532e08c3fafec391db29f55c81927b168f6aa8f81b73b' + assertSchemaSetPersisted(DATASPACE_NAME, SCHEMA_SET_NAME_NEW, + existingResourceId, existingResourceName, existingResourceContent, existingResourceChecksum) + } + + @Sql([CLEAR_DATA, SET_DATA]) + def 'Delete schema set with cascade delete prohibited but no anchors using it'() { + when: 'a schema set is deleted with cascade-prohibited option' + objectUnderTest.deleteSchemaSet(DATASPACE_NAME, SCHEMA_SET_NAME_NO_ANCHORS, + CASCADE_DELETE_PROHIBITED) + then: 'the schema set has been deleted' + schemaSetRepository.findByDataspaceAndName(dataspaceEntity, SCHEMA_SET_NAME_NO_ANCHORS).isPresent() == false + and: 'any orphaned (not used by any schema set anymore) yang resources are deleted' + def orphanedResourceId = 3100L + yangResourceRepository.findById(orphanedResourceId).isPresent() == false + and: 'any shared (still in use by other schema set) yang resources still persists' + def sharedResourceId = 3003L + yangResourceRepository.findById(sharedResourceId).isPresent() + } + + @Sql([CLEAR_DATA, SET_DATA]) + def 'Delete schema set with cascade allowed.'() { + when: 'a schema set is deleted with cascade-allowed option' + objectUnderTest.deleteSchemaSet(DATASPACE_NAME, SCHEMA_SET_NAME_WITH_ANCHORS_AND_DATA, + CASCADE_DELETE_ALLOWED) + then: 'the schema set has been deleted' + schemaSetRepository + .findByDataspaceAndName(dataspaceEntity, SCHEMA_SET_NAME_WITH_ANCHORS_AND_DATA).isPresent() == false + and: 'the associated anchors are removed' + def associatedAnchorsIds = [ 6001, 6002 ] + associatedAnchorsIds.each {anchorRepository.findById(it).isPresent() == false } + and: 'the fragment(s) under those anchors are removed' + def fragmentUnderAnchor1Id = 7001L + fragmentRepository.findById(fragmentUnderAnchor1Id).isPresent() == false + and: 'the shared resources still persist' + def sharedResourceIds = [ 3003L, 3004L ] + sharedResourceIds.each {yangResourceRepository.findById(it).isPresent() } + } + + @Sql([CLEAR_DATA, SET_DATA]) + def 'Delete schema set error scenario: #scenario.'() { + when: 'attempt to delete a schema set where #scenario' + objectUnderTest.deleteSchemaSet(dataspaceName, schemaSetName, CASCADE_DELETE_PROHIBITED) + then: 'an #expectedException is thrown' + thrown(expectedException) + where: 'the following data is used' + scenario | dataspaceName | schemaSetName || expectedException + 'dataspace does not exist' | 'unknown' | 'not-relevant' || DataspaceNotFoundException + 'schema set does not exists' | DATASPACE_NAME | 'unknown' || SchemaSetNotFoundException + 'cascade prohibited but schema set in use' | DATASPACE_NAME | SCHEMA_SET_NAME_WITH_ANCHORS_AND_DATA || SchemaSetInUseException + } + + def assertSchemaSetPersisted(expectedDataspaceName, + expectedSchemaSetName, + expectedYangResourceId, + expectedYangResourceName, + expectedYangResourceContent, + expectedYangResourceChecksum) { + // assert the schema set is persisted + def schemaSetEntity = schemaSetRepository + .findByDataspaceAndName(dataspaceEntity, expectedSchemaSetName).orElseThrow() + assert schemaSetEntity.name == expectedSchemaSetName + assert schemaSetEntity.dataspace.name == expectedDataspaceName + + // assert the attached yang resource is persisted + def yangResourceEntities = schemaSetEntity.getYangResources() + yangResourceEntities.size() == 1 + + // assert the attached yang resource content + YangResourceEntity yangResourceEntity = yangResourceEntities.iterator().next() + assert yangResourceEntity.id != null + if (expectedYangResourceId != NEW_RESOURCE_ABSTRACT_ID) { + // existing resource with known id + assert yangResourceEntity.id == expectedYangResourceId + } + yangResourceEntity.name == expectedYangResourceName + yangResourceEntity.content == expectedYangResourceContent + yangResourceEntity.checksum == expectedYangResourceChecksum + } + +} diff --git a/cps-ri/src/test/groovy/org/onap/cps/spi/impl/CpsModulePersistenceServiceSpec.groovy b/cps-ri/src/test/groovy/org/onap/cps/spi/impl/CpsModulePersistenceServiceSpec.groovy deleted file mode 100644 index f08aa03579..0000000000 --- a/cps-ri/src/test/groovy/org/onap/cps/spi/impl/CpsModulePersistenceServiceSpec.groovy +++ /dev/null @@ -1,191 +0,0 @@ -/* - * ============LICENSE_START======================================================= - * Copyright (C) 2021 Nordix Foundation - * ================================================================================ - * 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. - * - * SPDX-License-Identifier: Apache-2.0 - * ============LICENSE_END========================================================= - */ -package org.onap.cps.spi.impl - -import static org.onap.cps.spi.CascadeDeleteAllowed.CASCADE_DELETE_ALLOWED -import static org.onap.cps.spi.CascadeDeleteAllowed.CASCADE_DELETE_PROHIBITED - -import org.onap.cps.spi.CpsAdminPersistenceService -import org.onap.cps.spi.CpsModulePersistenceService -import org.onap.cps.spi.entities.YangResourceEntity -import org.onap.cps.spi.exceptions.DataspaceNotFoundException -import org.onap.cps.spi.exceptions.AlreadyDefinedException -import org.onap.cps.spi.exceptions.SchemaSetInUseException -import org.onap.cps.spi.exceptions.SchemaSetNotFoundException -import org.onap.cps.spi.repository.AnchorRepository -import org.onap.cps.spi.repository.SchemaSetRepository -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.test.context.jdbc.Sql - -class CpsModulePersistenceServiceSpec extends CpsPersistenceSpecBase { - - @Autowired - CpsModulePersistenceService objectUnderTest - - @Autowired - AnchorRepository anchorRepository - - @Autowired - SchemaSetRepository schemaSetRepository - - @Autowired - CpsAdminPersistenceService cpsAdminPersistenceService - - static final String SET_DATA = '/data/schemaset.sql' - static final String EXISTING_SCHEMA_SET_NAME = SCHEMA_SET_NAME1 - static final String SCHEMA_SET_NAME_NO_ANCHORS = 'SCHEMA-SET-100' - static final String SCHEMA_SET_NAME_WITH_ANCHORS_AND_DATA = 'SCHEMA-SET-101' - static final String SCHEMA_SET_NAME_NEW = 'SCHEMA-SET-NEW' - - static final Long NEW_RESOURCE_ABSTRACT_ID = 0L - static final String NEW_RESOURCE_NAME = 'some new resource' - static final String NEW_RESOURCE_CONTENT = 'some resource content' - static final String NEW_RESOURCE_CHECKSUM = '09002da02ee2683898d2c81c67f9e22cdbf8577d8c2de16c84d724e4ae44a0a6' - - def newYangResourcesNameToContentMap = [(NEW_RESOURCE_NAME):NEW_RESOURCE_CONTENT] - def dataspaceEntity - - def setup() { - dataspaceEntity = dataspaceRepository.getByName(DATASPACE_NAME) - } - - @Sql([CLEAR_DATA, SET_DATA]) - def 'Store schema set error scenario: #scenario.'() { - when: 'attempt to store schema set #schemaSetName in dataspace #dataspaceName' - objectUnderTest.storeSchemaSet(dataspaceName, schemaSetName, newYangResourcesNameToContentMap) - then: 'an #expectedException is thrown' - thrown(expectedException) - where: 'the following data is used' - scenario | dataspaceName | schemaSetName || expectedException - 'dataspace does not exist' | 'unknown' | 'not-relevant' || DataspaceNotFoundException - 'schema set already exists' | DATASPACE_NAME | EXISTING_SCHEMA_SET_NAME || AlreadyDefinedException - } - - @Sql([CLEAR_DATA, SET_DATA]) - def 'Store new schema set.'() { - when: 'a new schemaset is stored' - objectUnderTest.storeSchemaSet(DATASPACE_NAME, SCHEMA_SET_NAME_NEW, newYangResourcesNameToContentMap) - then: 'the schema set is persisted correctly' - assertSchemaSetPersisted(DATASPACE_NAME, SCHEMA_SET_NAME_NEW, NEW_RESOURCE_ABSTRACT_ID, NEW_RESOURCE_NAME, - NEW_RESOURCE_CONTENT, NEW_RESOURCE_CHECKSUM) - } - - @Sql([CLEAR_DATA, SET_DATA]) - def 'Retrieving schema set (resources) by anchor.'() { - given: 'a new schema set is stored' - objectUnderTest.storeSchemaSet(DATASPACE_NAME, SCHEMA_SET_NAME_NEW, newYangResourcesNameToContentMap) - and: 'an anchor is created with that schema set' - cpsAdminPersistenceService.createAnchor(DATASPACE_NAME, SCHEMA_SET_NAME_NEW, ANCHOR_NAME1) - when: 'the schema set resources for that anchor is retrieved' - def result = objectUnderTest.getYangSchemaSetResources(DATASPACE_NAME, ANCHOR_NAME1) - then: 'the correct resources are returned' - result == newYangResourcesNameToContentMap - } - - @Sql([CLEAR_DATA, SET_DATA]) - def 'Storing duplicate schema content.'() { - given: 'a new schema set with a resource with the same content as an existing resource' - def existingResourceContent = 'CONTENT-001' - def newYangResourcesNameToContentMap = [(NEW_RESOURCE_NAME):existingResourceContent] - when: 'the schema set with duplicate resource is stored' - objectUnderTest.storeSchemaSet(DATASPACE_NAME, SCHEMA_SET_NAME_NEW, newYangResourcesNameToContentMap) - then: 'the schema persisted (re)uses the existing id, name and has the same checksum' - def existingResourceId = 3001L - def existingResourceName = 'module1@2020-02-02.yang' - def existingResourceChecksum = 'e8bdda931099310de66532e08c3fafec391db29f55c81927b168f6aa8f81b73b' - assertSchemaSetPersisted(DATASPACE_NAME, SCHEMA_SET_NAME_NEW, - existingResourceId, existingResourceName, existingResourceContent, existingResourceChecksum) - } - - @Sql([CLEAR_DATA, SET_DATA]) - def 'Delete schema set with cascade delete prohibited but no anchors using it'() { - when: 'a schema set is deleted with cascade-prohibited option' - objectUnderTest.deleteSchemaSet(DATASPACE_NAME, SCHEMA_SET_NAME_NO_ANCHORS, - CASCADE_DELETE_PROHIBITED) - then: 'the schema set has been deleted' - schemaSetRepository.findByDataspaceAndName(dataspaceEntity, SCHEMA_SET_NAME_NO_ANCHORS).isPresent() == false - and: 'any orphaned (not used by any schema set anymore) yang resources are deleted' - def orphanedResourceId = 3100L - yangResourceRepository.findById(orphanedResourceId).isPresent() == false - and: 'any shared (still in use by other schema set) yang resources still persists' - def sharedResourceId = 3003L - yangResourceRepository.findById(sharedResourceId).isPresent() - } - - @Sql([CLEAR_DATA, SET_DATA]) - def 'Delete schema set with cascade allowed.'() { - when: 'a schema set is deleted with cascade-allowed option' - objectUnderTest.deleteSchemaSet(DATASPACE_NAME, SCHEMA_SET_NAME_WITH_ANCHORS_AND_DATA, - CASCADE_DELETE_ALLOWED) - then: 'the schema set has been deleted' - schemaSetRepository - .findByDataspaceAndName(dataspaceEntity, SCHEMA_SET_NAME_WITH_ANCHORS_AND_DATA).isPresent() == false - and: 'the associated anchors are removed' - def associatedAnchorsIds = [ 6001, 6002 ] - associatedAnchorsIds.each {anchorRepository.findById(it).isPresent() == false } - and: 'the fragment(s) under those anchors are removed' - def fragmentUnderAnchor1Id = 7001L - fragmentRepository.findById(fragmentUnderAnchor1Id).isPresent() == false - and: 'the shared resources still persist' - def sharedResourceIds = [ 3003L, 3004L ] - sharedResourceIds.each {yangResourceRepository.findById(it).isPresent() } - } - - @Sql([CLEAR_DATA, SET_DATA]) - def 'Delete schema set error scenario: #scenario.'() { - when: 'attempt to delete a schema set where #scenario' - objectUnderTest.deleteSchemaSet(dataspaceName, schemaSetName, CASCADE_DELETE_PROHIBITED) - then: 'an #expectedException is thrown' - thrown(expectedException) - where: 'the following data is used' - scenario | dataspaceName | schemaSetName || expectedException - 'dataspace does not exist' | 'unknown' | 'not-relevant' || DataspaceNotFoundException - 'schema set does not exists' | DATASPACE_NAME | 'unknown' || SchemaSetNotFoundException - 'cascade prohibited but schema set in use' | DATASPACE_NAME | SCHEMA_SET_NAME_WITH_ANCHORS_AND_DATA || SchemaSetInUseException - } - - def assertSchemaSetPersisted(expectedDataspaceName, - expectedSchemaSetName, - expectedYangResourceId, - expectedYangResourceName, - expectedYangResourceContent, - expectedYangResourceChecksum) { - // assert the schema set is persisted - def schemaSetEntity = schemaSetRepository - .findByDataspaceAndName(dataspaceEntity, expectedSchemaSetName).orElseThrow() - assert schemaSetEntity.name == expectedSchemaSetName - assert schemaSetEntity.dataspace.name == expectedDataspaceName - - // assert the attached yang resource is persisted - def yangResourceEntities = schemaSetEntity.getYangResources() - yangResourceEntities.size() == 1 - - // assert the attached yang resource content - YangResourceEntity yangResourceEntity = yangResourceEntities.iterator().next() - assert yangResourceEntity.id != null - if (expectedYangResourceId != NEW_RESOURCE_ABSTRACT_ID) { - // existing resource with known id - assert yangResourceEntity.id == expectedYangResourceId - } - yangResourceEntity.name == expectedYangResourceName - yangResourceEntity.content == expectedYangResourceContent - yangResourceEntity.checksum == expectedYangResourceChecksum - } - -} diff --git a/cps-ri/src/test/groovy/org/onap/cps/spi/impl/CpsModulePersistenceServiceUnitSpec.groovy b/cps-ri/src/test/groovy/org/onap/cps/spi/impl/CpsModulePersistenceServiceUnitSpec.groovy new file mode 100644 index 0000000000..ffbac485ea --- /dev/null +++ b/cps-ri/src/test/groovy/org/onap/cps/spi/impl/CpsModulePersistenceServiceUnitSpec.groovy @@ -0,0 +1,89 @@ +/* + * ============LICENSE_START======================================================= + * Copyright (c) 2021 Bell Canada. + * ================================================================================ + * 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. + * ============LICENSE_END========================================================= +*/ + +package org.onap.cps.spi.impl + +import org.hibernate.exception.ConstraintViolationException +import org.onap.cps.spi.CpsModulePersistenceService +import org.onap.cps.spi.exceptions.DuplicatedYangResourceException +import org.onap.cps.spi.repository.DataspaceRepository +import org.onap.cps.spi.repository.SchemaSetRepository +import org.onap.cps.spi.repository.YangResourceRepository +import org.springframework.dao.DataIntegrityViolationException +import spock.lang.Shared +import spock.lang.Specification + +import java.sql.SQLException + +/** + * Specification unit test class for CPS module persistence service. + */ +class CpsModulePersistenceServiceUnitSpec extends Specification { + + // Instance to test + CpsModulePersistenceService objectUnderTest + + // Mocks + def dataspaceRepositoryMock = Mock(DataspaceRepository) + def yangResourceRepositoryMock = Mock(YangResourceRepository) + def schemaSetRepositoryMock = Mock(SchemaSetRepository) + + // Constants + def yangResourceName = 'my-yang-resource-name' + def yangResourceContent = 'my-yang-resource-content' + + // Scenario data + @Shared + yangResourceChecksum = 'ac2352cc20c10467528b2390bbf2d72d48b0319152ebaabcda207786b4a641c2' + @Shared + yangResourceChecksumDbConstraint = 'yang_resource_checksum_key' + @Shared + sqlExceptionMessage = String.format('(checksum)=(%s)', yangResourceChecksum) + @Shared + checksumIntegrityException = + new DataIntegrityViolationException( + "checksum integrity exception", + new ConstraintViolationException('', new SQLException(sqlExceptionMessage), yangResourceChecksumDbConstraint)) + @Shared + anotherIntegrityException = new DataIntegrityViolationException("another integrity exception") + + def setup() { + objectUnderTest = new CpsModulePersistenceServiceImpl() + objectUnderTest.dataspaceRepository = dataspaceRepositoryMock + objectUnderTest.yangResourceRepository = yangResourceRepositoryMock + objectUnderTest.schemaSetRepository = schemaSetRepositoryMock + } + + def 'Store schema set error scenario: #scenario.'() { + given: 'no yang resource are currently saved' + yangResourceRepositoryMock.findAllByChecksumIn(_) >> Collections.emptyList() + and: 'persisting yang resource raises db constraint exception (in case of concurrent requests for example)' + yangResourceRepositoryMock.saveAll(_) >> { throw dbException } + when: 'attempt to store schema set ' + def newYangResourcesNameToContentMap = [(yangResourceName):yangResourceContent] + objectUnderTest.storeSchemaSet('my-dataspace', 'my-schema-set', newYangResourcesNameToContentMap) + then: 'an #expectedThrownException is thrown' + def e = thrown(expectedThrownException) + e.getMessage().contains(expectedThrownExceptionMessage) + where: 'the following data is used' + scenario | dbException || expectedThrownException | expectedThrownExceptionMessage + 'checksum data failure' | checksumIntegrityException || DuplicatedYangResourceException | yangResourceChecksum + 'other data failure' | anotherIntegrityException || DataIntegrityViolationException | 'another integrity exception' + } + +} diff --git a/cps-service/src/main/java/org/onap/cps/spi/exceptions/DuplicatedYangResourceException.java b/cps-service/src/main/java/org/onap/cps/spi/exceptions/DuplicatedYangResourceException.java new file mode 100644 index 0000000000..735e677428 --- /dev/null +++ b/cps-service/src/main/java/org/onap/cps/spi/exceptions/DuplicatedYangResourceException.java @@ -0,0 +1,47 @@ +/* + * ============LICENSE_START======================================================= + * Copyright (c) 2021 Bell Canada. + * ================================================================================ + * 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. + * ============LICENSE_END========================================================= + */ + +package org.onap.cps.spi.exceptions; + +import lombok.Getter; + +/** + * Duplicated Yang resource exception. It is raised when trying to persist a duplicated yang resource. + */ +@Getter +public class DuplicatedYangResourceException extends CpsException { + + private static final long serialVersionUID = 9085557087319212380L; + + private final String name; + private final String checksum; + + /** + * Constructor. + * @param cause the exception cause + */ + public DuplicatedYangResourceException(final String name, final String checksum, final Throwable cause) { + super( + String.format("Unexpected duplicated yang resource: '%s' with checksum '%s'", name, checksum), + "The error could be caused by concurrent requests. Retrying sending the request could be required.", + cause); + this.name = name; + this.checksum = checksum; + } + +} -- cgit 1.2.3-korg