@worker-manager/metrics 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,37 @@
1
+ import type { MetricsClient } from './connection';
2
+ import { type MetricsKeys } from './keys';
3
+ export interface Retention {
4
+ /** Days of minute-level detail. Doubles as the recorder's catch-up window. */
5
+ minutes: number;
6
+ /** Days of hourly rollup. */
7
+ hours: number;
8
+ /** Days of daily totals, which is what the shipped charts read. */
9
+ days: number;
10
+ }
11
+ export declare class HistoryStore {
12
+ private readonly redis;
13
+ private readonly keys;
14
+ readonly retention: Retention;
15
+ constructor(opts: {
16
+ redis: MetricsClient;
17
+ keys: MetricsKeys;
18
+ retention: Retention;
19
+ });
20
+ upsertMinute(queue: string, metric: string, minute: number, value: number): Promise<void>;
21
+ /**
22
+ * Raw HMGET of the totals hash: `null` means the day was never recorded, a present
23
+ * string (including `'0'`) means it was. Distinguishing "missing" from "stored zero"
24
+ * matters for callers deciding between empty history vs. a zero-backfilled series.
25
+ */
26
+ readDailyTotalsRaw(queue: string, metric: string, days: string[]): Promise<(string | null)[]>;
27
+ readDayMinutes(queue: string, metric: string, day: string): Promise<Record<string, number>>;
28
+ /**
29
+ * Hourly buckets for one day, keyed by absolute hour index.
30
+ *
31
+ * Falls back to folding the minute hash when the hourly rollup is absent, which covers
32
+ * days recorded before the rollup existed. Once those minute hashes age out, the days
33
+ * they cover are already outside the minute window anyway.
34
+ */
35
+ readDayHours(queue: string, metric: string, day: string): Promise<Record<string, number>>;
36
+ private readNumericHash;
37
+ }
@@ -0,0 +1,140 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HistoryStore = void 0;
4
+ const keys_1 = require("./keys");
5
+ /**
6
+ * Atomic, idempotent upsert of one minute bucket into all three resolutions at once.
7
+ *
8
+ * KEYS[1] queue minute hash ARGV[1] minute field ARGV[5] minute-tier ttl seconds
9
+ * KEYS[2] queue hour hash ARGV[2] hour field ARGV[6] hour-tier ttl seconds
10
+ * KEYS[3] queue totals hash ARGV[3] day field ARGV[7] day-tier ttl seconds
11
+ * KEYS[4] global minute hash ARGV[4] value ARGV[8] oldest day to keep
12
+ * KEYS[5] global hour hash
13
+ * KEYS[6] global totals hash
14
+ *
15
+ * The minute hash is the ledger the whole thing is built on: `delta` is the difference
16
+ * against the value already stored there, so re-snapshotting an overlapping window (a
17
+ * restart, or a second recorder process) applies a delta of zero and the coarser tiers
18
+ * never double-count. Rolling up on write rather than compacting later keeps that property:
19
+ * one script, one delta, every resolution consistent, nothing to schedule or resume.
20
+ *
21
+ * Retention is per tier. Day-scoped keys fall off on their own, since their TTL is only
22
+ * refreshed while that day is being written, so each dies its tier's retention after the
23
+ * day it holds. The totals hashes are written every day, so their TTL keeps rolling forward
24
+ * and they would otherwise grow a field per day forever; they are trimmed here instead.
25
+ * Day fields are ISO `YYYY-MM-DD`, which sorts lexicographically, so a plain string compare
26
+ * against the cutoff is enough. Trimming only runs on the first write of a new day, which
27
+ * is about once a day per queue.
28
+ */
29
+ const UPSERT_MINUTE = `
30
+ local function trim(key, cutoff)
31
+ local fields = redis.call('HKEYS', key)
32
+ local stale = {}
33
+ for i = 1, #fields do
34
+ if fields[i] < cutoff then
35
+ stale[#stale + 1] = fields[i]
36
+ if #stale == 256 then
37
+ redis.call('HDEL', key, unpack(stale))
38
+ stale = {}
39
+ end
40
+ end
41
+ end
42
+ if #stale > 0 then
43
+ redis.call('HDEL', key, unpack(stale))
44
+ end
45
+ end
46
+
47
+ local old = tonumber(redis.call('HGET', KEYS[1], ARGV[1]) or '0')
48
+ local val = tonumber(ARGV[4])
49
+ if val == old then
50
+ return 0
51
+ end
52
+ local delta = val - old
53
+ local newDay = redis.call('HEXISTS', KEYS[3], ARGV[3]) == 0
54
+ redis.call('HSET', KEYS[1], ARGV[1], val)
55
+ redis.call('HINCRBY', KEYS[2], ARGV[2], delta)
56
+ redis.call('HINCRBY', KEYS[3], ARGV[3], delta)
57
+ redis.call('HINCRBY', KEYS[4], ARGV[1], delta)
58
+ redis.call('HINCRBY', KEYS[5], ARGV[2], delta)
59
+ redis.call('HINCRBY', KEYS[6], ARGV[3], delta)
60
+ redis.call('EXPIRE', KEYS[1], ARGV[5])
61
+ redis.call('EXPIRE', KEYS[2], ARGV[6])
62
+ redis.call('EXPIRE', KEYS[3], ARGV[7])
63
+ redis.call('EXPIRE', KEYS[4], ARGV[5])
64
+ redis.call('EXPIRE', KEYS[5], ARGV[6])
65
+ redis.call('EXPIRE', KEYS[6], ARGV[7])
66
+ if newDay then
67
+ trim(KEYS[3], ARGV[8])
68
+ trim(KEYS[6], ARGV[8])
69
+ end
70
+ return delta
71
+ `;
72
+ const SECONDS_PER_DAY = 86400;
73
+ function ttl(days) {
74
+ return String(Math.max(1, Math.floor(days * SECONDS_PER_DAY)));
75
+ }
76
+ class HistoryStore {
77
+ constructor(opts) {
78
+ this.redis = opts.redis;
79
+ this.keys = opts.keys;
80
+ this.retention = {
81
+ minutes: Math.max(1, Math.floor(opts.retention.minutes)),
82
+ hours: Math.max(1, Math.floor(opts.retention.hours)),
83
+ days: Math.max(1, Math.floor(opts.retention.days)),
84
+ };
85
+ }
86
+ async upsertMinute(queue, metric, minute, value) {
87
+ const day = (0, keys_1.minuteToDay)(minute);
88
+ await this.redis.eval(UPSERT_MINUTE, 6, this.keys.day(queue, metric, day), this.keys.hour(queue, metric, day), this.keys.totals(queue, metric), this.keys.day(keys_1.GLOBAL_QUEUE, metric, day), this.keys.hour(keys_1.GLOBAL_QUEUE, metric, day), this.keys.totals(keys_1.GLOBAL_QUEUE, metric), String(minute), String((0, keys_1.minuteToHour)(minute)), day, String(value), ttl(this.retention.minutes), ttl(this.retention.hours), ttl(this.retention.days), (0, keys_1.shiftDay)(day, -this.retention.days));
89
+ }
90
+ /**
91
+ * Raw HMGET of the totals hash: `null` means the day was never recorded, a present
92
+ * string (including `'0'`) means it was. Distinguishing "missing" from "stored zero"
93
+ * matters for callers deciding between empty history vs. a zero-backfilled series.
94
+ */
95
+ async readDailyTotalsRaw(queue, metric, days) {
96
+ if (days.length === 0) {
97
+ return [];
98
+ }
99
+ // Pass `days` as a single array (ioredis flattens one level internally) instead of
100
+ // spreading it: spreading blows the JS argument-count limit for large ranges
101
+ // ("Maximum call stack size exceeded"). The cast works around ioredis's types only
102
+ // declaring the spread overload; kept as a method call to preserve `this` binding.
103
+ const redis = this.redis;
104
+ return redis.hmget(this.keys.totals(queue, metric), days);
105
+ }
106
+ async readDayMinutes(queue, metric, day) {
107
+ return this.readNumericHash(this.keys.day(queue, metric, day));
108
+ }
109
+ /**
110
+ * Hourly buckets for one day, keyed by absolute hour index.
111
+ *
112
+ * Falls back to folding the minute hash when the hourly rollup is absent, which covers
113
+ * days recorded before the rollup existed. Once those minute hashes age out, the days
114
+ * they cover are already outside the minute window anyway.
115
+ */
116
+ async readDayHours(queue, metric, day) {
117
+ var _a;
118
+ const hours = await this.readNumericHash(this.keys.hour(queue, metric, day));
119
+ if (Object.keys(hours).length > 0) {
120
+ return hours;
121
+ }
122
+ const minutes = await this.readDayMinutes(queue, metric, day);
123
+ const folded = {};
124
+ for (const field of Object.keys(minutes)) {
125
+ const hour = String((0, keys_1.minuteToHour)(Number(field)));
126
+ folded[hour] = ((_a = folded[hour]) !== null && _a !== void 0 ? _a : 0) + minutes[field];
127
+ }
128
+ return folded;
129
+ }
130
+ async readNumericHash(key) {
131
+ const raw = await this.redis.hgetall(key);
132
+ const out = {};
133
+ for (const field of Object.keys(raw)) {
134
+ out[field] = Number(raw[field]) || 0;
135
+ }
136
+ return out;
137
+ }
138
+ }
139
+ exports.HistoryStore = HistoryStore;
140
+ //# sourceMappingURL=HistoryStore.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"HistoryStore.js","sourceRoot":"","sources":["../src/HistoryStore.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,88 @@
1
+ import type { BaseAdapter } from '@worker-manager/api/baseAdapter';
2
+ import type { MetricsClient } from './connection';
3
+ import type { MetricsKeys } from './keys';
4
+ import type { LatencyStore } from './LatencyStore';
5
+ export interface LatencySamplerOptions {
6
+ redis: MetricsClient;
7
+ keys: MetricsKeys;
8
+ store: LatencyStore;
9
+ /** Recorder tick, used to size the lease and to bound a cold start. */
10
+ tickMs: number;
11
+ /** Above this, the tick subsamples uniformly rather than fetching every job. */
12
+ maxSamplesPerTick?: number;
13
+ /**
14
+ * How far back from now a scan stops. Defaults to SAFETY_MARGIN_MS. Injectable so tests
15
+ * can set it to 0 and sample jobs that just finished, rather than sleeping past the
16
+ * margin in every case.
17
+ */
18
+ safetyMarginMs?: number;
19
+ /**
20
+ * Called with anything `sample()` swallows. The default is silent, which keeps a broken
21
+ * latency scan from taking the counter snapshot down with it, but also makes a collector
22
+ * that is failing every tick look exactly like an idle board. Supply this to tell the two
23
+ * apart. Errors thrown by the hook itself are ignored, since a throwing reporter would
24
+ * undo the containment it was added to observe.
25
+ */
26
+ onError?: (error: unknown, queueName: string) => void;
27
+ }
28
+ /**
29
+ * Copies job durations out of BullMQ's finished sets on the recorder's tick.
30
+ *
31
+ * BullMQ's moveToFinished does `ZADD targetSet, timestamp, jobId` and writes the same value
32
+ * to finishedOn, so the completed and failed sets are sorted sets scored by finish time.
33
+ * Scanning past a stored watermark therefore returns exactly the jobs finished since the
34
+ * last tick, with no gaps and no bias. The only loss is a queue whose removeOnComplete
35
+ * trims faster than the tick runs.
36
+ */
37
+ export declare class LatencySampler {
38
+ private readonly redis;
39
+ private readonly keys;
40
+ private readonly store;
41
+ private readonly tickMs;
42
+ private readonly maxSamples;
43
+ private readonly safetyMarginMs;
44
+ private readonly onError?;
45
+ private readonly id;
46
+ private readonly redisBacked;
47
+ constructor(opts: LatencySamplerOptions);
48
+ static supports(adapter: BaseAdapter): boolean;
49
+ /**
50
+ * One queue, one tick. Swallows its own errors: the counter snapshot is the more
51
+ * important metric and must not fail as collateral damage from a latency scan. Pass
52
+ * `onError` to see what was swallowed; without it a collector failing every tick is
53
+ * indistinguishable from a queue with nothing to sample.
54
+ */
55
+ sample(adapter: BaseAdapter): Promise<void>;
56
+ /** `getQueueKey` answers for a PostgreSQL-backed queue too, with keys no Redis holds. */
57
+ private isRedisBacked;
58
+ /**
59
+ * Increments are not idempotent the way the counter upsert is, so two recorders scanning
60
+ * the same range would double every histogram. Only the lease holder scans.
61
+ *
62
+ * The TTL is a crash ceiling, not the normal lifetime: a process that dies mid-scan must
63
+ * not lock the queue out forever. The normal path releases in a finally, because a lease
64
+ * outliving its scan would make the next tick no-op and halve the sampling rate.
65
+ */
66
+ private acquireLease;
67
+ /** Compare and delete, so a lease that already expired and was retaken is left alone. */
68
+ private releaseLease;
69
+ /**
70
+ * Bounded rather than eternal: an unexpiring watermark would leave one key behind per
71
+ * queue forever once that queue is purged or decommissioned, which is exactly the
72
+ * unbounded-storage failure this package exists to avoid. The day retention is already the
73
+ * horizon everything else here is bounded by. Losing the watermark just means the next
74
+ * tick cold starts, which is already a supported path.
75
+ */
76
+ private watermarkTtlSeconds;
77
+ private sampleDurations;
78
+ private flush;
79
+ /**
80
+ * The backlog is not all in one place. `wait` holds it while the queue is running, but
81
+ * pausing RENAMEs that list to `paused` and routes new jobs there, and anything added with
82
+ * a priority goes to the `prioritized` sorted set instead, which can leave `wait`
83
+ * permanently empty. Reading only `wait` reports a healthy zero for a queue that is badly
84
+ * backed up, which is the opposite of what this gauge is for, so all three are consulted
85
+ * and the worst age wins.
86
+ */
87
+ private sampleQueueAge;
88
+ }
@@ -0,0 +1,285 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LatencySampler = void 0;
4
+ const histogram_1 = require("./histogram");
5
+ const MS_PER_HOUR = 3600000;
6
+ const SECONDS_PER_DAY = 86400;
7
+ const DEFAULT_MAX_SAMPLES = 5000;
8
+ /**
9
+ * ZADD into the finished set and this scan are not atomic with respect to each other, so a
10
+ * job finishing microseconds before the scan can be absent from it. Advancing the watermark
11
+ * to the highest score seen would skip that job forever. Scanning only up to a slightly
12
+ * stale bound, and advancing the watermark to that bound rather than to what was observed,
13
+ * means anything finishing inside the margin is picked up by the next tick instead.
14
+ * Costs a few seconds of freshness in data that is bucketed hourly.
15
+ *
16
+ * The score is `Date.now()` read in the worker process and passed into BullMQ's Lua, not a
17
+ * Redis-side clock, so the margin only holds while the worker and this recorder agree on
18
+ * the time: the effective margin is `margin - skew`. A worker running far enough ahead of
19
+ * the recorder can still land jobs below an already-advanced watermark and lose them.
20
+ */
21
+ const SAFETY_MARGIN_MS = 5000;
22
+ /**
23
+ * Copies job durations out of BullMQ's finished sets on the recorder's tick.
24
+ *
25
+ * BullMQ's moveToFinished does `ZADD targetSet, timestamp, jobId` and writes the same value
26
+ * to finishedOn, so the completed and failed sets are sorted sets scored by finish time.
27
+ * Scanning past a stored watermark therefore returns exactly the jobs finished since the
28
+ * last tick, with no gaps and no bias. The only loss is a queue whose removeOnComplete
29
+ * trims faster than the tick runs.
30
+ */
31
+ class LatencySampler {
32
+ constructor(opts) {
33
+ var _a, _b;
34
+ this.id = `${process.pid}-${Math.random().toString(36).slice(2)}`;
35
+ this.redisBacked = new Map();
36
+ this.redis = opts.redis;
37
+ this.keys = opts.keys;
38
+ this.store = opts.store;
39
+ this.tickMs = opts.tickMs;
40
+ this.maxSamples = (_a = opts.maxSamplesPerTick) !== null && _a !== void 0 ? _a : DEFAULT_MAX_SAMPLES;
41
+ this.safetyMarginMs = (_b = opts.safetyMarginMs) !== null && _b !== void 0 ? _b : SAFETY_MARGIN_MS;
42
+ this.onError = opts.onError;
43
+ }
44
+ static supports(adapter) {
45
+ return typeof adapter.getQueueKey === 'function';
46
+ }
47
+ /**
48
+ * One queue, one tick. Swallows its own errors: the counter snapshot is the more
49
+ * important metric and must not fail as collateral damage from a latency scan. Pass
50
+ * `onError` to see what was swallowed; without it a collector failing every tick is
51
+ * indistinguishable from a queue with nothing to sample.
52
+ */
53
+ async sample(adapter) {
54
+ var _a;
55
+ if (!LatencySampler.supports(adapter)) {
56
+ return;
57
+ }
58
+ const name = adapter.getName();
59
+ try {
60
+ if (!(await this.isRedisBacked(adapter, name))) {
61
+ return;
62
+ }
63
+ if (!(await this.acquireLease(name))) {
64
+ return;
65
+ }
66
+ try {
67
+ await this.sampleDurations(adapter, name);
68
+ await this.sampleQueueAge(adapter, name);
69
+ }
70
+ finally {
71
+ await this.releaseLease(name);
72
+ }
73
+ }
74
+ catch (error) {
75
+ // Intentionally swallowed, see the method comment.
76
+ try {
77
+ (_a = this.onError) === null || _a === void 0 ? void 0 : _a.call(this, error, name);
78
+ }
79
+ catch {
80
+ // A reporter that throws must not resurrect the failure this catch contains.
81
+ }
82
+ }
83
+ }
84
+ /** `getQueueKey` answers for a PostgreSQL-backed queue too, with keys no Redis holds. */
85
+ async isRedisBacked(adapter, name) {
86
+ const known = this.redisBacked.get(name);
87
+ if (known !== undefined) {
88
+ return known;
89
+ }
90
+ const backed = (await adapter.getRedisInfo()) !== null;
91
+ this.redisBacked.set(name, backed);
92
+ return backed;
93
+ }
94
+ /**
95
+ * Increments are not idempotent the way the counter upsert is, so two recorders scanning
96
+ * the same range would double every histogram. Only the lease holder scans.
97
+ *
98
+ * The TTL is a crash ceiling, not the normal lifetime: a process that dies mid-scan must
99
+ * not lock the queue out forever. The normal path releases in a finally, because a lease
100
+ * outliving its scan would make the next tick no-op and halve the sampling rate.
101
+ */
102
+ async acquireLease(name) {
103
+ const held = await this.redis.set(this.keys.lease(name), this.id, 'PX', this.tickMs * 2, 'NX');
104
+ return held === 'OK';
105
+ }
106
+ /** Compare and delete, so a lease that already expired and was retaken is left alone. */
107
+ async releaseLease(name) {
108
+ await this.redis.eval(`if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) end
109
+ return 0`, 1, this.keys.lease(name), this.id);
110
+ }
111
+ /**
112
+ * Bounded rather than eternal: an unexpiring watermark would leave one key behind per
113
+ * queue forever once that queue is purged or decommissioned, which is exactly the
114
+ * unbounded-storage failure this package exists to avoid. The day retention is already the
115
+ * horizon everything else here is bounded by. Losing the watermark just means the next
116
+ * tick cold starts, which is already a supported path.
117
+ */
118
+ watermarkTtlSeconds() {
119
+ return Math.max(1, Math.floor(this.store.retention.days * SECONDS_PER_DAY));
120
+ }
121
+ async sampleDurations(adapter, name) {
122
+ var _a;
123
+ const watermarkRaw = await this.redis.get(this.keys.watermark(name));
124
+ // Cold start covers one tick ending at the safety bound rather than backfilling, since a
125
+ // first run against a large completed set would be a surprise fetch storm. Ending at the
126
+ // bound rather than at now is what keeps a tick shorter than the margin from producing a
127
+ // window that is empty on every tick, leaving the watermark stuck forever.
128
+ const watermark = watermarkRaw
129
+ ? Number(watermarkRaw)
130
+ : Date.now() - this.tickMs - this.safetyMarginMs;
131
+ const upperBound = Date.now() - this.safetyMarginMs;
132
+ if (upperBound <= watermark) {
133
+ return; // ticks closer together than the margin; next tick covers this range
134
+ }
135
+ const ids = [];
136
+ for (const set of ['completed', 'failed']) {
137
+ const found = await this.redis.zrangebyscore(adapter.getQueueKey(set), `(${watermark}`, upperBound);
138
+ ids.push(...found);
139
+ }
140
+ if (ids.length === 0) {
141
+ await this.redis.set(this.keys.watermark(name), String(upperBound), 'EX', this.watermarkTtlSeconds());
142
+ return;
143
+ }
144
+ // The id list is one cheap round trip; the HMGETs are the real cost. Above the cap take
145
+ // a uniform subset rather than the first N, which would bias towards the tick's start.
146
+ const selected = ids.length > this.maxSamples ? sampleUniformly(ids, this.maxSamples) : ids;
147
+ // Counts are scaled back up by this ratio, so a subsampled hour reads as an estimate
148
+ // with the same shape rather than a dip. The fact that it was subsampled is currently
149
+ // invisible to clients: marking it would mean persisting a flag alongside the packed
150
+ // vector, which is a storage-format change. Known follow-up.
151
+ const ratio = ids.length / selected.length;
152
+ const pipeline = this.redis.pipeline();
153
+ for (const id of selected) {
154
+ pipeline.hmget(adapter.getQueueKey(String(id)), 'timestamp', 'processedOn', 'finishedOn',
155
+ // BullMQ 5 counts attempts in `atm`; `attemptsMade` is the pre-v5 name and is read
156
+ // as a fallback exactly the way Job.fromJSON does.
157
+ 'atm', 'attemptsMade');
158
+ }
159
+ const rows = await pipeline.exec();
160
+ const runByHour = new Map();
161
+ const waitByHour = new Map();
162
+ for (const row of rows !== null && rows !== void 0 ? rows : []) {
163
+ const values = row === null || row === void 0 ? void 0 : row[1];
164
+ if (!values) {
165
+ continue;
166
+ }
167
+ const [timestamp, processedOn, finishedOn, atm, attemptsMade] = values;
168
+ if (!processedOn || !finishedOn) {
169
+ continue;
170
+ }
171
+ const finished = Number(finishedOn);
172
+ const processed = Number(processedOn);
173
+ const hour = Math.floor(finished / MS_PER_HOUR);
174
+ observe(runByHour, hour, finished - processed, ratio);
175
+ // A retried job's timestamp is its creation, but processedOn is the latest attempt,
176
+ // so wait would absorb every prior attempt and backoff. Run time is unaffected.
177
+ const attempts = Number((_a = atm !== null && atm !== void 0 ? atm : attemptsMade) !== null && _a !== void 0 ? _a : 0);
178
+ if (timestamp && attempts <= 1) {
179
+ // Every one of these is a Date.now() taken in some client process, not a Redis-side
180
+ // clock: timestamp in the producer, processedOn in the worker. Skew between the two
181
+ // can make the difference negative, hence the clamp.
182
+ observe(waitByHour, hour, Math.max(0, processed - Number(timestamp)), ratio);
183
+ }
184
+ }
185
+ await this.flush(name, 'runtime', runByHour);
186
+ await this.flush(name, 'waittime', waitByHour);
187
+ // The bound, not the highest score observed. See SAFETY_MARGIN_MS.
188
+ await this.redis.set(this.keys.watermark(name), String(upperBound), 'EX', this.watermarkTtlSeconds());
189
+ }
190
+ async flush(name, metric, byHour) {
191
+ for (const [hour, vector] of byHour) {
192
+ // Scaled counts are fractional. Left unrounded, join(',') would write seventeen
193
+ // significant digits per bucket and blow up the packed value the storage design
194
+ // depends on staying small.
195
+ await this.store.addSamples(name, metric, hour, vector.map((v) => Math.round(v)));
196
+ }
197
+ }
198
+ /**
199
+ * The backlog is not all in one place. `wait` holds it while the queue is running, but
200
+ * pausing RENAMEs that list to `paused` and routes new jobs there, and anything added with
201
+ * a priority goes to the `prioritized` sorted set instead, which can leave `wait`
202
+ * permanently empty. Reading only `wait` reports a healthy zero for a queue that is badly
203
+ * backed up, which is the opposite of what this gauge is for, so all three are consulted
204
+ * and the worst age wins.
205
+ */
206
+ async sampleQueueAge(adapter, name) {
207
+ var _a;
208
+ const hour = Math.floor(Date.now() / MS_PER_HOUR);
209
+ const candidates = await this.redis
210
+ .pipeline()
211
+ // BullMQ LPUSHes to the wait list and workers RPOPLPUSH from it, so the tail is oldest.
212
+ .lrange(adapter.getQueueKey('wait'), -1, -1)
213
+ .lrange(adapter.getQueueKey('paused'), -1, -1)
214
+ // The prioritized set is scored by priority, not by time, so neither end is guaranteed
215
+ // to hold the oldest job. Both ends are an approximation, and a cheap one: the true
216
+ // oldest would mean fetching the whole set every tick.
217
+ // String indices: ioredis v6 types `zrange`'s stop arg as string-only (Redis coerces
218
+ // either way), so numeric literals no longer typecheck. '0'/'-1' are the first/last members.
219
+ .zrange(adapter.getQueueKey('prioritized'), '0', '0')
220
+ .zrange(adapter.getQueueKey('prioritized'), '-1', '-1')
221
+ .exec();
222
+ // A pipeline reports failures per command: a Redis error arrives in the entry's error
223
+ // slot next to a null result, rather than as a throw. Reading only the result slot turns
224
+ // a failed read into an empty backlog and records an age of 0, which is the most
225
+ // reassuring number this gauge can produce, at the moment it is least entitled to. A gap
226
+ // in the series reads as "not measured" and is recoverable; a zero is a wrong answer that
227
+ // looks like a healthy one, so a tick that could not read cleanly records nothing.
228
+ if (!candidates || candidates.some(([error]) => error)) {
229
+ return;
230
+ }
231
+ const ids = new Set();
232
+ for (const entry of candidates) {
233
+ for (const id of (_a = entry[1]) !== null && _a !== void 0 ? _a : []) {
234
+ ids.add(String(id));
235
+ }
236
+ }
237
+ if (ids.size === 0) {
238
+ await this.store.recordQueueAge(name, hour, 0);
239
+ return;
240
+ }
241
+ const stamps = this.redis.pipeline();
242
+ for (const id of ids) {
243
+ stamps.hget(adapter.getQueueKey(id), 'timestamp');
244
+ }
245
+ const rows = await stamps.exec();
246
+ // Same reasoning: a failed timestamp read does not zero the gauge, it understates it,
247
+ // which is the same wrong-but-reassuring answer in a quieter form.
248
+ if (!rows || rows.some(([error]) => error)) {
249
+ return;
250
+ }
251
+ const now = Date.now();
252
+ let oldest = 0;
253
+ for (const row of rows) {
254
+ const raw = row[1];
255
+ if (!raw) {
256
+ continue;
257
+ }
258
+ const enqueuedAt = Number(raw);
259
+ if (!Number.isFinite(enqueuedAt)) {
260
+ continue;
261
+ }
262
+ oldest = Math.max(oldest, now - enqueuedAt);
263
+ }
264
+ await this.store.recordQueueAge(name, hour, Math.max(0, oldest));
265
+ }
266
+ }
267
+ exports.LatencySampler = LatencySampler;
268
+ function observe(byHour, hour, durationMs, ratio) {
269
+ let vector = byHour.get(hour);
270
+ if (!vector) {
271
+ vector = (0, histogram_1.emptyVector)();
272
+ byHour.set(hour, vector);
273
+ }
274
+ vector[(0, histogram_1.bucketIndex)(durationMs)] += ratio;
275
+ }
276
+ /** Evenly spaced pick across the list, which preserves the distribution's shape. */
277
+ function sampleUniformly(ids, target) {
278
+ const stride = ids.length / target;
279
+ const out = [];
280
+ for (let i = 0; i < target; i++) {
281
+ out.push(ids[Math.floor(i * stride)]);
282
+ }
283
+ return out;
284
+ }
285
+ //# sourceMappingURL=LatencySampler.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"LatencySampler.js","sourceRoot":"","sources":["../src/LatencySampler.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,21 @@
1
+ import type { MetricsClient } from './connection';
2
+ import type { Retention } from './HistoryStore';
3
+ import { type MetricsKeys } from './keys';
4
+ export type LatencyMetric = 'runtime' | 'waittime';
5
+ export declare const QUEUE_AGE_METRIC = "queueage";
6
+ export declare class LatencyStore {
7
+ private readonly redis;
8
+ private readonly keys;
9
+ readonly retention: Retention;
10
+ constructor(opts: {
11
+ redis: MetricsClient;
12
+ keys: MetricsKeys;
13
+ retention: Retention;
14
+ });
15
+ addSamples(queue: string, metric: LatencyMetric, hour: number, vector: number[]): Promise<void>;
16
+ recordQueueAge(queue: string, hour: number, ms: number): Promise<void>;
17
+ readRange(queue: string, metric: LatencyMetric, granularity: 'hour' | 'day', days: string[]): Promise<Record<string, number[]>>;
18
+ readQueueAge(queue: string, granularity: 'hour' | 'day', days: string[]): Promise<Record<string, number>>;
19
+ /** Epoch ms for an absolute hour index, for building response timestamps. */
20
+ static hourToMs(hour: number): number;
21
+ }