Skip to content

Add POST /v1/patients/batch endpoint with partial-success semantics#3

Open
devin-ai-integration[bot] wants to merge 1 commit into
mainfrom
devin/1776101463-batch-patients-endpoint
Open

Add POST /v1/patients/batch endpoint with partial-success semantics#3
devin-ai-integration[bot] wants to merge 1 commit into
mainfrom
devin/1776101463-batch-patients-endpoint

Conversation

@devin-ai-integration
Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration Bot commented Apr 13, 2026

Summary

Adds a POST /v1/patients/batch endpoint that accepts a JSON array of PatientDTO objects and creates them with partial-success semantics. Each item is processed independently within a single transaction — successes are returned in created, failures in errors.

New files:

  • BatchPatientResult.java — Response DTO with counts, created list, and error list (with nested BatchItemError)
  • PatientControllerBatchTest.java — Integration tests (5 cases) using TestRestTemplate against H2

Modified files:

  • PatientController.java — New POST /v1/patients/batch endpoint; returns 201 if all succeed, 207 if any fail; rejects empty or >100 item batches
  • PatientService.java — New createPatientsBatch method with inlined per-item creation logic (avoids AOP proxy self-invocation issue), @AuditAccess for HIPAA compliance, and pre-persist validation via javax.validation.Validator

Key implementation details:

  • Entity validation is performed before save() to prevent ConstraintViolationException from tainting the Hibernate session and marking the transaction rollback-only
  • entityManager.flush() after each save to eagerly catch DB errors; entityManager.clear() on failure to reset the persistence context for subsequent items
  • MRN uniqueness is checked in-code before save (not solely via DB constraint)

Review & Testing Checklist for Human

  • entityManager.clear() detaches all managed entities: After a failed item, clear() evicts all entities from the persistence context, including previously saved patients in the same batch. The code maps saved entities to DTOs immediately after save, so this should be safe — but verify that no downstream logic (e.g., in AuditAspect) references detached entities after a partial failure
  • Empty list / >100 items returns 500, not 400: The controller throws IllegalArgumentException for these cases, but the existing GlobalExceptionHandler does not map IllegalArgumentException → 400. Tests assert 500 accordingly. If 400 is the desired behavior, an exception handler mapping needs to be added
  • MRN uniqueness race condition: The check-then-save pattern (findByMrnsave) is not atomic. Concurrent batch requests with the same MRN could both pass the check; the second would fail at the DB constraint level, which would taint the session despite clear(). Acceptable for low-concurrency use but worth noting
  • Single transaction scope: All batch items share one @Transactional boundary. A DB-level error that escapes the try/catch (e.g., connection loss) rolls back the entire batch including previously flushed items. The user spec acknowledges this trade-off
  • Suggested manual test plan: Start the app, POST to /v1/patients/batch via Swagger UI or curl with (a) 2 valid patients → expect 201, (b) 2 patients with same MRN → expect 207 with 1 success / 1 error, (c) empty array → observe status code, (d) verify endpoint appears in Swagger under "Patient" tag

Notes

  • The Validator and EntityManager are new constructor-injected dependencies on PatientService (via Lombok @RequiredArgsConstructor). Both are standard Spring-managed beans and should autowire without issue, but worth confirming no existing tests break from the changed constructor signature.
  • The validation error test (batchCreateValidationErrorCaptured) uses a loose assertion assertThat(statusCode).isIn(207, 400) — it passes regardless of which path handles the missing firstName. This was intentional to avoid brittleness but means the test doesn't pin down exact behavior.

Link to Devin session: https://app.devin.ai/sessions/43e276cecb1b4fe58ccad1f19d515b9e
Requested by: @clivingston-cognition


Open with Devin

- Create BatchPatientResult DTO with nested BatchItemError
- Add createPatientsBatch method to PatientService with per-item error handling
- Add batch endpoint to PatientController (max 100 items, 201/207 status)
- Add pre-persist validation to avoid Hibernate session tainting
- Use entityManager.flush()/clear() for proper transaction handling
- Add integration tests covering success, partial failure, empty/oversize batches

Co-Authored-By: Chris Livingston <chris.livingston@cognition.ai>
@devin-ai-integration
Copy link
Copy Markdown
Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

Copy link
Copy Markdown
Author

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

View 5 additional findings in Devin Review.

Open in Devin Review

