Repository: hapifhir/hapi-fhir
Pull request #6866: Added query chunking in DeleteExpungeSqlBuilder.findResourceLinksWithTargetPidIn()
Author: login: AbayomiE-T
Created: 2025-04-11T16:02:48Z | Merged: 2025-04-15T14:25:28Z

This fixes an issue where a delete-expunge operation with cascading deletes enabled sometimes fails due to too many query parameters in the SQL statement used to find child resources.


Diff:
diff --git a/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/8_2_0/6864-hapi-0550-error-encountered-when-performing-a-delete-expunge-operation-with-cascading-deletes-enabled.yaml b/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/8_2_0/6864-hapi-0550-error-encountered-when-performing-a-delete-expunge-operation-with-cascading-deletes-enabled.yaml
new file mode 100644
index 000000000000..6d7b7a198d06
--- /dev/null
+++ b/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/8_2_0/6864-hapi-0550-error-encountered-when-performing-a-delete-expunge-operation-with-cascading-deletes-enabled.yaml
@@ -0,0 +1,5 @@
+---
+type: fix
+issue: 6864
+title: "Fixed an issue where performing a delete-expunge operation with cascading deletes enabled would sometimes be unsuccessful due to a failure
+ in finding large amounts of child resources."
diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/delete/batch2/DeleteExpungeSqlBuilder.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/delete/batch2/DeleteExpungeSqlBuilder.java
index 4c382b1c7753..62691522a8a8 100644
--- a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/delete/batch2/DeleteExpungeSqlBuilder.java
+++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/delete/batch2/DeleteExpungeSqlBuilder.java
@@ -28,6 +28,7 @@
 import ca.uhn.fhir.jpa.model.config.PartitionSettings;
 import ca.uhn.fhir.jpa.model.dao.JpaPid;
 import ca.uhn.fhir.jpa.model.entity.ResourceLink;
+import ca.uhn.fhir.jpa.util.QueryChunker;
 import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
 import jakarta.annotation.Nonnull;
 import org.slf4j.Logger;
@@ -163,18 +164,23 @@ public void findResourceLinksWithTargetPidIn(
 		// We only need to find one conflict, so if we found one already in an earlier partition run, we can skip the
 		// rest of the searches
 		if (theConflictResourceLinks.isEmpty()) {
-			List<ResourceLink> conflictResourceLinks =
-					myResourceLinkDao.findWithTargetPidIn((theSomeTargetPids)).stream()
-							// Filter out resource links for which we are planning to delete the source.
-							// theAllTargetPids contains a list of all the pids we are planning to delete.  So we only
-							// want
-							// to consider a link to be a conflict if the source of that link is not in
-							// theAllTargetPids.
-							.filter(link -> !(theAllTargetPids).contains(link.getSourceResourcePk()))
-							.collect(Collectors.toList());
-
-			// We do this in two steps to avoid lock contention on this synchronized list
-			theConflictResourceLinks.addAll(conflictResourceLinks);
+			// Chunker is used because theSomeTargetPids can contain list sizes over 100,000, a number that some
+			// databases can't handle as a query parameter count in an IN clause of a query.
+			QueryChunker.chunk(theSomeTargetPids, targetPidsChunk -> {
+				List<ResourceLink> conflictResourceLinks =
+						myResourceLinkDao.findWithTargetPidIn((targetPidsChunk)).stream()
+								// Filter out resource links for which we are planning to delete the source.
+								// theAllTargetPids contains a list of all the pids we are planning to delete.  So we
+								// only
+								// want
+								// to consider a link to be a conflict if the source of that link is not in
+								// theAllTargetPids.
+								.filter(link -> !(theAllTargetPids).contains(link.getSourceResourcePk()))
+								.collect(Collectors.toList());
+
+				// We do this in two steps to avoid lock contention on this synchronized list
+				theConflictResourceLinks.addAll(conflictResourceLinks);
+			});
 		}
 	}
 
diff --git a/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/batch2/DeleteExpungeSqlBuilderTest.java b/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/batch2/DeleteExpungeSqlBuilderTest.java
new file mode 100644
index 000000000000..9de2c07e1f5b
--- /dev/null
+++ b/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/batch2/DeleteExpungeSqlBuilderTest.java
@@ -0,0 +1,60 @@
+package ca.uhn.fhir.jpa.batch2;
+
+import ca.uhn.fhir.jpa.api.config.JpaStorageSettings;
+import ca.uhn.fhir.jpa.api.svc.IIdHelperService;
+import ca.uhn.fhir.jpa.dao.data.IResourceLinkDao;
+import ca.uhn.fhir.jpa.dao.expunge.ResourceTableFKProvider;
+import ca.uhn.fhir.jpa.delete.batch2.DeleteExpungeSqlBuilder;
+import ca.uhn.fhir.jpa.model.config.PartitionSettings;
+import ca.uhn.fhir.jpa.model.dao.JpaPid;
+import ca.uhn.fhir.jpa.util.QueryChunker;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+import org.springframework.beans.factory.annotation.Autowired;
+
+import java.util.List;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyCollection;
+import static org.mockito.Mockito.times;
+
+public class DeleteExpungeSqlBuilderTest {
+	@Autowired
+	private ResourceTableFKProvider theResourceTableFKProvider;
+	private JpaStorageSettings myStorageSettings = new JpaStorageSettings();
+	private IIdHelperService<JpaPid> myIdHelper;
+	@Mock
+	private IResourceLinkDao theResourceLinkDao;
+	private PartitionSettings thePartitionSettings = new PartitionSettings();
+	private DeleteExpungeSqlBuilder myDeleteExpungeSqlBuilderTest;
+
+	@BeforeEach
+	public void beforeEach() {
+		myDeleteExpungeSqlBuilderTest = new DeleteExpungeSqlBuilder(
+			theResourceTableFKProvider,
+			myStorageSettings,
+			myIdHelper,
+			theResourceLinkDao,
+			thePartitionSettings
+		);
+	}
+
+	@Test
+	public void testQueryChunkingWhenFindingChildResources(){
+		//Given: We have a list of pids.
+		try (MockedStatic<QueryChunker> queryChunker = Mockito.mockStatic(QueryChunker.class)) {
+			JpaPid firstPid = JpaPid.fromIdAndResourceType(1L, "Patient");
+			JpaPid secondPid = JpaPid.fromIdAndResourceType(1L, "Patient");
+			queryChunker.when(()-> QueryChunker.chunk(anyCollection(), any())).then(invocationOnMock -> null);
+
+			//When: Searching for child resources (i.e. other resources that reference the target PIDs)
+			myDeleteExpungeSqlBuilderTest.findResourceLinksWithTargetPidIn(List.of(firstPid), List.of(secondPid), List.of());
+
+			//Verify: That the SQL query is done in chunks.
+			queryChunker.verify(() -> QueryChunker.chunk(anyCollection(), any()), times(1));
+		}
+	}
+}
diff --git a/hapi-fhir-jpaserver-test-r4/src/test/java/ca/uhn/fhir/jpa/stresstest/StressTestR4Test.java b/hapi-fhir-jpaserver-test-r4/src/test/java/ca/uhn/fhir/jpa/stresstest/StressTestR4Test.java
index 83b7e061ed3a..201cf2929d5c 100644
--- a/hapi-fhir-jpaserver-test-r4/src/test/java/ca/uhn/fhir/jpa/stresstest/StressTestR4Test.java
+++ b/hapi-fhir-jpaserver-test-r4/src/test/java/ca/uhn/fhir/jpa/stresstest/StressTestR4Test.java
@@ -3,7 +3,9 @@
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import ca.uhn.fhir.batch2.model.StatusEnum;
 import ca.uhn.fhir.jpa.api.config.JpaStorageSettings;
+import ca.uhn.fhir.jpa.api.model.DeleteMethodOutcome;
 import ca.uhn.fhir.jpa.api.svc.ISearchCoordinatorSvc;
+import ca.uhn.fhir.jpa.model.util.JpaConstants;
 import ca.uhn.fhir.jpa.provider.BaseResourceProviderR4Test;
 import ca.uhn.fhir.jpa.search.DatabaseBackedPagingProvider;
 import ca.uhn.fhir.jpa.search.SearchCoordinatorSvcImpl;
@@ -39,6 +41,7 @@
 import org.hl7.fhir.r4.model.IdType;
 import org.hl7.fhir.r4.model.ListResource;
 import org.hl7.fhir.r4.model.Observation;
+import org.hl7.fhir.r4.model.OperationOutcome;
 import org.hl7.fhir.r4.model.Parameters;
 import org.hl7.fhir.r4.model.Patient;
 import org.hl7.fhir.r4.model.Reference;
