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.context.FhirContext;
023import ca.uhn.fhir.i18n.Msg;
024import ca.uhn.fhir.interceptor.api.HookParams;
025import ca.uhn.fhir.interceptor.api.IInterceptorBroadcaster;
026import ca.uhn.fhir.interceptor.api.Pointcut;
027import ca.uhn.fhir.jpa.api.config.JpaStorageSettings;
028import ca.uhn.fhir.jpa.api.model.DeleteConflict;
029import ca.uhn.fhir.jpa.api.model.DeleteConflictList;
030import ca.uhn.fhir.jpa.dao.BaseStorageDao;
031import ca.uhn.fhir.jpa.dao.data.IResourceLinkDao;
032import ca.uhn.fhir.jpa.model.entity.ResourceLink;
033import ca.uhn.fhir.jpa.model.entity.ResourceTable;
034import ca.uhn.fhir.model.primitive.IdDt;
035import ca.uhn.fhir.rest.api.server.RequestDetails;
036import ca.uhn.fhir.rest.api.server.storage.TransactionDetails;
037import ca.uhn.fhir.rest.server.exceptions.ResourceVersionConflictException;
038import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails;
039import ca.uhn.fhir.rest.server.util.CompositeInterceptorBroadcaster;
040import ca.uhn.fhir.util.OperationOutcomeUtil;
041import com.google.common.annotations.VisibleForTesting;
042import org.hl7.fhir.instance.model.api.IBaseOperationOutcome;
043import org.slf4j.Logger;
044import org.slf4j.LoggerFactory;
045import org.springframework.beans.factory.annotation.Autowired;
046import org.springframework.stereotype.Service;
047
048import java.util.List;
049
050@Service
051public class DeleteConflictService {
052        public static final int FIRST_QUERY_RESULT_COUNT = 1;
053        private static final Logger ourLog = LoggerFactory.getLogger(DeleteConflictService.class);
054        public static int MAX_RETRY_ATTEMPTS = 10;
055        public static String MAX_RETRY_ATTEMPTS_EXCEEDED_MSG =
056                        "Requested delete operation stopped before all conflicts were handled. May need to increase the configured Maximum Delete Conflict Query Count.";
057
058        @Autowired
059        protected IResourceLinkDao myResourceLinkDao;
060
061        @Autowired
062        protected IInterceptorBroadcaster myInterceptorBroadcaster;
063
064        @Autowired
065        DeleteConflictFinderService myDeleteConflictFinderService;
066
067        @Autowired
068        JpaStorageSettings myStorageSettings;
069
070        @Autowired
071        private FhirContext myFhirContext;
072
073        private DeleteConflictOutcome findAndHandleConflicts(
074                        RequestDetails theRequest,
075                        DeleteConflictList theDeleteConflicts,
076                        ResourceTable theEntity,
077                        boolean theForValidate,
078                        int theMinQueryResultCount,
079                        TransactionDetails theTransactionDetails) {
080                List<ResourceLink> resultList = myDeleteConflictFinderService.findConflicts(theEntity, theMinQueryResultCount);
081                if (resultList.isEmpty()) {
082                        return null;
083                }
084
085                return handleConflicts(
086                                theRequest, theDeleteConflicts, theEntity, theForValidate, resultList, theTransactionDetails);
087        }
088
089        private DeleteConflictOutcome handleConflicts(
090                        RequestDetails theRequest,
091                        DeleteConflictList theDeleteConflicts,
092                        ResourceTable theEntity,
093                        boolean theForValidate,
094                        List<ResourceLink> theResultList,
095                        TransactionDetails theTransactionDetails) {
096                if (!myStorageSettings.isEnforceReferentialIntegrityOnDelete() && !theForValidate) {
097                        ourLog.debug("Deleting {} resource dependencies which can no longer be satisfied", theResultList.size());
098                        myResourceLinkDao.deleteAll(theResultList);
099                        return null;
100                }
101
102                addConflictsToList(theDeleteConflicts, theEntity, theResultList);
103
104                if (theDeleteConflicts.isEmpty()) {
105                        return new DeleteConflictOutcome();
106                }
107
108                // Notify Interceptors about pre-action call
109                HookParams hooks = new HookParams()
110                                .add(DeleteConflictList.class, theDeleteConflicts)
111                                .add(RequestDetails.class, theRequest)
112                                .addIfMatchesType(ServletRequestDetails.class, theRequest)
113                                .add(TransactionDetails.class, theTransactionDetails);
114                return (DeleteConflictOutcome) CompositeInterceptorBroadcaster.doCallHooksAndReturnObject(
115                                myInterceptorBroadcaster, theRequest, Pointcut.STORAGE_PRESTORAGE_DELETE_CONFLICTS, hooks);
116        }
117
118        private void addConflictsToList(
119                        DeleteConflictList theDeleteConflicts, ResourceTable theEntity, List<ResourceLink> theResultList) {
120                for (ResourceLink link : theResultList) {
121                        IdDt targetId = theEntity.getIdDt();
122                        IdDt sourceId = link.getSourceResource().getIdDt();
123                        String sourcePath = link.getSourcePath();
124                        if (theDeleteConflicts.isResourceIdMarkedForDeletion(sourceId)) {
125                                if (theDeleteConflicts.isResourceIdMarkedForDeletion(targetId)) {
126                                        continue;
127                                }
128                        }
129
130                        theDeleteConflicts.add(new DeleteConflict(sourceId, sourcePath, targetId));
131                }
132        }
133
134        public int validateOkToDelete(
135                        DeleteConflictList theDeleteConflicts,
136                        ResourceTable theEntity,
137                        boolean theForValidate,
138                        RequestDetails theRequest,
139                        TransactionDetails theTransactionDetails) {
140
141                // We want the list of resources that are marked to be the same list even as we
142                // drill into conflict resolution stacks.. this allows us to not get caught by
143                // circular references
144                DeleteConflictList newConflicts = new DeleteConflictList(theDeleteConflicts);
145
146                // In most cases, there will be no hooks, and so we only need to check if there is at least
147                // FIRST_QUERY_RESULT_COUNT conflict and populate that.
148                // Only in the case where there is a hook do we need to go back and collect larger batches of conflicts for
149                // processing.
150
151                DeleteConflictOutcome outcome = findAndHandleConflicts(
152                                theRequest, newConflicts, theEntity, theForValidate, FIRST_QUERY_RESULT_COUNT, theTransactionDetails);
153
154                int retryCount = 0;
155                while (outcome != null) {
156                        int shouldRetryCount = Math.min(outcome.getShouldRetryCount(), MAX_RETRY_ATTEMPTS);
157                        if (!(retryCount < shouldRetryCount)) break;
158                        newConflicts = new DeleteConflictList(newConflicts);
159                        outcome = findAndHandleConflicts(
160                                        theRequest,
161                                        newConflicts,
162                                        theEntity,
163                                        theForValidate,
164                                        myStorageSettings.getMaximumDeleteConflictQueryCount(),
165                                        theTransactionDetails);
166                        ++retryCount;
167                }
168                theDeleteConflicts.addAll(newConflicts);
169                if (retryCount >= MAX_RETRY_ATTEMPTS && !theDeleteConflicts.isEmpty()) {
170                        IBaseOperationOutcome oo = OperationOutcomeUtil.newInstance(myFhirContext);
171                        OperationOutcomeUtil.addIssue(
172                                        myFhirContext,
173                                        oo,
174                                        BaseStorageDao.OO_SEVERITY_ERROR,
175                                        MAX_RETRY_ATTEMPTS_EXCEEDED_MSG,
176                                        null,
177                                        "processing");
178                        throw new ResourceVersionConflictException(Msg.code(821) + MAX_RETRY_ATTEMPTS_EXCEEDED_MSG, oo);
179                }
180                return retryCount;
181        }
182
183        @VisibleForTesting
184        static void setMaxRetryAttempts(Integer theMaxRetryAttempts) {
185                MAX_RETRY_ATTEMPTS = theMaxRetryAttempts;
186        }
187}