Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/main/environment/bengen_docker.properties
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ no-of-benID-to-be-generate=25000
### Total available Ben should not be less than this
lower-limit-of-beneficiary=20000

### Health API warns (status DEGRADED) when available Ben IDs fall below this
health.min-available-beneficiary-ids=5000

### Redis IP
spring.redis.host=${REDIS_HOST}

Expand Down
6 changes: 6 additions & 0 deletions src/main/environment/bengen_example.properties
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ start-bengen-scheduler=true
# To Run scheduler Every MON,WED,FRI
cron-scheduler-bengen=0 1 0 ? * MON,WED,FRI,SUN *

# To Run scheduler Every Day
# cron-scheduler-bengen=0 1 0 * * ? *

# To Run scheduler Every Minute
#cron-scheduler-bengen=0 0/1 * * * ? *

Expand All @@ -22,6 +25,9 @@ no-of-benID-to-be-generate=25000
### Total available Ben should not be less than this
lower-limit-of-beneficiary=20000

### Health API warns (status DEGRADED) when available Ben IDs fall below this
health.min-available-beneficiary-ids=5000

### Redis IP
spring.redis.host=localhost
jwt.secret=my-32-character-ultra-secure-and-ultra-long-secret
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ Long countByReservedForPSMapIdAndProvisionedAndReserved(

@Query(nativeQuery = true, value = "Select benregMap.benRegId, benregMap.beneficiaryId, " +
"benregMap.CreatedDate "
+ "from db_identity.m_beneficiaryregidmapping benregMap "
+ "from m_beneficiaryregidmapping benregMap "
+ "where benregMap.provisioned =false and benregMap.reserved =true " +
"and benregMap.vanID=:vanID order by benregMap.benRegId desc limit :num ")
List<Object[]> getBenIDGenerated(@Param("vanID") Integer vanID, @Param("num") Long num);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,11 @@ public StringBuffer createQuery(Integer num) {

Generator g = new Generator();
StringBuffer sb = new StringBuffer(
"INSERT INTO `db_identity`.`m_beneficiaryregidmapping` " +
"INSERT INTO `m_beneficiaryregidmapping` " +
"(`BeneficiaryID`,`Provisioned`,`Deleted`," +
"`CreatedDate`,`CreatedBy`) VALUES ");

// INSERT INTO `db_identity`.`m_beneficiaryregidmapping`
// INSERT INTO `m_beneficiaryregidmapping`
// (`BeneficiaryID`,`Provisioned`,`Deleted`,`CreatedDate`,`CreatedBy`) VALUES
// (<{BeneficiaryID: }>, <{Provisioned: b'0'}>, <{Deleted: b'0'}>,
// <{CreatedDate: CURRENT_TIMESTAMP}>, <{CreatedBy: }>);
Expand Down Expand Up @@ -143,7 +143,7 @@ public StringBuffer createQuery(Integer num) {
public void testLoopGenr() {
List<String> strList = new ArrayList<String>();
StringBuffer sb = new StringBuffer(
"INSERT INTO `db_identity`.`m_beneficiaryregidmapping` " +
"INSERT INTO `m_beneficiaryregidmapping` " +
"(`BeneficiaryID`,`Provisioned`,`Deleted`,`CreatedDate`," +
"`CreatedBy`) VALUES ");
Timestamp ts = Timestamp.from(Instant.now());
Expand All @@ -167,7 +167,7 @@ public List<M_BeneficiaryRegidMapping> getBeneficiaryIDs(Long num, Integer vanID
long strt = System.currentTimeMillis();
Generator g = new Generator();
StringBuffer sb = new StringBuffer(
"INSERT INTO `db_identity`.`m_beneficiaryregidmapping` " +
"INSERT INTO `m_beneficiaryregidmapping` " +
"(`BeneficiaryID`,`Provisioned`,`Deleted`,`Reserved`," +
"`CreatedDate`,`CreatedBy`,`VanID`) VALUES ");
Timestamp ts = Timestamp.from(Instant.now());
Expand Down
172 changes: 151 additions & 21 deletions src/main/java/com/iemr/common/bengen/service/health/HealthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
Expand All @@ -47,8 +48,16 @@
private static final String RESPONSE_TIME_KEY = "responseTimeMs";
private static final long MYSQL_TIMEOUT_SECONDS = 3;
private static final long REDIS_TIMEOUT_SECONDS = 3;
private static final long BENEFICIARY_POOL_TIMEOUT_SECONDS = 3;
private static final long ADVANCED_CHECKS_THROTTLE_SECONDS = 30;
private static final long POOL_CHECK_THROTTLE_SECONDS = 30;
private static final long RESPONSE_TIME_THRESHOLD_MS = 2000;
private static final String BENEFICIARY_POOL_KEY = "beneficiaryIdPool";
private static final String AVAILABLE_IDS_KEY = "availableIds";
private static final String THRESHOLD_KEY = "threshold";
// Mirrors BeneficiaryIdRepo.countBenID(): unprovisioned and unreserved IDs are the usable pool.
private static final String COUNT_AVAILABLE_IDS_SQL =
"SELECT COUNT(*) FROM m_beneficiaryregidmapping WHERE Provisioned = 0 AND Reserved = 0";
private static final String DIAGNOSTIC_LOCK_WAIT = "MYSQL_LOCK_WAIT";
private static final String DIAGNOSTIC_SLOW_QUERIES = "MYSQL_SLOW_QUERIES";
private static final String DIAGNOSTIC_POOL_EXHAUSTED = "MYSQL_POOL_EXHAUSTED";
Expand All @@ -61,14 +70,24 @@
private volatile long lastAdvancedCheckTime = 0;
private volatile AdvancedCheckResult cachedAdvancedCheckResult = null;
private final ReentrantReadWriteLock advancedCheckLock = new ReentrantReadWriteLock();


// Warn when the usable beneficiary ID pool falls below this count
private final int minAvailableBeneficiaryIds;

// Cached pool count so frequent health polls do not repeatedly scan the table.
// A benign race here only costs one extra COUNT query, so no lock is needed.
private volatile long lastPoolCheckTime = 0;
private volatile Long cachedAvailableIdCount = null;

// Advanced checks always enabled
private static final boolean ADVANCED_HEALTH_CHECKS_ENABLED = true;

public HealthService(DataSource dataSource,
@Autowired(required = false) RedisTemplate<String, Object> redisTemplate) {
@Autowired(required = false) RedisTemplate<String, Object> redisTemplate,
@Value("${health.min-available-beneficiary-ids:5000}") int minAvailableBeneficiaryIds) {
this.dataSource = dataSource;
this.redisTemplate = redisTemplate;
this.minAvailableBeneficiaryIds = minAvailableBeneficiaryIds;
this.executorService = Executors.newFixedThreadPool(6);
}

Expand All @@ -92,34 +111,39 @@
public Map<String, Object> checkHealth() {
Map<String, Object> mysqlStatus = new ConcurrentHashMap<>();
Map<String, Object> redisStatus = new ConcurrentHashMap<>();
Map<String, Object> beneficiaryPoolStatus = new ConcurrentHashMap<>();

Future<?> mysqlFuture = executorService.submit(
() -> performHealthCheck("MySQL", mysqlStatus, this::checkMySQLHealthSync));
Future<?> redisFuture = executorService.submit(
() -> performHealthCheck("Redis", redisStatus, this::checkRedisHealthSync));
Future<?> beneficiaryPoolFuture = executorService.submit(
() -> checkBeneficiaryIdPool(beneficiaryPoolStatus));

// Wait for both checks to complete with combined timeout (shared deadline)
long maxTimeout = Math.max(MYSQL_TIMEOUT_SECONDS, REDIS_TIMEOUT_SECONDS) + 1;
awaitHealthChecks(mysqlFuture, redisFuture, maxTimeout);
// Wait for all checks to complete with combined timeout (shared deadline)
long maxTimeout = Math.max(Math.max(MYSQL_TIMEOUT_SECONDS, REDIS_TIMEOUT_SECONDS),
BENEFICIARY_POOL_TIMEOUT_SECONDS) + 1;
awaitHealthChecks(maxTimeout, mysqlFuture, redisFuture, beneficiaryPoolFuture);

// Ensure timed-out or unfinished components are marked DOWN
ensurePopulated(mysqlStatus, "MySQL");
ensurePopulated(redisStatus, "Redis");
if (!beneficiaryPoolStatus.containsKey(STATUS_KEY)) {
markPoolUnknown(beneficiaryPoolStatus, "Beneficiary ID pool check did not complete in time");
}

// Build response in the standardized AMRIT shape (aligned with Common-API):
// top-level status + checkedAt, then per-service status/severity summaries.
Map<String, Object> response = new LinkedHashMap<>();

String mysqlOverall = (String) mysqlStatus.get(STATUS_KEY);
String redisOverall = (String) redisStatus.get(STATUS_KEY);
boolean overallUp = !STATUS_DOWN.equals(mysqlOverall) && !STATUS_DOWN.equals(redisOverall);

response.put(STATUS_KEY, overallUp ? STATUS_UP : STATUS_DOWN);
response.put(STATUS_KEY, computeOverallStatus(mysqlStatus, redisStatus, beneficiaryPoolStatus));
response.put("checkedAt", Instant.now().toString());

// Expose only status and severity; keep diagnostics (responseTime, messages) internal
response.put("mysql", summarize(mysqlStatus));
response.put("redis", summarize(redisStatus));
// The pool also reports its count and threshold, which operators need to act on the warning
response.put(BENEFICIARY_POOL_KEY, summarizeBeneficiaryPool(beneficiaryPoolStatus));

return response;
}
Expand All @@ -131,30 +155,136 @@
return summary;
}

private void awaitHealthChecks(Future<?> mysqlFuture, Future<?> redisFuture, long maxTimeoutSeconds) {
private Map<String, Object> summarizeBeneficiaryPool(Map<String, Object> componentStatus) {
Map<String, Object> summary = summarize(componentStatus);
if (componentStatus.containsKey(AVAILABLE_IDS_KEY)) {
summary.put(AVAILABLE_IDS_KEY, componentStatus.get(AVAILABLE_IDS_KEY));
}
summary.put(THRESHOLD_KEY, minAvailableBeneficiaryIds);
if (componentStatus.containsKey(MESSAGE_KEY)) {
summary.put(MESSAGE_KEY, componentStatus.get(MESSAGE_KEY));
}
if (componentStatus.containsKey(ERROR_KEY)) {
summary.put(ERROR_KEY, componentStatus.get(ERROR_KEY));
}
return summary;
}

/**
* Overall status is DOWN if any component is DOWN, DEGRADED if any component
* reports a warning (for example a low beneficiary ID pool), otherwise UP.
*/
@SafeVarargs
private final String computeOverallStatus(Map<String, Object>... componentStatuses) {
boolean hasDegraded = false;
for (Map<String, Object> componentStatus : componentStatuses) {
String status = (String) componentStatus.get(STATUS_KEY);
String severity = (String) componentStatus.get(SEVERITY_KEY);
if (STATUS_DOWN.equals(status) || SEVERITY_CRITICAL.equals(severity)) {
return STATUS_DOWN;
}
if (STATUS_DEGRADED.equals(status) || SEVERITY_WARNING.equals(severity)) {
hasDegraded = true;
}
}
return hasDegraded ? STATUS_DEGRADED : STATUS_UP;
}

private void awaitHealthChecks(long maxTimeoutSeconds, Future<?>... futures) {
long deadlineNs = System.nanoTime() + TimeUnit.SECONDS.toNanos(maxTimeoutSeconds);
try {
mysqlFuture.get(maxTimeoutSeconds, TimeUnit.SECONDS);
long remainingNs = deadlineNs - System.nanoTime();
if (remainingNs > 0) {
redisFuture.get(remainingNs, TimeUnit.NANOSECONDS);
} else {
redisFuture.cancel(true);
for (Future<?> future : futures) {
long remainingNs = deadlineNs - System.nanoTime();
if (remainingNs <= 0) {
future.cancel(true);
continue;
}
future.get(remainingNs, TimeUnit.NANOSECONDS);
}
} catch (TimeoutException e) {
logger.warn("Health check aggregate timeout after {} seconds", maxTimeoutSeconds);
mysqlFuture.cancel(true);
redisFuture.cancel(true);
cancelAll(futures);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
logger.warn("Health check was interrupted");
mysqlFuture.cancel(true);
redisFuture.cancel(true);
cancelAll(futures);
} catch (Exception e) {
logger.warn("Health check execution error: {}", e.getMessage());
}
}

private void cancelAll(Future<?>... futures) {
for (Future<?> future : futures) {
future.cancel(true);
}
}

/**
* Flags a WARNING when the usable beneficiary ID pool drops below the configured
* threshold, so a draining pool is visible before registration stops entirely.
* Reported as DEGRADED rather than DOWN, so the endpoint still returns HTTP 200.
*/
private void checkBeneficiaryIdPool(Map<String, Object> status) {
long startTime = System.currentTimeMillis();
try {
long availableIds = countAvailableBeneficiaryIdsWithThrottle();
status.put(RESPONSE_TIME_KEY, System.currentTimeMillis() - startTime);
status.put(AVAILABLE_IDS_KEY, availableIds);

if (availableIds < minAvailableBeneficiaryIds) {
status.put(STATUS_KEY, STATUS_DEGRADED);
status.put(SEVERITY_KEY, SEVERITY_WARNING);
status.put(MESSAGE_KEY, "Available beneficiary ID pool is below the configured threshold");
logger.warn("Beneficiary ID pool low: {} available, warning threshold is {}",
availableIds, minAvailableBeneficiaryIds);
} else {
status.put(STATUS_KEY, STATUS_UP);
status.put(SEVERITY_KEY, SEVERITY_OK);
}
} catch (Exception e) {
logger.warn("Beneficiary ID pool check failed: {}", e.getMessage(), e);
status.put(RESPONSE_TIME_KEY, System.currentTimeMillis() - startTime);
// A failed count is not an outage: MySQL itself is checked separately, so this
// stays DEGRADED to avoid a slow count returning 503 and dropping the instance
// out of load-balancer rotation.
markPoolUnknown(status, "Beneficiary ID pool count could not be determined");
}
}

private void markPoolUnknown(Map<String, Object> status, String error) {
status.put(STATUS_KEY, STATUS_DEGRADED);
status.put(SEVERITY_KEY, SEVERITY_WARNING);
status.put(ERROR_KEY, error);
}

private long countAvailableBeneficiaryIdsWithThrottle() throws Exception {
long currentTime = System.currentTimeMillis();
Long cached = cachedAvailableIdCount;
if (cached != null && (currentTime - lastPoolCheckTime) < POOL_CHECK_THROTTLE_SECONDS * 1000) {
return cached;
}

long availableIds = countAvailableBeneficiaryIds();
lastPoolCheckTime = currentTime;
cachedAvailableIdCount = availableIds;
return availableIds;
}

private long countAvailableBeneficiaryIds() throws Exception {

Check warning on line 273 in src/main/java/com/iemr/common/bengen/service/health/HealthService.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace generic exceptions with specific library exceptions or a custom exception.

See more on https://sonarcloud.io/project/issues?id=PSMRI_BeneficiaryID-Generation-API&issues=AZ_10TpQyvrdxWitIuqG&open=AZ_10TpQyvrdxWitIuqG&pullRequest=69
try (Connection connection = dataSource.getConnection();
PreparedStatement stmt = connection.prepareStatement(COUNT_AVAILABLE_IDS_SQL)) {

stmt.setQueryTimeout((int) BENEFICIARY_POOL_TIMEOUT_SECONDS);

try (ResultSet rs = stmt.executeQuery()) {
if (!rs.next()) {
throw new IllegalStateException("No result from beneficiary ID pool count query");
}
return rs.getLong(1);
}
}
}

private void ensurePopulated(Map<String, Object> status, String componentName) {
if (!status.containsKey(STATUS_KEY)) {
status.put(STATUS_KEY, STATUS_DOWN);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ class GenerateBeneficiaryServiceTest {
Path tempDir;

private static final BigInteger MOCKED_BENEFICIARY_ID = new BigInteger("12345678901");
private static final String EXPECTED_TABLE_NAME = "`db_identity`.`m_beneficiaryregidmapping`";
private static final String EXPECTED_TABLE_NAME = "`m_beneficiaryregidmapping`";
private static final String EXPECTED_CREATOR = "admin-batch";

@BeforeEach
Expand Down
Loading