@worker-manager/metrics 1.0.1 → 1.1.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.
Files changed (62) hide show
  1. package/README.md +86 -6
  2. package/dist/HistoryAdmin.d.ts +23 -87
  3. package/dist/HistoryAdmin.js +21 -330
  4. package/dist/HistoryAdmin.js.map +1 -1
  5. package/dist/HistoryStore.d.ts +9 -9
  6. package/dist/HistoryStore.js +22 -0
  7. package/dist/HistoryStore.js.map +1 -1
  8. package/dist/LatencySampler.d.ts +27 -32
  9. package/dist/LatencySampler.js +55 -156
  10. package/dist/LatencySampler.js.map +1 -1
  11. package/dist/LatencyStore.d.ts +8 -3
  12. package/dist/LatencyStore.js +18 -0
  13. package/dist/LatencyStore.js.map +1 -1
  14. package/dist/MetricsRecorder.d.ts +34 -10
  15. package/dist/MetricsRecorder.js +30 -20
  16. package/dist/MetricsRecorder.js.map +1 -1
  17. package/dist/RedisHistoryAdmin.d.ts +95 -0
  18. package/dist/RedisHistoryAdmin.js +328 -0
  19. package/dist/RedisHistoryAdmin.js.map +1 -0
  20. package/dist/RedisMetricsHistoryProvider.d.ts +7 -17
  21. package/dist/RedisMetricsHistoryProvider.js +9 -136
  22. package/dist/RedisMetricsHistoryProvider.js.map +1 -1
  23. package/dist/RedisMetricsStore.d.ts +27 -0
  24. package/dist/RedisMetricsStore.js +42 -0
  25. package/dist/RedisMetricsStore.js.map +1 -0
  26. package/dist/StoreHistoryProvider.d.ts +27 -0
  27. package/dist/StoreHistoryProvider.js +127 -0
  28. package/dist/StoreHistoryProvider.js.map +1 -0
  29. package/dist/index.d.ts +10 -2
  30. package/dist/index.js +10 -1
  31. package/dist/index.js.map +1 -1
  32. package/dist/jobSources.d.ts +97 -0
  33. package/dist/jobSources.js +248 -0
  34. package/dist/jobSources.js.map +1 -0
  35. package/dist/keys.d.ts +7 -0
  36. package/dist/keys.js +23 -1
  37. package/dist/keys.js.map +1 -1
  38. package/dist/postgres/PostgresMetricsHistoryProvider.d.ts +37 -0
  39. package/dist/postgres/PostgresMetricsHistoryProvider.js +30 -0
  40. package/dist/postgres/PostgresMetricsHistoryProvider.js.map +1 -0
  41. package/dist/postgres/PostgresMetricsStore.d.ts +65 -0
  42. package/dist/postgres/PostgresMetricsStore.js +88 -0
  43. package/dist/postgres/PostgresMetricsStore.js.map +1 -0
  44. package/dist/postgres/admin.d.ts +31 -0
  45. package/dist/postgres/admin.js +178 -0
  46. package/dist/postgres/admin.js.map +1 -0
  47. package/dist/postgres/connection.d.ts +53 -0
  48. package/dist/postgres/connection.js +54 -0
  49. package/dist/postgres/connection.js.map +1 -0
  50. package/dist/postgres/context.d.ts +29 -0
  51. package/dist/postgres/context.js +76 -0
  52. package/dist/postgres/context.js.map +1 -0
  53. package/dist/postgres/schema.d.ts +26 -0
  54. package/dist/postgres/schema.js +148 -0
  55. package/dist/postgres/schema.js.map +1 -0
  56. package/dist/postgres/stores.d.ts +51 -0
  57. package/dist/postgres/stores.js +252 -0
  58. package/dist/postgres/stores.js.map +1 -0
  59. package/dist/store.d.ts +80 -0
  60. package/dist/store.js +3 -0
  61. package/dist/store.js.map +1 -0
  62. package/package.json +13 -3
package/README.md CHANGED
@@ -1,10 +1,11 @@
1
1
  # @worker-manager/metrics
2
2
 
3
- > Status: Beta. The API and Redis storage layout may still change in a minor release while the feature settles. It is safe to run (opt-in, and it only writes its own namespaced keys), but pin an exact version if you depend on the storage format.
3
+ > Status: Beta. The API and the Redis and PostgreSQL storage layouts may still change in a minor release while the feature settles. It is safe to run (opt-in, and it only writes its own namespaced keys), but pin an exact version if you depend on the storage format.
4
4
 
