Enforce limit on the number of resources resolved by a deleteByUrl call. (#5689)

* Enforce limit on the number of resources resolved by a deleteByUrl call.

* Add changelog and TODO comment.

* Code review suggestion.
This commit is contained in:
Luke deGruchy 2024-02-08 11:13:21 -05:00 committed by GitHub
parent 43f1e4b2dd
commit a7eceda5d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 188 additions and 13 deletions

View File

@ -89,6 +89,7 @@ ca.uhn.fhir.jpa.dao.BaseHapiFhirDao.invalidMatchUrlInvalidResourceType=Invalid m
ca.uhn.fhir.jpa.dao.BaseStorageDao.invalidMatchUrlNoMatches=Invalid match URL "{0}" - No resources match this search
ca.uhn.fhir.jpa.dao.BaseStorageDao.inlineMatchNotSupported=Inline match URLs are not supported on this server. Cannot process reference: "{0}"
ca.uhn.fhir.jpa.dao.BaseStorageDao.transactionOperationWithMultipleMatchFailure=Failed to {0} resource with match URL "{1}" because this search matched {2} resources
ca.uhn.fhir.jpa.dao.BaseStorageDao.deleteByUrlThresholdExceeded=Failed to DELETE resources with match URL "{0}" because the resolved number of resources: {1} exceeds the threshold of {2}
ca.uhn.fhir.jpa.dao.BaseStorageDao.transactionOperationWithIdNotMatchFailure=Failed to {0} resource with match URL "{1}" because the matching resource does not match the provided ID
ca.uhn.fhir.jpa.dao.BaseHapiFhirDao.transactionOperationFailedNoId=Failed to {0} resource in transaction because no ID was provided
ca.uhn.fhir.jpa.dao.BaseHapiFhirDao.transactionOperationFailedUnknownId=Failed to {0} resource in transaction because no resource could be found with ID {1}

View File

@ -0,0 +1,7 @@
---
type: fix
issue: 5690
title: "Previously, a DELETE on a specific URL search string would always attempt to delete no matter the number of
resolved resources.
This has been fixed by adding a storage setting to enforce a threshold for resolved resources, above which
the DELETE operation will fail to execute with HAPI-2496."

View File

@ -852,7 +852,10 @@ public abstract class BaseHapiFhirResourceDao<T extends IBaseResource> extends B
return deleteExpunge(theUrl, theRequest);
}
return myTransactionService.execute(theRequest, transactionDetails, tx -> {
return myTransactionService
.withRequest(theRequest)
.withTransactionDetails(transactionDetails)
.execute(tx -> {
DeleteConflictList deleteConflicts = new DeleteConflictList();
DeleteMethodOutcome outcome = deleteByUrl(theUrl, deleteConflicts, theRequest, transactionDetails);
DeleteConflictUtil.validateDeleteConflictsEmptyOrThrowException(getContext(), deleteConflicts);
@ -872,10 +875,10 @@ public abstract class BaseHapiFhirResourceDao<T extends IBaseResource> extends B
@Nonnull TransactionDetails theTransactionDetails) {
validateDeleteEnabled();
return myTransactionService.execute(
theRequestDetails,
theTransactionDetails,
tx -> doDeleteByUrl(theUrl, deleteConflicts, theTransactionDetails, theRequestDetails));
return myTransactionService
.withRequest(theRequestDetails)
.withTransactionDetails(theTransactionDetails)
.execute(tx -> doDeleteByUrl(theUrl, deleteConflicts, theTransactionDetails, theRequestDetails));
}
@Nonnull
@ -902,6 +905,19 @@ public abstract class BaseHapiFhirResourceDao<T extends IBaseResource> extends B
theUrl,
resourceIds.size()));
}
// TODO: LD: There is a still a bug on slow deletes: https://github.com/hapifhir/hapi-fhir/issues/5675
final long threshold = getStorageSettings().getRestDeleteByUrlResourceIdThreshold();
if (resourceIds.size() > threshold) {
throw new PreconditionFailedException(Msg.code(2496)
+ getContext()
.getLocalizer()
.getMessageSanitized(
BaseStorageDao.class,
"deleteByUrlThresholdExceeded",
theUrl,
resourceIds.size(),
threshold));
}
}
return deletePidList(theUrl, resourceIds, deleteConflicts, theRequestDetails, theTransactionDetails);

