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}