5
5
  Opt-in long-retention historical job metrics for [Worker Manager](https://github.com/naldomadeira/worker-manager).
6
6
 
7
- Snapshots native BullMQ per-minute metrics into long-retention Redis buckets and exposes a
7
+ Snapshots native BullMQ per-minute metrics into long-retention buckets, in Redis or in
8
+ PostgreSQL (see [PostgreSQL storage](#postgresql-storage)), and exposes a
8
9
  `MetricsHistoryProvider` that feeds bull-board's history charts. Everything is opt-in: the core
9
10
  `@worker-manager/api` stays stateless.
10
11
 
@@ -74,7 +75,7 @@ The CLI and the Docker image reach a cluster with `--cluster`, where `--history`
74
75
 
75
76
  Alongside the completed/failed counters, the recorder tracks two histograms per queue: wait time (`processedOn - timestamp`, how long a job sat before a worker picked it up) and run time (`finishedOn - processedOn`, how long the handler took). They diagnose different problems, so they're kept separate rather than combined into one number.
76
77
 
77
- Both are collected by scanning the completed and failed sorted sets (BullMQ scores them by finish time via `moveToFinished`'s `ZADD`) past a watermark on the recorder's existing tick, so no worker changes are needed and there's no precondition on `queue.getMetrics()`.
78
+ Both are collected by scanning the completed and failed sorted sets (BullMQ scores them by finish time via `moveToFinished`'s `ZADD`), or the `job` table of a PostgreSQL-backed queue, past a watermark on the recorder's existing tick, so no worker changes are needed and there's no precondition on `queue.getMetrics()`.
78
79
 
79
80
  The wait histogram only sees jobs that finished, so it goes quiet exactly when a queue is backed up and jobs stop finishing. A queue-age gauge (oldest job still waiting) is recorded alongside it for that reason, and the UI overlays it on the wait chart. Retries are excluded from wait time only, since `timestamp` is a job's creation but `processedOn` is its latest attempt. Percentiles are estimates bounded by bucket width; the bucket layout is fixed, not configurable, because two ranges with different layouts can't be merged into one percentile.
80
81
 
@@ -82,10 +83,88 @@ The wait histogram only sees jobs that finished, so it goes quiet exactly when a
82
83
 
83
84
  ## PostgreSQL-backed queues
84
85
 
85
- A BullMQ 6 queue backed by PostgreSQL records no history. Its `getMetrics()` reports `prevTS` as 0, which leaves the per-minute buffer undatable, so the counters are dropped rather than dated from the recorder's clock; the field is tracked in the backend's schema, so this may resolve upstream. Latency sampling needs BullMQ's Redis keys, which such a queue does not have, so it is skipped rather than recorded as an empty backlog. Redis-backed queues on the same board are unaffected.
86
+ A BullMQ 6 queue backed by PostgreSQL is recorded like any other. Its counters come from `getMetrics()`, whose per-minute buffer the adapter anchors from BullMQ's `metrics` table (BullMQ's own PostgreSQL `getMetrics()` reports `prevTS` as 0). Latency and queue age are read from BullMQ's `job` table through the queue's own pool: finished jobs by `(queue, state, finished_at_ms)`, which BullMQ indexes, and the backlog from three index probes on the ready index rather than a scan. Paused and prioritized jobs are ordinary `waiting` rows there, so both count towards the backlog, as they do on Redis.
87
+
88
+ Where a queue lives and where its history is stored are independent: PostgreSQL queues can record into Redis next to Redis queues, and the reverse works too. A board with no Redis at all stores its history in PostgreSQL, below.
89
+
90
+ ## PostgreSQL storage
91
+
92
+ For deployments that run BullMQ 6 entirely on PostgreSQL, the history can live there too. Same tiers, retention, `__global__` rollup, latency histograms and queue-age gauge as the Redis store, behind the same `MetricsHistoryProvider` contract, so the board, its charts and its storage panel cannot tell the two apart.
93
+
94
+ `pg` is an optional peer dependency: install it (`npm install pg`) only for this. Redis-only installs never load it.
95
+
96
+ import {
97
+ MetricsRecorder,
98
+ PostgresMetricsHistoryProvider,
99
+ PostgresMetricsStore,
100
+ } from '@worker-manager/metrics';
101
+
102
+ const store = new PostgresMetricsStore({
103
+ connection: process.env.DATABASE_URL, // or a pg.Pool, or a pool config
104
+ schema: 'bullmq', // default: `schema` of a pool config, then `public`
105
+ migrate: true, // create or upgrade the tables on first use
106
+ });
107
+
108
+ const recorder = new MetricsRecorder({ queues, store, retentionDays: 90 });
109
+ recorder.start();
110
+
111
+ createBullBoard({
112
+ queues,
113
+ serverAdapter,
114
+ options: { historyProvider: new PostgresMetricsHistoryProvider({ store, retentionDays: 90 }) },
115
+ });
116
+
117
+ // On shutdown: recorder.stop(), then await store.close().
118
+
119
+ `connection` takes what BullMQ's PostgreSQL backend takes: a `pg.Pool` (left open by `close()`), a node-postgres pool config, or a connection string (both of which get a pool that `close()` ends). A `schema` key in a pool config is honoured, so the object you hand BullMQ can be reused as is. The provider also accepts `{ connection, schema, tablePrefix, migrate }` directly and then owns its store (`await provider.disconnect()`). `MetricsHistoryAdmin` takes `{ store }` the same way.
120
+
121
+ A store only writes; the queues it records are whatever the recorder is given, on any datastore. Pass `onSnapshotError` to the recorder to see a tick that failed because the database was unreachable; the next tick retries.
122
+
123
+ ### Schema and migrations
124
+
125
+ Four tables, named `<tablePrefix><name>` (`tablePrefix` defaults to `bull_board_metrics_`) in `schema`:
126
+
127
+ | Table | Key | Holds |
128
+ | --- | --- | --- |
129
+ | `counters` | `(queue, metric, tier, bucket)` | completed/failed sums at minute, hour and day resolution; the queue-age max at hour and day |
130
+ | `histograms` | `(queue, metric, tier, bucket)` | runtime/waittime bucket counts, a `bigint[]` of the 18 fixed bounds, at hour and day |
131
+ | `sampler_state` | `(queue, kind)` | the sampler's lease and finish-time watermark, with an expiry |
132
+ | `meta` | `name` | `schema_version` |
133
+
134
+ `bucket` is an absolute minute, hour or day index since the epoch, UTC; `queue = '__global__'` is the cross-queue rollup. `counters` and `histograms` carry a `(tier, bucket)` index for retention.
135
+
136
+ `migrate: true` creates the schema (only when it is missing, so no database-level `CREATE` privilege is needed when it exists) and the tables on first use, in one transaction behind an advisory lock, so several processes can start at once. Without it the first query checks `schema_version` and fails with instructions if the tables are missing. Where the application role has no DDL rights, migrate from a deploy step instead:
137
+
138
+ import { migratePostgresMetrics } from '@worker-manager/metrics';
139
+ await migratePostgresMetrics({ connection: process.env.DATABASE_URL, schema: 'bullmq' });
140
+
141
+ Migrations are versioned and append-only; a database newer than the installed package is refused rather than written to. Two boards can share a database by `schema` or `tablePrefix`, the equivalent of the Redis `prefix`.
142
+
143
+ ### Idempotency and concurrency
144
+
145
+ Each snapshot of a queue's metric is one transaction: a transaction-scoped advisory lock on (tables, queue, metric), then one statement that diffs the incoming minutes against the minute rows and applies only the differences to the hour and day rows of the queue and of `__global__` (`INSERT ... ON CONFLICT DO UPDATE SET value = value + delta`). Re-snapshotting an overlapping window after a restart, or from a second recorder, applies a delta of zero, exactly like the Redis `EVAL`. Histograms merge element-wise and the queue-age gauge keeps `GREATEST`, in one multi-row upsert each. The sampler's lease is an upsert that only overwrites an expired row, on the database clock, so two recorders never both scan a queue on the same tick.
146
+
147
+ ### Retention
148
+
149
+ The same per-tier windows as Redis. Instead of TTLs, each writer deletes the rows older than each tier's window on its first write and then once per new UTC day, anchored on the day being written, which is how the Redis scripts trim the totals hashes. Expired sampler state goes at the same time. Deleted rows are reclaimed by autovacuum as usual.
150
+
151
+ ### Sizing
152
+
153
+ Every bucket is its own row, so PostgreSQL costs more per bucket than a Redis hash field. Measured on PostgreSQL 17, including the primary key and retention indexes:
154
+
155
+ | Row | On disk |
156
+ | --- | --- |
157
+ | Minute or hour counter | ~180 bytes |
158
+ | Histogram (hour or day) | ~300 bytes |
159
+
160
+ For a queue busy every minute, that is ~250 KB per metric per day of minute detail, so at the default retention (7 days of minutes, 90 of hours and days) about 6.5 MB per busy queue across both counters, both histograms and the gauge, and the same again once for the `__global__` rollup. That is several times the Redis figure below; the minute window is still the one to tune. Idle minutes are never written, on either store.
161
+
162
+ `getUsage()` (the storage panel) reports `keys` as row counts, and `bytes` as the tables' real on-disk size (`pg_total_relation_size`: heap, TOAST and indexes) apportioned to queues and tiers by each row's `pg_column_size`, so the parts add up to what the tables occupy. Dead rows count until autovacuum reclaims them. `minutes`, `days` and the day range mean what they mean on Redis. `purge()` reports minute and hour rows as `keysDeleted` and day rows as `fieldsDeleted`.
86
163
 
87
164
  ## Storage
88
165
 
166
+ These are the Redis figures; see [Sizing](#sizing) for PostgreSQL.
167
+
89
168
  Each snapshot is written at three resolutions at once, each with its own retention, because they cost very different amounts:
90
169
 
91
170
  | Tier | Default retention | Size per busy day, per queue and metric |
@@ -121,15 +200,16 @@ Retention is enforced by Redis. Day-scoped keys expire on their own TTL; the dai
121
200
  import { MetricsHistoryAdmin } from '@worker-manager/metrics';
122
201
 
123
202
  const admin = new MetricsHistoryAdmin({ connection }); // add `prefix` if the recorder has one
203
+ // or, for PostgreSQL storage: new MetricsHistoryAdmin({ store })
124
204
 
125
205
  await admin.stats(); // bytes per tier and per queue, day range
126
206
  await admin.purge(); // delete everything
127
207
  await admin.purge({ queue: 'mailer' }); // delete one queue
128
208
  await admin.purge({ before: '2026-06-01' }); // delete anything older than a day
129
209
 
130
- Both are `SCAN`-driven and confined to this package's namespace, so they never block Redis and never touch BullMQ's own keys. On a cluster they scan every master, since `SCAN` carries no key for the client to route by. Purging a single queue also subtracts it from the cross-queue rollup. Call `admin.disconnect()` when done.
210
+ On Redis both are `SCAN`-driven and confined to this package's namespace, so they never block Redis and never touch BullMQ's own keys. On a cluster they scan every master, since `SCAN` carries no key for the client to route by. Purging a single queue also subtracts it from the cross-queue rollup. Call `admin.disconnect()` when done.
131
211
 
132
- `RedisMetricsHistoryProvider` exposes the same two operations to the board, which turns them into a storage panel on the Metrics history page with a confirmation before anything is deleted.
212
+ `RedisMetricsHistoryProvider` and `PostgresMetricsHistoryProvider` expose the same two operations to the board, which turns them into a storage panel on the Metrics history page with a confirmation before anything is deleted.
133
213
 
134
214
  ## Scope
135
215
 
@@ -1,7 +1,12 @@
1
- import { type MetricsConnection } from './connection';
1
+ import type { MetricsConnection } from './connection';
2
+ import type { HistoryAdministration, MetricsStore } from './store';
3
+ export { parseHistoryKey } from './RedisHistoryAdmin';
2
4
  export interface TierStats {
3
5
  keys: number;
4
- /** Sum of `MEMORY USAGE` over this tier's keys, in bytes. */
6
+ /**
7
+ * Redis: sum of `MEMORY USAGE` over this tier's keys. PostgreSQL: this tier's share of the
8
+ * tables' on-disk size. In bytes either way.
9
+ */
5
10
  bytes: number;
6
11
  }
7
12
  export interface HistoryQueueStats {
@@ -36,99 +41,30 @@ export interface PurgeResult {
36
41
  /** Day fields removed from totals hashes. */
37
42
  fieldsDeleted: number;
38
43
  }
39
- export interface MetricsHistoryAdminOptions {
44
+ export type MetricsHistoryAdminOptions = {
45
+ /** Redis. Shorthand for `store: new RedisMetricsStore({ connection, prefix })`. */
40
46
  connection: MetricsConnection;
41
47
  /** Must match the recorder's. See `MetricsRecorderOptions.prefix`. */
42
48
  prefix?: string;
43
- }
49
+ store?: never;
50
+ } | {
51
+ /** Where the history lives, e.g. a `PostgresMetricsStore`. Left open on `disconnect()`. */
52
+ store: MetricsStore;
53
+ connection?: never;
54
+ prefix?: never;
55
+ };
44
56
  export type HistoryTier = 'minute' | 'hour' | 'day';
45
- interface ParsedKey {
46
- queue: string;
47
- metric: string;
48
- tier: HistoryTier;
49
- /** ISO day the key covers, `null` for the daily totals hash. */
50
- day: string | null;
51
- }
52
57
  /**
53
- * Parses the three key shapes from the right, because queue names may themselves contain
54
- * colons:
55
- *
56
- * <ns>:<queue>:<metric>:<day> minute buckets
57
- * <ns>:<queue>:<metric>:hour:<day> hourly rollup
58
- * <ns>:<queue>:<metric>:totals daily totals
59
- *
60
- * The metric segment is checked against the known set, so a queue named `hour` or one
61
- * ending in `:completed` still resolves correctly. Anything that doesn't fit returns null
62
- * and is then reported but never deleted, so a stray key can't be destroyed by accident.
58
+ * Inspection and cleanup for the history written by `MetricsRecorder`, in whichever store it
59
+ * lives. See `RedisHistoryAdmin` and `PostgresHistoryAdmin` for what each one measures.
63
60
  */
64
- export declare function parseHistoryKey(key: string, namespace: string): ParsedKey | null;
65
- /**
66
- * Inspection and cleanup for the Redis keys written by `MetricsRecorder`.
67
- *
68
- * Every operation is confined to the recorder's namespace and driven by SCAN, so it never
69
- * blocks Redis and never touches BullMQ's own keys. Deletes use UNLINK.
70
- */
71
- export declare class MetricsHistoryAdmin {
72
- private readonly redis;
73
- private readonly keys;
74
- private readonly ownsRedis;
61
+ export declare class MetricsHistoryAdmin implements HistoryAdministration {
62
+ private readonly admin;
63
+ /** Set only when this admin built the store itself, from a `connection`. */
64
+ private readonly ownedStore;
75
65
  constructor(opts: MetricsHistoryAdminOptions);
66
+ /** Closes the Redis connection this admin opened itself. A connection handed in is left open. */
76
67
  disconnect(): void;
77
- /**
78
- * Per-queue footprint of the stored history.
79
- *
80
- * Every key has to be measured individually, since only `MEMORY USAGE` knows what a hash
81
- * really costs. Issuing those one at a time would mean a round trip per key, which at a
82
- * 90-day retention across a dozen queues runs into the thousands, so the measurements go
83
- * out in pipelined batches instead. Still an ops-scale call rather than a hot path: it
84
- * reads the whole namespace, so it belongs behind a debug endpoint, not a poll.
85
- */
86
68
  stats(): Promise<HistoryStats>;
87
- /**
88
- * Size and entry count for each key, in pipelined batches so the cost is a handful of
89
- * round trips rather than one per key. A key that expires between the scan and the
90
- * measurement simply reads as zero rather than failing the whole call.
91
- */
92
- private measure;
93
- /**
94
- * Deletes recorded history. Purging a single queue also subtracts that queue's minutes
95
- * from the global rollup, so the cross-queue chart stays correct instead of keeping the
96
- * removed queue's throughput folded into it forever.
97
- *
98
- * That correction covers the counter metrics only. The global runtime, waittime and
99
- * queueage rollups keep the purged queue's contribution until their own retention drops
100
- * it: a packed bucket vector cannot be decremented field by field, and a max gauge has no
101
- * record of which queue produced the maximum, so there is nothing to subtract. The
102
- * per-queue keys are still deleted either way. See SUMMABLE_METRICS.
103
- */
104
69
  purge(opts?: PurgeOptions): Promise<PurgeResult>;
105
- /**
106
- * Removes one queue's buckets from the matching global key, so the cross-queue series
107
- * reflects the queues that are left rather than keeping the removed queue folded in.
108
- * Each tier is corrected from its own source key, because the tiers have independent
109
- * retention and the minute hash may already be gone while the hourly one survives.
110
- * Fields that drain to zero are dropped: the recorder never writes a zero bucket, so a
111
- * leftover zero would read as recorded-but-idle instead of not recorded.
112
- * Returns the number of global keys it deleted.
113
- *
114
- * Only the summable metrics are touched; the latency ones are skipped outright rather than
115
- * silently producing a no-op subtraction of their packed values. See SUMMABLE_METRICS.
116
- */
117
- private subtractDayFromGlobal;
118
- /**
119
- * Same idea for the daily rollup: the global totals hash is the sum of the per-queue
120
- * totals hashes, so it is corrected from those rather than re-derived from day hashes,
121
- * which may already have expired. Returns the number of global fields it removed.
122
- *
123
- * Skips the latency metrics for the same reason as subtractDayFromGlobal.
124
- */
125
- private subtractTotalsFromGlobal;
126
- /**
127
- * SCAN over the namespace, once per master: SCAN carries no key, so a cluster client has
128
- * no slot to route by and would answer from one arbitrary node. SCAN may also hand back the
129
- * same key on more than one cursor iteration, which would double-count in `stats()`, so
130
- * emissions are de-duped here. The set is bounded by queues x metrics x retention days.
131
- */
132
- private scan;
133
70
  }
134
- export {};
@@ -1,344 +1,35 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.MetricsHistoryAdmin = void 0;
4
- exports.parseHistoryKey = parseHistoryKey;
5
- const connection_1 = require("./connection");
6
- const keys_1 = require("./keys");
7
- const SCAN_COUNT = 500;
8
- const BATCH = 256;
9
- /** Keys per pipelined `stats()` batch. Each key costs two commands. */
10
- const MEASURE_BATCH = 250;
11
- const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
12
- const METRICS = ['completed', 'failed', 'runtime', 'waittime', 'queueage'];
3
+ exports.MetricsHistoryAdmin = exports.parseHistoryKey = void 0;
4
+ const RedisMetricsStore_1 = require("./RedisMetricsStore");
5
+ var RedisHistoryAdmin_1 = require("./RedisHistoryAdmin");
6
+ Object.defineProperty(exports, "parseHistoryKey", { enumerable: true, get: function () { return RedisHistoryAdmin_1.parseHistoryKey; } });
13
7
  /**
14
- * Metrics whose global rollup is a plain summable counter, so one queue's share can be taken
15
- * back out of it with HINCRBY. The latency metrics are deliberately absent: runtime and
16
- * waittime pack a whole bucket vector into a single field and queueage holds a max gauge,
17
- * and neither can be corrected by a scalar decrement. See `purge`.
18
- */
19
- const SUMMABLE_METRICS = ['completed', 'failed'];
20
- /**
21
- * Parses the three key shapes from the right, because queue names may themselves contain
22
- * colons:
23
- *
24
- * <ns>:<queue>:<metric>:<day> minute buckets
25
- * <ns>:<queue>:<metric>:hour:<day> hourly rollup
26
- * <ns>:<queue>:<metric>:totals daily totals
27
- *
28
- * The metric segment is checked against the known set, so a queue named `hour` or one
29
- * ending in `:completed` still resolves correctly. Anything that doesn't fit returns null
30
- * and is then reported but never deleted, so a stray key can't be destroyed by accident.
31
- */
32
- function parseHistoryKey(key, namespace) {
33
- const prefix = `${namespace}:`;
34
- if (!key.startsWith(prefix)) {
35
- return null;
36
- }
37
- const parts = key.slice(prefix.length).split(':');
38
- if (parts.length < 3) {
39
- return null;
40
- }
41
- const last = parts[parts.length - 1];
42
- if (last === 'totals' && METRICS.includes(parts[parts.length - 2])) {
43
- const queue = parts.slice(0, -2).join(':');
44
- return queue ? { queue, metric: parts[parts.length - 2], tier: 'day', day: null } : null;
45
- }
46
- if (!DAY_PATTERN.test(last)) {
47
- return null;
48
- }
49
- if (parts.length >= 4 && parts[parts.length - 2] === keys_1.HOUR_TIER) {
50
- const metric = parts[parts.length - 3];
51
- const queue = parts.slice(0, -3).join(':');
52
- return queue && METRICS.includes(metric) ? { queue, metric, tier: 'hour', day: last } : null;
53
- }
54
- const metric = parts[parts.length - 2];
55
- const queue = parts.slice(0, -2).join(':');
56
- return queue && METRICS.includes(metric) ? { queue, metric, tier: 'minute', day: last } : null;
57
- }
58
- function emptyTiers() {
59
- return {
60
- minute: { keys: 0, bytes: 0 },
61
- hour: { keys: 0, bytes: 0 },
62
- day: { keys: 0, bytes: 0 },
63
- };
64
- }
65
- /** The global rollup key mirroring a per-queue key, same tier and same day. */
66
- function globalKeyFor(keys, parsed) {
67
- if (parsed.day === null) {
68
- return keys.totals(keys_1.GLOBAL_QUEUE, parsed.metric);
69
- }
70
- return parsed.tier === 'hour'
71
- ? keys.hour(keys_1.GLOBAL_QUEUE, parsed.metric, parsed.day)
72
- : keys.day(keys_1.GLOBAL_QUEUE, parsed.metric, parsed.day);
73
- }
74
- function toDay(value) {
75
- if (typeof value === 'string') {
76
- if (!DAY_PATTERN.test(value)) {
77
- throw new Error(`Expected a YYYY-MM-DD day or a Date, got "${value}"`);
78
- }
79
- return value;
80
- }
81
- return value.toISOString().slice(0, 10);
82
- }
83
- /**
84
- * Inspection and cleanup for the Redis keys written by `MetricsRecorder`.
85
- *
86
- * Every operation is confined to the recorder's namespace and driven by SCAN, so it never
87
- * blocks Redis and never touches BullMQ's own keys. Deletes use UNLINK.
8
+ * Inspection and cleanup for the history written by `MetricsRecorder`, in whichever store it
9
+ * lives. See `RedisHistoryAdmin` and `PostgresHistoryAdmin` for what each one measures.
88
10
  */
89
11
  class MetricsHistoryAdmin {
90
12
  constructor(opts) {
91
- const { client, owned } = (0, connection_1.resolveClient)(opts.connection);
92
- this.redis = client;
93
- this.ownsRedis = owned;
94
- this.keys = (0, keys_1.metricsKeys)((0, keys_1.resolveNamespace)(opts.prefix, (0, connection_1.isCluster)(client)));
95
- }
96
- disconnect() {
97
- if (this.ownsRedis) {
98
- this.redis.disconnect();
99
- }
100
- }
101
- /**
102
- * Per-queue footprint of the stored history.
103
- *
104
- * Every key has to be measured individually, since only `MEMORY USAGE` knows what a hash
105
- * really costs. Issuing those one at a time would mean a round trip per key, which at a
106
- * 90-day retention across a dozen queues runs into the thousands, so the measurements go
107
- * out in pipelined batches instead. Still an ops-scale call rather than a hot path: it
108
- * reads the whole namespace, so it belongs behind a debug endpoint, not a poll.
109
- */
110
- async stats() {
111
- var _a;
112
- const byQueue = new Map();
113
- const tiers = emptyTiers();
114
- let keys = 0;
115
- let bytes = 0;
116
- let minutes = 0;
117
- let oldestDay = null;
118
- let newestDay = null;
119
- const found = [];
120
- for await (const key of this.scan()) {
121
- const parsed = parseHistoryKey(key, this.keys.namespace);
122
- if (parsed) {
123
- found.push({ key, parsed });
124
- }
125
- }
126
- const measurements = await this.measure(found.map((item) => item.key));
127
- for (const [index, { parsed }] of found.entries()) {
128
- const { size, len } = measurements[index];
129
- const entry = (_a = byQueue.get(parsed.queue)) !== null && _a !== void 0 ? _a : {
130
- queue: parsed.queue,
131
- keys: 0,
132
- bytes: 0,
133
- minutes: 0,
134
- days: [],
135
- tiers: emptyTiers(),
136
- };
137
- entry.keys += 1;
138
- entry.bytes += size;
139
- entry.tiers[parsed.tier].keys += 1;
140
- entry.tiers[parsed.tier].bytes += size;
141
- keys += 1;
142
- bytes += size;
143
- tiers[parsed.tier].keys += 1;
144
- tiers[parsed.tier].bytes += size;
145
- if (parsed.tier === 'minute') {
146
- entry.minutes += len;
147
- minutes += len;
148
- }
149
- if (parsed.day) {
150
- entry.days.push(parsed.day);
151
- if (oldestDay === null || parsed.day < oldestDay) {
152
- oldestDay = parsed.day;
153
- }
154
- if (newestDay === null || parsed.day > newestDay) {
155
- newestDay = parsed.day;
156
- }
157
- }
158
- byQueue.set(parsed.queue, entry);
159
- }
160
- const queues = [...byQueue.values()].sort((a, b) => b.bytes - a.bytes);
161
- for (const queue of queues) {
162
- queue.days = [...new Set(queue.days)].sort();
163
- }
164
- return { keys, bytes, minutes, oldestDay, newestDay, tiers, queues };
165
- }
166
- /**
167
- * Size and entry count for each key, in pipelined batches so the cost is a handful of
168
- * round trips rather than one per key. A key that expires between the scan and the
169
- * measurement simply reads as zero rather than failing the whole call.
170
- */
171
- async measure(keys) {
172
- var _a, _b, _c, _d;
173
- const out = [];
174
- for (let i = 0; i < keys.length; i += MEASURE_BATCH) {
175
- const chunk = keys.slice(i, i + MEASURE_BATCH);
176
- const pipeline = this.redis.pipeline();
177
- for (const key of chunk) {
178
- pipeline.memory('USAGE', key);
179
- pipeline.hlen(key);
180
- }
181
- const res = await pipeline.exec();
182
- for (let j = 0; j < chunk.length; j++) {
183
- out.push({
184
- size: Number((_b = (_a = res === null || res === void 0 ? void 0 : res[j * 2]) === null || _a === void 0 ? void 0 : _a[1]) !== null && _b !== void 0 ? _b : 0) || 0,
185
- len: Number((_d = (_c = res === null || res === void 0 ? void 0 : res[j * 2 + 1]) === null || _c === void 0 ? void 0 : _c[1]) !== null && _d !== void 0 ? _d : 0) || 0,
186
- });
187
- }
188
- }
189
- return out;
190
- }
191
- /**
192
- * Deletes recorded history. Purging a single queue also subtracts that queue's minutes
193
- * from the global rollup, so the cross-queue chart stays correct instead of keeping the
194
- * removed queue's throughput folded into it forever.
195
- *
196
- * That correction covers the counter metrics only. The global runtime, waittime and
197
- * queueage rollups keep the purged queue's contribution until their own retention drops
198
- * it: a packed bucket vector cannot be decremented field by field, and a max gauge has no
199
- * record of which queue produced the maximum, so there is nothing to subtract. The
200
- * per-queue keys are still deleted either way. See SUMMABLE_METRICS.
201
- */
202
- async purge(opts = {}) {
203
- const before = opts.before === undefined ? null : toDay(opts.before);
204
- const result = { keysDeleted: 0, fieldsDeleted: 0 };
205
- const dayKeys = [];
206
- const totalsKeys = [];
207
- for await (const key of this.scan()) {
208
- const parsed = parseHistoryKey(key, this.keys.namespace);
209
- if (!parsed) {
210
- continue;
211
- }
212
- if (opts.queue !== undefined && parsed.queue !== opts.queue) {
213
- continue;
214
- }
215
- if (parsed.day === null) {
216
- totalsKeys.push({ key, parsed });
217
- }
218
- else if (before === null || parsed.day < before) {
219
- dayKeys.push({ key, parsed });
220
- }
13
+ if (opts.store) {
14
+ this.admin = opts.store.administration();
15
+ this.ownedStore = null;
221
16
  }
222
- // Rewriting the global rollup only makes sense when a single queue is being removed:
223
- // a full purge drops the global keys outright, along with everything else.
224
- const adjustGlobal = opts.queue !== undefined && opts.queue !== keys_1.GLOBAL_QUEUE;
225
- for (const { key, parsed } of dayKeys) {
226
- if (adjustGlobal) {
227
- result.keysDeleted += await this.subtractDayFromGlobal(key, parsed);
228
- }
229
- result.keysDeleted += await this.redis.unlink(key);
17
+ else {
18
+ const store = new RedisMetricsStore_1.RedisMetricsStore({ connection: opts.connection, prefix: opts.prefix });
19
+ this.admin = store.administration();
20
+ this.ownedStore = store;
230
21
  }
231
- for (const { key, parsed } of totalsKeys) {
232
- const stale = (await this.redis.hkeys(key)).filter((day) => before === null || day < before);
233
- if (adjustGlobal && stale.length > 0) {
234
- const totals = await this.redis.hmget(key, ...stale);
235
- result.fieldsDeleted += await this.subtractTotalsFromGlobal(parsed.metric, stale, totals);
236
- }
237
- if (before === null) {
238
- result.keysDeleted += await this.redis.unlink(key);
239
- continue;
240
- }
241
- for (let i = 0; i < stale.length; i += BATCH) {
242
- result.fieldsDeleted += await this.redis.hdel(key, ...stale.slice(i, i + BATCH));
243
- }
244
- if ((await this.redis.hlen(key)) === 0) {
245
- result.keysDeleted += await this.redis.unlink(key);
246
- }
247
- }
248
- return result;
249
22
  }
250
- /**
251
- * Removes one queue's buckets from the matching global key, so the cross-queue series
252
- * reflects the queues that are left rather than keeping the removed queue folded in.
253
- * Each tier is corrected from its own source key, because the tiers have independent
254
- * retention and the minute hash may already be gone while the hourly one survives.
255
- * Fields that drain to zero are dropped: the recorder never writes a zero bucket, so a
256
- * leftover zero would read as recorded-but-idle instead of not recorded.
257
- * Returns the number of global keys it deleted.
258
- *
259
- * Only the summable metrics are touched; the latency ones are skipped outright rather than
260
- * silently producing a no-op subtraction of their packed values. See SUMMABLE_METRICS.
261
- */
262
- async subtractDayFromGlobal(key, parsed) {
263
- if (parsed.day === null || !SUMMABLE_METRICS.includes(parsed.metric)) {
264
- return 0;
265
- }
266
- const minutes = await this.redis.hgetall(key);
267
- const globalDay = globalKeyFor(this.keys, parsed);
268
- const pipeline = this.redis.multi();
269
- const touched = [];
270
- for (const field of Object.keys(minutes)) {
271
- const value = Number(minutes[field]) || 0;
272
- if (value === 0) {
273
- continue;
274
- }
275
- touched.push(field);
276
- pipeline.hincrby(globalDay, field, -value);
277
- }
278
- if (touched.length === 0) {
279
- return 0;
280
- }
281
- const res = await pipeline.exec();
282
- const drained = touched.filter((_, i) => { var _a, _b; return Number((_b = (_a = res === null || res === void 0 ? void 0 : res[i]) === null || _a === void 0 ? void 0 : _a[1]) !== null && _b !== void 0 ? _b : 0) <= 0; });
283
- for (let i = 0; i < drained.length; i += BATCH) {
284
- await this.redis.hdel(globalDay, ...drained.slice(i, i + BATCH));
285
- }
286
- return (await this.redis.hlen(globalDay)) === 0 ? await this.redis.unlink(globalDay) : 0;
23
+ /** Closes the Redis connection this admin opened itself. A connection handed in is left open. */
24
+ disconnect() {
25
+ var _a;
26
+ void ((_a = this.ownedStore) === null || _a === void 0 ? void 0 : _a.close());
287
27
  }
288
- /**
289
- * Same idea for the daily rollup: the global totals hash is the sum of the per-queue
290
- * totals hashes, so it is corrected from those rather than re-derived from day hashes,
291
- * which may already have expired. Returns the number of global fields it removed.
292
- *
293
- * Skips the latency metrics for the same reason as subtractDayFromGlobal.
294
- */
295
- async subtractTotalsFromGlobal(metric, days, values) {
296
- if (!SUMMABLE_METRICS.includes(metric)) {
297
- return 0;
298
- }
299
- const globalTotals = this.keys.totals(keys_1.GLOBAL_QUEUE, metric);
300
- const pipeline = this.redis.multi();
301
- const touched = [];
302
- days.forEach((day, i) => {
303
- const value = Number(values[i]) || 0;
304
- if (value === 0) {
305
- return;
306
- }
307
- touched.push(day);
308
- pipeline.hincrby(globalTotals, day, -value);
309
- });
310
- if (touched.length === 0) {
311
- return 0;
312
- }
313
- const res = await pipeline.exec();
314
- const drained = touched.filter((_, i) => { var _a, _b; return Number((_b = (_a = res === null || res === void 0 ? void 0 : res[i]) === null || _a === void 0 ? void 0 : _a[1]) !== null && _b !== void 0 ? _b : 0) <= 0; });
315
- let removed = 0;
316
- for (let i = 0; i < drained.length; i += BATCH) {
317
- removed += await this.redis.hdel(globalTotals, ...drained.slice(i, i + BATCH));
318
- }
319
- return removed;
28
+ stats() {
29
+ return this.admin.stats();
320
30
  }
321
- /**
322
- * SCAN over the namespace, once per master: SCAN carries no key, so a cluster client has
323
- * no slot to route by and would answer from one arbitrary node. SCAN may also hand back the
324
- * same key on more than one cursor iteration, which would double-count in `stats()`, so
325
- * emissions are de-duped here. The set is bounded by queues x metrics x retention days.
326
- */
327
- async *scan() {
328
- const seen = new Set();
329
- for (const target of (0, connection_1.scanTargets)(this.redis)) {
330
- let cursor = '0';
331
- do {
332
- const [next, batch] = await target.scan(cursor, 'MATCH', this.keys.scanPattern, 'COUNT', SCAN_COUNT);
333
- cursor = next;
334
- for (const key of batch) {
335
- if (!seen.has(key)) {
336
- seen.add(key);
337
- yield key;
338
- }
339
- }
340
- } while (cursor !== '0');
341
- }
31
+ purge(opts = {}) {
32
+ return this.admin.purge(opts);
342
33
  }
343
34
  }
344
35
  exports.MetricsHistoryAdmin = MetricsHistoryAdmin;