pipework 0.173.0 → 0.174.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.
@@ -1 +1 @@
1
- {"version":3,"file":"namespace.js","sourceRoot":"","sources":["../../../src/infra/alerts/namespace.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAC1C,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAA;AACtC,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAA;AACjD,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AACpD,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAA;AACvD,OAAO,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,aAAa,EAAE,YAAY,EAAE,oBAAoB,EAAE,eAAe,EAAE,iBAAiB,EAAE,SAAS,EAAE,MAAM,cAAc,CAAA;AAEpK,oHAAoH;AACpH,MAAM,CAAC,MAAM,MAAM,GAAG;IACpB,gCAAgC;IAChC,MAAM,EAAE,YAAY;IACpB,0HAA0H;IAC1H,IAAI,EAAE,UAAU;IAChB,wLAAwL;IACxL,YAAY;IACZ,oHAAoH;IACpH,aAAa,EAAE,iBAAiB;IAChC,2DAA2D;IAC3D,OAAO,EAAE,WAAW;IACpB,+DAA+D;IAC/D,MAAM,EAAE,UAAU;IAClB,kJAAkJ;IAClJ,SAAS;IACT,MAAM,EAAE;QACN,4EAA4E;QAC5E,UAAU,EAAE,gBAAgB;QAC5B,iFAAiF;QACjF,WAAW,EAAE,iBAAiB;QAC9B,mGAAmG;QACnG,OAAO,EAAE,aAAa;QACtB,yGAAyG;QACzG,cAAc,EAAE,oBAAoB;QACpC,yDAAyD;QACzD,MAAM,EAAE,YAAY;QACpB,6IAA6I;QAC7I,SAAS,EAAE,eAAe;QAC1B,gNAAgN;QAChN,WAAW,EAAE,iBAAiB;KAC/B;CACO,CAAA"}
1
+ {"version":3,"file":"namespace.js","sourceRoot":"","sources":["../../../src/infra/alerts/namespace.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAC1C,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAA;AACtC,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAA;AACjD,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AACpD,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAA;AACvD,OAAO,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,aAAa,EAAE,YAAY,EAAE,oBAAoB,EAAE,eAAe,EAAE,iBAAiB,EAAE,SAAS,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,cAAc,CAAA;AAChM,OAAO,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAA;AAEjG,oHAAoH;AACpH,MAAM,CAAC,MAAM,MAAM,GAAG;IACpB,gCAAgC;IAChC,MAAM,EAAE,YAAY;IACpB,0HAA0H;IAC1H,IAAI,EAAE,UAAU;IAChB,wLAAwL;IACxL,YAAY;IACZ,oHAAoH;IACpH,aAAa,EAAE,iBAAiB;IAChC,2DAA2D;IAC3D,OAAO,EAAE,WAAW;IACpB,+DAA+D;IAC/D,MAAM,EAAE,UAAU;IAClB,kJAAkJ;IAClJ,SAAS;IACT,MAAM,EAAE;QACN,4EAA4E;QAC5E,UAAU,EAAE,gBAAgB;QAC5B,iFAAiF;QACjF,WAAW,EAAE,iBAAiB;QAC9B,mGAAmG;QACnG,OAAO,EAAE,aAAa;QACtB,yGAAyG;QACzG,cAAc,EAAE,oBAAoB;QACpC,yDAAyD;QACzD,MAAM,EAAE,YAAY;QACpB,6IAA6I;QAC7I,SAAS,EAAE,eAAe;QAC1B,2HAA2H;QAC3H,KAAK,EAAE,WAAW;QAClB,iIAAiI;QACjI,OAAO,EAAE,aAAa;QACtB,0IAA0I;QAC1I,cAAc,EAAE,oBAAoB;QACpC,yJAAyJ;QACzJ,aAAa,EAAE,mBAAmB;QAClC,gIAAgI;QAChI,OAAO,EAAE,aAAa;QACtB,gNAAgN;QAChN,WAAW,EAAE,iBAAiB;KAC/B;CACO,CAAA"}
@@ -1,4 +1,5 @@
1
1
  import type { HealthCheck } from '../../core/lifecycle/types.js';
2
+ import type { SQL } from '../../data/db/index.js';
2
3
  import type { Manifold } from '../../core/pipework.js';
3
4
  import type { AlertSeverity, JsonValue } from './handle.js';
4
5
  /** A condition a source finds true right now. */
@@ -29,12 +30,74 @@ interface TableOption {
29
30
  readonly table?: string;
30
31
  readonly database?: string;
31
32
  }
33
+ type Row = Record<string, unknown>;
34
+ interface SourceBasics {
35
+ readonly severity?: AlertSeverity;
36
+ readonly owner?: string;
37
+ readonly runbook?: string;
38
+ readonly database?: string;
39
+ }
40
+ /** One source from several: it evaluates each in turn and owns what any of them owns. */
41
+ export declare function composeSources(name: string, parts: readonly AlertSource[]): AlertSource;
42
+ export interface QuerySourceOptions extends SourceBasics {
43
+ /** Key prefix: conditions are keyed `${name}:${id}`. Non-empty, no ':'. */
44
+ readonly name: string;
45
+ /** Returns one row per subject that is in trouble right now. */
46
+ readonly query: SQL;
47
+ readonly id: (row: Row) => string;
48
+ readonly message: (row: Row) => string;
49
+ readonly context?: (row: Row) => Record<string, JsonValue>;
50
+ readonly subject?: (row: Row) => AlertCondition['subject'];
51
+ }
52
+ /**
53
+ * For a condition that is true right now: runs `query` (as the system, any tenant) on every tick and raises
54
+ * `${name}:${id}` for each row. An open alert under `${name}:` that a tick no longer returns is resolved.
55
+ */
56
+ export declare function querySource(options: QuerySourceOptions): AlertSource;
57
+ export interface NewRowsGroup {
58
+ readonly id: string;
59
+ readonly count: number;
60
+ readonly last: Row;
61
+ readonly sum: number;
62
+ }
63
+ export interface NewRowsSourceOptions extends SourceBasics {
64
+ /** Key prefix: conditions are keyed `${name}:${id}`; also the cursor's state key unless `stateKey` is given. */
65
+ readonly name: string;
66
+ /** The table holding the recorded failures (an identifier, not schema-qualified). */
67
+ readonly table: string;
68
+ /** The timestamp column a row is stamped with when the failure is recorded. */
69
+ readonly at: string;
70
+ /** Extra filter; the table is aliased `t`. */
71
+ readonly where?: SQL;
72
+ /** A column of the table, or an SQL expression over alias `t`; one condition per distinct value. */
73
+ readonly groupBy: string | SQL;
74
+ readonly message: (group: NewRowsGroup) => string;
75
+ readonly context?: (group: NewRowsGroup) => Record<string, JsonValue>;
76
+ readonly subject?: (group: NewRowsGroup) => AlertCondition['subject'];
77
+ /** A numeric column to total per group, handed to the callbacks as `sum` (0 without it). */
78
+ readonly sum?: string;
79
+ /** Rows are read once they are older than this many ms (default 30_000); see `newRowsSource`. */
80
+ readonly settleMs?: number;
81
+ /** The cursor's key in `pipework_alert_source_state`; default `name`. */
82
+ readonly stateKey?: string;
83
+ }
84
+ /**
85
+ * For a condition that is "new since the last tick": raises `${name}:${id}` for each group of rows in `table`
86
+ * stamped in `at` after the cursor. The cursor lives in `pipework_alert_source_state`, shared by every process;
87
+ * the first evaluation only stores it, so history is not replayed. Each evaluation reads rows with
88
+ * `cursor < at <= clock_timestamp() - settleMs` and moves the cursor to that upper bound, so a row stamped with
89
+ * its transaction's start time that commits within `settleMs` is still read. `settleMs` must exceed the longest
90
+ * transaction that writes the table. It has no `owns`: the condition clears by the handle's quiet period
91
+ * (`resolveAfterQuietMs` on `alerts.create`).
92
+ */
93
+ export declare function newRowsSource(options: NewRowsSourceOptions): AlertSource;
32
94
  /**
33
95
  * Raises `job-dead-letter:<jobType>` for jobs that exhausted their attempts since the last
34
96
  * evaluation (any process: the position is kept in `pipework_alert_source_state`; the first
35
97
  * evaluation starts from now, so history is not replayed). The condition clears only by the
36
98
  * handle's quiet period, so set `resolveAfterQuietMs` on `alerts.create`. `types` limits it to those job
37
99
  * types and `excludeTypes` leaves them out (not both, neither empty); each filter keeps its own position.
100
+ * Jobs are read `settleMs` (default 30_000) after they dead-letter, so one that commits late is not missed.
38
101
  * Conditions carry `owner`, `runbook` and a context of `count` and `lastError`.
39
102
  */
40
103
  export declare function deadLetterSource(options?: TableOption & {
@@ -43,6 +106,7 @@ export declare function deadLetterSource(options?: TableOption & {
43
106
  excludeTypes?: readonly string[];
44
107
  owner?: string;
45
108
  runbook?: string;
109
+ settleMs?: number;
46
110
  }): AlertSource;
47
111
  /** Raises `job-long-running:<jobType>` while a job of that type has been claimed for longer than `olderThanMs`; resolves when none is. */
48
112
  export declare function longRunningSource(options: TableOption & {
@@ -1 +1 @@
1
- {"version":3,"file":"sources.d.ts","sourceRoot":"","sources":["../../../src/infra/alerts/sources.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"sources.d.ts","sourceRoot":"","sources":["../../../src/infra/alerts/sources.ts"],"names":[],"mappings":"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"}
@@ -4,10 +4,133 @@ import { pipeSystem } from '../../data/db/db.js';
4
4
  import { assertValidIdentifier, quoteIdentifier } from '../../data/db/identifiers.js';
5
5
  import { ALERTS_TICK_LOOP, LOOP_BEATS_QUERY, staleLoops } from './beat.js';
6
6
  import { getContext } from '../../request/context/store.js';
7
+ function assertSourceName(name, who) {
8
+ if (typeof name !== 'string' || name === '' || name.includes(':')) {
9
+ throw new Error(`[pipework] ${who}: name must be a non-empty string without ':', got ${JSON.stringify(name)}.`);
10
+ }
11
+ }
12
+ function ownerFields(options) {
13
+ return {
14
+ ...(options.owner !== undefined ? { owner: options.owner } : {}),
15
+ ...(options.runbook !== undefined ? { runbook: options.runbook } : {}),
16
+ };
17
+ }
18
+ /** One source from several: it evaluates each in turn and owns what any of them owns. */
19
+ export function composeSources(name, parts) {
20
+ const owning = parts.filter(p => p.owns !== undefined);
21
+ return {
22
+ name,
23
+ ...(owning.length > 0 ? { owns: (key) => owning.some(p => p.owns(key)) } : {}),
24
+ async evaluate() {
25
+ const out = [];
26
+ for (const part of parts)
27
+ out.push(...await part.evaluate());
28
+ return out;
29
+ },
30
+ };
31
+ }
32
+ /**
33
+ * For a condition that is true right now: runs `query` (as the system, any tenant) on every tick and raises
34
+ * `${name}:${id}` for each row. An open alert under `${name}:` that a tick no longer returns is resolved.
35
+ */
36
+ export function querySource(options) {
37
+ assertSourceName(options.name, 'alerts.source.query');
38
+ const prefix = `${options.name}:`;
39
+ const severity = options.severity ?? 'error';
40
+ return {
41
+ name: options.name,
42
+ owns: key => key.startsWith(prefix),
43
+ async evaluate() {
44
+ const rows = await pipeSystem(options.database).execute(options.query);
45
+ return rows.map((r) => {
46
+ const subject = options.subject?.(r);
47
+ const context = options.context?.(r);
48
+ return {
49
+ key: `${prefix}${options.id(r)}`,
50
+ severity,
51
+ message: options.message(r),
52
+ ...(subject !== undefined ? { subject } : {}),
53
+ ...ownerFields(options),
54
+ ...(context !== undefined ? { context } : {}),
55
+ };
56
+ });
57
+ },
58
+ };
59
+ }
60
+ /**
61
+ * For a condition that is "new since the last tick": raises `${name}:${id}` for each group of rows in `table`
62
+ * stamped in `at` after the cursor. The cursor lives in `pipework_alert_source_state`, shared by every process;
63
+ * the first evaluation only stores it, so history is not replayed. Each evaluation reads rows with
64
+ * `cursor < at <= clock_timestamp() - settleMs` and moves the cursor to that upper bound, so a row stamped with
65
+ * its transaction's start time that commits within `settleMs` is still read. `settleMs` must exceed the longest
66
+ * transaction that writes the table. It has no `owns`: the condition clears by the handle's quiet period
67
+ * (`resolveAfterQuietMs` on `alerts.create`).
68
+ */
69
+ export function newRowsSource(options) {
70
+ assertSourceName(options.name, 'alerts.source.newRows');
71
+ const settleMs = options.settleMs ?? 30_000;
72
+ if (typeof settleMs !== 'number' || !Number.isFinite(settleMs) || settleMs < 0) {
73
+ throw new Error(`[pipework] alerts.source.newRows settleMs must be a non-negative number of milliseconds, got ${String(settleMs)}.`);
74
+ }
75
+ const table = quoteIdentifier(options.table, 'newRows table');
76
+ const at = `t.${quoteIdentifier(options.at, 'newRows at column')}`;
77
+ const group = typeof options.groupBy === 'string' ? sql.raw(`t.${quoteIdentifier(options.groupBy, 'newRows groupBy column')}`) : options.groupBy;
78
+ const total = options.sum === undefined ? sql `0` : sql.raw(`sum(t.${quoteIdentifier(options.sum, 'newRows sum column')})`);
79
+ const where = options.where === undefined ? sql `` : sql `AND (${options.where})`;
80
+ const stateKey = options.stateKey ?? options.name;
81
+ const severity = options.severity ?? 'error';
82
+ const prefix = `${options.name}:`;
83
+ return {
84
+ name: options.name,
85
+ async evaluate() {
86
+ const db = pipeSystem(options.database);
87
+ const first = await db.execute(sql `
88
+ INSERT INTO pipework_alert_source_state (name, cursor_at)
89
+ VALUES (${stateKey}, clock_timestamp() - ${settleMs} * interval '1 millisecond')
90
+ ON CONFLICT (name) DO NOTHING RETURNING name`);
91
+ if (first.length > 0)
92
+ return [];
93
+ const rows = await db.execute(sql `
94
+ WITH cur AS (
95
+ SELECT cursor_at, clock_timestamp() - ${settleMs} * interval '1 millisecond' AS upto
96
+ FROM pipework_alert_source_state WHERE name = ${stateKey} FOR UPDATE),
97
+ totals AS (
98
+ SELECT (${group})::text AS gid, count(*)::int AS n, (${total})::float8 AS total
99
+ FROM ${sql.raw(table)} t, cur
100
+ WHERE ${sql.raw(at)} > cur.cursor_at AND ${sql.raw(at)} <= cur.upto ${where}
101
+ GROUP BY 1),
102
+ newest AS (
103
+ SELECT DISTINCT ON (1) (${group})::text AS gid, to_jsonb(t) AS last
104
+ FROM ${sql.raw(table)} t, cur
105
+ WHERE ${sql.raw(at)} > cur.cursor_at AND ${sql.raw(at)} <= cur.upto ${where}
106
+ ORDER BY 1, ${sql.raw(at)} DESC),
107
+ found AS (
108
+ SELECT totals.gid AS id, n, total, last FROM totals JOIN newest ON newest.gid IS NOT DISTINCT FROM totals.gid),
109
+ moved AS (
110
+ UPDATE pipework_alert_source_state s SET cursor_at = GREATEST(s.cursor_at, cur.upto)
111
+ FROM cur WHERE s.name = ${stateKey} RETURNING 1)
112
+ SELECT id, n, total, last, (SELECT count(*) FROM moved) AS moved FROM found ORDER BY id`);
113
+ return rows.map((r) => {
114
+ const last = (typeof r['last'] === 'string' ? JSON.parse(r['last']) : r['last']);
115
+ const g = { id: r['id'] === null ? '(none)' : String(r['id']), count: Number(r['n']), last, sum: Number(r['total']) };
116
+ const subject = options.subject?.(g);
117
+ const context = options.context?.(g);
118
+ return {
119
+ key: `${prefix}${g.id}`,
120
+ severity,
121
+ message: options.message(g),
122
+ ...(subject !== undefined ? { subject } : {}),
123
+ ...ownerFields(options),
124
+ ...(context !== undefined ? { context } : {}),
125
+ };
126
+ });
127
+ },
128
+ };
129
+ }
7
130
  function jobsTable(table) {
8
131
  const name = table ?? 'pipework_jobs';
9
132
  assertValidIdentifier(name, 'queue table');
10
- return quoteIdentifier(name, 'queue table');
133
+ return name;
11
134
  }
12
135
  /**
13
136
  * Raises `job-dead-letter:<jobType>` for jobs that exhausted their attempts since the last
@@ -15,6 +138,7 @@ function jobsTable(table) {
15
138
  * evaluation starts from now, so history is not replayed). The condition clears only by the
16
139
  * handle's quiet period, so set `resolveAfterQuietMs` on `alerts.create`. `types` limits it to those job
17
140
  * types and `excludeTypes` leaves them out (not both, neither empty); each filter keeps its own position.
141
+ * Jobs are read `settleMs` (default 30_000) after they dead-letter, so one that commits late is not missed.
18
142
  * Conditions carry `owner`, `runbook` and a context of `count` and `lastError`.
19
143
  */
20
144
  export function deadLetterSource(options = {}) {
@@ -27,7 +151,6 @@ export function deadLetterSource(options = {}) {
27
151
  }
28
152
  }
29
153
  const jobs = jobsTable(options.table);
30
- const severity = options.severity ?? 'error';
31
154
  const name = 'job-dead-letter';
32
155
  const mode = options.types !== undefined ? 'only' : options.excludeTypes !== undefined ? 'except' : null;
33
156
  const listed = [...new Set(options.types ?? options.excludeTypes ?? [])].sort();
@@ -35,65 +158,47 @@ export function deadLetterSource(options = {}) {
35
158
  const stateKey = mode === null ? name
36
159
  : `${name}:${mode}:${joined.length > 200 ? createHash('sha256').update(joined).digest('hex') : joined}`;
37
160
  const listJson = JSON.stringify(listed);
38
- const typeFilter = mode === 'only' ? sql `AND j.job_type IN (SELECT jsonb_array_elements_text(${listJson}::jsonb))`
39
- : mode === 'except' ? sql `AND j.job_type NOT IN (SELECT jsonb_array_elements_text(${listJson}::jsonb))`
161
+ const typeFilter = mode === 'only' ? sql `AND t.job_type IN (SELECT jsonb_array_elements_text(${listJson}::jsonb))`
162
+ : mode === 'except' ? sql `AND t.job_type NOT IN (SELECT jsonb_array_elements_text(${listJson}::jsonb))`
40
163
  : sql ``;
41
- return {
42
- name,
43
- async evaluate() {
44
- const db = pipeSystem(options.database);
45
- const first = await db.execute(sql `
46
- INSERT INTO pipework_alert_source_state (name, cursor_at) VALUES (${stateKey}, clock_timestamp())
47
- ON CONFLICT (name) DO NOTHING RETURNING name`);
48
- if (first.length > 0)
49
- return [];
50
- const rows = await db.execute(sql `
51
- WITH cur AS (SELECT cursor_at FROM pipework_alert_source_state WHERE name = ${stateKey} FOR UPDATE),
52
- found AS (
53
- SELECT j.job_type, count(*)::int AS n, max(j.completed_at) AS hi,
54
- (array_agg(j.error_payload->>'message' ORDER BY j.completed_at DESC))[1] AS last_error
55
- FROM ${sql.raw(jobs)} j, cur
56
- WHERE j.status = 'dead_letter' AND j.completed_at > cur.cursor_at ${typeFilter}
57
- GROUP BY j.job_type),
58
- moved AS (
59
- UPDATE pipework_alert_source_state s SET cursor_at = GREATEST(s.cursor_at, (SELECT max(hi) FROM found))
60
- WHERE s.name = ${stateKey} AND EXISTS (SELECT 1 FROM found) RETURNING 1)
61
- SELECT job_type, n, last_error, (SELECT count(*) FROM moved) AS moved FROM found ORDER BY job_type`);
62
- return rows.map(r => ({
63
- key: `job-dead-letter:${String(r['job_type'])}`,
64
- severity,
65
- message: `${String(r['n'])} ${String(r['job_type'])} job(s) dead-lettered`
66
- + (r['last_error'] !== null && r['last_error'] !== undefined ? `; last error: ${String(r['last_error'])}` : ''),
67
- subject: { entity: 'job', entityId: String(r['job_type']) },
68
- ...(options.owner !== undefined ? { owner: options.owner } : {}),
69
- ...(options.runbook !== undefined ? { runbook: options.runbook } : {}),
70
- context: { count: Number(r['n']), lastError: r['last_error'] === null || r['last_error'] === undefined ? null : String(r['last_error']) },
71
- }));
72
- },
164
+ const lastError = (g) => {
165
+ const payload = g.last['error_payload'];
166
+ const message = typeof payload === 'object' && payload !== null ? payload['message'] : undefined;
167
+ return message === null || message === undefined ? null : String(message);
73
168
  };
169
+ return newRowsSource({
170
+ name,
171
+ stateKey,
172
+ table: jobs,
173
+ at: 'completed_at',
174
+ where: sql `t.status = 'dead_letter' ${typeFilter}`,
175
+ groupBy: 'job_type',
176
+ ...(options.settleMs !== undefined ? { settleMs: options.settleMs } : {}),
177
+ ...(options.severity !== undefined ? { severity: options.severity } : {}),
178
+ ...(options.database !== undefined ? { database: options.database } : {}),
179
+ ...ownerFields(options),
180
+ message: g => `${String(g.count)} ${g.id} job(s) dead-lettered` + (lastError(g) !== null ? `; last error: ${lastError(g)}` : ''),
181
+ subject: g => ({ entity: 'job', entityId: g.id }),
182
+ context: g => ({ count: g.count, lastError: lastError(g) }),
183
+ });
74
184
  }
75
185
  /** Raises `job-long-running:<jobType>` while a job of that type has been claimed for longer than `olderThanMs`; resolves when none is. */
76
186
  export function longRunningSource(options) {
77
- const jobs = jobsTable(options.table);
78
- const severity = options.severity ?? 'warning';
79
- return {
187
+ const jobs = quoteIdentifier(jobsTable(options.table), 'queue table');
188
+ return querySource({
80
189
  name: 'job-long-running',
81
- owns: key => key.startsWith('job-long-running:'),
82
- async evaluate() {
83
- const rows = await pipeSystem(options.database).execute(sql `
84
- SELECT job_type, count(*)::int AS n, (extract(epoch FROM clock_timestamp() - min(claimed_at)) * 1000)::bigint AS oldest_ms
85
- FROM ${sql.raw(jobs)}
86
- WHERE status IN ('claimed', 'running')
87
- AND claimed_at < clock_timestamp() - ${options.olderThanMs} * interval '1 millisecond'
88
- GROUP BY job_type ORDER BY job_type`);
89
- return rows.map(r => ({
90
- key: `job-long-running:${String(r['job_type'])}`,
91
- severity,
92
- message: `${String(r['n'])} ${String(r['job_type'])} job(s) running for more than ${String(options.olderThanMs)} ms (oldest ${String(r['oldest_ms'])} ms)`,
93
- subject: { entity: 'job', entityId: String(r['job_type']) },
94
- }));
95
- },
96
- };
190
+ severity: options.severity ?? 'warning',
191
+ ...(options.database !== undefined ? { database: options.database } : {}),
192
+ query: sql `
193
+ SELECT job_type, count(*)::int AS n, (extract(epoch FROM clock_timestamp() - min(claimed_at)) * 1000)::bigint AS oldest_ms
194
+ FROM ${sql.raw(jobs)}
195
+ WHERE status IN ('claimed', 'running')
196
+ AND claimed_at < clock_timestamp() - ${options.olderThanMs} * interval '1 millisecond'
197
+ GROUP BY job_type ORDER BY job_type`,
198
+ id: r => String(r['job_type']),
199
+ message: r => `${String(r['n'])} ${String(r['job_type'])} job(s) running for more than ${String(options.olderThanMs)} ms (oldest ${String(r['oldest_ms'])} ms)`,
200
+ subject: r => ({ entity: 'job', entityId: String(r['job_type']) }),
201
+ });
97
202
  }
98
203
  /**
99
204
  * Raises `queue-backlog:<jobType>` while more than `maxPending` jobs of a type are ready to claim, or the
@@ -104,40 +209,31 @@ export function backlogSource(options) {
104
209
  if (options.maxPending === undefined && options.maxOldestAgeMs === undefined) {
105
210
  throw new Error('[pipework] alerts.source.backlog needs maxPending, maxOldestAgeMs or both.');
106
211
  }
107
- const jobs = jobsTable(options.table);
108
- const severity = options.severity ?? 'warning';
212
+ const jobs = quoteIdentifier(jobsTable(options.table), 'queue table');
109
213
  const maxPending = options.maxPending ?? null;
110
214
  const maxAge = options.maxOldestAgeMs ?? null;
111
- return {
215
+ return querySource({
112
216
  name: 'queue-backlog',
113
- owns: key => key.startsWith('queue-backlog:'),
114
- async evaluate() {
115
- const rows = await pipeSystem(options.database).execute(sql `
116
- SELECT job_type, n, running, oldest_ms FROM (
117
- SELECT job_type,
118
- (count(*) FILTER (WHERE status = 'pending' AND (next_retry_at IS NULL OR next_retry_at <= clock_timestamp())))::int AS n,
119
- (count(*) FILTER (WHERE status IN ('claimed', 'running')))::int AS running,
120
- (extract(epoch FROM clock_timestamp() - min(queued_at) FILTER (WHERE status = 'pending' AND (next_retry_at IS NULL OR next_retry_at <= clock_timestamp()))) * 1000)::bigint AS oldest_ms
121
- FROM ${sql.raw(jobs)}
122
- WHERE status IN ('pending', 'claimed', 'running')
123
- GROUP BY job_type) p
124
- WHERE (${maxPending}::int IS NOT NULL AND n > ${maxPending}::int)
125
- OR (${maxAge}::bigint IS NOT NULL AND oldest_ms > ${maxAge}::bigint)
126
- ORDER BY job_type`);
127
- return rows.map(r => {
128
- const oldest = Number(r['oldest_ms']);
129
- return {
130
- key: `queue-backlog:${String(r['job_type'])}`,
131
- severity,
132
- message: `${String(r['n'])} ${String(r['job_type'])} job(s) waiting (${String(r['running'])} running), the oldest for ${String(oldest)} ms`,
133
- subject: { entity: 'job', entityId: String(r['job_type']) },
134
- ...(options.owner !== undefined ? { owner: options.owner } : {}),
135
- ...(options.runbook !== undefined ? { runbook: options.runbook } : {}),
136
- context: { ready: Number(r['n']), running: Number(r['running']), oldestReadyWaitMs: oldest },
137
- };
138
- });
139
- },
140
- };
217
+ severity: options.severity ?? 'warning',
218
+ ...(options.database !== undefined ? { database: options.database } : {}),
219
+ ...ownerFields(options),
220
+ query: sql `
221
+ SELECT job_type, n, running, oldest_ms FROM (
222
+ SELECT job_type,
223
+ (count(*) FILTER (WHERE status = 'pending' AND (next_retry_at IS NULL OR next_retry_at <= clock_timestamp())))::int AS n,
224
+ (count(*) FILTER (WHERE status IN ('claimed', 'running')))::int AS running,
225
+ (extract(epoch FROM clock_timestamp() - min(queued_at) FILTER (WHERE status = 'pending' AND (next_retry_at IS NULL OR next_retry_at <= clock_timestamp()))) * 1000)::bigint AS oldest_ms
226
+ FROM ${sql.raw(jobs)}
227
+ WHERE status IN ('pending', 'claimed', 'running')
228
+ GROUP BY job_type) p
229
+ WHERE (${maxPending}::int IS NOT NULL AND n > ${maxPending}::int)
230
+ OR (${maxAge}::bigint IS NOT NULL AND oldest_ms > ${maxAge}::bigint)
231
+ ORDER BY job_type`,
232
+ id: r => String(r['job_type']),
233
+ message: r => `${String(r['n'])} ${String(r['job_type'])} job(s) waiting (${String(r['running'])} running), the oldest for ${String(Number(r['oldest_ms']))} ms`,
234
+ subject: r => ({ entity: 'job', entityId: String(r['job_type']) }),
235
+ context: r => ({ ready: Number(r['n']), running: Number(r['running']), oldestReadyWaitMs: Number(r['oldest_ms']) }),
236
+ });
141
237
  }
142
238
  /** Raises `health:<name>` while a check reports unhealthy or throws; resolves when it is healthy again. Degraded is not a fault. */
143
239
  export function healthSource(checks, options = {}) {
@@ -177,47 +273,36 @@ export function unitFaultSource(options) {
177
273
  if (options.units !== undefined && options.units.length === 0) {
178
274
  throw new Error('[pipework] alerts.source.unitFault units must not be empty; omit it to watch every unit.');
179
275
  }
180
- const severity = options.severity ?? 'error';
181
276
  const units = options.units === undefined ? null : JSON.stringify(options.units);
182
- return {
277
+ const text = (v) => v === null || v === undefined ? null : String(v);
278
+ return querySource({
183
279
  name: 'unit-fault',
184
- owns: key => key.startsWith('unit-fault:'),
185
- async evaluate() {
186
- const rows = await pipeSystem(options.database).execute(sql `
187
- SELECT unit, count(*)::int AS n,
188
- (array_agg(result ORDER BY at DESC))[1] AS last_result,
189
- (array_agg(exit_code ORDER BY at DESC))[1] AS last_exit_code,
190
- (array_agg(exit_status ORDER BY at DESC))[1] AS last_exit_status,
191
- min(at) AS first_at, max(at) AS last_at,
192
- ceil(extract(epoch FROM clock_timestamp() - min(at)) / 60)::int AS minutes
193
- FROM pipework_unit_faults
194
- WHERE at > clock_timestamp() - ${options.window} * interval '1 millisecond'
195
- AND (${units}::jsonb IS NULL OR unit IN (SELECT jsonb_array_elements_text(${units}::jsonb)))
196
- GROUP BY unit ORDER BY unit`);
197
- const text = (v) => v === null || v === undefined ? null : String(v);
198
- return rows.map(r => {
199
- const unit = String(r['unit']);
200
- const lastResult = String(r['last_result']);
201
- const count = Number(r['n']);
202
- return {
203
- key: `unit-fault:${unit}`,
204
- severity,
205
- message: `${unit}: ${lastResult} x${String(count)} in ${String(Math.max(1, Number(r['minutes'])))} min`,
206
- subject: { entity: 'unit', entityId: unit },
207
- ...(options.owner !== undefined ? { owner: options.owner } : {}),
208
- ...(options.runbook !== undefined ? { runbook: options.runbook } : {}),
209
- context: {
210
- count,
211
- lastResult,
212
- lastExitCode: text(r['last_exit_code']),
213
- lastExitStatus: text(r['last_exit_status']),
214
- firstAt: new Date(r['first_at']).toISOString(),
215
- lastAt: new Date(r['last_at']).toISOString(),
216
- },
217
- };
218
- });
219
- },
220
- };
280
+ severity: options.severity ?? 'error',
281
+ ...(options.database !== undefined ? { database: options.database } : {}),
282
+ ...ownerFields(options),
283
+ query: sql `
284
+ SELECT unit, count(*)::int AS n,
285
+ (array_agg(result ORDER BY at DESC))[1] AS last_result,
286
+ (array_agg(exit_code ORDER BY at DESC))[1] AS last_exit_code,
287
+ (array_agg(exit_status ORDER BY at DESC))[1] AS last_exit_status,
288
+ min(at) AS first_at, max(at) AS last_at,
289
+ ceil(extract(epoch FROM clock_timestamp() - min(at)) / 60)::int AS minutes
290
+ FROM pipework_unit_faults
291
+ WHERE at > clock_timestamp() - ${options.window} * interval '1 millisecond'
292
+ AND (${units}::jsonb IS NULL OR unit IN (SELECT jsonb_array_elements_text(${units}::jsonb)))
293
+ GROUP BY unit ORDER BY unit`,
294
+ id: r => String(r['unit']),
295
+ message: r => `${String(r['unit'])}: ${String(r['last_result'])} x${String(Number(r['n']))} in ${String(Math.max(1, Number(r['minutes'])))} min`,
296
+ subject: r => ({ entity: 'unit', entityId: String(r['unit']) }),
297
+ context: r => ({
298
+ count: Number(r['n']),
299
+ lastResult: String(r['last_result']),
300
+ lastExitCode: text(r['last_exit_code']),
301
+ lastExitStatus: text(r['last_exit_status']),
302
+ firstAt: new Date(r['first_at']).toISOString(),
303
+ lastAt: new Date(r['last_at']).toISOString(),
304
+ }),
305
+ });
221
306
  }
222
307
  const pad2 = (n) => String(n).padStart(2, '0');
223
308
  function untilText(reset, now) {
@@ -240,47 +325,47 @@ export function quotaExhaustedSource(options = {}) {
240
325
  if (Array.isArray(keys) && keys.length === 0) {
241
326
  throw new Error('[pipework] alerts.source.quotaExhausted keys must not be empty; omit it to watch every key.');
242
327
  }
243
- const severity = options.severity ?? 'error';
244
328
  const keyFilter = Array.isArray(keys)
245
329
  ? sql `AND key IN (SELECT jsonb_array_elements_text(${JSON.stringify([...new Set(keys)])}::jsonb))`
246
330
  : sql ``;
247
- return {
331
+ const base = querySource({
248
332
  name: 'quota-exhausted',
249
- owns: key => key.startsWith('quota-exhausted:'),
333
+ severity: options.severity ?? 'error',
334
+ ...(options.database !== undefined ? { database: options.database } : {}),
335
+ ...ownerFields(options),
336
+ query: sql `
337
+ SELECT key, remaining, "limit", reset_at,
338
+ (remaining > ${floor} AND refused_at IS NOT NULL AND refused_at >= updated_at) AS refused_only,
339
+ (refused_at IS NOT NULL AND refused_at >= updated_at) AS refused
340
+ FROM outbound_quota
341
+ WHERE reset_at > now() ${keyFilter}
342
+ AND (remaining <= ${floor} OR (refused_at IS NOT NULL AND refused_at >= updated_at))
343
+ ORDER BY key`,
344
+ id: r => String(r['key']),
345
+ message: r => {
346
+ const remaining = Number(r['remaining']);
347
+ const limit = r['limit'] === null ? null : Number(r['limit']);
348
+ const head = limit === null ? `${String(r['key'])} ${String(remaining)} remaining` : `${String(r['key'])} ${String(remaining)}/${String(limit)}`;
349
+ return `${head} until ${untilText(new Date(r['reset_at']), new Date())}${r['refused_only'] === true ? '; a call was refused' : ''}`;
350
+ },
351
+ subject: r => ({ entity: 'quota', entityId: String(r['key']) }),
352
+ context: r => ({
353
+ key: String(r['key']),
354
+ remaining: Number(r['remaining']),
355
+ limit: r['limit'] === null ? null : Number(r['limit']),
356
+ resetAt: new Date(r['reset_at']).toISOString(),
357
+ refused: r['refused'] === true,
358
+ }),
359
+ });
360
+ if (!(keys instanceof RegExp))
361
+ return base;
362
+ return {
363
+ ...base,
250
364
  async evaluate() {
251
- const rows = await pipeSystem(options.database).execute(sql `
252
- SELECT key, remaining, "limit", reset_at,
253
- (remaining > ${floor} AND refused_at IS NOT NULL AND refused_at >= updated_at) AS refused_only,
254
- (refused_at IS NOT NULL AND refused_at >= updated_at) AS refused
255
- FROM outbound_quota
256
- WHERE reset_at > now() ${keyFilter}
257
- AND (remaining <= ${floor} OR (refused_at IS NOT NULL AND refused_at >= updated_at))
258
- ORDER BY key`);
259
- const now = new Date();
260
- const out = [];
261
- for (const r of rows) {
262
- const key = String(r['key']);
263
- if (keys instanceof RegExp) {
264
- keys.lastIndex = 0;
265
- if (!keys.test(key))
266
- continue;
267
- }
268
- const remaining = Number(r['remaining']);
269
- const limit = r['limit'] === null ? null : Number(r['limit']);
270
- const resetAt = new Date(r['reset_at']);
271
- const refusedOnly = r['refused_only'] === true;
272
- const head = limit === null ? `${key} ${String(remaining)} remaining` : `${key} ${String(remaining)}/${String(limit)}`;
273
- out.push({
274
- key: `quota-exhausted:${key}`,
275
- severity,
276
- message: `${head} until ${untilText(resetAt, now)}${refusedOnly ? '; a call was refused' : ''}`,
277
- subject: { entity: 'quota', entityId: key },
278
- ...(options.owner !== undefined ? { owner: options.owner } : {}),
279
- ...(options.runbook !== undefined ? { runbook: options.runbook } : {}),
280
- context: { key, remaining, limit, resetAt: resetAt.toISOString(), refused: r['refused'] === true },
281
- });
282
- }
283
- return out;
365
+ return (await base.evaluate()).filter(c => {
366
+ keys.lastIndex = 0;
367
+ return keys.test(c.key.slice('quota-exhausted:'.length));
368
+ });
284
369
  },
285
370
  };
286
371
  }