001/*- 002 * #%L 003 * HAPI FHIR JPA Server 004 * %% 005 * Copyright (C) 2014 - 2024 Smile CDR, Inc. 006 * %% 007 * Licensed under the Apache License, Version 2.0 (the "License"); 008 * you may not use this file except in compliance with the License. 009 * You may obtain a copy of the License at 010 * 011 * http://www.apache.org/licenses/LICENSE-2.0 012 * 013 * Unless required by applicable law or agreed to in writing, software 014 * distributed under the License is distributed on an "AS IS" BASIS, 015 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 016 * See the License for the specific language governing permissions and 017 * limitations under the License. 018 * #L% 019 */ 020package ca.uhn.fhir.jpa.delete; 021 022import ca.uhn.fhir.interceptor.api.HookParams; 023import ca.uhn.fhir.interceptor.api.IInterceptorBroadcaster; 024import ca.uhn.fhir.interceptor.api.Pointcut; 025import ca.uhn.fhir.jpa.api.dao.DaoRegistry; 026import ca.uhn.fhir.jpa.api.dao.IFhirResourceDao; 027import ca.uhn.fhir.jpa.api.model.DaoMethodOutcome; 028import ca.uhn.fhir.jpa.api.model.DeleteConflict; 029import ca.uhn.fhir.jpa.api.model.DeleteConflictList; 030import ca.uhn.fhir.jpa.dao.tx.IHapiTransactionService; 031import ca.uhn.fhir.jpa.interceptor.CascadingDeleteInterceptor; 032import ca.uhn.fhir.model.primitive.IdDt; 033import ca.uhn.fhir.rest.api.server.RequestDetails; 034import ca.uhn.fhir.rest.api.server.storage.TransactionDetails; 035import ca.uhn.fhir.rest.server.exceptions.ResourceGoneException; 036import ca.uhn.fhir.rest.server.exceptions.ResourceVersionConflictException; 037import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails; 038import ca.uhn.fhir.rest.server.util.CompositeInterceptorBroadcaster; 039import org.hl7.fhir.instance.model.api.IBaseResource; 040import org.slf4j.Logger; 041import org.slf4j.LoggerFactory; 042import org.springframework.retry.backoff.FixedBackOffPolicy; 043import org.springframework.retry.policy.SimpleRetryPolicy; 044import org.springframework.retry.support.RetryTemplate; 045import org.springframework.transaction.annotation.Propagation; 046 047import java.util.Collections; 048import java.util.List; 049 050/** 051 * Used by {@link CascadingDeleteInterceptor} to handle {@link DeleteConflictList}s in a thead-safe way. 052 * <p> 053 * Specifically, this class spawns an inner transaction for each {@link DeleteConflictList}. This class is meant to handle any potential delete collisions (ex {@link ResourceGoneException} or {@link ResourceVersionConflictException}. In the former case, we swallow the Exception in the inner transaction then continue. In the latter case, we retry according to the RETRY_BACKOFF_PERIOD and RETRY_MAX_ATTEMPTS before giving up. 054 */ 055public class ThreadSafeResourceDeleterSvc { 056 057 public static final long RETRY_BACKOFF_PERIOD = 100L; 058 public static final int RETRY_MAX_ATTEMPTS = 4; 059 private static final String REQ_DET_KEY_IN_NEW_TRANSACTION = 060 ThreadSafeResourceDeleterSvc.class.getName() + "REQ_DET_KEY_IN_NEW_TRANSACTION"; 061 private static final Logger ourLog = LoggerFactory.getLogger(ThreadSafeResourceDeleterSvc.class); 062 private final DaoRegistry myDaoRegistry; 063 private final IInterceptorBroadcaster myInterceptorBroadcaster; 064 065 private final RetryTemplate myRetryTemplate = getRetryTemplate(); 066 private final IHapiTransactionService myTransactionService; 067 068 public ThreadSafeResourceDeleterSvc( 069 DaoRegistry theDaoRegistry, 070 IInterceptorBroadcaster theInterceptorBroadcaster, 071 IHapiTransactionService theTransactionService) { 072 myDaoRegistry = theDaoRegistry; 073 myInterceptorBroadcaster = theInterceptorBroadcaster; 074 myTransactionService = theTransactionService; 075 } 076 077 /** 078 * @return number of resources that were successfully deleted 079 */ 080 public Integer delete( 081 RequestDetails theRequest, DeleteConflictList theConflictList, TransactionDetails theTransactionDetails) { 082 Integer retVal = 0; 083 084 List<String> cascadeDeleteIdCache = CascadingDeleteInterceptor.getCascadedDeletesList(theRequest, true); 085 for (DeleteConflict next : theConflictList) { 086 IdDt nextSource = next.getSourceId(); 087 String nextSourceId = nextSource.toUnqualifiedVersionless().getValue(); 088 089 if (!cascadeDeleteIdCache.contains(nextSourceId)) { 090 cascadeDeleteIdCache.add(nextSourceId); 091 retVal += handleNextSource( 092 theRequest, theConflictList, theTransactionDetails, next, nextSource, nextSourceId); 093 } 094 } 095 096 return retVal; 097 } 098 099 /** 100 * @return number of resources that were successfully deleted 101 */ 102 private Integer handleNextSource( 103 RequestDetails theRequest, 104 DeleteConflictList theConflictList, 105 TransactionDetails theTransactionDetails, 106 DeleteConflict next, 107 IdDt nextSource, 108 String nextSourceId) { 109 IFhirResourceDao<?> dao = myDaoRegistry.getResourceDao(nextSource.getResourceType()); 110 111 // We will retry deletes on any occurrence of ResourceVersionConflictException up to RETRY_MAX_ATTEMPTS 112 return myRetryTemplate.execute(retryContext -> { 113 String previousNewTransactionValue = null; 114 if (theRequest != null) { 115 previousNewTransactionValue = (String) theRequest.getUserData().get(REQ_DET_KEY_IN_NEW_TRANSACTION); 116 } 117 118 try { 119 if (retryContext.getRetryCount() > 0) { 120 ourLog.info("Retrying delete of {} - Attempt #{}", nextSourceId, retryContext.getRetryCount()); 121 } 122 123 // Avoid nesting multiple new transactions deep. This can easily cause 124 // thread pools to get exhausted. 125 Propagation propagation; 126 if (theRequest == null || previousNewTransactionValue != null) { 127 propagation = Propagation.REQUIRED; 128 } else { 129 theRequest.getUserData().put(REQ_DET_KEY_IN_NEW_TRANSACTION, REQ_DET_KEY_IN_NEW_TRANSACTION); 130 propagation = Propagation.REQUIRES_NEW; 131 } 132 133 myTransactionService 134 .withRequest(theRequest) 135 .withTransactionDetails(theTransactionDetails) 136 .withPropagation(propagation) 137 .execute(() -> doDelete(theRequest, theConflictList, theTransactionDetails, nextSource, dao)); 138 139 return 1; 140 } catch (ResourceGoneException exception) { 141 ourLog.info("{} is already deleted. Skipping cascade delete of this resource", nextSourceId); 142 } finally { 143 if (theRequest != null) { 144 theRequest.getUserData().put(REQ_DET_KEY_IN_NEW_TRANSACTION, previousNewTransactionValue); 145 } 146 } 147 148 return 0; 149 }); 150 } 151 152 private DaoMethodOutcome doDelete( 153 RequestDetails theRequest, 154 DeleteConflictList theConflictList, 155 TransactionDetails theTransactionDetails, 156 IdDt nextSource, 157 IFhirResourceDao<?> dao) { 158 159 // Remove the version so we grab the latest version to delete 160 IBaseResource resource = dao.read(nextSource.toVersionless(), theRequest); 161 162 // Interceptor call: STORAGE_CASCADE_DELETE 163 IInterceptorBroadcaster compositeBroadcaster = 164 CompositeInterceptorBroadcaster.newCompositeBroadcaster(myInterceptorBroadcaster, theRequest); 165 HookParams params = new HookParams() 166 .add(RequestDetails.class, theRequest) 167 .addIfMatchesType(ServletRequestDetails.class, theRequest) 168 .add(DeleteConflictList.class, theConflictList) 169 .add(IBaseResource.class, resource); 170 compositeBroadcaster.callHooks(Pointcut.STORAGE_CASCADE_DELETE, params); 171 172 return dao.delete(resource.getIdElement(), theConflictList, theRequest, theTransactionDetails); 173 } 174 175 private static RetryTemplate getRetryTemplate() { 176 final RetryTemplate retryTemplate = new RetryTemplate(); 177 178 final FixedBackOffPolicy fixedBackOffPolicy = new FixedBackOffPolicy(); 179 fixedBackOffPolicy.setBackOffPeriod(RETRY_BACKOFF_PERIOD); 180 retryTemplate.setBackOffPolicy(fixedBackOffPolicy); 181 182 final SimpleRetryPolicy retryPolicy = new SimpleRetryPolicy( 183 RETRY_MAX_ATTEMPTS, Collections.singletonMap(ResourceVersionConflictException.class, true)); 184 retryTemplate.setRetryPolicy(retryPolicy); 185 186 return retryTemplate; 187 } 188}