deepline 0.3.105 → 0.3.107
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.
- package/dist/bundling-sources/sdk/src/release.ts +1 -1
- package/dist/bundling-sources/shared_libs/observability/queue-health.ts +678 -0
- package/dist/bundling-sources/shared_libs/observability/scheduled-jobs.json +424 -70
- package/dist/bundling-sources/shared_libs/observability/scheduled-work-manual.ts +1 -0
- package/dist/bundling-sources/shared_libs/observability/scheduled-work-v2.ts +380 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/runtime-queue-health.ts +718 -0
- package/dist/cli/index.js +24 -8
- package/dist/cli/index.mjs +24 -8
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/dist/install-integrity.json +3 -0
- package/dist/release.d.mts +1 -1
- package/dist/release.d.ts +1 -1
- package/dist/release.js +1 -1
- package/dist/release.mjs +1 -1
- package/package.json +1 -1
package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/runtime-queue-health.ts
ADDED
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
import type { PostgresSchedulerQueryClient } from './postgres';
|
|
2
|
+
import {
|
|
3
|
+
deriveQueueHealth,
|
|
4
|
+
exact,
|
|
5
|
+
lowerBound,
|
|
6
|
+
notApplicable,
|
|
7
|
+
unavailable,
|
|
8
|
+
type QueueDefinition,
|
|
9
|
+
type QueueHealth,
|
|
10
|
+
type QueueObservation,
|
|
11
|
+
} from '../../observability/queue-health';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Bounded, read-only health projection for the durable runtime queues.
|
|
15
|
+
*
|
|
16
|
+
* This intentionally lives beside the scheduler adapters rather than in the
|
|
17
|
+
* metrics package: Absurd and Runtime Postgres are the sources of truth for
|
|
18
|
+
* these rows. A caller receives one coherent snapshot (the queries run on a
|
|
19
|
+
* single connection) and can publish it to any transport or dashboard.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const RUNTIME_QUEUE_HEALTH_MAX_ABSURD_QUEUES = 128;
|
|
23
|
+
export const RUNTIME_QUEUE_HEALTH_DEFAULT_STALE_AFTER_MS = 90_000;
|
|
24
|
+
export const RUNTIME_QUEUE_HEALTH_DEFAULT_WARN_AGE_MS = 60_000;
|
|
25
|
+
export const RUNTIME_QUEUE_HEALTH_DEFAULT_FAIL_AGE_MS = 300_000;
|
|
26
|
+
/**
|
|
27
|
+
* Queue health is a cheap control-plane read. A bucket may contain more
|
|
28
|
+
* work than this cap; in that case its depth is explicitly reported as a
|
|
29
|
+
* lower bound instead of making the observer scan the whole queue.
|
|
30
|
+
*/
|
|
31
|
+
export const RUNTIME_QUEUE_HEALTH_BUCKET_SAMPLE_LIMIT = 1_000;
|
|
32
|
+
|
|
33
|
+
export type RuntimeQueueHealthStatus = 'green' | 'amber' | 'red' | 'unknown';
|
|
34
|
+
|
|
35
|
+
export type RuntimeQueueDepth = {
|
|
36
|
+
ready: number;
|
|
37
|
+
delayed: number;
|
|
38
|
+
inFlight: number;
|
|
39
|
+
blocked: number;
|
|
40
|
+
deadLettered: number;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type RuntimeQueueOldestAge = {
|
|
44
|
+
ready: number | null;
|
|
45
|
+
delayed: number | null;
|
|
46
|
+
inFlight: number | null;
|
|
47
|
+
blocked: number | null;
|
|
48
|
+
deadLettered: number | null;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type RuntimeQueueFlow = {
|
|
52
|
+
admitted: number;
|
|
53
|
+
started: number;
|
|
54
|
+
completed: number;
|
|
55
|
+
failed: number;
|
|
56
|
+
retried: number;
|
|
57
|
+
poisoned: number;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type RuntimeQueueHealthProjection = {
|
|
61
|
+
id: string;
|
|
62
|
+
kind: 'absurd' | 'run_ledger_outbox' | 'sandbox_cleanup';
|
|
63
|
+
source: 'absurd' | 'runtime_postgres';
|
|
64
|
+
observedAt: string;
|
|
65
|
+
status: RuntimeQueueHealthStatus;
|
|
66
|
+
statusReason: string;
|
|
67
|
+
/** Shared scheduler-neutral contract consumed by transports and dashboards. */
|
|
68
|
+
observation: QueueObservation;
|
|
69
|
+
health: QueueHealth;
|
|
70
|
+
depth: RuntimeQueueDepth;
|
|
71
|
+
oldestAgeMs: RuntimeQueueOldestAge;
|
|
72
|
+
flow: RuntimeQueueFlow;
|
|
73
|
+
lifecycle: {
|
|
74
|
+
started: number;
|
|
75
|
+
completed: number;
|
|
76
|
+
unresolved: number;
|
|
77
|
+
triggerGap: number;
|
|
78
|
+
};
|
|
79
|
+
thresholds: {
|
|
80
|
+
warnAgeMs: number;
|
|
81
|
+
failAgeMs: number;
|
|
82
|
+
staleAfterMs: number;
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export type RuntimeQueueHealthSnapshot = {
|
|
87
|
+
observedAt: string;
|
|
88
|
+
staleAfterMs: number;
|
|
89
|
+
queues: RuntimeQueueHealthProjection[];
|
|
90
|
+
summary: {
|
|
91
|
+
green: number;
|
|
92
|
+
amber: number;
|
|
93
|
+
red: number;
|
|
94
|
+
unknown: number;
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export type RuntimeQueueHealthOptions = {
|
|
99
|
+
schema?: string;
|
|
100
|
+
now?: Date;
|
|
101
|
+
staleAfterMs?: number;
|
|
102
|
+
warnAgeMs?: number;
|
|
103
|
+
failAgeMs?: number;
|
|
104
|
+
/** Restrict Absurd rows to these queue names. Registry values are validated. */
|
|
105
|
+
absurdQueueNames?: string[];
|
|
106
|
+
maxAbsurdQueues?: number;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export type RuntimeQueueHealthThresholds = Required<
|
|
110
|
+
Pick<
|
|
111
|
+
RuntimeQueueHealthOptions,
|
|
112
|
+
'now' | 'staleAfterMs' | 'warnAgeMs' | 'failAgeMs'
|
|
113
|
+
>
|
|
114
|
+
>;
|
|
115
|
+
|
|
116
|
+
type CountRow = {
|
|
117
|
+
ready: string | number;
|
|
118
|
+
delayed: string | number;
|
|
119
|
+
in_flight: string | number;
|
|
120
|
+
blocked: string | number;
|
|
121
|
+
dead_lettered: string | number;
|
|
122
|
+
oldest_ready_ms: string | number | null;
|
|
123
|
+
oldest_delayed_ms: string | number | null;
|
|
124
|
+
oldest_in_flight_ms: string | number | null;
|
|
125
|
+
oldest_blocked_ms: string | number | null;
|
|
126
|
+
oldest_dead_lettered_ms: string | number | null;
|
|
127
|
+
admitted: string | number;
|
|
128
|
+
started: string | number;
|
|
129
|
+
completed: string | number;
|
|
130
|
+
failed: string | number;
|
|
131
|
+
retried: string | number;
|
|
132
|
+
ready_truncated?: boolean;
|
|
133
|
+
delayed_truncated?: boolean;
|
|
134
|
+
in_flight_truncated?: boolean;
|
|
135
|
+
blocked_truncated?: boolean;
|
|
136
|
+
dead_lettered_truncated?: boolean;
|
|
137
|
+
flow_available?: boolean;
|
|
138
|
+
flow_unavailable_reason?: string | null;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
function quoteIdentifier(value: string, label: string): string {
|
|
142
|
+
if (!value || Buffer.byteLength(value, 'utf8') > 63) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
`${label} must be a non-empty SQL identifier of at most 63 bytes.`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
return `"${value.replaceAll('"', '""')}"`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function schemaName(schema?: string): string {
|
|
151
|
+
const value = schema?.trim() || 'runtime_scheduler';
|
|
152
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]{0,62}$/.test(value)) {
|
|
153
|
+
throw new Error('schema must be a simple SQL identifier.');
|
|
154
|
+
}
|
|
155
|
+
return quoteIdentifier(value, 'schema');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function numberValue(value: string | number | null | undefined): number {
|
|
159
|
+
if (value === null || value === undefined) return 0;
|
|
160
|
+
const parsed = typeof value === 'number' ? value : Number(value);
|
|
161
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function ageValue(value: string | number | null | undefined): number | null {
|
|
165
|
+
if (value === null || value === undefined) return null;
|
|
166
|
+
const parsed = numberValue(value);
|
|
167
|
+
return parsed > 0 ? Math.floor(parsed) : null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function optionsWithDefaults(
|
|
171
|
+
input: RuntimeQueueHealthOptions = {},
|
|
172
|
+
): RuntimeQueueHealthThresholds {
|
|
173
|
+
const now = input.now ?? new Date();
|
|
174
|
+
const staleAfterMs =
|
|
175
|
+
input.staleAfterMs ?? RUNTIME_QUEUE_HEALTH_DEFAULT_STALE_AFTER_MS;
|
|
176
|
+
const warnAgeMs = input.warnAgeMs ?? RUNTIME_QUEUE_HEALTH_DEFAULT_WARN_AGE_MS;
|
|
177
|
+
const failAgeMs = input.failAgeMs ?? RUNTIME_QUEUE_HEALTH_DEFAULT_FAIL_AGE_MS;
|
|
178
|
+
if (
|
|
179
|
+
!Number.isFinite(staleAfterMs) ||
|
|
180
|
+
staleAfterMs <= 0 ||
|
|
181
|
+
!Number.isFinite(warnAgeMs) ||
|
|
182
|
+
warnAgeMs <= 0 ||
|
|
183
|
+
!Number.isFinite(failAgeMs) ||
|
|
184
|
+
failAgeMs < warnAgeMs
|
|
185
|
+
) {
|
|
186
|
+
throw new Error('Queue health age thresholds are invalid.');
|
|
187
|
+
}
|
|
188
|
+
return { now, staleAfterMs, warnAgeMs, failAgeMs };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function deriveRuntimeQueueHealthStatus(input: {
|
|
192
|
+
depth: RuntimeQueueDepth;
|
|
193
|
+
oldestAgeMs: RuntimeQueueOldestAge;
|
|
194
|
+
options: RuntimeQueueHealthThresholds;
|
|
195
|
+
observedAt: Date;
|
|
196
|
+
statusReason?: string;
|
|
197
|
+
}): { status: RuntimeQueueHealthStatus; statusReason: string } {
|
|
198
|
+
if (input.statusReason)
|
|
199
|
+
return { status: 'unknown', statusReason: input.statusReason };
|
|
200
|
+
const age =
|
|
201
|
+
input.oldestAgeMs.ready ??
|
|
202
|
+
input.oldestAgeMs.inFlight ??
|
|
203
|
+
input.oldestAgeMs.delayed;
|
|
204
|
+
const ageForFailure = [
|
|
205
|
+
input.oldestAgeMs.ready,
|
|
206
|
+
input.oldestAgeMs.inFlight,
|
|
207
|
+
input.oldestAgeMs.blocked,
|
|
208
|
+
input.oldestAgeMs.deadLettered,
|
|
209
|
+
].reduce<number | null>(
|
|
210
|
+
(max, current) =>
|
|
211
|
+
current === null ? max : max === null ? current : Math.max(max, current),
|
|
212
|
+
null,
|
|
213
|
+
);
|
|
214
|
+
const ageForWarning =
|
|
215
|
+
age ?? input.oldestAgeMs.blocked ?? input.oldestAgeMs.deadLettered;
|
|
216
|
+
if (
|
|
217
|
+
input.options.now.getTime() - input.observedAt.getTime() >
|
|
218
|
+
input.options.staleAfterMs
|
|
219
|
+
) {
|
|
220
|
+
return { status: 'unknown', statusReason: 'observation_stale' };
|
|
221
|
+
}
|
|
222
|
+
if (input.depth.deadLettered > 0) {
|
|
223
|
+
return { status: 'red', statusReason: 'dead_lettered_work' };
|
|
224
|
+
}
|
|
225
|
+
if (input.depth.blocked > 0) {
|
|
226
|
+
return { status: 'red', statusReason: 'blocked_work' };
|
|
227
|
+
}
|
|
228
|
+
if (ageForFailure !== null && ageForFailure >= input.options.failAgeMs) {
|
|
229
|
+
return { status: 'red', statusReason: 'oldest_work_exceeded_failure_slo' };
|
|
230
|
+
}
|
|
231
|
+
if (ageForWarning !== null && ageForWarning >= input.options.warnAgeMs) {
|
|
232
|
+
return { status: 'amber', statusReason: 'oldest_work_approaching_slo' };
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
status: 'green',
|
|
236
|
+
statusReason:
|
|
237
|
+
input.depth.ready + input.depth.delayed + input.depth.inFlight === 0
|
|
238
|
+
? 'idle'
|
|
239
|
+
: 'within_slo',
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function projectionFromRow(input: {
|
|
244
|
+
id: string;
|
|
245
|
+
kind: RuntimeQueueHealthProjection['kind'];
|
|
246
|
+
source: RuntimeQueueHealthProjection['source'];
|
|
247
|
+
row: CountRow;
|
|
248
|
+
observedAt: Date;
|
|
249
|
+
options: RuntimeQueueHealthThresholds;
|
|
250
|
+
}): RuntimeQueueHealthProjection {
|
|
251
|
+
const depth: RuntimeQueueDepth = {
|
|
252
|
+
ready: numberValue(input.row.ready),
|
|
253
|
+
delayed: numberValue(input.row.delayed),
|
|
254
|
+
inFlight: numberValue(input.row.in_flight),
|
|
255
|
+
blocked: numberValue(input.row.blocked),
|
|
256
|
+
deadLettered: numberValue(input.row.dead_lettered),
|
|
257
|
+
};
|
|
258
|
+
const oldestAgeMs: RuntimeQueueOldestAge = {
|
|
259
|
+
ready: ageValue(input.row.oldest_ready_ms),
|
|
260
|
+
delayed: ageValue(input.row.oldest_delayed_ms),
|
|
261
|
+
inFlight: ageValue(input.row.oldest_in_flight_ms),
|
|
262
|
+
blocked: ageValue(input.row.oldest_blocked_ms),
|
|
263
|
+
deadLettered: ageValue(input.row.oldest_dead_lettered_ms),
|
|
264
|
+
};
|
|
265
|
+
const lifecycle = {
|
|
266
|
+
started: numberValue(input.row.started),
|
|
267
|
+
completed: numberValue(input.row.completed),
|
|
268
|
+
unresolved: depth.ready + depth.delayed + depth.inFlight + depth.blocked,
|
|
269
|
+
// A trigger gap is the durable work that has not started yet. It is a
|
|
270
|
+
// signal, not a failure: an expected-but-not-started schedule occurrence
|
|
271
|
+
// needs the scheduler's own occurrence ledger to become actionable.
|
|
272
|
+
triggerGap: depth.ready + depth.delayed,
|
|
273
|
+
};
|
|
274
|
+
const derivedStatus = deriveRuntimeQueueHealthStatus({
|
|
275
|
+
depth,
|
|
276
|
+
oldestAgeMs,
|
|
277
|
+
options: input.options,
|
|
278
|
+
observedAt: input.observedAt,
|
|
279
|
+
});
|
|
280
|
+
const hasTruncatedDepth = [
|
|
281
|
+
input.row.ready_truncated,
|
|
282
|
+
input.row.delayed_truncated,
|
|
283
|
+
input.row.in_flight_truncated,
|
|
284
|
+
input.row.blocked_truncated,
|
|
285
|
+
input.row.dead_lettered_truncated,
|
|
286
|
+
].some(Boolean);
|
|
287
|
+
const derived =
|
|
288
|
+
hasTruncatedDepth && derivedStatus.status === 'green'
|
|
289
|
+
? {
|
|
290
|
+
status: 'amber' as const,
|
|
291
|
+
statusReason: 'bounded_depth_sample',
|
|
292
|
+
}
|
|
293
|
+
: derivedStatus;
|
|
294
|
+
const definition: QueueDefinition = {
|
|
295
|
+
id: input.id,
|
|
296
|
+
owner: 'runtime',
|
|
297
|
+
purpose:
|
|
298
|
+
input.kind === 'absurd'
|
|
299
|
+
? 'Run admitted Play runtime tasks to terminal state'
|
|
300
|
+
: input.kind === 'run_ledger_outbox'
|
|
301
|
+
? 'Deliver causal Run Ledger events to control-plane projections'
|
|
302
|
+
: 'Complete durable sandbox deletion obligations',
|
|
303
|
+
unit: 'items',
|
|
304
|
+
kind: input.kind === 'absurd' ? 'fifo' : 'leased_workset',
|
|
305
|
+
ageSlo: {
|
|
306
|
+
warnMs: input.options.warnAgeMs,
|
|
307
|
+
failMs: input.options.failAgeMs,
|
|
308
|
+
},
|
|
309
|
+
staleAfterMs: input.options.staleAfterMs,
|
|
310
|
+
};
|
|
311
|
+
const measureAge = (value: number | null, truncated?: boolean) =>
|
|
312
|
+
value === null
|
|
313
|
+
? notApplicable<number>('no work in this bucket')
|
|
314
|
+
: truncated
|
|
315
|
+
? lowerBound(value)
|
|
316
|
+
: exact(value);
|
|
317
|
+
const measureDepth = (value: number, truncated?: boolean) =>
|
|
318
|
+
truncated ? lowerBound(value) : exact(value);
|
|
319
|
+
const flowAvailable = input.row.flow_available !== false;
|
|
320
|
+
const flowUnavailableReason =
|
|
321
|
+
input.row.flow_unavailable_reason ??
|
|
322
|
+
'runtime queue health does not include an exact rolling lifecycle projection';
|
|
323
|
+
const observation: QueueObservation = {
|
|
324
|
+
observedAt: input.observedAt.getTime(),
|
|
325
|
+
adapter: `runtime-queue-health/${input.source}`,
|
|
326
|
+
depth: {
|
|
327
|
+
ready: measureDepth(depth.ready, input.row.ready_truncated),
|
|
328
|
+
delayed: measureDepth(depth.delayed, input.row.delayed_truncated),
|
|
329
|
+
inFlight: measureDepth(depth.inFlight, input.row.in_flight_truncated),
|
|
330
|
+
blocked: measureDepth(depth.blocked, input.row.blocked_truncated),
|
|
331
|
+
deadLettered: measureDepth(
|
|
332
|
+
depth.deadLettered,
|
|
333
|
+
input.row.dead_lettered_truncated,
|
|
334
|
+
),
|
|
335
|
+
},
|
|
336
|
+
oldestAgeMs: {
|
|
337
|
+
ready: measureAge(oldestAgeMs.ready, input.row.ready_truncated),
|
|
338
|
+
delayed: measureAge(oldestAgeMs.delayed, input.row.delayed_truncated),
|
|
339
|
+
inFlight: measureAge(oldestAgeMs.inFlight, input.row.in_flight_truncated),
|
|
340
|
+
blocked: measureAge(oldestAgeMs.blocked, input.row.blocked_truncated),
|
|
341
|
+
deadLettered: measureAge(
|
|
342
|
+
oldestAgeMs.deadLettered,
|
|
343
|
+
input.row.dead_lettered_truncated,
|
|
344
|
+
),
|
|
345
|
+
},
|
|
346
|
+
flow: {
|
|
347
|
+
admitted: flowAvailable
|
|
348
|
+
? exact(numberValue(input.row.admitted))
|
|
349
|
+
: unavailable(flowUnavailableReason),
|
|
350
|
+
started: flowAvailable
|
|
351
|
+
? exact(lifecycle.started)
|
|
352
|
+
: unavailable(flowUnavailableReason),
|
|
353
|
+
completed: flowAvailable
|
|
354
|
+
? exact(lifecycle.completed)
|
|
355
|
+
: unavailable(flowUnavailableReason),
|
|
356
|
+
failed: flowAvailable
|
|
357
|
+
? exact(numberValue(input.row.failed))
|
|
358
|
+
: unavailable(flowUnavailableReason),
|
|
359
|
+
retried: flowAvailable
|
|
360
|
+
? exact(numberValue(input.row.retried))
|
|
361
|
+
: unavailable(flowUnavailableReason),
|
|
362
|
+
poisoned: flowAvailable
|
|
363
|
+
? exact(depth.deadLettered)
|
|
364
|
+
: unavailable(flowUnavailableReason),
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
const health = deriveQueueHealth(
|
|
368
|
+
definition,
|
|
369
|
+
observation,
|
|
370
|
+
input.options.now.getTime(),
|
|
371
|
+
);
|
|
372
|
+
return {
|
|
373
|
+
id: input.id,
|
|
374
|
+
kind: input.kind,
|
|
375
|
+
source: input.source,
|
|
376
|
+
observedAt: input.observedAt.toISOString(),
|
|
377
|
+
status: derived.status,
|
|
378
|
+
statusReason: derived.statusReason,
|
|
379
|
+
observation,
|
|
380
|
+
health,
|
|
381
|
+
depth,
|
|
382
|
+
oldestAgeMs,
|
|
383
|
+
flow: {
|
|
384
|
+
admitted: numberValue(input.row.admitted),
|
|
385
|
+
started: lifecycle.started,
|
|
386
|
+
completed: lifecycle.completed,
|
|
387
|
+
failed: numberValue(input.row.failed),
|
|
388
|
+
retried: numberValue(input.row.retried),
|
|
389
|
+
poisoned: depth.deadLettered,
|
|
390
|
+
},
|
|
391
|
+
lifecycle,
|
|
392
|
+
thresholds: {
|
|
393
|
+
warnAgeMs: input.options.warnAgeMs,
|
|
394
|
+
failAgeMs: input.options.failAgeMs,
|
|
395
|
+
staleAfterMs: input.options.staleAfterMs,
|
|
396
|
+
},
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function intervalExpr(column: string, nowParameter = '$1'): string {
|
|
401
|
+
return `GREATEST(0, floor(extract(epoch FROM (${nowParameter}::timestamptz - ${column})) * 1000))`;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function absurdProjectionQuery(queueTable: string): string {
|
|
405
|
+
const table = `absurd.${quoteIdentifier(queueTable, 'queue table')}`;
|
|
406
|
+
return `
|
|
407
|
+
WITH sampled AS (
|
|
408
|
+
(SELECT 'ready'::text AS bucket, available_at AS age_at
|
|
409
|
+
FROM ${table}
|
|
410
|
+
WHERE state = 'pending' AND available_at <= $1::timestamptz
|
|
411
|
+
ORDER BY available_at, run_id
|
|
412
|
+
LIMIT $3)
|
|
413
|
+
UNION ALL
|
|
414
|
+
(SELECT 'delayed'::text, available_at
|
|
415
|
+
FROM ${table}
|
|
416
|
+
WHERE state = 'sleeping'
|
|
417
|
+
ORDER BY available_at, run_id
|
|
418
|
+
LIMIT $3)
|
|
419
|
+
UNION ALL
|
|
420
|
+
(SELECT 'delayed'::text, available_at
|
|
421
|
+
FROM ${table}
|
|
422
|
+
WHERE state = 'pending' AND available_at > $1::timestamptz
|
|
423
|
+
ORDER BY available_at, run_id
|
|
424
|
+
LIMIT $3)
|
|
425
|
+
UNION ALL
|
|
426
|
+
(SELECT 'in_flight'::text, coalesce(started_at, created_at)
|
|
427
|
+
FROM ${table}
|
|
428
|
+
WHERE state = 'running'
|
|
429
|
+
AND (claim_expires_at IS NULL OR claim_expires_at > $1::timestamptz)
|
|
430
|
+
ORDER BY available_at, run_id
|
|
431
|
+
LIMIT $3)
|
|
432
|
+
UNION ALL
|
|
433
|
+
(SELECT 'blocked'::text, coalesce(claim_expires_at, started_at, created_at)
|
|
434
|
+
FROM ${table}
|
|
435
|
+
WHERE state = 'running'
|
|
436
|
+
AND claim_expires_at IS NOT NULL
|
|
437
|
+
AND claim_expires_at <= $1::timestamptz
|
|
438
|
+
ORDER BY claim_expires_at, run_id
|
|
439
|
+
LIMIT $3)
|
|
440
|
+
UNION ALL
|
|
441
|
+
(SELECT 'dead_lettered'::text, coalesce(failed_at, created_at)
|
|
442
|
+
FROM ${table}
|
|
443
|
+
WHERE state = 'failed'
|
|
444
|
+
ORDER BY available_at, run_id
|
|
445
|
+
LIMIT $3)
|
|
446
|
+
)
|
|
447
|
+
SELECT
|
|
448
|
+
least(count(*) FILTER (WHERE bucket = 'ready'), $4) AS ready,
|
|
449
|
+
least(count(*) FILTER (WHERE bucket = 'delayed'), $4) AS delayed,
|
|
450
|
+
least(count(*) FILTER (WHERE bucket = 'in_flight'), $4) AS in_flight,
|
|
451
|
+
least(count(*) FILTER (WHERE bucket = 'blocked'), $4) AS blocked,
|
|
452
|
+
least(count(*) FILTER (WHERE bucket = 'dead_lettered'), $4) AS dead_lettered,
|
|
453
|
+
max(${intervalExpr('age_at')}) FILTER (WHERE bucket = 'ready') AS oldest_ready_ms,
|
|
454
|
+
max(${intervalExpr('age_at')}) FILTER (WHERE bucket = 'delayed') AS oldest_delayed_ms,
|
|
455
|
+
max(${intervalExpr('age_at')}) FILTER (WHERE bucket = 'in_flight') AS oldest_in_flight_ms,
|
|
456
|
+
max(${intervalExpr('age_at')}) FILTER (WHERE bucket = 'blocked') AS oldest_blocked_ms,
|
|
457
|
+
max(${intervalExpr('age_at')}) FILTER (WHERE bucket = 'dead_lettered') AS oldest_dead_lettered_ms,
|
|
458
|
+
count(*) FILTER (WHERE bucket = 'ready') > $4 AS ready_truncated,
|
|
459
|
+
count(*) FILTER (WHERE bucket = 'delayed') > $4 AS delayed_truncated,
|
|
460
|
+
count(*) FILTER (WHERE bucket = 'in_flight') > $4 AS in_flight_truncated,
|
|
461
|
+
count(*) FILTER (WHERE bucket = 'blocked') > $4 AS blocked_truncated,
|
|
462
|
+
count(*) FILTER (WHERE bucket = 'dead_lettered') > $4 AS dead_lettered_truncated,
|
|
463
|
+
0::bigint AS admitted,
|
|
464
|
+
0::bigint AS started,
|
|
465
|
+
0::bigint AS completed,
|
|
466
|
+
0::bigint AS failed,
|
|
467
|
+
0::bigint AS retried,
|
|
468
|
+
false AS flow_available,
|
|
469
|
+
'Absurd run history has no bounded exact rolling flow projection'::text AS flow_unavailable_reason
|
|
470
|
+
FROM sampled
|
|
471
|
+
`;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function outboxProjectionQuery(schema: string): string {
|
|
475
|
+
const table = `${schema}."outbox"`;
|
|
476
|
+
return `
|
|
477
|
+
WITH pending_sample AS (
|
|
478
|
+
SELECT
|
|
479
|
+
CASE
|
|
480
|
+
WHEN lease_owner IS NULL AND coalesce(next_attempt_at, created_at) <= $1::timestamptz THEN 'ready'
|
|
481
|
+
WHEN lease_owner IS NULL THEN 'delayed'
|
|
482
|
+
WHEN lease_expires_at > $1::timestamptz THEN 'in_flight'
|
|
483
|
+
ELSE 'blocked'
|
|
484
|
+
END AS bucket,
|
|
485
|
+
CASE
|
|
486
|
+
WHEN lease_owner IS NULL AND coalesce(next_attempt_at, created_at) > $1::timestamptz
|
|
487
|
+
THEN coalesce(next_attempt_at, created_at)
|
|
488
|
+
WHEN lease_owner IS NULL THEN created_at
|
|
489
|
+
WHEN lease_expires_at > $1::timestamptz
|
|
490
|
+
THEN coalesce(delivery_batch_started_at, created_at)
|
|
491
|
+
ELSE coalesce(lease_expires_at, created_at)
|
|
492
|
+
END AS age_at
|
|
493
|
+
FROM ${table}
|
|
494
|
+
WHERE delivered_at IS NULL AND failed_at IS NULL
|
|
495
|
+
ORDER BY coalesce(next_attempt_at, created_at), created_at, event_id
|
|
496
|
+
LIMIT $3
|
|
497
|
+
), dead_sample AS (
|
|
498
|
+
SELECT 'dead_lettered'::text AS bucket, coalesce(failed_at, created_at) AS age_at
|
|
499
|
+
FROM ${table}
|
|
500
|
+
WHERE delivered_at IS NULL AND failed_at IS NOT NULL
|
|
501
|
+
ORDER BY failed_at, event_id
|
|
502
|
+
LIMIT $3
|
|
503
|
+
), sampled AS (
|
|
504
|
+
SELECT * FROM pending_sample
|
|
505
|
+
UNION ALL
|
|
506
|
+
SELECT * FROM dead_sample
|
|
507
|
+
)
|
|
508
|
+
SELECT
|
|
509
|
+
least(count(*) FILTER (WHERE bucket = 'ready'), $4) AS ready,
|
|
510
|
+
least(count(*) FILTER (WHERE bucket = 'delayed'), $4) AS delayed,
|
|
511
|
+
least(count(*) FILTER (WHERE bucket = 'in_flight'), $4) AS in_flight,
|
|
512
|
+
least(count(*) FILTER (WHERE bucket = 'blocked'), $4) AS blocked,
|
|
513
|
+
least(count(*) FILTER (WHERE bucket = 'dead_lettered'), $4) AS dead_lettered,
|
|
514
|
+
max(${intervalExpr('age_at')}) FILTER (WHERE bucket = 'ready') AS oldest_ready_ms,
|
|
515
|
+
max(${intervalExpr('age_at')}) FILTER (WHERE bucket = 'delayed') AS oldest_delayed_ms,
|
|
516
|
+
max(${intervalExpr('age_at')}) FILTER (WHERE bucket = 'in_flight') AS oldest_in_flight_ms,
|
|
517
|
+
max(${intervalExpr('age_at')}) FILTER (WHERE bucket = 'blocked') AS oldest_blocked_ms,
|
|
518
|
+
max(${intervalExpr('age_at')}) FILTER (WHERE bucket = 'dead_lettered') AS oldest_dead_lettered_ms,
|
|
519
|
+
count(*) FILTER (WHERE bucket <> 'dead_lettered') > $4 AS ready_truncated,
|
|
520
|
+
count(*) FILTER (WHERE bucket <> 'dead_lettered') > $4 AS delayed_truncated,
|
|
521
|
+
count(*) FILTER (WHERE bucket <> 'dead_lettered') > $4 AS in_flight_truncated,
|
|
522
|
+
count(*) FILTER (WHERE bucket <> 'dead_lettered') > $4 AS blocked_truncated,
|
|
523
|
+
count(*) FILTER (WHERE bucket = 'dead_lettered') > $4 AS dead_lettered_truncated,
|
|
524
|
+
0::bigint AS admitted,
|
|
525
|
+
0::bigint AS started,
|
|
526
|
+
0::bigint AS completed,
|
|
527
|
+
0::bigint AS failed,
|
|
528
|
+
0::bigint AS retried,
|
|
529
|
+
false AS flow_available,
|
|
530
|
+
'Outbox health has no bounded exact rolling flow projection'::text AS flow_unavailable_reason
|
|
531
|
+
FROM sampled
|
|
532
|
+
`;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function cleanupProjectionQuery(schema: string): string {
|
|
536
|
+
const table = `${schema}."sandbox_cleanup_jobs_v2"`;
|
|
537
|
+
return `
|
|
538
|
+
WITH sampled AS (
|
|
539
|
+
SELECT
|
|
540
|
+
CASE
|
|
541
|
+
WHEN blocked_at IS NOT NULL THEN 'blocked'
|
|
542
|
+
WHEN lease_owner IS NOT NULL AND lease_expires_at > $1::timestamptz THEN 'in_flight'
|
|
543
|
+
WHEN lease_owner IS NOT NULL OR status = 'deleting' THEN 'blocked'
|
|
544
|
+
WHEN available_at > $1::timestamptz THEN 'delayed'
|
|
545
|
+
ELSE 'ready'
|
|
546
|
+
END AS bucket,
|
|
547
|
+
CASE
|
|
548
|
+
WHEN blocked_at IS NOT NULL THEN blocked_at
|
|
549
|
+
WHEN lease_owner IS NOT NULL AND lease_expires_at > $1::timestamptz
|
|
550
|
+
THEN coalesce(updated_at, created_at)
|
|
551
|
+
WHEN lease_owner IS NOT NULL OR status = 'deleting'
|
|
552
|
+
THEN coalesce(lease_expires_at, created_at)
|
|
553
|
+
WHEN available_at > $1::timestamptz THEN available_at
|
|
554
|
+
ELSE created_at
|
|
555
|
+
END AS age_at
|
|
556
|
+
FROM ${table}
|
|
557
|
+
WHERE status <> 'deleted'
|
|
558
|
+
ORDER BY available_at, created_at, provider, sandbox_id
|
|
559
|
+
LIMIT $3
|
|
560
|
+
)
|
|
561
|
+
SELECT
|
|
562
|
+
least(count(*) FILTER (WHERE bucket = 'ready'), $4) AS ready,
|
|
563
|
+
least(count(*) FILTER (WHERE bucket = 'delayed'), $4) AS delayed,
|
|
564
|
+
least(count(*) FILTER (WHERE bucket = 'in_flight'), $4) AS in_flight,
|
|
565
|
+
least(count(*) FILTER (WHERE bucket = 'blocked'), $4) AS blocked,
|
|
566
|
+
0::bigint AS dead_lettered,
|
|
567
|
+
max(${intervalExpr('age_at')}) FILTER (WHERE bucket = 'ready') AS oldest_ready_ms,
|
|
568
|
+
max(${intervalExpr('age_at')}) FILTER (WHERE bucket = 'delayed') AS oldest_delayed_ms,
|
|
569
|
+
max(${intervalExpr('age_at')}) FILTER (WHERE bucket = 'in_flight') AS oldest_in_flight_ms,
|
|
570
|
+
max(${intervalExpr('age_at')}) FILTER (WHERE bucket = 'blocked') AS oldest_blocked_ms,
|
|
571
|
+
NULL::bigint AS oldest_dead_lettered_ms,
|
|
572
|
+
count(*) > $4 AS ready_truncated,
|
|
573
|
+
count(*) > $4 AS delayed_truncated,
|
|
574
|
+
count(*) > $4 AS in_flight_truncated,
|
|
575
|
+
count(*) > $4 AS blocked_truncated,
|
|
576
|
+
false AS dead_lettered_truncated,
|
|
577
|
+
0::bigint AS admitted,
|
|
578
|
+
0::bigint AS started,
|
|
579
|
+
0::bigint AS completed,
|
|
580
|
+
0::bigint AS failed,
|
|
581
|
+
0::bigint AS retried,
|
|
582
|
+
false AS flow_available,
|
|
583
|
+
'Sandbox cleanup health has no bounded exact rolling flow projection'::text AS flow_unavailable_reason
|
|
584
|
+
FROM sampled
|
|
585
|
+
`;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function rowFromResult(result: { rows: CountRow[] }): CountRow {
|
|
589
|
+
return (
|
|
590
|
+
result.rows[0] ?? {
|
|
591
|
+
ready: 0,
|
|
592
|
+
delayed: 0,
|
|
593
|
+
in_flight: 0,
|
|
594
|
+
blocked: 0,
|
|
595
|
+
dead_lettered: 0,
|
|
596
|
+
oldest_ready_ms: null,
|
|
597
|
+
oldest_delayed_ms: null,
|
|
598
|
+
oldest_in_flight_ms: null,
|
|
599
|
+
oldest_blocked_ms: null,
|
|
600
|
+
oldest_dead_lettered_ms: null,
|
|
601
|
+
admitted: 0,
|
|
602
|
+
started: 0,
|
|
603
|
+
completed: 0,
|
|
604
|
+
failed: 0,
|
|
605
|
+
retried: 0,
|
|
606
|
+
}
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** Read all bounded runtime queue projections on one scheduler connection. */
|
|
611
|
+
export async function readRuntimeQueueHealthFromClient(
|
|
612
|
+
client: PostgresSchedulerQueryClient,
|
|
613
|
+
input: RuntimeQueueHealthOptions = {},
|
|
614
|
+
): Promise<RuntimeQueueHealthSnapshot> {
|
|
615
|
+
const options = optionsWithDefaults(input);
|
|
616
|
+
const now = options.now;
|
|
617
|
+
const schema = schemaName(input.schema);
|
|
618
|
+
const sampleLimit = RUNTIME_QUEUE_HEALTH_BUCKET_SAMPLE_LIMIT;
|
|
619
|
+
const maxQueues = Math.max(
|
|
620
|
+
1,
|
|
621
|
+
Math.min(
|
|
622
|
+
input.maxAbsurdQueues ?? RUNTIME_QUEUE_HEALTH_MAX_ABSURD_QUEUES,
|
|
623
|
+
RUNTIME_QUEUE_HEALTH_MAX_ABSURD_QUEUES,
|
|
624
|
+
),
|
|
625
|
+
);
|
|
626
|
+
const requestedNames = input.absurdQueueNames?.length
|
|
627
|
+
? [
|
|
628
|
+
...new Set(
|
|
629
|
+
input.absurdQueueNames.map((name) => name.trim()).filter(Boolean),
|
|
630
|
+
),
|
|
631
|
+
]
|
|
632
|
+
: undefined;
|
|
633
|
+
if (requestedNames && requestedNames.length > maxQueues) {
|
|
634
|
+
throw new Error(
|
|
635
|
+
`Requested ${requestedNames.length} Absurd queues, exceeding the ${maxQueues} queue health limit.`,
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
const registry = requestedNames
|
|
639
|
+
? await client.query<{ queue_name: string }>(
|
|
640
|
+
`SELECT queue_name FROM absurd.queues WHERE queue_name = ANY($1::text[]) ORDER BY queue_name LIMIT $2`,
|
|
641
|
+
[requestedNames, maxQueues],
|
|
642
|
+
)
|
|
643
|
+
: await client.query<{ queue_name: string }>(
|
|
644
|
+
`SELECT queue_name FROM absurd.queues ORDER BY queue_name LIMIT $1`,
|
|
645
|
+
[maxQueues],
|
|
646
|
+
);
|
|
647
|
+
const queueNames = registry.rows.map((row) => row.queue_name);
|
|
648
|
+
if (requestedNames) {
|
|
649
|
+
const found = new Set(queueNames);
|
|
650
|
+
const missing = requestedNames.filter((name) => !found.has(name));
|
|
651
|
+
if (missing.length > 0) {
|
|
652
|
+
throw new Error(
|
|
653
|
+
`Requested Absurd queues are not registered: ${missing.join(', ')}`,
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
const projections: RuntimeQueueHealthProjection[] = [];
|
|
658
|
+
for (const queueName of queueNames) {
|
|
659
|
+
const result = await client.query<CountRow>(
|
|
660
|
+
absurdProjectionQuery(`r_${queueName}`),
|
|
661
|
+
[now, now, sampleLimit + 1, sampleLimit],
|
|
662
|
+
);
|
|
663
|
+
projections.push(
|
|
664
|
+
projectionFromRow({
|
|
665
|
+
id: `absurd:${queueName}`,
|
|
666
|
+
kind: 'absurd',
|
|
667
|
+
source: 'absurd',
|
|
668
|
+
row: rowFromResult(result),
|
|
669
|
+
observedAt: now,
|
|
670
|
+
options,
|
|
671
|
+
}),
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
const [outbox, cleanup] = await Promise.all([
|
|
675
|
+
client.query<CountRow>(outboxProjectionQuery(schema), [
|
|
676
|
+
now,
|
|
677
|
+
now,
|
|
678
|
+
sampleLimit + 1,
|
|
679
|
+
sampleLimit,
|
|
680
|
+
]),
|
|
681
|
+
client.query<CountRow>(cleanupProjectionQuery(schema), [
|
|
682
|
+
now,
|
|
683
|
+
now,
|
|
684
|
+
sampleLimit + 1,
|
|
685
|
+
sampleLimit,
|
|
686
|
+
]),
|
|
687
|
+
]);
|
|
688
|
+
projections.push(
|
|
689
|
+
projectionFromRow({
|
|
690
|
+
id: 'runtime:run-ledger-outbox',
|
|
691
|
+
kind: 'run_ledger_outbox',
|
|
692
|
+
source: 'runtime_postgres',
|
|
693
|
+
row: rowFromResult(outbox),
|
|
694
|
+
observedAt: now,
|
|
695
|
+
options,
|
|
696
|
+
}),
|
|
697
|
+
projectionFromRow({
|
|
698
|
+
id: 'runtime:sandbox-cleanup-jobs-v2',
|
|
699
|
+
kind: 'sandbox_cleanup',
|
|
700
|
+
source: 'runtime_postgres',
|
|
701
|
+
row: rowFromResult(cleanup),
|
|
702
|
+
observedAt: now,
|
|
703
|
+
options,
|
|
704
|
+
}),
|
|
705
|
+
);
|
|
706
|
+
const summary = {
|
|
707
|
+
green: projections.filter((p) => p.status === 'green').length,
|
|
708
|
+
amber: projections.filter((p) => p.status === 'amber').length,
|
|
709
|
+
red: projections.filter((p) => p.status === 'red').length,
|
|
710
|
+
unknown: projections.filter((p) => p.status === 'unknown').length,
|
|
711
|
+
};
|
|
712
|
+
return {
|
|
713
|
+
observedAt: now.toISOString(),
|
|
714
|
+
staleAfterMs: options.staleAfterMs,
|
|
715
|
+
queues: projections,
|
|
716
|
+
summary,
|
|
717
|
+
};
|
|
718
|
+
}
|