diff --git a/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteRestTemplateConfig.java b/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteRestTemplateConfig.java index 87079a18..16af36ec 100644 --- a/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteRestTemplateConfig.java +++ b/src/main/java/com/iemr/flw/integration/provider/emrlite/EmrLiteRestTemplateConfig.java @@ -14,10 +14,17 @@ public class EmrLiteRestTemplateConfig { private static final int VENDOR_CALL_TIMEOUT_MS = 20000; + // Result responses carry document assets inline as base64, so reads get more headroom than connects. + private static final int VENDOR_READ_TIMEOUT_MS = 60000; + private static final int PING_TIMEOUT_MS = 5000; + // Timeouts are required, not optional: order pushes run while holding the per-beneficiary+orderType + // order lock (see DiagnosticOrderServiceImpl), so a hung vendor call without them would hold that lock forever. @Bean public RestTemplate emrLiteRestTemplate(EmrLiteTokenManager emrLiteTokenManager) { SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(VENDOR_CALL_TIMEOUT_MS); + factory.setReadTimeout(VENDOR_READ_TIMEOUT_MS); RestTemplate restTemplate = new RestTemplate(factory); restTemplate.getInterceptors().add(new EmrLiteAuthInterceptor(emrLiteTokenManager)); return restTemplate; @@ -28,8 +35,8 @@ public RestTemplate emrLiteRestTemplate(EmrLiteTokenManager emrLiteTokenManager) @Bean public RestTemplate emrLitePingRestTemplate() { SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); - factory.setConnectTimeout(VENDOR_CALL_TIMEOUT_MS); - factory.setReadTimeout(VENDOR_CALL_TIMEOUT_MS); + factory.setConnectTimeout(PING_TIMEOUT_MS); + factory.setReadTimeout(PING_TIMEOUT_MS); return new RestTemplate(factory); } } \ No newline at end of file diff --git a/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java b/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java index fa3e79b4..bad44ff2 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/DiagnosticOrderRepo.java @@ -8,6 +8,7 @@ import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; +import java.sql.Timestamp; import java.util.List; import java.util.Optional; @@ -25,20 +26,24 @@ Optional findFirstByBeneficiaryIdAndOrderTypeAndDeletedFalseOrd @Query("SELECT o FROM DiagnosticOrder o WHERE o.beneficiaryId = :beneficiaryId AND o.deleted = false ORDER BY o.createdDate DESC") List findByBeneficiaryId(@Param("beneficiaryId") Long beneficiaryId); + // Polling and the daily close only touch orders created within [dayStart, dayEnd) — i.e. today's. @Query("SELECT o FROM DiagnosticOrder o WHERE o.orderType = 'XRAY_CHEST' " + "AND o.status IN ('PENDING', 'IN_PROGRESS') " + "AND o.deleted = false " + + "AND o.createdDate >= :dayStart AND o.createdDate < :dayEnd " + "ORDER BY o.lastPolledAt ASC NULLS FIRST") - List findXrayDueForPoll(); + List findXrayDueForPoll(@Param("dayStart") Timestamp dayStart, @Param("dayEnd") Timestamp dayEnd); @Query("SELECT o FROM DiagnosticOrder o WHERE o.orderType IN ('MTB', 'MTB_PLUS', 'MDR_RIF') " + "AND o.status IN ('PENDING', 'IN_PROGRESS') " + "AND o.deleted = false " + + "AND o.createdDate >= :dayStart AND o.createdDate < :dayEnd " + "ORDER BY o.lastPolledAt ASC NULLS FIRST") - List findTrueNatDueForPoll(); + List findTrueNatDueForPoll(@Param("dayStart") Timestamp dayStart, @Param("dayEnd") Timestamp dayEnd); - @Query("SELECT o FROM DiagnosticOrder o WHERE o.status IN ('PENDING', 'IN_PROGRESS') AND o.deleted = false") - List findAllOutstandingOrders(); + @Query("SELECT o FROM DiagnosticOrder o WHERE o.status IN ('PENDING', 'IN_PROGRESS') AND o.deleted = false " + + "AND o.createdDate >= :dayStart AND o.createdDate < :dayEnd") + List findAllOutstandingOrders(@Param("dayStart") Timestamp dayStart, @Param("dayEnd") Timestamp dayEnd); @Transactional @Modifying diff --git a/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java index 4383e241..25747002 100644 --- a/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/DiagnosticOrderServiceImpl.java @@ -18,19 +18,19 @@ import com.iemr.flw.integration.provider.DiagnosticPushResult; import com.iemr.flw.masterEnum.DiagnosticOrderStatus; import com.iemr.flw.masterEnum.DiagnosticOrderType; -import com.iemr.flw.dto.iemr.UserServiceRoleDTO; import com.iemr.flw.repo.identity.BeneficiaryRepo; import com.iemr.flw.repo.iemr.DiagnosticOrderRepo; import com.iemr.flw.repo.iemr.DiagnosticResultRepo; import com.iemr.flw.repo.iemr.EmployeeMasterRepo; import com.iemr.flw.repo.iemr.TBSuspectedRepo; -import com.iemr.flw.repo.iemr.UserServiceRoleRepo; import com.iemr.flw.service.CampConfigService; import com.iemr.flw.service.DiagnosticDocumentService; import com.iemr.flw.service.DiagnosticOrderService; import com.iemr.flw.service.TBStopVisitService; import com.iemr.flw.utils.JwtUtil; -import com.google.common.util.concurrent.Striped; +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -43,7 +43,9 @@ import java.util.Set; import java.util.UUID; import java.util.concurrent.Callable; +import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; @Service public class DiagnosticOrderServiceImpl implements DiagnosticOrderService { @@ -123,19 +125,36 @@ public DiagnosticOrder createAndPushOrderAsSystem(DiagnosticOrderRequestDto requ // two simultaneous pushes for the same beneficiary could both pass the dedup check (or both create a // visit for today) and send the same patient to the vendor twice — shown as duplicate names on the // TrueNat machine. Keyed by beneficiary, not orderType, because the visit is shared across order types. + // (release-3.9.3 keys its lock by beneficiary+orderType because there the client supplies the + // visitCode; here the server creates the visit, so the key must be the beneficiary alone.) // In-memory, so it only serialises within this JVM: correct while orders are created only by the - // single van server that can reach the vendor. Striped keeps the lock registry a fixed size for the - // JVM's lifetime: the same beneficiary always maps to the same stripe, while unrelated beneficiaries - // that happen to share a stripe merely wait on each other briefly. Stripes are reentrant, and no path - // ever holds two beneficiaries' locks at once, so sharing a stripe can't deadlock. - private static final int BENEFICIARY_LOCK_STRIPES = 1024; - private final Striped beneficiaryOrderLocks = Striped.lock(BENEFICIARY_LOCK_STRIPES); + // single van server that can reach the vendor. Same mechanism as release-3.9.3: one ReentrantLock per + // beneficiary, held weakly so an idle beneficiary's lock is garbage-collected — any thread waiting on + // or holding it keeps a strong reference, so concurrent callers always share the same instance. + // User requests wait at most LOCK_WAIT_SECONDS and then fail with a retry message, instead of piling + // up behind a slow vendor push (bounded by the EmrLiteRestTemplateConfig timeouts). + private final LoadingCache beneficiaryOrderLocks = CacheBuilder.newBuilder() + .weakValues() + .build(CacheLoader.from(beneficiaryId -> new ReentrantLock())); + + private static final long LOCK_WAIT_SECONDS = 30; private T withBeneficiaryLock(Long beneficiaryId, Callable action) throws Exception { - if (beneficiaryId == null) { - throw new IllegalArgumentException("beneficiaryId is required"); + Lock lock = beneficiaryLock(beneficiaryId); + if (!lock.tryLock(LOCK_WAIT_SECONDS, TimeUnit.SECONDS)) { + throw new IllegalStateException("Another diagnostic order operation is still in progress for beneficiaryId=" + + beneficiaryId + " — please retry shortly"); + } + try { + return action.call(); + } finally { + lock.unlock(); } - Lock lock = beneficiaryOrderLocks.get(beneficiaryId); + } + + // For background work with no caller to retry it (the automatic retest): waits as long as it takes. + private T withBeneficiaryLockBlocking(Long beneficiaryId, Callable action) throws Exception { + Lock lock = beneficiaryLock(beneficiaryId); lock.lock(); try { return action.call(); @@ -144,6 +163,13 @@ private T withBeneficiaryLock(Long beneficiaryId, Callable action) throws } } + private Lock beneficiaryLock(Long beneficiaryId) { + if (beneficiaryId == null) { + throw new IllegalArgumentException("beneficiaryId is required"); + } + return beneficiaryOrderLocks.getUnchecked(beneficiaryId); + } + private DiagnosticOrder createAndPushOrder(DiagnosticOrderRequestDto request, String createdBy) throws Exception { return withBeneficiaryLock(request.getBeneficiaryId(), () -> createAndPushOrderLocked(request, createdBy)); } @@ -239,11 +265,6 @@ private DiagnosticOrder createAndPushOrderLocked(DiagnosticOrderRequestDto reque } throw dive; } - // Stamp the sync key before the (possibly slow) vendor push, so a crash/restart mid-push can't leave it NULL. - if (order.getVanSerialNo() == null) { - diagnosticOrderRepo.updateVanSerialNo(order.getId()); - order.setVanSerialNo(order.getId()); - } if (noVendor) { logger.info("No active vendor configured for orderType={}, beneficiaryId={} — order saved for manual entry", @@ -531,9 +552,7 @@ public DiagnosticPollResult pollOnce(DiagnosticOrder order) throws Exception { order.setLastPolledAt(new Timestamp(System.currentTimeMillis())); order.setModifiedBy("SYSTEM"); order.setProcessed("N"); - order = diagnosticOrderRepo.save(order); - // The scheduler's copy may predate createAndPushOrder's updateVanSerialNo(); fill it if still missing. - if (order.getVanSerialNo() == null) diagnosticOrderRepo.updateVanSerialNo(order.getId()); + diagnosticOrderRepo.save(order); return null; } DiagnosticProvider provider = providerFactory.getProvider(order.getProviderCode()); @@ -750,7 +769,7 @@ private static boolean isInvalidResult(String orderTypeCode, String resultSummar // pushed to the vendor straight away. Never throws — a failure here must not undo the close. private void pushRetestOrder(DiagnosticOrder closed) { try { - withBeneficiaryLock(closed.getBeneficiaryId(), () -> { + withBeneficiaryLockBlocking(closed.getBeneficiaryId(), () -> { pushRetestOrderLocked(closed); return null; }); diff --git a/src/main/java/com/iemr/flw/service/impl/DiagnosticPollSchedulerService.java b/src/main/java/com/iemr/flw/service/impl/DiagnosticPollSchedulerService.java index 2259d934..07f04110 100644 --- a/src/main/java/com/iemr/flw/service/impl/DiagnosticPollSchedulerService.java +++ b/src/main/java/com/iemr/flw/service/impl/DiagnosticPollSchedulerService.java @@ -2,7 +2,6 @@ import com.iemr.flw.domain.iemr.DiagnosticOrder; import com.iemr.flw.integration.provider.DiagnosticProviderFactory; -import com.iemr.flw.masterEnum.DiagnosticOrderStatus; import com.iemr.flw.masterEnum.DiagnosticOrderType; import com.iemr.flw.repo.iemr.DiagnosticOrderRepo; import com.iemr.flw.service.DiagnosticOrderService; @@ -14,15 +13,9 @@ import java.sql.Timestamp; import java.time.LocalDate; -import java.time.ZoneId; import java.util.List; +import java.util.function.Supplier; -// Every due order (status PENDING) is polled on every tick from the moment it's created — -// there is no per-order initial delay or rolling give-up window anymore. A tick is skipped entirely -// for an order-type family if diagnostic.provider.xray / diagnostic.provider.truenat has no active -// vendor configured right now (checked live via DiagnosticProviderFactory, not any value stored on the -// order row). Once a day, expireOutstandingOrders sweeps up everything still PENDING (for -// order-type families that do have an active vendor) and marks it CLOSED. @Service public class DiagnosticPollSchedulerService { @@ -39,50 +32,70 @@ public class DiagnosticPollSchedulerService { @Scheduled(fixedDelayString = "${diagnostic.poll.xray.tick-ms:15000}") public void pollXrayOrders() { - if (noActiveVendor(DiagnosticOrderType.XRAY_CHEST)) { - logger.debug("No active vendor configured for XRAY_CHEST — skipping poll tick"); - return; - } - List candidates = diagnosticOrderRepo.findXrayDueForPoll(); - logger.info("XRAY poll tick: {} order(s) in due-for-poll queue", candidates.size()); - if (candidates.isEmpty()) return; - - for (DiagnosticOrder order : candidates) { - pollSingle(order); - } - logger.info("Polled {} pending XRAY diagnostic orders", candidates.size()); + pollDueOrders("XRAY", DiagnosticOrderType.XRAY_CHEST, + () -> diagnosticOrderRepo.findXrayDueForPoll(startOfToday(), startOfTomorrow())); } @Scheduled(fixedDelayString = "${diagnostic.poll.truenat.tick-ms:60000}") public void pollTrueNatOrders() { - if (noActiveVendor(DiagnosticOrderType.MTB)) { - logger.debug("No active vendor configured for TrueNat — skipping poll tick"); + pollDueOrders("TrueNat", DiagnosticOrderType.MTB, + () -> diagnosticOrderRepo.findTrueNatDueForPoll(startOfToday(), startOfTomorrow())); + } + + private void pollDueOrders(String label, DiagnosticOrderType orderType, Supplier> dueOrders) { + String providerCode = providerFactory.getProviderCodeForOrderType(orderType); + if (providerCode == null || providerCode.isBlank()) { + logger.debug("No active vendor configured for {} — skipping poll tick", label); return; } - List candidates = diagnosticOrderRepo.findTrueNatDueForPoll(); - logger.info("TrueNat poll tick: {} order(s) in due-for-poll queue", candidates.size()); + List candidates = dueOrders.get(); + logger.info("{} poll tick: {} order(s) in due-for-poll queue", label, candidates.size()); if (candidates.isEmpty()) return; - for (DiagnosticOrder order : candidates) { - pollSingle(order); + if (!providerFactory.getProvider(providerCode).checkHealth(orderType)) { + logger.warn("{} vendor ping failed — skipping poll tick, {} order(s) left for the next tick", + label, candidates.size()); + return; } - logger.info("Polled {} pending TrueNat diagnostic orders", candidates.size()); - } - @Scheduled(cron = "${diagnostic.poll.expiry-cron:0 50 23 * * *}") - public void expireOutstandingOrders() { - List outstanding = diagnosticOrderRepo.findAllOutstandingOrders(); - int expired = 0; - for (DiagnosticOrder order : outstanding) { - if (noActiveVendor(DiagnosticOrderType.fromCode(order.getOrderType()))) { - continue; // awaiting manual entry — not subject to the polling cutoff + int polled = 0; + for (DiagnosticOrder order : candidates) { + polled++; + if (!pollSingle(order)) { + logger.warn("{} vendor unreachable mid-tick — stopping, {} order(s) left for the next tick", + label, candidates.size() - polled); + break; } - expire(order); - expired++; - } - if (expired > 0) { - logger.warn("Daily polling cutoff reached: marked {} outstanding order(s) CLOSED", expired); } + logger.info("Polled {} of {} pending {} diagnostic orders", polled, candidates.size(), label); + } + + // Auto-closure of outstanding orders at the daily cutoff is disabled for now — orders are no longer + // closed automatically. Uncomment this job (and expire() below) to re-enable it. + // @Scheduled(cron = "${diagnostic.poll.expiry-cron:0 50 23 * * *}") + // public void expireOutstandingOrders() { + // List outstanding = diagnosticOrderRepo.findAllOutstandingOrders(startOfToday(), startOfTomorrow()); + // int expired = 0; + // for (DiagnosticOrder order : outstanding) { + // if (noActiveVendor(DiagnosticOrderType.fromCode(order.getOrderType()))) { + // continue; // awaiting manual entry — not subject to the polling cutoff + // } + // expire(order); + // expired++; + // } + // if (expired > 0) { + // logger.warn("Daily polling cutoff reached: marked {} outstanding order(s) CLOSED", expired); + // } + // } + + // Polling and the daily close only handle orders created today. JVM default zone on purpose: it's the + // same clock created_date is stamped with (@CreationTimestamp), so the day boundaries line up. + private static Timestamp startOfToday() { + return Timestamp.valueOf(LocalDate.now().atStartOfDay()); + } + + private static Timestamp startOfTomorrow() { + return Timestamp.valueOf(LocalDate.now().plusDays(1).atStartOfDay()); } private boolean noActiveVendor(DiagnosticOrderType orderType) { @@ -90,22 +103,27 @@ private boolean noActiveVendor(DiagnosticOrderType orderType) { return providerCode == null || providerCode.isBlank(); } - private void expire(DiagnosticOrder order) { - String reason = "Order validity expired for the day " + LocalDate.now(ZoneId.of("Asia/Kolkata")); - order.setStatus(DiagnosticOrderStatus.CLOSED.name()); - order.setReasonToClose(reason); - order.setLastPolledAt(new Timestamp(System.currentTimeMillis())); - order.setModifiedBy("SYSTEM"); - order.setProcessed("N"); - diagnosticOrderRepo.save(order); - diagnosticOrderService.notifyProviderOrderClosed(order, reason); - } + // Used only by the (disabled) auto-closure job above. + // private void expire(DiagnosticOrder order) { + // String reason = "Order validity expired for the day " + LocalDate.now(ZoneId.of("Asia/Kolkata")); + // order.setStatus(DiagnosticOrderStatus.CLOSED.name()); + // order.setReasonToClose(reason); + // order.setLastPolledAt(new Timestamp(System.currentTimeMillis())); + // order.setModifiedBy("SYSTEM"); + // order.setProcessed("N"); + // diagnosticOrderRepo.save(order); + // diagnosticOrderService.notifyProviderOrderClosed(order, reason); + // } - private void pollSingle(DiagnosticOrder order) { + // Returns false only when the vendor couldn't be reached at all (connect/read timeout, refused + // connection) — the caller stops the tick then. Any other failure is specific to this order + // (bad data, vendor-side rejection), so it's recorded and the tick carries on. + private boolean pollSingle(DiagnosticOrder order) { try { // pollOnce now saves the result/order fields and ingests any documents itself — // nothing left to do here on success. diagnosticOrderService.pollOnce(order); + return true; } catch (Exception e) { logger.error("Poll failed for orderId={}: {}", order.getId(), e.getMessage()); order.setRetryCount(order.getRetryCount() + 1); @@ -114,6 +132,8 @@ private void pollSingle(DiagnosticOrder order) { order.setModifiedBy("SYSTEM"); order.setProcessed("N"); diagnosticOrderRepo.save(order); + return false; } } + } \ No newline at end of file