@@ -19,10 +19,30 @@ export type HealthInput = {
1919 * now = live env-level depth; normal = 7d baseline (omitted on the snapshot path, which has
2020 * no real 7d pending baseline — so we never mislabel a live-window average as "7d normal");
2121 * series measured (v2) or estimated (v1).
22+ *
23+ * `availability: "unknown"` = the depth could NOT be measured at all (Redis down with no
24+ * measured fallback, or the measured source failed for an unrecognized reason). `now` is then
25+ * a placeholder, NEVER a confident 0 — flow is reported unassessable instead of healthy.
2226 */
23- pending : { now : number ; normal ?: number ; series : number [ ] ; estimated : boolean } ;
27+ pending : {
28+ now : number ;
29+ normal ?: number ;
30+ series : number [ ] ;
31+ estimated : boolean ;
32+ availability ?: "measured" | "unknown" ;
33+ } ;
2434 startLatency : { p95Ms : number ; normalP95Ms : number ; series : number [ ] } ;
25- throughput : { donePerMin : number ; triggeredPerMin : number ; normalTriggeredPerMin : number } ;
35+ /**
36+ * finishedPerMin = ALL terminal runs per minute — the rate work actually LEAVES the queue, so
37+ * it's what net/drain math uses. completedPerMin (successes only) is an execution-side metric
38+ * and would understate the drain rate whenever runs fail/expire/cancel.
39+ */
40+ throughput : {
41+ finishedPerMin : number ;
42+ completedPerMin : number ;
43+ triggeredPerMin : number ;
44+ normalTriggeredPerMin : number ;
45+ } ;
2646 failures : { rate : number ; normalRate : number ; series : number [ ] } ;
2747 duration : { p95Ms : number ; normalP95Ms : number } ;
2848 /** Age of the freshest telemetry (ms). null = no signal to assess -> freshness unknown. */
@@ -33,6 +53,18 @@ export type HealthInput = {
3353 */
3454 flowEvidence : {
3555 runningSeries : number [ ] ;
56+ /**
57+ * Epoch ms of each `runningSeries` bucket, aligned by index. Empty/absent when the source
58+ * can't say (snapshot path) — then contiguity falls back to index adjacency.
59+ */
60+ runningBucketsMs ?: number [ ] ;
61+ /**
62+ * Bucket cadence of `runningSeries` and how many buckets the window SHOULD contain. The rows
63+ * are NOT gap-filled, so this is the only way to tell "pinned for 60 of 60 minutes" from
64+ * "two fresh samples arrived in a 60-minute window". Absent = cadence unknown; the legacy
65+ * gap-free assumption then applies (received buckets spread evenly over the window).
66+ */
67+ sampling ?: { bucketMinutes : number ; expectedBuckets : number } | null ;
3668 envLimit : number ;
3769 throttledShare : number ;
3870 worstQueue : { name : string ; share : number } | null ;
@@ -67,6 +99,12 @@ export const HEALTH_THRESHOLDS = {
6799 // trigger_surge: with NO usable baseline (normal 0), a multiplier is meaningless, so an
68100 // absolute floor picks the "new volume" cause instead of dropping to the v1 fallback.
69101 surgePerMin : 100 ,
102+ // Minimum share of the window's EXPECTED buckets that must have arrived before a
103+ // concurrency-shaped cause (pin / stall) may be named. Below it the series is too gappy to
104+ // support a cause or a duration, so flow drops to a symptom-level verdict. Metric buckets are
105+ // produced by queue activity rather than a heartbeat, so a sparse window is normal for a quiet
106+ // env — and a saturation/stall claim isn't supportable there anyway.
107+ minCoverage : 0.5 ,
70108 } ,
71109 attribution : { minShare : 0.5 } , // name a queue/task/region only when it owns >= half the problem
72110} ;
@@ -99,6 +137,55 @@ function multiplierSeverity(
99137 return classifySeverity ( value / normal , { warn : warnMult , crit : critMult } ) ;
100138}
101139
140+ /** True when the backlog depth could not be measured — `pending.now` is a placeholder. */
141+ export function isPendingUnknown ( input : HealthInput ) : boolean {
142+ return input . pending . availability === "unknown" ;
143+ }
144+
145+ export type BucketCoverage = {
146+ /** buckets the window should contain at the source's cadence. */
147+ expectedBuckets : number ;
148+ /** buckets that actually arrived. */
149+ receivedBuckets : number ;
150+ /** minutes per bucket. */
151+ bucketMinutes : number ;
152+ /** received / expected. 1 when the cadence is unknown (legacy gap-free assumption). */
153+ coverage : number ;
154+ /** enough of the window arrived to support a cause + a duration. */
155+ sufficient : boolean ;
156+ /** true when the source told us its cadence (so gaps are detectable at all). */
157+ known : boolean ;
158+ } ;
159+
160+ /**
161+ * Coverage of the running series: how much of the window actually arrived. Without the source's
162+ * cadence we can only assume the received buckets span the window evenly (the pre-existing
163+ * assumption); with it, a gappy feed is visible and shares are expressed against EXPECTED buckets.
164+ */
165+ export function bucketCoverage ( input : HealthInput ) : BucketCoverage {
166+ const received = input . flowEvidence . runningSeries . length ;
167+ const sampling = input . flowEvidence . sampling ;
168+ if ( ! sampling || sampling . expectedBuckets <= 0 ) {
169+ return {
170+ expectedBuckets : received ,
171+ receivedBuckets : received ,
172+ bucketMinutes : received > 0 ? input . windowMinutes / received : 0 ,
173+ coverage : 1 ,
174+ sufficient : true ,
175+ known : false ,
176+ } ;
177+ }
178+ const coverage = received / sampling . expectedBuckets ;
179+ return {
180+ expectedBuckets : sampling . expectedBuckets ,
181+ receivedBuckets : received ,
182+ bucketMinutes : sampling . bucketMinutes ,
183+ coverage,
184+ sufficient : coverage >= HEALTH_THRESHOLDS . flowCause . minCoverage ,
185+ known : true ,
186+ } ;
187+ }
188+
102189/** Look up a metric by id; throws if absent (buildMetrics guarantees the standard set exists). */
103190export function metricById ( metrics : Metric [ ] , id : string ) : Metric {
104191 const m = metrics . find ( ( x ) => x . id === id ) ;
@@ -133,32 +220,44 @@ export function buildMetrics(input: HealthInput): Metric[] {
133220 ) ,
134221 } ;
135222
223+ // Unmeasurable depth: `now` is a placeholder, so it must not be CLASSIFIED (a placeholder 0
224+ // would read as a confident "no backlog" green). `availability: "unknown"` says so, and the
225+ // flow analyzer turns it into an unassessable verdict.
226+ const pendingUnknown = isPendingUnknown ( input ) ;
136227 const pending : Metric = {
137228 id : "pending" ,
138229 value : input . pending . now ,
139230 unit : "count" ,
231+ availability : pendingUnknown ? "unknown" : "measured" ,
140232 normal : input . pending . normal ,
141- delta : delta ( input . pending . now , input . pending . normal ) ,
233+ delta : pendingUnknown ? undefined : delta ( input . pending . now , input . pending . normal ) ,
142234 series : {
143235 points : input . pending . series ,
144236 kind : input . pending . estimated ? "estimated" : "measured" ,
145237 } ,
146- severity : multiplierSeverity (
147- input . pending . now ,
148- input . pending . normal ,
149- t . pending . warnMult ,
150- t . pending . critMult ,
151- t . pending . floor
152- ) ,
238+ severity : pendingUnknown
239+ ? "ok"
240+ : multiplierSeverity (
241+ input . pending . now ,
242+ input . pending . normal ,
243+ t . pending . warnMult ,
244+ t . pending . critMult ,
245+ t . pending . floor
246+ ) ,
153247 } ;
154248
155- const net = input . throughput . donePerMin - input . throughput . triggeredPerMin ;
249+ // Net drain uses FINISHED (all terminal) runs — every terminal run leaves the queue, so
250+ // completions alone would show a permanent deficit on any env with failures.
251+ const net = input . throughput . finishedPerMin - input . throughput . triggeredPerMin ;
156252 const throughput : Metric = {
157253 id : "throughput" ,
158254 value : net ,
159255 unit : "perMin" ,
160256 aggregation : "rate" ,
161- breakdown : { done : input . throughput . donePerMin , triggered : input . throughput . triggeredPerMin } ,
257+ breakdown : {
258+ done : input . throughput . finishedPerMin ,
259+ triggered : input . throughput . triggeredPerMin ,
260+ } ,
162261 severity : net < 0 && isPendingIncreasing ( input . pending . series ) ? "warn" : "ok" ,
163262 } ;
164263
@@ -262,10 +361,15 @@ export function buildMetrics(input: HealthInput): Metric[] {
262361// ---------------------------------------------------------------------------
263362
264363export function computeDrain ( input : HealthInput ) : { drainMinutes : number ; isDrainable : boolean } {
265- const donePerMin = input . throughput . donePerMin ;
266- const drainMinutes = donePerMin === 0 ? Number . POSITIVE_INFINITY : input . pending . now / donePerMin ;
364+ // Drain rate = runs LEAVING the queue (all terminal), not just successful completions.
365+ const finishedPerMin = input . throughput . finishedPerMin ;
366+ const drainMinutes =
367+ finishedPerMin === 0 ? Number . POSITIVE_INFINITY : input . pending . now / finishedPerMin ;
267368 return {
268369 drainMinutes,
269- isDrainable : drainMinutes < HEALTH_THRESHOLDS . flowPolicy . drainCritMinutes ,
370+ // An unmeasurable depth can't produce an ETA — never offer "do nothing, it drains" off a
371+ // placeholder.
372+ isDrainable :
373+ ! isPendingUnknown ( input ) && drainMinutes < HEALTH_THRESHOLDS . flowPolicy . drainCritMinutes ,
270374 } ;
271375}
0 commit comments