Comment on lines +101 to +163
@Transactional
@AuditAccess(action = AuditAction.CREATE, resourceType = "Patient",
description = "Batch create patients")
public BatchPatientResult createPatientsBatch(List<PatientDTO> patients) {
List<PatientDTO> created = new ArrayList<>();
List<BatchPatientResult.BatchItemError> errors = new ArrayList<>();

for (int i = 0; i < patients.size(); i++) {
PatientDTO patientDTO = patients.get(i);
try {
if (patientDTO.getMrn() != null) {
Optional<Patient> existing =
patientRepository.findByMrn(patientDTO.getMrn());
if (existing.isPresent()) {
throw new IllegalArgumentException(
"Patient with MRN "
+ patientDTO.getMrn()
+ " already exists");
}
}
Patient patient = patientMapper.toEntity(patientDTO);
if (patient.getMrn() == null) {
patient.setMrn(generateMrn());
}
Set<ConstraintViolation<Patient>> violations =
validator.validate(patient);
if (!violations.isEmpty()) {
String msg = violations.stream()
.map(v -> v.getPropertyPath()
+ " " + v.getMessage())
.collect(Collectors.joining(", "));
throw new IllegalArgumentException(
"Validation failed: " + msg);
}
Patient saved = patientRepository.save(patient);
entityManager.flush();
log.info("Batch create - created patient with MRN: {}",
saved.getMrn());
created.add(patientMapper.toDto(saved));
} catch (Exception e) {
log.warn(
"Batch create - failed to create patient at index {}: {}",
i, e.getMessage());
entityManager.clear();
errors.add(BatchPatientResult.BatchItemError.builder()
.index(i)
.input(patientDTO)
.errorMessage(e.getMessage())
.build());
}
}

log.info("Batch create complete: {} succeeded, {} failed out of {}",
created.size(), errors.size(), patients.size());

return BatchPatientResult.builder()
.totalRequested(patients.size())
.successCount(created.size())
.failureCount(errors.size())
.created(created)
.errors(errors)
.build();
}
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Single @transactional with entityManager.clear() breaks partial-success semantics on PostgreSQL

The createPatientsBatch method runs under a single @Transactional and attempts partial success by catching exceptions per-item and calling entityManager.clear(). On PostgreSQL (the production database, configured at src/main/resources/application.yml and application-dev.yml), when a SQL statement fails (e.g., unique constraint violation during flush() at line 136), PostgreSQL aborts the entire transaction — all subsequent SQL statements fail with "current transaction is aborted, commands ignored until end of transaction block." This means:

  1. After the first item failure, all remaining patients in the batch will also fail (their save/flush calls hit the aborted transaction)
  2. When the method returns and Spring attempts to commit, PostgreSQL rolls back the entire transaction — all previously "successful" inserts are lost
  3. The response body claims patients were created (successCount > 0, created list populated) but the database contains none of them

The tests use H2 (application-test.yml), which does not replicate PostgreSQL's transaction-abort behavior, so this bug is invisible in CI. Each item needs its own transaction boundary (e.g., TransactionTemplate with REQUIRES_NEW propagation per item, or a helper method with @Transactional(propagation = Propagation.REQUIRES_NEW)).

Prompt for agents
The createPatientsBatch method in PatientService.java (lines 101-163) attempts partial-success semantics (some patients succeed, some fail) within a single @Transactional method. This is fundamentally broken on PostgreSQL because a failed SQL statement during flush() causes PostgreSQL to abort the entire transaction.

To fix this, each individual patient save needs its own transaction boundary. Two approaches:

1. Extract the per-patient save logic into a separate Spring bean method annotated with @Transactional(propagation = Propagation.REQUIRES_NEW). This requires the method to be on a different bean (or accessed via the proxy) because Spring AOP doesn't intercept self-invocations. You could create a helper service like PatientBatchItemService with a method like saveOnePatient(PatientDTO) that has REQUIRES_NEW propagation.

2. Use TransactionTemplate programmatically within the loop. Inject a PlatformTransactionManager, create a TransactionTemplate with REQUIRES_NEW propagation, and execute each patient save within template.execute(). This keeps all logic in one class.

In either case, the outer createPatientsBatch method should NOT be @Transactional (or should be @Transactional(propagation = Propagation.NOT_SUPPORTED)) so that each inner transaction is truly independent. Also remove the entityManager.clear() calls since each item would have its own persistence context lifecycle.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines 165 to 167
private String generateMrn() {
return "MRN" + System.currentTimeMillis();
}
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 generateMrn() produces duplicate MRNs for multiple patients in the same millisecond within a batch

generateMrn() at src/main/java/com/medchart/ehr/service/PatientService.java:165-167 uses System.currentTimeMillis() which has millisecond resolution. In the batch loop, multiple patients without a pre-assigned MRN that are processed within the same millisecond will receive identical MRNs. Since the mrn column has a UNIQUE constraint (Patient.java:32), the second (and subsequent) patient with the same generated MRN will fail at entityManager.flush() with a constraint violation. This is nearly guaranteed to occur in a tight loop for any batch containing more than one patient without an MRN, making the batch endpoint unreliable for auto-MRN generation.

Suggested change
private String generateMrn() {
return "MRN" + System.currentTimeMillis();
}
private String generateMrn() {
return "MRN" + System.currentTimeMillis() + "-" + java.util.concurrent.ThreadLocalRandom.current().nextInt(100000);
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant