001/*-
002 * #%L
003 * HAPI FHIR JPA Server
004 * %%
005 * Copyright (C) 2014 - 2025 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                IInterceptorBroadcaster compositeBroadcaster =
110                                CompositeInterceptorBroadcaster.newCompositeBroadcaster(myInterceptorBroadcaster, theRequest);
111                HookParams hooks = new HookParams()
112                                .add(DeleteConflictList.class, theDeleteConflicts)
113                                .add(RequestDetails.class, theRequest)
114                                .addIfMatchesType(ServletRequestDetails.class, theRequest)
115                                .add(TransactionDetails.class, theTransactionDetails);
116                return (DeleteConflictOutcome)
117                                compositeBroadcaster.callHooksAndReturnObject(Pointcut.STORAGE_PRESTORAGE_DELETE_CONFLICTS, hooks);
118        }
119
120        private void addConflictsToList(
121                        DeleteConflictList theDeleteConflicts, ResourceTable theEntity, List<ResourceLink> theResultList) {
122                for (ResourceLink link : theResultList) {
123                        IdDt targetId = theEntity.getIdDt();
124                        IdDt sourceId = link.getSourceResource().getIdDt();
125                        String sourcePath = link.getSourcePath();
126                        if (theDeleteConflicts.isResourceIdMarkedForDeletion(sourceId)) {
127                                if (theDeleteConflicts.isResourceIdMarkedForDeletion(targetId)) {
128                                        continue;
129                                }
130                        }
131
132                        theDeleteConflicts.add(new DeleteConflict(sourceId, sourcePath, targetId));
133                }
134        }
135
136        public int validateOkToDelete(
137                        DeleteConflictList theDeleteConflicts,
138                        ResourceTable theEntity,
139                        boolean theForValidate,
140                        RequestDetails theRequest,
141                        TransactionDetails theTransactionDetails) {
142
143                // We want the list of resources that are marked to be the same list even as we
144                // drill into conflict resolution stacks.. this allows us to not get caught by
145                // circular references
146                DeleteConflictList newConflicts = new DeleteConflictList(theDeleteConflicts);
147
148                // In most cases, there will be no hooks, and so we only need to check if there is at least
149                // FIRST_QUERY_RESULT_COUNT conflict and populate that.
150                // Only in the case where there is a hook do we need to go back and collect larger batches of conflicts for
151                // processing.
152
153                DeleteConflictOutcome outcome = findAndHandleConflicts(
154                                theRequest, newConflicts, theEntity, theForValidate, FIRST_QUERY_RESULT_COUNT, theTransactionDetails);
155
156                int retryCount = 0;
157                while (outcome != null) {
158                        int shouldRetryCount = Math.min(outcome.getShouldRetryCount(), MAX_RETRY_ATTEMPTS);
159                        if (!(retryCount < shouldRetryCount)) break;
160                        newConflicts = new DeleteConflictList(newConflicts);
161                        outcome = findAndHandleConflicts(
162                                        theRequest,
163                                        newConflicts,
164                                        theEntity,
165                                        theForValidate,
166                                        myStorageSettings.getMaximumDeleteConflictQueryCount(),
167                                        theTransactionDetails);
168                        ++retryCount;
169                }
170                theDeleteConflicts.addAll(newConflicts);
171                if (retryCount >= MAX_RETRY_ATTEMPTS && !theDeleteConflicts.isEmpty()) {
172                        IBaseOperationOutcome oo = OperationOutcomeUtil.newInstance(myFhirContext);
173                        OperationOutcomeUtil.addIssue(
174                                        myFhirContext,
175                                        oo,
176                                        BaseStorageDao.OO_SEVERITY_ERROR,
177                                        MAX_RETRY_ATTEMPTS_EXCEEDED_MSG,
178                                        null,
179                                        "processing");
180                        throw new ResourceVersionConflictException(Msg.code(821) + MAX_RETRY_ATTEMPTS_EXCEEDED_MSG, oo);
181                }
182                return retryCount;
183        }
184
185        @VisibleForTesting
186        static void setMaxRetryAttempts(Integer theMaxRetryAttempts) {
187                MAX_RETRY_ATTEMPTS = theMaxRetryAttempts;
188        }
189}