diff --git a/src/main/java/com/iemr/admin/controller/nikshayjoblog/NikshayJobLogController.java b/src/main/java/com/iemr/admin/controller/nikshayjoblog/NikshayJobLogController.java index ecefb13..c909a4f 100644 --- a/src/main/java/com/iemr/admin/controller/nikshayjoblog/NikshayJobLogController.java +++ b/src/main/java/com/iemr/admin/controller/nikshayjoblog/NikshayJobLogController.java @@ -33,18 +33,21 @@ import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import com.iemr.admin.data.nikshay.NikshayJobHistoryLog; +import com.iemr.admin.data.nikshay.NikshayJobRunDTO; import com.iemr.admin.service.nikshayjoblog.NikshayFailureReason; import com.iemr.admin.service.nikshayjoblog.NikshayJobHistoryLogService; import com.iemr.admin.service.nikshayjoblog.NikshayPhase; +import com.iemr.admin.service.nikshayjoblog.NikshayTriggerType; import io.swagger.v3.oas.annotations.Operation; -/** Read side of the Ni-kshay phase history: failed items and one beneficiary's history. */ +/** Read side of the Ni-kshay phase history: failed items, one beneficiary's history and the runs. */ @RestController @RequestMapping(value = "/nikshay/log", headers = "Authorization") public class NikshayJobLogController { @@ -84,6 +87,46 @@ public ResponseEntity getFailed(@RequestParam(value = "phase", required = fal } } + @Operation(summary = "Ni-kshay runs (the whole job, cron or manual, and manual phase runs), newest first, " + + "with their picked, succeeded, failed, notApplicable and waiting counts") + @GetMapping(value = "/runs") + public ResponseEntity getRuns(@RequestParam(value = "triggerType", required = false) NikshayTriggerType triggerType, + @RequestParam(value = "fromDate", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate, + @RequestParam(value = "toDate", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate toDate, + @RequestParam(value = "page", defaultValue = "0") int page, + @RequestParam(value = "size", defaultValue = "50") int size) { + if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) { + return ResponseEntity.badRequest().body("page must be >= 0 and size between 1 and " + MAX_PAGE_SIZE); + } + try { + // Dates are whole days: toDate is included, so the query bound is the next day's start. + Page runs = logService.getRuns(triggerType, + fromDate != null ? Timestamp.valueOf(fromDate.atStartOfDay()) : null, + toDate != null ? Timestamp.valueOf(toDate.plusDays(1).atStartOfDay()) : null, page, size); + Map body = new HashMap<>(); + body.put("runs", runs.getContent()); + body.put("totalElements", runs.getTotalElements()); + body.put("page", page); + body.put("size", size); + return ResponseEntity.ok(body); + } catch (Exception e) { + logger.error("Error reading Ni-kshay runs", e); + return ResponseEntity.status(500).body("Could not read the Ni-kshay runs"); + } + } + + @Operation(summary = "One Ni-kshay run by its jobRunId, with its counts per phase") + @GetMapping(value = "/runs/{jobRunId}") + public ResponseEntity getRun(@PathVariable("jobRunId") String jobRunId) { + try { + NikshayJobRunDTO run = logService.getRun(jobRunId); + return run != null ? ResponseEntity.ok(run) : ResponseEntity.status(404).body("No Ni-kshay run " + jobRunId); + } catch (Exception e) { + logger.error("Error reading Ni-kshay run {}", jobRunId, e); + return ResponseEntity.status(500).body("Could not read the Ni-kshay run"); + } + } + @Operation(summary = "Every Ni-kshay phase entry of one beneficiary (benRegId), newest first") @GetMapping(value = "/history") public ResponseEntity getHistory(@RequestParam("benId") Long benId) { diff --git a/src/main/java/com/iemr/admin/data/nikshay/NikshayJobRunDTO.java b/src/main/java/com/iemr/admin/data/nikshay/NikshayJobRunDTO.java new file mode 100644 index 0000000..96a7088 --- /dev/null +++ b/src/main/java/com/iemr/admin/data/nikshay/NikshayJobRunDTO.java @@ -0,0 +1,51 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technologies +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.admin.data.nikshay; + +import java.sql.Timestamp; + +import com.fasterxml.jackson.databind.JsonNode; +import com.iemr.admin.service.nikshayjoblog.NikshayLogStatus; +import com.iemr.admin.service.nikshayjoblog.NikshayTriggerType; + +import lombok.Getter; +import lombok.Setter; + +/** + * One Ni-kshay run (the whole job, or one manual phase run), from its JOB row in + * nikshay_worker_job_history_log. summary is the counts saved as JSON in the row's message; + * rows written before the counts were added have plain text, given in message instead. + */ +@Getter +@Setter +public class NikshayJobRunDTO { + private String jobRunId; + private NikshayTriggerType triggerType; + private String triggeredBy; + private NikshayLogStatus status; + // From the summary. For rows written before the times were added, startedAt is null and + // finishedAt is the row's created_at. + private Timestamp startedAt; + private Timestamp finishedAt; + private JsonNode summary; + private String message; +} diff --git a/src/main/java/com/iemr/admin/repo/nikshayjoblog/NikshayJobHistoryLogRepo.java b/src/main/java/com/iemr/admin/repo/nikshayjoblog/NikshayJobHistoryLogRepo.java index ed454ab..f4e992a 100644 --- a/src/main/java/com/iemr/admin/repo/nikshayjoblog/NikshayJobHistoryLogRepo.java +++ b/src/main/java/com/iemr/admin/repo/nikshayjoblog/NikshayJobHistoryLogRepo.java @@ -35,6 +35,7 @@ import com.iemr.admin.service.nikshayjoblog.NikshayFailureReason; import com.iemr.admin.service.nikshayjoblog.NikshayLogStatus; import com.iemr.admin.service.nikshayjoblog.NikshayPhase; +import com.iemr.admin.service.nikshayjoblog.NikshayTriggerType; @Repository public interface NikshayJobHistoryLogRepo extends CrudRepository { @@ -69,6 +70,21 @@ Page findLatestFailed(@Param("failed") NikshayLogStatus fa List findByBenIdOrderByIdDesc(Long benId); + // JOB summary rows (one per run), newest first. Every filter is optional (null = no + // filter); toDate is exclusive. + String RUNS_WHERE = " WHERE l.phase = :job " + + " AND (:triggerType IS NULL OR l.triggerType = :triggerType) " + + " AND (:fromDate IS NULL OR l.createdAt >= :fromDate) " + + " AND (:toDate IS NULL OR l.createdAt < :toDate) "; + + @Query(value = " SELECT l FROM NikshayJobHistoryLog l " + RUNS_WHERE + " ORDER BY l.id DESC ", + countQuery = " SELECT COUNT(l) FROM NikshayJobHistoryLog l " + RUNS_WHERE) + Page findRuns(@Param("job") NikshayPhase job, + @Param("triggerType") NikshayTriggerType triggerType, @Param("fromDate") Timestamp fromDate, + @Param("toDate") Timestamp toDate, Pageable pageable); + + NikshayJobHistoryLog findFirstByJobRunIdAndPhaseOrderByIdDesc(String jobRunId, NikshayPhase phase); + // Which of the given references already have a successful upload in this phase @Query(" SELECT DISTINCT l.referenceId FROM NikshayJobHistoryLog l WHERE l.phase = :phase " + " AND l.status = :success AND l.referenceId IN :referenceIds ") diff --git a/src/main/java/com/iemr/admin/service/nikshayidgenerate/NikshayIdGenerateService.java b/src/main/java/com/iemr/admin/service/nikshayidgenerate/NikshayIdGenerateService.java index 887f959..c9a2971 100644 --- a/src/main/java/com/iemr/admin/service/nikshayidgenerate/NikshayIdGenerateService.java +++ b/src/main/java/com/iemr/admin/service/nikshayidgenerate/NikshayIdGenerateService.java @@ -113,13 +113,23 @@ public void runEnrolment(List benRegIds, int limit, NikshayPhaseRun run) { logger.info("Found {} beneficiaries eligible for Ni-kshay ID generation", eligible.size()); List positive = toRun(filterDiagnosisComplete( filterHouseholdScreened(keepRequested(eligible, benRegIds)), benRegIds, run), benRegIds, limit); + pick(run, positive); processBeneficiaries(positive, NikshayFlowScenario.POSITIVE, run); // The query already requires the whole household to be screened List allNegative = nikshayIdGenerateRepository.findAllNegativeBeneficiaries(); logger.info("Found {} members of all-negative households for Ni-kshay ID generation", allNegative.size()); - processBeneficiaries(toRun(keepRequested(allNegative, benRegIds), benRegIds, limit - positive.size()), - NikshayFlowScenario.ALL_NEGATIVE, run); + List allNegativeToRun = toRun(keepRequested(allNegative, benRegIds), benRegIds, + limit - positive.size()); + pick(run, allNegativeToRun); + processBeneficiaries(allNegativeToRun, NikshayFlowScenario.ALL_NEGATIVE, run); + } + + // Counted in the run summary as picked by ENROLMENT + private static void pick(NikshayPhaseRun run, List beneficiaries) { + for (NikshayIdGenerateBeneficiaryDTO beneficiary : beneficiaries) { + run.getPickedBenRegIds().add(beneficiary.getBeneficiaryId()); + } } // The beneficiaries asked for (all of them when benRegIds is null) @@ -295,6 +305,7 @@ public void runContacts(List benRegIds, int limit, NikshayPhaseRun run) { } } } + run.getPickedBenRegIds().addAll(ids); logger.info("Adding the contacts of {} index patient(s)", ids.size()); if (ids.isEmpty()) { return; diff --git a/src/main/java/com/iemr/admin/service/nikshayjoblog/NikshayFailureReason.java b/src/main/java/com/iemr/admin/service/nikshayjoblog/NikshayFailureReason.java index 2d9ee05..2f5d329 100644 --- a/src/main/java/com/iemr/admin/service/nikshayjoblog/NikshayFailureReason.java +++ b/src/main/java/com/iemr/admin/service/nikshayjoblog/NikshayFailureReason.java @@ -44,5 +44,7 @@ public enum NikshayFailureReason { // More than one Ni-kshay record matches MULTIPLE_DUPLICATE_MATCHES, // The test result is an error / invalid run (e.g. NAAT "Error-2"): not uploaded, waits for a retest - TEST_RESULT_INVALID + TEST_RESULT_INVALID, + // JOB summary row only: a phase pass threw an error; each such pass has its "error" in the message JSON + PHASE_COULD_NOT_RUN } diff --git a/src/main/java/com/iemr/admin/service/nikshayjoblog/NikshayJobHistoryLogService.java b/src/main/java/com/iemr/admin/service/nikshayjoblog/NikshayJobHistoryLogService.java index 69b26bd..063f092 100644 --- a/src/main/java/com/iemr/admin/service/nikshayjoblog/NikshayJobHistoryLogService.java +++ b/src/main/java/com/iemr/admin/service/nikshayjoblog/NikshayJobHistoryLogService.java @@ -35,7 +35,9 @@ import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +import com.fasterxml.jackson.databind.ObjectMapper; import com.iemr.admin.data.nikshay.NikshayJobHistoryLog; +import com.iemr.admin.data.nikshay.NikshayJobRunDTO; import com.iemr.admin.repo.nikshayjoblog.NikshayJobHistoryLogRepo; import com.iemr.admin.service.nikshayclient.NikshayApiException; @@ -49,6 +51,9 @@ public class NikshayJobHistoryLogService { private static final Logger logger = LoggerFactory.getLogger(NikshayJobHistoryLogService.class); private static final int MESSAGE_MAX_LENGTH = 1000; + // A JOB row's message is the run summary JSON (message is TEXT since AMRIT-DB V141) + public static final int SUMMARY_MAX_LENGTH = 10000; + private static final ObjectMapper MAPPER = new ObjectMapper(); @Autowired private NikshayJobHistoryLogRepo logRepo; @@ -113,6 +118,47 @@ public List getHistory(Long benId) { return logRepo.findByBenIdOrderByIdDesc(benId); } + /** The runs (their JOB rows), newest first. Null filters are ignored. */ + public Page getRuns(NikshayTriggerType triggerType, Timestamp fromDate, Timestamp toDate, + int page, int size) { + return logRepo.findRuns(NikshayPhase.JOB, triggerType, fromDate, toDate, PageRequest.of(page, size)) + .map(this::toRun); + } + + /** One run by its jobRunId, or null when it has no JOB row. */ + public NikshayJobRunDTO getRun(String jobRunId) { + NikshayJobHistoryLog row = logRepo.findFirstByJobRunIdAndPhaseOrderByIdDesc(jobRunId, NikshayPhase.JOB); + return row == null ? null : toRun(row); + } + + private NikshayJobRunDTO toRun(NikshayJobHistoryLog row) { + NikshayJobRunDTO run = new NikshayJobRunDTO(); + run.setJobRunId(row.getJobRunId()); + run.setTriggerType(row.getTriggerType()); + run.setTriggeredBy(row.getTriggeredBy()); + run.setStatus(row.getStatus()); + run.setFinishedAt(row.getCreatedAt()); + String message = row.getMessage(); + if (message != null && message.trim().startsWith("{")) { + try { + run.setSummary(MAPPER.readTree(message)); + if (run.getSummary().path("startedAt").asLong() > 0) { + run.setStartedAt(new Timestamp(run.getSummary().path("startedAt").asLong())); + } + if (run.getSummary().path("finishedAt").asLong() > 0) { + run.setFinishedAt(new Timestamp(run.getSummary().path("finishedAt").asLong())); + } + } catch (Exception e) { + logger.warn("Run {}: summary is not valid JSON", row.getJobRunId()); + } + } + if (run.getSummary() == null) { + // Older row: plain text + run.setMessage(message); + } + return run; + } + private NikshayJobHistoryLog entry(NikshayJobRunContext ctx, NikshayPhase phase, Long benId, String referenceId, NikshayLogStatus status, String message) { NikshayJobHistoryLog entry = new NikshayJobHistoryLog(); @@ -120,9 +166,8 @@ private NikshayJobHistoryLog entry(NikshayJobRunContext ctx, NikshayPhase phase, entry.setPhase(phase); entry.setReferenceId(referenceId); entry.setStatus(status); - entry.setMessage(message != null && message.length() > MESSAGE_MAX_LENGTH - ? message.substring(0, MESSAGE_MAX_LENGTH) - : message); + int maxLength = phase == NikshayPhase.JOB ? SUMMARY_MAX_LENGTH : MESSAGE_MAX_LENGTH; + entry.setMessage(message != null && message.length() > maxLength ? message.substring(0, maxLength) : message); entry.setJobRunId(ctx.getJobRunId()); entry.setTriggerType(ctx.getTriggerType()); entry.setTriggeredBy(ctx.getTriggeredBy()); diff --git a/src/main/java/com/iemr/admin/service/nikshayphasestatus/NikshayJobRunSummary.java b/src/main/java/com/iemr/admin/service/nikshayphasestatus/NikshayJobRunSummary.java new file mode 100644 index 0000000..68dacc3 --- /dev/null +++ b/src/main/java/com/iemr/admin/service/nikshayphasestatus/NikshayJobRunSummary.java @@ -0,0 +1,146 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technologies +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.admin.service.nikshayphasestatus; + +import java.util.ArrayList; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iemr.admin.service.nikshayjoblog.NikshayJobHistoryLogService; + +import lombok.Getter; + +/** + * The counts of one run (the whole job, or one manual phase run), saved as JSON in the + * message of its JOB row in nikshay_worker_job_history_log: the totals of every pass, then + * each pass. + */ +@Getter +@JsonPropertyOrder({ "startedAt", "finishedAt", "picked", "succeeded", "failed", "notApplicable", "waiting", + "phases" }) +public class NikshayJobRunSummary { + private static final Logger logger = LoggerFactory.getLogger(NikshayJobRunSummary.class); + private static final ObjectMapper MAPPER = new ObjectMapper(); + // Longest summary saved (nikshay_worker_job_history_log.message is TEXT since AMRIT-DB V141) + private static final int MESSAGE_MAX_LENGTH = NikshayJobHistoryLogService.SUMMARY_MAX_LENGTH; + + // When the run started and ended, in epoch milliseconds (Admin-API's clock) + private final long startedAt; + private long finishedAt; + private int picked; + private int succeeded; + private int failed; + private int notApplicable; + private int waiting; + private List phases = new ArrayList<>(); + + /** A summary for a run starting now. */ + public NikshayJobRunSummary() { + this(System.currentTimeMillis()); + } + + public NikshayJobRunSummary(long startedAt) { + this.startedAt = startedAt; + } + + /** Sets finishedAt to now; called when the run's last pass is done. */ + public void finish() { + finishedAt = System.currentTimeMillis(); + } + + public void add(NikshayPhaseMetrics metrics) { + phases.add(metrics); + picked += valueOf(metrics.getPicked()); + succeeded += valueOf(metrics.getSucceeded()); + failed += valueOf(metrics.getFailed()); + notApplicable += valueOf(metrics.getNotApplicable()); + waiting += valueOf(metrics.getWaiting()); + } + + // A pass that did not run has no counts + private static int valueOf(Integer count) { + return count == null ? 0 : count; + } + + /** True when a pass threw an error; the JOB row is then saved as FAILED. */ + @JsonIgnore + public boolean isAnyCouldNotRun() { + for (NikshayPhaseMetrics metrics : phases) { + if (NikshayPhaseMetrics.COULD_NOT_RUN.equals(metrics.getResult())) { + return true; + } + } + return false; + } + + /** + * The JSON for the message column. Every pass is always kept. A full job's summary is + * about 1,100 characters with the largest counts and up to about 2,500 with an error on + * every pass, far below the limit; if it were ever longer, the errors are cut to 80, then + * 40 characters, and only then are the passes left out (the totals and times stay). + */ + public String toMessage() { + List allPhases = phases; + try { + String json = MAPPER.writeValueAsString(this); + if (json.length() <= MESSAGE_MAX_LENGTH) { + return json; + } + logger.warn("Ni-kshay run summary is {} characters, over {}: saving a shorter one", json.length(), + MESSAGE_MAX_LENGTH); + phases = cutErrors(allPhases, 80); + json = MAPPER.writeValueAsString(this); + if (json.length() <= MESSAGE_MAX_LENGTH) { + return json; + } + phases = cutErrors(allPhases, 40); + json = MAPPER.writeValueAsString(this); + if (json.length() <= MESSAGE_MAX_LENGTH) { + return json; + } + phases = new ArrayList<>(); + return MAPPER.writeValueAsString(this); + } catch (JsonProcessingException e) { + logger.warn("Could not write the Ni-kshay run summary as JSON", e); + return "{\"startedAt\":" + startedAt + ",\"finishedAt\":" + finishedAt + ",\"picked\":" + picked + + ",\"succeeded\":" + succeeded + ",\"failed\":" + failed + ",\"notApplicable\":" + notApplicable + + ",\"waiting\":" + waiting + "}"; + } finally { + phases = allPhases; + } + } + + // Copies of every pass with its error cut to maxLength + private static List cutErrors(List passes, int maxLength) { + List copies = new ArrayList<>(); + for (NikshayPhaseMetrics metrics : passes) { + copies.add(metrics.withErrorCut(maxLength)); + } + return copies; + } +} diff --git a/src/main/java/com/iemr/admin/service/nikshayphasestatus/NikshayPhaseJob.java b/src/main/java/com/iemr/admin/service/nikshayphasestatus/NikshayPhaseJob.java index da067f8..2d8a512 100644 --- a/src/main/java/com/iemr/admin/service/nikshayphasestatus/NikshayPhaseJob.java +++ b/src/main/java/com/iemr/admin/service/nikshayphasestatus/NikshayPhaseJob.java @@ -21,18 +21,13 @@ */ package com.iemr.admin.service.nikshayphasestatus; -import java.util.ArrayList; -import java.util.List; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import com.iemr.admin.data.nikshay.NikshayJobHistoryLog; -import com.iemr.admin.data.nikshayidgenerate.NikshayErroredBeneficiaryDTO; import com.iemr.admin.data.nikshayphasestatus.NikshayPhase; -import com.iemr.admin.service.nikshayjoblog.NikshayJobHistoryLogService; import com.iemr.admin.service.nikshayjoblog.NikshayJobRunContext; import com.iemr.admin.service.nikshayjoblog.NikshayTriggerType; @@ -41,8 +36,9 @@ * fires it with the cron in nikshay.job.cron (one cron for every phase), and * POST /nikshay/phase/job/run runs it by hand. Only one whole run goes at a time. Each run * goes through the phases in order. A phase that is already running (a manual push) is - * skipped until the next run; a phase that fails does not stop the others. One summary - * row is written to nikshay_worker_job_history_log. + * skipped until the next run; a phase that fails does not stop the others. One JOB summary + * row is written to nikshay_worker_job_history_log, with the picked, succeeded, failed, not + * applicable and waiting counts of the run and of each pass as JSON in its message. */ @Component public class NikshayPhaseJob { @@ -66,9 +62,6 @@ public class NikshayPhaseJob { @Autowired private NikshayPhaseRunService phaseRunService; - @Autowired - private NikshayJobHistoryLogService logService; - // True while a whole job run (cron or POST /nikshay/phase/job/run) is going private boolean jobRunning; @@ -103,56 +96,41 @@ public NikshayJobHistoryLog runAllPhases(NikshayJobRunContext context) { private NikshayJobHistoryLog runPhases(NikshayJobRunContext context) { logger.info("Ni-kshay job {} started by {}", context.getJobRunId(), context.getTriggeredBy()); - List ran = new ArrayList<>(); - List skipped = new ArrayList<>(); - int failedBeneficiaries = 0; - boolean phaseCrashed = false; + NikshayJobRunSummary summary = new NikshayJobRunSummary(); int uploadPass = 0; for (NikshayPhase phase : PHASE_ORDER) { + // The first X-ray and TrueNat passes use the batch size. The passes after + // contacts upload every waiting contact, and transfer sends everyone ready. + boolean contactFollowUp; + if (phase == NikshayPhase.XRAY_UPLOAD || phase == NikshayPhase.NAAT_UPLOAD) { + contactFollowUp = ++uploadPass > 2; + } else { + contactFollowUp = phase == NikshayPhase.TRANSFER_OUT; + } + NikshayPhaseMetrics metrics; try { - // The first X-ray and TrueNat passes use the batch size. The passes after - // contacts upload every waiting contact, and transfer sends everyone ready. - boolean contactFollowUp; - if (phase == NikshayPhase.XRAY_UPLOAD || phase == NikshayPhase.NAAT_UPLOAD) { - contactFollowUp = ++uploadPass > 2; - } else { - contactFollowUp = phase == NikshayPhase.TRANSFER_OUT; - } - List errored = contactFollowUp - ? phaseRunService.run(phase, null, CONTACT_FOLLOW_UP_LIMIT, + metrics = contactFollowUp + ? phaseRunService.runInJob(phase, CONTACT_FOLLOW_UP_LIMIT, phase == NikshayPhase.TRANSFER_OUT ? null : "CONTACT", context) - : phaseRunService.run(phase, null, context); - failedBeneficiaries += errored.size(); - ran.add(phase.name() + " (" + errored.size() + " failed)"); - logger.info("Ni-kshay job: {} done, {} beneficiaries failed", phase, errored.size()); + : phaseRunService.runInJob(phase, context); + logger.info("Ni-kshay job: {} done, {} beneficiaries failed", phase, metrics.getFailed()); } catch (IllegalStateException e) { // Already running (a manual push): the next run picks it up logger.info("Ni-kshay job: {} skipped: {}", phase, e.getMessage()); - skipped.add(phase.name()); + metrics = NikshayPhaseMetrics.skipped(phase.name()); } catch (Exception e) { logger.error("Ni-kshay job: {} could not run", phase, e); - ran.add(phase.name() + " (could not run)"); - phaseCrashed = true; + metrics = NikshayPhaseMetrics.couldNotRun(phase.name(), e); } + if (contactFollowUp && phase != NikshayPhase.TRANSFER_OUT) { + metrics.setPass(2); + } + summary.add(metrics); } - String message = (context.getTriggerType() == NikshayTriggerType.JOB ? "Cron run" : "Manual run") - + ". Ran: " + (ran.isEmpty() ? "none" : String.join(", ", ran)) - + ". Skipped (already running): " + (skipped.isEmpty() ? "none" : String.join(", ", skipped)) - + ". Beneficiaries failed: " + failedBeneficiaries; - logger.info("Ni-kshay job {} finished. {}", context.getJobRunId(), message); - NikshayJobHistoryLog entry = null; - try { - entry = phaseCrashed - ? logService.failure(context, com.iemr.admin.service.nikshayjoblog.NikshayPhase.JOB, null, null, - null, message) - : logService.success(context, com.iemr.admin.service.nikshayjoblog.NikshayPhase.JOB, null, null, - null, message); - List entries = new ArrayList<>(); - entries.add(entry); - logService.saveAll(entries); - } catch (Exception e) { - logger.error("Ni-kshay job {}: could not write the run summary", context.getJobRunId(), e); - } - return entry; + summary.finish(); + logger.info("Ni-kshay job {} finished: picked {}, succeeded {}, failed {}, not applicable {}, waiting {}", + context.getJobRunId(), summary.getPicked(), summary.getSucceeded(), summary.getFailed(), + summary.getNotApplicable(), summary.getWaiting()); + return phaseRunService.saveSummary(context, summary); } } diff --git a/src/main/java/com/iemr/admin/service/nikshayphasestatus/NikshayPhaseMetrics.java b/src/main/java/com/iemr/admin/service/nikshayphasestatus/NikshayPhaseMetrics.java new file mode 100644 index 0000000..6b132ff --- /dev/null +++ b/src/main/java/com/iemr/admin/service/nikshayphasestatus/NikshayPhaseMetrics.java @@ -0,0 +1,114 @@ +/* +* AMRIT – Accessible Medical Records via Integrated Technologies +* Integrated EHR (Electronic Health Records) Solution +* +* Copyright (C) "Piramal Swasthya Management and Research Institute" +* +* This file is part of AMRIT. +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ +package com.iemr.admin.service.nikshayphasestatus; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; + +import lombok.Getter; +import lombok.Setter; + +/** + * Counts of one phase pass, by the status each picked beneficiary has in the phase once the + * pass ends: DONE = succeeded, FAILED = failed, NOT_APPLICABLE = notApplicable, anything + * else (PENDING, or no row) = waiting. The four always add up to picked. A pass that did + * not run has no counts (they are left out of the JSON) and, when it threw, its error. + */ +@Getter +@Setter +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonPropertyOrder({ "phase", "pass", "result", "picked", "succeeded", "failed", "notApplicable", "waiting", + "error" }) +public class NikshayPhaseMetrics { + // The pass ran and was counted + public static final String RAN = "RAN"; + // The pass threw an error (see the Admin-API log) + public static final String COULD_NOT_RUN = "COULD_NOT_RUN"; + // The phase was already running (a manual push), so this pass was skipped + public static final String SKIPPED_ALREADY_RUNNING = "SKIPPED_ALREADY_RUNNING"; + + private String phase; + // 1, or 2 for the job's second X-ray and NAAT pass (the contacts) + private int pass = 1; + private String result = RAN; + private Integer picked = 0; + private Integer succeeded = 0; + private Integer failed = 0; + private Integer notApplicable = 0; + private Integer waiting = 0; + // COULD_NOT_RUN only: the error's type and message (the full stack trace is in the log) + private String error; + + // Keeps the summary within the message column + private static final int ERROR_MAX_LENGTH = 200; + + public NikshayPhaseMetrics(String phase) { + this.phase = phase; + } + + /** A pass skipped because the phase was already running. */ + public static NikshayPhaseMetrics skipped(String phase) { + return notRun(phase, SKIPPED_ALREADY_RUNNING); + } + + /** A pass that threw: COULD_NOT_RUN, with the error of its deepest cause. */ + public static NikshayPhaseMetrics couldNotRun(String phase, Throwable e) { + NikshayPhaseMetrics metrics = notRun(phase, COULD_NOT_RUN); + Throwable cause = e; + while (cause.getCause() != null && cause.getCause() != cause) { + cause = cause.getCause(); + } + String error = cause.getClass().getSimpleName() + + (cause.getMessage() == null ? "" : ": " + cause.getMessage().replaceAll("\\s+", " ").trim()); + metrics.setError(cut(error, ERROR_MAX_LENGTH)); + return metrics; + } + + /** A copy whose error is cut to maxLength (used to fit the summary in the message column). */ + public NikshayPhaseMetrics withErrorCut(int maxLength) { + NikshayPhaseMetrics copy = new NikshayPhaseMetrics(phase); + copy.setPass(pass); + copy.setResult(result); + copy.setPicked(picked); + copy.setSucceeded(succeeded); + copy.setFailed(failed); + copy.setNotApplicable(notApplicable); + copy.setWaiting(waiting); + copy.setError(cut(error, maxLength)); + return copy; + } + + private static NikshayPhaseMetrics notRun(String phase, String result) { + NikshayPhaseMetrics metrics = new NikshayPhaseMetrics(phase); + metrics.setResult(result); + metrics.setPicked(null); + metrics.setSucceeded(null); + metrics.setFailed(null); + metrics.setNotApplicable(null); + metrics.setWaiting(null); + return metrics; + } + + private static String cut(String text, int maxLength) { + return text != null && text.length() > maxLength ? text.substring(0, maxLength) : text; + } +} diff --git a/src/main/java/com/iemr/admin/service/nikshayphasestatus/NikshayPhaseRun.java b/src/main/java/com/iemr/admin/service/nikshayphasestatus/NikshayPhaseRun.java index d0829fb..9859267 100644 --- a/src/main/java/com/iemr/admin/service/nikshayphasestatus/NikshayPhaseRun.java +++ b/src/main/java/com/iemr/admin/service/nikshayphasestatus/NikshayPhaseRun.java @@ -22,7 +22,9 @@ package com.iemr.admin.service.nikshayphasestatus; import java.util.ArrayList; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Set; import com.iemr.admin.data.nikshay.NikshayJobHistoryLog; import com.iemr.admin.data.nikshayidgenerate.NikshayErroredBeneficiaryDTO; @@ -32,13 +34,15 @@ /** * One run of a Ni-kshay phase: who triggered it, the beneficiaries that failed (the run's - * response) and the job history log entries, written once when the run ends. + * response), the job history log entries, written once when the run ends, and the + * BenRegIds the phase picked (counted in the run summary). */ @Getter public class NikshayPhaseRun { private final NikshayJobRunContext context; private final List erroredBeneficiaries = new ArrayList<>(); private final List logEntries = new ArrayList<>(); + private final Set pickedBenRegIds = new LinkedHashSet<>(); public NikshayPhaseRun(NikshayJobRunContext context) { this.context = context; diff --git a/src/main/java/com/iemr/admin/service/nikshayphasestatus/NikshayPhaseRunService.java b/src/main/java/com/iemr/admin/service/nikshayphasestatus/NikshayPhaseRunService.java index 1133f60..32e07d3 100644 --- a/src/main/java/com/iemr/admin/service/nikshayphasestatus/NikshayPhaseRunService.java +++ b/src/main/java/com/iemr/admin/service/nikshayphasestatus/NikshayPhaseRunService.java @@ -21,6 +21,7 @@ */ package com.iemr.admin.service.nikshayphasestatus; +import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; @@ -31,9 +32,13 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import com.iemr.admin.data.nikshay.NikshayJobHistoryLog; import com.iemr.admin.data.nikshayidgenerate.NikshayErroredBeneficiaryDTO; import com.iemr.admin.data.nikshayphasestatus.NikshayPhase; +import com.iemr.admin.data.nikshayphasestatus.NikshayPhaseCandidate; +import com.iemr.admin.data.nikshayphasestatus.NikshayPhaseStatus; import com.iemr.admin.service.nikshayidgenerate.NikshayIdGenerateService; +import com.iemr.admin.service.nikshayjoblog.NikshayFailureReason; import com.iemr.admin.service.nikshayjoblog.NikshayJobHistoryLogService; import com.iemr.admin.service.nikshayjoblog.NikshayJobRunContext; import com.iemr.admin.service.nikshaytransferout.NikshayTransferOutService; @@ -43,6 +48,8 @@ * Runs one Ni-kshay phase: ENROLMENT, XRAY_UPLOAD, NAAT_UPLOAD (MTB and Rif), CONTACTS or * TRANSFER_OUT. Only one run of a phase at a time; each run takes up to * nikshay.phase.batch-size beneficiaries and writes its job history log entries when it ends. + * A manual run of one phase also writes a JOB summary row with its counts (the whole job + * writes one row for all its phases, see NikshayPhaseJob). */ @Service public class NikshayPhaseRunService { @@ -85,6 +92,104 @@ public List run(NikshayPhase phase, List ben */ public List run(NikshayPhase phase, List benRegIds, int limit, String beneficiaryType, NikshayJobRunContext context) { + long startedAt = System.currentTimeMillis(); + NikshayPhaseRun run; + try { + run = execute(phase, benRegIds, limit, beneficiaryType, context); + } catch (IllegalStateException e) { + // Already running: no summary, as before + throw e; + } catch (RuntimeException e) { + saveSummary(context, summaryOf(startedAt, NikshayPhaseMetrics.couldNotRun(phase.name(), e))); + throw e; + } + saveSummary(context, summaryOf(startedAt, count(phase, run))); + return run.getErroredBeneficiaries(); + } + + /** + * One pass of the whole job: runs the phase like run(...) and returns its counts. No + * summary row is written here; the job writes one for all its passes. + */ + public NikshayPhaseMetrics runInJob(NikshayPhase phase, NikshayJobRunContext context) { + return runInJob(phase, batchSize, null, context); + } + + /** Same as runInJob(phase, context), with limit and beneficiaryType as in run(...). */ + public NikshayPhaseMetrics runInJob(NikshayPhase phase, int limit, String beneficiaryType, + NikshayJobRunContext context) { + return count(phase, execute(phase, null, limit, beneficiaryType, context)); + } + + /** + * Saves the run's JOB row in nikshay_worker_job_history_log, its counts as JSON in the + * message (FAILED with reason PHASE_COULD_NOT_RUN when a pass could not run). Returns the + * row, or null when it could not be saved; the run itself is not affected. + */ + public NikshayJobHistoryLog saveSummary(NikshayJobRunContext context, NikshayJobRunSummary summary) { + try { + String message = summary.toMessage(); + NikshayJobHistoryLog entry = summary.isAnyCouldNotRun() + ? logService.failure(context, com.iemr.admin.service.nikshayjoblog.NikshayPhase.JOB, null, null, + NikshayFailureReason.PHASE_COULD_NOT_RUN, message) + : logService.success(context, com.iemr.admin.service.nikshayjoblog.NikshayPhase.JOB, null, null, + null, message); + List entries = new ArrayList<>(); + entries.add(entry); + logService.saveAll(entries); + return entry; + } catch (Exception e) { + logger.error("Run {}: could not write the Ni-kshay run summary", context.getJobRunId(), e); + return null; + } + } + + private static NikshayJobRunSummary summaryOf(long startedAt, NikshayPhaseMetrics metrics) { + NikshayJobRunSummary summary = new NikshayJobRunSummary(startedAt); + summary.add(metrics); + summary.finish(); + return summary; + } + + /** + * Counts the picked beneficiaries by their status in the phase now. A failure here is + * only logged: the counts stay 0 apart from picked, and the run goes on. + */ + private NikshayPhaseMetrics count(NikshayPhase phase, NikshayPhaseRun run) { + NikshayPhaseMetrics metrics = new NikshayPhaseMetrics(phase.name()); + List picked = new ArrayList<>(run.getPickedBenRegIds()); + metrics.setPicked(picked.size()); + if (picked.isEmpty()) { + return metrics; + } + try { + int succeeded = 0; + int failed = 0; + int notApplicable = 0; + for (NikshayPhaseCandidate row : phaseStatusService.findRows(phase, picked)) { + if (NikshayPhaseStatus.DONE.name().equals(row.getStatus())) { + succeeded++; + } else if (NikshayPhaseStatus.FAILED.name().equals(row.getStatus())) { + failed++; + } else if (NikshayPhaseStatus.NOT_APPLICABLE.name().equals(row.getStatus())) { + notApplicable++; + } + } + metrics.setSucceeded(succeeded); + metrics.setFailed(failed); + metrics.setNotApplicable(notApplicable); + // PENDING, IN_PROGRESS or no row yet + metrics.setWaiting(picked.size() - succeeded - failed - notApplicable); + } catch (Exception e) { + logger.warn("{} run {}: could not count the picked beneficiaries", phase, + run.getContext().getJobRunId(), e); + } + return metrics; + } + + // The phase run itself (unchanged): lock, run, write the history log entries + private NikshayPhaseRun execute(NikshayPhase phase, List benRegIds, int limit, String beneficiaryType, + NikshayJobRunContext context) { synchronized (running) { if (!running.add(phase)) { throw new IllegalStateException(phase + " is already running"); @@ -123,7 +228,7 @@ public List run(NikshayPhase phase, List ben } logger.info("{} run {} finished: {} beneficiaries failed", phase, context.getJobRunId(), run.getErroredBeneficiaries().size()); - return run.getErroredBeneficiaries(); + return run; } /** diff --git a/src/main/java/com/iemr/admin/service/nikshaytransferout/NikshayTransferOutService.java b/src/main/java/com/iemr/admin/service/nikshaytransferout/NikshayTransferOutService.java index 2355e8c..b8e8dfd 100644 --- a/src/main/java/com/iemr/admin/service/nikshaytransferout/NikshayTransferOutService.java +++ b/src/main/java/com/iemr/admin/service/nikshaytransferout/NikshayTransferOutService.java @@ -122,6 +122,9 @@ private static class Transfer { */ public void runTransferOut(List benRegIds, int limit, NikshayPhaseRun run) { List candidates = findCandidates(benRegIds, limit, run); + for (NikshayPhaseCandidate candidate : candidates) { + run.getPickedBenRegIds().add(candidate.getBenRegId()); + } logger.info("TRANSFER_OUT: {} beneficiaries to check", candidates.size()); if (candidates.isEmpty()) { return; diff --git a/src/main/java/com/iemr/admin/service/nikshayupload/NikshayUploadService.java b/src/main/java/com/iemr/admin/service/nikshayupload/NikshayUploadService.java index c17df2b..fd23fd6 100644 --- a/src/main/java/com/iemr/admin/service/nikshayupload/NikshayUploadService.java +++ b/src/main/java/com/iemr/admin/service/nikshayupload/NikshayUploadService.java @@ -135,6 +135,9 @@ public void runPhase(NikshayPhase phase, List benRegIds, int limit, String } } } + for (NikshayPhaseCandidate candidate : candidates) { + run.getPickedBenRegIds().add(candidate.getBenRegId()); + } logger.info("{}: {} beneficiaries to upload", phase, candidates.size()); if (candidates.isEmpty()) { return;