@@ -66,6 +69,7 @@
 import static org.apache.commons.lang3.StringUtils.leftPad;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.junit.jupiter.api.Assertions.fail;
+import static org.mockito.Mockito.when;
 
 @TestPropertySource(properties = {
 	"max_db_connections=10"
@@ -420,6 +424,87 @@ public void testMultithreadedSearch() throws Exception {
 
 	}
 
+	/**
+	 * This tests that the SQL statement generated in DeleteExpungeSqlBuilder.findResourceLinksWithTargetPidIn()
+	 * doesn't throw an error due to too many query parameters in the IN clause.
+	 */
+	@Disabled("Stress test")
+	@Test
+	public void testDeleteExpungeWithCascadingDeletesWithLargeDataSet() {
+		myStorageSettings.setAllowMultipleDelete(true);
+		myStorageSettings.setExpungeEnabled(true);
+		myStorageSettings.setDeleteExpungeEnabled(true);
+		//Given: A database with 5 patients and 100s of other resources referencing the 5 patients.
+		myStorageSettings.setExpungeBatchSize(1000);
+		int numOfRecords = 2505;
+		IIdType p1 = createPatient(withActiveTrue());
+		IIdType p2 = createPatient(withActiveTrue());
+		IIdType p3 = createPatient(withActiveTrue());
+		IIdType p4 = createPatient(withActiveTrue());
+		IIdType p5 = createPatient(withActiveTrue());
+		for (int i = 0; i < numOfRecords; ++i) {
+			createObservation(withSubject(p1));
+			createObservation(withSubject(p1));
+			createObservation(withSubject(p1));
+			createObservation(withSubject(p1));
+			createObservation(withSubject(p2));
+			createObservation(withSubject(p2));
+			createObservation(withSubject(p2));
+			createObservation(withSubject(p2));
+			createObservation(withSubject(p3));
+			createObservation(withSubject(p3));
+			createObservation(withSubject(p3));
+			createObservation(withSubject(p4));
+			createObservation(withSubject(p4));
+			createObservation(withSubject(p4));
+			createObservation(withSubject(p5));
+			createObservation(withSubject(p5));
+			createObservation(withSubject(p5));
+			createObservation(withSubject(p5));
+			createObservation(withSubject(p5));
+			createObservation(withSubject(p5));
+			createObservation(withSubject(p5));
+			createEncounter(withReference("subject", p1));
+			createEncounter(withReference("subject", p1));
+			createEncounter(withReference("subject", p1));
+			createEncounter(withReference("subject", p2));
+			createEncounter(withReference("subject", p2));
+			createEncounter(withReference("subject", p2));
+			createEncounter(withReference("subject", p3));
+			createEncounter(withReference("subject", p3));
+			createEncounter(withReference("subject", p3));
+			createEncounter(withReference("subject", p3));
+			createEncounter(withReference("subject", p4));
+			createEncounter(withReference("subject", p4));
+			createEncounter(withReference("subject", p4));
+			createEncounter(withReference("subject", p4));
+			createEncounter(withReference("subject", p5));
+			createEncounter(withReference("subject", p5));
+			createEncounter(withReference("subject", p5));
+			createEncounter(withReference("subject", p5));
+			createEncounter(withReference("subject", p5));
+		}
+
+		when(mySrd.getParameters()).thenReturn(Map.of(
+			Constants.PARAMETER_CASCADE_DELETE, new String[]{Constants.CASCADE_DELETE},
+			JpaConstants.PARAM_DELETE_EXPUNGE, new String[]{"true"}
+		));
+
+		//When: A delete expunge operation is initiated with cascading deletes
+		DeleteMethodOutcome outcome = myPatientDao.deleteByUrl("Patient?" + JpaConstants.PARAM_DELETE_EXPUNGE + "=true", mySrd);
+
+		//validate: Records are deleted
+		String jobId = jobExecutionIdFromOutcome(outcome);
+		myBatch2JobHelper.awaitJobCompletion(jobId, 120);
+		assertEquals(100205, myBatch2JobHelper.getCombinedRecordsProcessed(jobId));
+
+		JpaStorageSettings defaultStorageSettings = new JpaStorageSettings();
+		myStorageSettings.setAllowMultipleDelete(defaultStorageSettings.isAllowMultipleDelete());
+		myStorageSettings.setExpungeEnabled(defaultStorageSettings.isExpungeEnabled());
+		myStorageSettings.setDeleteExpungeEnabled(defaultStorageSettings.isDeleteExpungeEnabled());
+		myStorageSettings.setExpungeBatchSize(defaultStorageSettings.getExpungeBatchSize());
+	}
+
 	@Test
 	public void testMultiThreadedCreateWithDuplicateClientAssignedIdsInTransaction() throws Exception {
 		ExecutorService executor = Executors.newFixedThreadPool(20);
@@ -662,6 +747,13 @@ private void validateNoErrors(List<BaseTask> tasks) {
 		ourLog.info("Loaded {} searches", total);
 	}
 
+	private String jobExecutionIdFromOutcome(DeleteMethodOutcome theResult) {
+		OperationOutcome operationOutcome = (OperationOutcome) theResult.getOperationOutcome();
+		String diagnostics = operationOutcome.getIssueFirstRep().getDiagnostics();
+		String[] parts = diagnostics.split("Delete job submitted with id ");
+		return parts[1];
+	}
+
 	private final class SearchTask extends BaseTask {
 
 		@Override


Reviews:
user: login: tadgh
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T16:32:04Z
user: login: AbayomiE-T
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T21:55:49Z
user: login: AbayomiE-T
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T21:56:07Z
user: login: tadgh
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-14T22:02:27Z

Review comments:
diff_hunk: @@ -195,6 +195,77 @@ public void testDeleteExpungeRespectsExpungeBatchSize() {
 //		assertEquals(10, job.getExecutionContext().getLong(PidReaderCounterListener.RESOURCE_TOTAL_PROCESSED));
 	}
 
+	/**
+	 * This tests that the SQL statement generated in DeleteExpungeSqlBuilder.findResourceLinksWithTargetPidIn()
+	 * doesn't throw an error due to too many query parameters in the IN clause.
+	 */
+	@Test
+	public void testDeleteExpungeWithCascadingDeletesWithLargeDataSet() {
+		//Given: A database with 5 patients and 100s of other resources referencing the 5 patients.
+		myStorageSettings.setExpungeBatchSize(1000);
+		int numOfRecords = 2505;
+		IIdType p1 = createPatient(withActiveTrue());
+		IIdType p2 = createPatient(withActiveTrue());
+		IIdType p3 = createPatient(withActiveTrue());
+		IIdType p4 = createPatient(withActiveTrue());
+		IIdType p5 = createPatient(withActiveTrue());
+		for (int i = 0; i < numOfRecords; ++i) {
+			createObservation(withSubject(p1));
+			createObservation(withSubject(p1));
+			createObservation(withSubject(p1));
+			createObservation(withSubject(p1));
+			createObservation(withSubject(p2));
+			createObservation(withSubject(p2));
+			createObservation(withSubject(p2));
+			createObservation(withSubject(p2));
+			createObservation(withSubject(p3));
+			createObservation(withSubject(p3));
+			createObservation(withSubject(p3));
+			createObservation(withSubject(p4));
+			createObservation(withSubject(p4));
+			createObservation(withSubject(p4));
+			createObservation(withSubject(p5));
+			createObservation(withSubject(p5));
+			createObservation(withSubject(p5));
+			createObservation(withSubject(p5));
+			createObservation(withSubject(p5));
+			createObservation(withSubject(p5));
+			createObservation(withSubject(p5));
+			createEncounter(withReference("subject", p1));
+			createEncounter(withReference("subject", p1));
+			createEncounter(withReference("subject", p1));
+			createEncounter(withReference("subject", p2));
+			createEncounter(withReference("subject", p2));
+			createEncounter(withReference("subject", p2));
+			createEncounter(withReference("subject", p3));
+			createEncounter(withReference("subject", p3));
+			createEncounter(withReference("subject", p3));
+			createEncounter(withReference("subject", p3));
+			createEncounter(withReference("subject", p4));
+			createEncounter(withReference("subject", p4));
+			createEncounter(withReference("subject", p4));
+			createEncounter(withReference("subject", p4));
+			createEncounter(withReference("subject", p5));
+			createEncounter(withReference("subject", p5));
+			createEncounter(withReference("subject", p5));
+			createEncounter(withReference("subject", p5));
+			createEncounter(withReference("subject", p5));
+		}
+
+		when(mySrd.getParameters()).thenReturn(Map.of(
+			Constants.PARAMETER_CASCADE_DELETE, new String[]{Constants.CASCADE_DELETE},
+			JpaConstants.PARAM_DELETE_EXPUNGE, new String[]{"true"}
+		));
+
+		//When: A delete expunge operation is initiated with cascading deletes
+		DeleteMethodOutcome outcome = myPatientDao.deleteByUrl("Patient?" + JpaConstants.PARAM_DELETE_EXPUNGE + "=true", mySrd);
+
+		//validate: Records are deleted
+		String jobId = jobExecutionIdFromOutcome(outcome);
+		myBatch2JobHelper.awaitJobCompletion(jobId, 120);
+		assertEquals(100205, myBatch2JobHelper.getCombinedRecordsProcessed(jobId));
+	}
path: hapi-fhir-jpaserver-test-r4/src/test/java/ca/uhn/fhir/jpa/dao/expunge/DeleteExpungeDaoTest.java
user: login: tadgh
body: Issue: I am going to assume this test will explode under load. Awaiting a job completion on 100k resources is not really tractable as a testing strategy. I recommend You place this test in a StressTest class, maybe `StressTestR4Test`. I can tell by reading this class that it will inevitably cause failures in CI. While this test is _useful_, i don't want it enabled in production. Out of curiosity, can you please run it locally, and let me know how long it takes?
created_at: 2025-04-11T16:17:40Z
author_association: COLLABORATOR
diff_hunk: @@ -195,6 +195,77 @@ public void testDeleteExpungeRespectsExpungeBatchSize() {
 //		assertEquals(10, job.getExecutionContext().getLong(PidReaderCounterListener.RESOURCE_TOTAL_PROCESSED));
 	}
 
+	/**
+	 * This tests that the SQL statement generated in DeleteExpungeSqlBuilder.findResourceLinksWithTargetPidIn()
+	 * doesn't throw an error due to too many query parameters in the IN clause.
+	 */
+	@Test
+	public void testDeleteExpungeWithCascadingDeletesWithLargeDataSet() {
path: hapi-fhir-jpaserver-test-r4/src/test/java/ca/uhn/fhir/jpa/dao/expunge/DeleteExpungeDaoTest.java
user: login: tadgh
body: suggestion: I recommend a more targeted test against the DeleteExpungeSqlBuilder, which ensures that the chunker is called when expected. You can do this by mocking out the chunker and invoking `findResourceLinksWithTargetPidIn`, then checking that the chunker was successfully called N times. 
created_at: 2025-04-11T16:19:03Z
author_association: COLLABORATOR
diff_hunk: @@ -195,6 +195,77 @@ public void testDeleteExpungeRespectsExpungeBatchSize() {
 //		assertEquals(10, job.getExecutionContext().getLong(PidReaderCounterListener.RESOURCE_TOTAL_PROCESSED));
 	}
 
+	/**
+	 * This tests that the SQL statement generated in DeleteExpungeSqlBuilder.findResourceLinksWithTargetPidIn()
+	 * doesn't throw an error due to too many query parameters in the IN clause.
+	 */
+	@Test
+	public void testDeleteExpungeWithCascadingDeletesWithLargeDataSet() {
+		//Given: A database with 5 patients and 100s of other resources referencing the 5 patients.
+		myStorageSettings.setExpungeBatchSize(1000);
+		int numOfRecords = 2505;
+		IIdType p1 = createPatient(withActiveTrue());
+		IIdType p2 = createPatient(withActiveTrue());
+		IIdType p3 = createPatient(withActiveTrue());
+		IIdType p4 = createPatient(withActiveTrue());
+		IIdType p5 = createPatient(withActiveTrue());
+		for (int i = 0; i < numOfRecords; ++i) {
+			createObservation(withSubject(p1));
+			createObservation(withSubject(p1));
+			createObservation(withSubject(p1));
+			createObservation(withSubject(p1));
+			createObservation(withSubject(p2));
+			createObservation(withSubject(p2));
+			createObservation(withSubject(p2));
+			createObservation(withSubject(p2));
+			createObservation(withSubject(p3));
+			createObservation(withSubject(p3));
+			createObservation(withSubject(p3));
+			createObservation(withSubject(p4));
+			createObservation(withSubject(p4));
+			createObservation(withSubject(p4));
+			createObservation(withSubject(p5));
+			createObservation(withSubject(p5));
+			createObservation(withSubject(p5));
+			createObservation(withSubject(p5));
+			createObservation(withSubject(p5));
+			createObservation(withSubject(p5));
+			createObservation(withSubject(p5));
+			createEncounter(withReference("subject", p1));
+			createEncounter(withReference("subject", p1));
+			createEncounter(withReference("subject", p1));
+			createEncounter(withReference("subject", p2));
+			createEncounter(withReference("subject", p2));
+			createEncounter(withReference("subject", p2));
+			createEncounter(withReference("subject", p3));
+			createEncounter(withReference("subject", p3));
+			createEncounter(withReference("subject", p3));
+			createEncounter(withReference("subject", p3));
+			createEncounter(withReference("subject", p4));
+			createEncounter(withReference("subject", p4));
+			createEncounter(withReference("subject", p4));
+			createEncounter(withReference("subject", p4));
+			createEncounter(withReference("subject", p5));
+			createEncounter(withReference("subject", p5));
+			createEncounter(withReference("subject", p5));
+			createEncounter(withReference("subject", p5));
+			createEncounter(withReference("subject", p5));
+		}
+
+		when(mySrd.getParameters()).thenReturn(Map.of(
+			Constants.PARAMETER_CASCADE_DELETE, new String[]{Constants.CASCADE_DELETE},
+			JpaConstants.PARAM_DELETE_EXPUNGE, new String[]{"true"}
+		));
+
+		//When: A delete expunge operation is initiated with cascading deletes
+		DeleteMethodOutcome outcome = myPatientDao.deleteByUrl("Patient?" + JpaConstants.PARAM_DELETE_EXPUNGE + "=true", mySrd);
+
+		//validate: Records are deleted
+		String jobId = jobExecutionIdFromOutcome(outcome);
+		myBatch2JobHelper.awaitJobCompletion(jobId, 120);
+		assertEquals(100205, myBatch2JobHelper.getCombinedRecordsProcessed(jobId));
+	}
path: hapi-fhir-jpaserver-test-r4/src/test/java/ca/uhn/fhir/jpa/dao/expunge/DeleteExpungeDaoTest.java
user: login: AbayomiE-T
body: Totally understandable. I moved it to `StressTestR4Test` as suggested 👍 . Locally, the test ran for 1 minute and 42 seconds.
created_at: 2025-04-14T21:55:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -195,6 +195,77 @@ public void testDeleteExpungeRespectsExpungeBatchSize() {
 //		assertEquals(10, job.getExecutionContext().getLong(PidReaderCounterListener.RESOURCE_TOTAL_PROCESSED));
 	}
 
+	/**
+	 * This tests that the SQL statement generated in DeleteExpungeSqlBuilder.findResourceLinksWithTargetPidIn()
+	 * doesn't throw an error due to too many query parameters in the IN clause.
+	 */
+	@Test
+	public void testDeleteExpungeWithCascadingDeletesWithLargeDataSet() {
path: hapi-fhir-jpaserver-test-r4/src/test/java/ca/uhn/fhir/jpa/dao/expunge/DeleteExpungeDaoTest.java
user: login: AbayomiE-T
body: Done!
created_at: 2025-04-14T21:56:07Z
author_association: CONTRIBUTOR

PR comments:
user: login: robogary
created_at: 2025-04-11T16:04:32Z
body: <!-- add-pr-comment:add-pr-comment -->

**This Pull Request has failed the formatting check**

Please run `mvn spotless:apply` or `mvn clean install -DskipTests` to fix the formatting issues. 

You can automate this auto-formatting process to execute on the git pre-push hook, by installing [pre-commit](https://pre-commit.com/) and then calling `pre-commit install --hook-type pre-push`. This will cause formatting to run automatically whenever you push.
author_association: CONTRIBUTOR
user: login: robogary
created_at: 2025-04-11T16:06:42Z
body: <!-- add-pr-comment:add-pr-comment -->

Formatting check succeeded!
author_association: CONTRIBUTOR
user: login: robogary
created_at: 2025-04-11T16:12:47Z
body: <!-- add-pr-comment:add-pr-comment -->

Formatting check succeeded!
author_association: CONTRIBUTOR
user: login: robogary
created_at: 2025-04-14T21:54:19Z
body: <!-- add-pr-comment:add-pr-comment -->

Formatting check succeeded!
author_association: CONTRIBUTOR