View File

@ -6,55 +6,76 @@ import ca.uhn.fhir.batch2.jobs.parameters.UrlPartitioner;
import ca.uhn.fhir.batch2.jobs.reindex.ReindexJobParameters;
import ca.uhn.fhir.batch2.model.JobInstanceStartRequest;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.context.RuntimeResourceDefinition;
import ca.uhn.fhir.i18n.Msg;
import ca.uhn.fhir.interceptor.model.RequestPartitionId;
import ca.uhn.fhir.jpa.api.config.JpaStorageSettings;
import ca.uhn.fhir.jpa.api.dao.IFhirSystemDao;
import ca.uhn.fhir.jpa.api.model.DaoMethodOutcome;
import ca.uhn.fhir.jpa.api.model.DeleteConflictList;
import ca.uhn.fhir.jpa.api.model.DeleteMethodOutcome;
import ca.uhn.fhir.jpa.api.svc.IIdHelperService;
import ca.uhn.fhir.jpa.dao.tx.HapiTransactionService;
import ca.uhn.fhir.jpa.dao.tx.IHapiTransactionService;
import ca.uhn.fhir.jpa.delete.DeleteConflictService;
import ca.uhn.fhir.jpa.model.dao.JpaPid;
import ca.uhn.fhir.jpa.model.entity.ForcedId;
import ca.uhn.fhir.jpa.model.entity.ResourceTable;
import ca.uhn.fhir.jpa.partition.IRequestPartitionHelperSvc;
import ca.uhn.fhir.jpa.search.ResourceSearchUrlSvc;
import ca.uhn.fhir.jpa.searchparam.MatchUrlService;
import ca.uhn.fhir.jpa.searchparam.ResourceSearch;
import ca.uhn.fhir.jpa.searchparam.SearchParameterMap;
import ca.uhn.fhir.jpa.svc.MockHapiTransactionService;
import ca.uhn.fhir.rest.api.server.SystemRequestDetails;
import ca.uhn.fhir.rest.api.server.RequestDetails;
import ca.uhn.fhir.rest.api.server.SystemRequestDetails;
import ca.uhn.fhir.rest.api.server.storage.TransactionDetails;
import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
import ca.uhn.fhir.rest.server.exceptions.PreconditionFailedException;
import ca.uhn.fhir.rest.server.exceptions.ResourceNotFoundException;
import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails;
import ca.uhn.fhir.rest.server.util.ISearchParamRegistry;
import com.google.common.collect.Lists;
import jakarta.persistence.EntityManager;
import org.hl7.fhir.instance.model.api.IIdType;
import org.hl7.fhir.r4.model.IdType;
import org.hl7.fhir.r4.model.Patient;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.ArgumentCaptor;
import org.mockito.ArgumentMatchers;
import org.mockito.Captor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.stubbing.Answer;
import org.springframework.context.ApplicationContext;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.TransactionCallback;
import jakarta.persistence.EntityManager;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.fail;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.isNotNull;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ -76,6 +97,9 @@ class BaseHapiFhirResourceDaoTest {
@Mock
private IJobCoordinator myJobCoordinator;
@Mock
private IJpaStorageResourceParser myJpaStorageResourceParser;
@Mock
private UrlPartitioner myUrlPartitioner;
@ -91,6 +115,23 @@ class BaseHapiFhirResourceDaoTest {
@Mock
private ISearchBuilder<JpaPid> myISearchBuilder;
@Mock
private MatchUrlService myMatchUrlService;
@Mock
private MatchResourceUrlService<JpaPid> myMatchResourceUrlService;
@Mock
private HapiTransactionService myTransactionService;
@Mock
private DeleteConflictService myDeleteConflictService;
@Mock
private IFhirSystemDao<?, ?> mySystemDao;
@Mock
private ResourceSearchUrlSvc myResourceSearchUrlSvc;
@Captor
private ArgumentCaptor<SearchParameterMap> mySearchParameterMapCaptor;
@ -100,6 +141,8 @@ class BaseHapiFhirResourceDaoTest {
@InjectMocks
private TestResourceDao mySvc;
private TestResourceDao mySpiedSvc;
@BeforeEach
public void init() {
// set our context
@ -108,6 +151,7 @@ class BaseHapiFhirResourceDaoTest {
// the individual tests will have to start
// by calling setup themselves
mySvc.setContext(myFhirContext);
mySpiedSvc = spy(mySvc);
}
/**
@ -198,7 +242,7 @@ class BaseHapiFhirResourceDaoTest {
)).thenReturn(jpaPid);
when(myEntityManager.find(
any(Class.class),
Mockito.anyLong()
anyLong()
)).thenReturn(entity);
// we don't stub myConfig.getResourceClientIdStrategy()
// because even a null return isn't ANY...
@ -288,6 +332,97 @@ class BaseHapiFhirResourceDaoTest {
);
}
@Nested
class DeleteThresholds {
private static final String URL = "Patient?_lastUpdated=gt2024-01-01";
private static final RequestDetails REQUEST = new SystemRequestDetails();
private static final DeleteMethodOutcome EXPECTED_DELETE_OUTCOME = new DeleteMethodOutcome();
@BeforeEach
void beforeEach() {
when(myStorageSettings.isDeleteEnabled()).thenReturn(true);
when(myMatchUrlService.getResourceSearch(URL))
.thenReturn(new ResourceSearch(mock(RuntimeResourceDefinition.class), SearchParameterMap.newSynchronous(), RequestPartitionId.allPartitions()));
// mocks for transaction handling:
final IHapiTransactionService.IExecutionBuilder mockExecutionBuilder = mock(IHapiTransactionService.IExecutionBuilder.class);
when(mockExecutionBuilder.withTransactionDetails(any(TransactionDetails.class))).thenReturn(mockExecutionBuilder);
when(myTransactionService.withRequest(REQUEST)).thenReturn(mockExecutionBuilder);
final Answer<DeleteMethodOutcome> answer = theInvocationOnMock -> {
final TransactionCallback<DeleteMethodOutcome> arg = theInvocationOnMock.getArgument(0);
return arg.doInTransaction(mock(TransactionStatus.class));
};
when(mockExecutionBuilder.execute(ArgumentMatchers.<TransactionCallback<DeleteMethodOutcome>>any()))
.thenAnswer(answer);
}
@ParameterizedTest
@MethodSource("thresholdsAndResourceIds_Pass")
void deleteByUrlConsiderThresholdUnder_Pass(long theThreshold, Set<Long> theResourceIds) {
if (theResourceIds.size() > 1) {
when(myStorageSettings.isAllowMultipleDelete()).thenReturn(true);
when(myStorageSettings.getRestDeleteByUrlResourceIdThreshold()).thenReturn(theThreshold);
}
doReturn(EXPECTED_DELETE_OUTCOME).when(mySpiedSvc).deletePidList(any(), any(), any(), any(), any());
handleExpectedResourceIds(theResourceIds);
final DeleteMethodOutcome deleteMethodOutcome = mySpiedSvc.deleteByUrl(URL, REQUEST);
assertEquals(EXPECTED_DELETE_OUTCOME, deleteMethodOutcome);
}
@ParameterizedTest
@MethodSource("thresholdsAndResourceIds_Fail")
void deleteByUrlConsiderThreshold_Over_Fail(long theThreshold, Set<Long> theResourceIds) {
when(myStorageSettings.isAllowMultipleDelete()).thenReturn(true);
when(myStorageSettings.getRestDeleteByUrlResourceIdThreshold()).thenReturn(theThreshold);
final Set<JpaPid> expectedResourceIds = handleExpectedResourceIds(theResourceIds);
try {
mySpiedSvc.deleteByUrl(URL, REQUEST);
fail();
} catch (PreconditionFailedException exception) {
assertEquals(String.format("HAPI-2496: Failed to DELETE resources with match URL \"Patient?_lastUpdated=gt2024-01-01\" because the resolved number of resources: %s exceeds the threshold of %s", expectedResourceIds.size(), theThreshold), exception.getMessage());
}
}
private Set<JpaPid> handleExpectedResourceIds(Set<Long> theResourceIds) {
final Set<JpaPid> expectedResourceIds = theResourceIds.stream().map(JpaPid::fromId).collect(Collectors.toUnmodifiableSet());
when(myMatchResourceUrlService.search(any(), any(), any(), any())).thenReturn(expectedResourceIds);
return expectedResourceIds;
}
static Stream<Arguments> thresholdsAndResourceIds_Pass() {
return Stream.of(
Arguments.of(0, Collections.emptySet()),
Arguments.of(1, Collections.emptySet()),
Arguments.of(2, Collections.emptySet()),
Arguments.of(3, Collections.emptySet()),
Arguments.of(4, Collections.emptySet()),
Arguments.of(5, Collections.emptySet()),
Arguments.of(1, Set.of(1L)),
Arguments.of(2, Set.of(1L)),
Arguments.of(3, Set.of(1L)),
Arguments.of(4, Set.of(1L)),
Arguments.of(5, Set.of(1L)),
Arguments.of(4, Set.of(1L,2L,3L)),
Arguments.of(5, Set.of(1L,2L,3L))
);
}
static Stream<Arguments> thresholdsAndResourceIds_Fail() {
return Stream.of(
Arguments.of(0, Set.of(1L,2L)),
Arguments.of(1, Set.of(1L,2L)),
Arguments.of(0, Set.of(1L,2L,3L)),
Arguments.of(1, Set.of(1L,2L,3L)),
Arguments.of(2, Set.of(1L,2L,3L))
);
}
}
static class TestResourceDao extends BaseHapiFhirResourceDao<Patient> {
@Override

View File

@ -110,6 +110,7 @@ public class JpaStorageSettings extends StorageSettings {
private static final Integer DEFAULT_INTERNAL_SYNCHRONOUS_SEARCH_SIZE = 10000;
private static final boolean DEFAULT_PREVENT_INVALIDATING_CONDITIONAL_MATCH_CRITERIA = false;
private static final long DEFAULT_REST_DELETE_BY_URL_RESOURCE_ID_THRESHOLD = 10000;
/**
* Do not change default of {@code 0}!
@ -344,6 +345,13 @@ public class JpaStorageSettings extends StorageSettings {
private boolean myPreventInvalidatingConditionalMatchCriteria =
DEFAULT_PREVENT_INVALIDATING_CONDITIONAL_MATCH_CRITERIA;
/**
* This setting helps to enforce a threshold in number of resolved resources for DELETE by URL REST calls
*
* @since 7.2.0
*/
private long myRestDeleteByUrlResourceIdThreshold = DEFAULT_REST_DELETE_BY_URL_RESOURCE_ID_THRESHOLD;
/**
* Constructor
*/
@ -2427,6 +2435,14 @@ public class JpaStorageSettings extends StorageSettings {
return myPreventInvalidatingConditionalMatchCriteria;
}
public long getRestDeleteByUrlResourceIdThreshold() {
return myRestDeleteByUrlResourceIdThreshold;
}
public void setRestDeleteByUrlResourceIdThreshold(long theRestDeleteByUrlResourceIdThreshold) {
myRestDeleteByUrlResourceIdThreshold = theRestDeleteByUrlResourceIdThreshold;
}
public enum StoreMetaSourceInformationEnum {
NONE(false, false),
SOURCE_URI(true, false),