You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
GET /health computes airdrop_expiry job health but never folds it into the aggregate status field — a dead reconciliation job is invisible to monitoring #123
GET /health's top-level aggregate status field — the one value any external monitor, load balancer, or Kubernetes liveness/readiness probe would reasonably check — is computed from only two of the three leader-elected background jobs. The third, airdrop_expiry, has its detailed health computed and included in the response body, but is never folded into the aggregate status calculation at all.
airdropExpiryHealth is computed via wrappedAirdropExpiryJob.getHealth() and faithfully reported under jobs.airdrop_expiry in the response body — but the if (!redisConnected || !priceRefreshHealth.healthy || !webhookWorkerHealth.healthy) condition that gates the entire status computation references only redisConnected, priceRefreshHealth, and webhookWorkerHealth. airdropExpiryHealth never appears in that condition, nor in the jobsDegraded computation, nor in the inner ternary. If the airdrop-expiry reconciliation job stalls or dies — for example, if Horizon becomes persistently unreachable (the exact failure mode its own tick() function explicitly anticipates and logs a warning for), or if it's affected by the leader-election lifecycle bug described in a companion issue in this batch — GET /health's top-level status field will report 'ok' regardless, forever, as long as Redis and the other two jobs are fine.
This is precisely the failure mode a health-check aggregate exists to catch, and precisely the field any external system is most likely to check in isolation (nobody wires a liveness probe to parse jobs.airdrop_expiry.healthy out of a nested JSON body; they check the top-level status string, or an HTTP status code derived from it). A completely dead airdrop-expiry job — meaning airdrops silently never auto-expire against expiry_ledger again, silently undoing the entire point of already-closed issue #88 — would be invisible to any monitoring built against this endpoint's primary signal, even though the detailed data proving it's broken is sitting right there in the same response, one level down, unused by the very logic whose job is to summarize it.
Requirements
Include airdropExpiryHealth in the status/jobsDegraded aggregate computation on equal footing with priceRefreshHealth and webhookWorkerHealth.
Add a regression test that specifically stalls/fails only the airdrop-expiry job (leaving Redis, price-refresh, and webhook-retry healthy) and asserts the top-level status reflects that degradation — this is the exact scenario the current code fails silently on, and the one a test needs to target directly rather than only testing the two jobs that already participate in the aggregate.
While making this change, consider extracting the per-job aggregation logic into a small, named helper function that takes an array of { name, health } pairs, rather than a hand-written boolean expression enumerating each job by name — this bug is exactly the class of mistake ("added a third job but the boolean expression still only mentions two") that a loop over a list, rather than a hardcoded expression, would have made structurally impossible to reintroduce the next time a fourth leader-elected job is added.
Acceptance Criteria
GET /health's top-level status field changes (to 'degraded' or 'unhealthy' per the existing semantics) when airdrop_expiry's health is unhealthy/stalled, even when Redis, price-refresh, and webhook-retry-worker are all healthy.
A test explicitly covers this scenario (mocking/stubbing wrappedAirdropExpiryJob.getHealth() to return an unhealthy/stalled state while the other two jobs report healthy) and asserts the aggregate status is not 'ok'.
The fix does not regress the existing two-job aggregation behavior already covered by test/health.test.js.
(Recommended, not required) The per-job aggregation is refactored into a form that iterates over all registered jobs rather than naming each one explicitly in the boolean expression, to prevent this specific class of regression when a future job is added.
Additional Notes
More precise references
src/index.js/health handler: confirmed airdropExpiryHealth = wrappedAirdropExpiryJob.getHealth(); is computed (alongside priceRefreshHealth/webhookWorkerHealth) before the status computation begins.
Confirmed the status computation's if condition and the nested jobsDegraded boolean each reference priceRefreshHealth/webhookWorkerHealth/redisConnected explicitly by name, and neither references airdropExpiryHealth anywhere.
Confirmed jobs.airdrop_expiryis included in the JSON response body with healthy, stalled, last_error, leader, etc. — i.e. the data needed to fix this is already computed and available at the point the status variable is assigned; this is purely a "forgot to include it in the boolean expression" gap, not a missing-data gap.
Additional edge cases
Because leader-elected, non-leader instances report their own jobs as healthy: false by design (per the explicit comment in src/index.js: "a non-leader instance reports its jobs as not healthy... but that's expected — the leader is doing the work... distinguishes 'not leader' from 'stalled' via the leader field"), any fix here needs to preserve that same not-leader-vs-actually-stalled distinction for airdrop_expiry specifically — i.e. don't naively treat !airdropExpiryHealth.healthy as unconditionally bad; use airdropExpiryHealth.stalled the same way the existing two jobs do, consistent with the existing pattern, not a new one.
This gap compounds with the leader-election non-reentrancy issue described in a companion issue in this batch: if leaderAwareJob.js's accumulating-wrapper bug ever caused airdrop_expiry specifically to misbehave after a restart/reconfiguration cycle, this health-check gap is exactly what would prevent that from ever being noticed via /health.
Test/reproduction plan
jest.spyOn(wrappedAirdropExpiryJob,'getHealth').mockReturnValue({healthy: false,stalled: true,lastError: 'Horizon unreachable',lastSuccessAt: null});jest.spyOn(wrappedPriceRefreshJob,'getHealth').mockReturnValue({healthy: true,stalled: false});jest.spyOn(wrappedWebhookRetryWorker,'getHealth').mockReturnValue({healthy: true,stalled: false});constres=awaitrequest(app).get('/health');expect(res.body.status).not.toBe('ok');// currently fails — status stays 'ok'
Companion issue in this batch — "leaderAwareJob.start()/stop() is not reentrant" — a plausible real-world way airdrop_expiry specifically could end up unhealthy without anyone noticing, given this issue's gap.
Overview
GET /health's top-level aggregatestatusfield — the one value any external monitor, load balancer, or Kubernetes liveness/readiness probe would reasonably check — is computed from only two of the three leader-elected background jobs. The third,airdrop_expiry, has its detailed health computed and included in the response body, but is never folded into the aggregatestatuscalculation at all.airdropExpiryHealthis computed viawrappedAirdropExpiryJob.getHealth()and faithfully reported underjobs.airdrop_expiryin the response body — but theif (!redisConnected || !priceRefreshHealth.healthy || !webhookWorkerHealth.healthy)condition that gates the entirestatuscomputation references onlyredisConnected,priceRefreshHealth, andwebhookWorkerHealth.airdropExpiryHealthnever appears in that condition, nor in thejobsDegradedcomputation, nor in the inner ternary. If the airdrop-expiry reconciliation job stalls or dies — for example, if Horizon becomes persistently unreachable (the exact failure mode its owntick()function explicitly anticipates and logs a warning for), or if it's affected by the leader-election lifecycle bug described in a companion issue in this batch —GET /health's top-levelstatusfield will report'ok'regardless, forever, as long as Redis and the other two jobs are fine.This is precisely the failure mode a health-check aggregate exists to catch, and precisely the field any external system is most likely to check in isolation (nobody wires a liveness probe to parse
jobs.airdrop_expiry.healthyout of a nested JSON body; they check the top-levelstatusstring, or an HTTP status code derived from it). A completely dead airdrop-expiry job — meaning airdrops silently never auto-expire againstexpiry_ledgeragain, silently undoing the entire point of already-closed issue #88 — would be invisible to any monitoring built against this endpoint's primary signal, even though the detailed data proving it's broken is sitting right there in the same response, one level down, unused by the very logic whose job is to summarize it.Requirements
airdropExpiryHealthin thestatus/jobsDegradedaggregate computation on equal footing withpriceRefreshHealthandwebhookWorkerHealth.statusreflects that degradation — this is the exact scenario the current code fails silently on, and the one a test needs to target directly rather than only testing the two jobs that already participate in the aggregate.{ name, health }pairs, rather than a hand-written boolean expression enumerating each job by name — this bug is exactly the class of mistake ("added a third job but the boolean expression still only mentions two") that a loop over a list, rather than a hardcoded expression, would have made structurally impossible to reintroduce the next time a fourth leader-elected job is added.Acceptance Criteria
GET /health's top-levelstatusfield changes (to'degraded'or'unhealthy'per the existing semantics) whenairdrop_expiry's health is unhealthy/stalled, even when Redis, price-refresh, and webhook-retry-worker are all healthy.wrappedAirdropExpiryJob.getHealth()to return an unhealthy/stalled state while the other two jobs report healthy) and asserts the aggregatestatusis not'ok'.test/health.test.js.Additional Notes
More precise references
src/index.js/healthhandler: confirmedairdropExpiryHealth = wrappedAirdropExpiryJob.getHealth();is computed (alongsidepriceRefreshHealth/webhookWorkerHealth) before thestatuscomputation begins.statuscomputation'sifcondition and the nestedjobsDegradedboolean each referencepriceRefreshHealth/webhookWorkerHealth/redisConnectedexplicitly by name, and neither referencesairdropExpiryHealthanywhere.jobs.airdrop_expiryis included in the JSON response body withhealthy,stalled,last_error,leader, etc. — i.e. the data needed to fix this is already computed and available at the point thestatusvariable is assigned; this is purely a "forgot to include it in the boolean expression" gap, not a missing-data gap.Additional edge cases
healthy: falseby design (per the explicit comment insrc/index.js: "a non-leader instance reports its jobs as not healthy... but that's expected — the leader is doing the work... distinguishes 'not leader' from 'stalled' via theleaderfield"), any fix here needs to preserve that same not-leader-vs-actually-stalled distinction forairdrop_expiryspecifically — i.e. don't naively treat!airdropExpiryHealth.healthyas unconditionally bad; useairdropExpiryHealth.stalledthe same way the existing two jobs do, consistent with the existing pattern, not a new one.leaderAwareJob.js's accumulating-wrapper bug ever causedairdrop_expiryspecifically to misbehave after a restart/reconfiguration cycle, this health-check gap is exactly what would prevent that from ever being noticed via/health.Test/reproduction plan
Cross-references
airdrop_expiryspecifically could end up unhealthy without anyone noticing, given this issue's gap.