@cronwatch/sdk 0.9.0 → 0.10.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.
- package/dist/anthropic.d.cts +1 -1
- package/dist/anthropic.d.ts +1 -1
- package/dist/bugsnag.d.cts +1 -1
- package/dist/bugsnag.d.ts +1 -1
- package/dist/{client-CQm-8-mR.d.ts → client-BcIjVBiZ.d.ts} +68 -13
- package/dist/{client-D35cfnNV.d.cts → client-DUoSea7X.d.cts} +68 -13
- package/dist/d1.cjs.map +1 -1
- package/dist/d1.d.cts +1 -1
- package/dist/d1.d.ts +1 -1
- package/dist/d1.js.map +1 -1
- package/dist/datadog.d.cts +1 -1
- package/dist/datadog.d.ts +1 -1
- package/dist/discord.cjs +17 -2
- package/dist/discord.cjs.map +1 -1
- package/dist/discord.d.cts +11 -2
- package/dist/discord.d.ts +11 -2
- package/dist/discord.js +16 -3
- package/dist/discord.js.map +1 -1
- package/dist/{email-D1d5niMy.d.cts → email-JZs3vxwq.d.cts} +1 -1
- package/dist/{email-BxCp9RQV.d.ts → email-VrGsVz43.d.ts} +1 -1
- package/dist/honeybadger.d.cts +1 -1
- package/dist/honeybadger.d.ts +1 -1
- package/dist/index.cjs +207 -88
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +4 -4
- package/dist/index.d.ts +4 -4
- package/dist/index.js +207 -88
- package/dist/index.js.map +1 -1
- package/dist/mailgun.d.cts +2 -2
- package/dist/mailgun.d.ts +2 -2
- package/dist/newrelic.d.cts +1 -1
- package/dist/newrelic.d.ts +1 -1
- package/dist/pg-cron.cjs +2 -1
- package/dist/pg-cron.cjs.map +1 -1
- package/dist/pg-cron.d.cts +2 -2
- package/dist/pg-cron.d.ts +2 -2
- package/dist/pg-cron.js +2 -1
- package/dist/pg-cron.js.map +1 -1
- package/dist/postgres.cjs.map +1 -1
- package/dist/postgres.d.cts +1 -1
- package/dist/postgres.d.ts +1 -1
- package/dist/postgres.js.map +1 -1
- package/dist/postmark.d.cts +2 -2
- package/dist/postmark.d.ts +2 -2
- package/dist/resend.d.cts +2 -2
- package/dist/resend.d.ts +2 -2
- package/dist/rollbar.d.cts +1 -1
- package/dist/rollbar.d.ts +1 -1
- package/dist/sendgrid.d.cts +2 -2
- package/dist/sendgrid.d.ts +2 -2
- package/dist/sentry.d.cts +1 -1
- package/dist/sentry.d.ts +1 -1
- package/dist/ses.d.cts +2 -2
- package/dist/ses.d.ts +2 -2
- package/dist/slack.d.cts +1 -1
- package/dist/slack.d.ts +1 -1
- package/dist/sqlite.cjs.map +1 -1
- package/dist/sqlite.d.cts +1 -1
- package/dist/sqlite.d.ts +1 -1
- package/dist/sqlite.js.map +1 -1
- package/dist/twilio.d.cts +1 -1
- package/dist/twilio.d.ts +1 -1
- package/dist/{types-Cz47Boks.d.cts → types-DyWCxlSi.d.cts} +14 -1
- package/dist/{types-Cz47Boks.d.ts → types-DyWCxlSi.d.ts} +14 -1
- package/dist/webhook.d.cts +1 -1
- package/dist/webhook.d.ts +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -268,6 +268,9 @@ function runDuration(startedAt, finishedAt) {
|
|
|
268
268
|
const ms = finishedAt - startedAt;
|
|
269
269
|
return ms > 0 ? Math.min(ms, MAX_DURATION_MS) : 0;
|
|
270
270
|
}
|
|
271
|
+
function silenceEnd(now, ms) {
|
|
272
|
+
return Math.min(now + Math.floor(Math.min(ms, MAX_DURATION_MS)), MAX_DURATION_MS);
|
|
273
|
+
}
|
|
271
274
|
function stateVersion(state) {
|
|
272
275
|
const version = state?.version;
|
|
273
276
|
return typeof version === "number" && Number.isSafeInteger(version) && version >= 0 ? version + 0 : 0;
|
|
@@ -282,15 +285,64 @@ function emptyState(job) {
|
|
|
282
285
|
}
|
|
283
286
|
function normalizeState(state, job) {
|
|
284
287
|
if (!state) return emptyState(job);
|
|
288
|
+
const { sending, ...rest } = state;
|
|
285
289
|
return {
|
|
286
290
|
...emptyState(job),
|
|
287
|
-
...
|
|
291
|
+
...rest,
|
|
288
292
|
open: { ...state.open },
|
|
289
293
|
consecutiveFailures: failureCount(state),
|
|
290
294
|
pendingRecovery: [...state.pendingRecovery ?? []],
|
|
291
|
-
undelivered: [...state.undelivered ?? []]
|
|
295
|
+
undelivered: [...state.undelivered ?? []],
|
|
296
|
+
...Array.isArray(sending) && sending.length > 0 ? { sending: [...sending] } : {}
|
|
292
297
|
};
|
|
293
298
|
}
|
|
299
|
+
var MAX_UNDELIVERED = 20;
|
|
300
|
+
var SEND_LEASE_MS = 5 * 6e4;
|
|
301
|
+
function alertKey(alert) {
|
|
302
|
+
return `${alert.type}|${alert.at}|${alert.run?.id ?? ""}`;
|
|
303
|
+
}
|
|
304
|
+
function newest(list, max) {
|
|
305
|
+
return { kept: list.slice(-max), dropped: Math.max(0, list.length - max) };
|
|
306
|
+
}
|
|
307
|
+
function queueUndelivered(state, alerts) {
|
|
308
|
+
const next = cloneState(state);
|
|
309
|
+
const byKey = new Map(alerts.map((alert) => [alertKey(alert), alert]));
|
|
310
|
+
const queue = next.undelivered.map((alert) => byKey.get(alertKey(alert)) ?? alert);
|
|
311
|
+
const known = new Set(queue.map(alertKey));
|
|
312
|
+
queue.push(...alerts.filter((alert) => !known.has(alertKey(alert))));
|
|
313
|
+
const { kept, dropped } = newest(queue, MAX_UNDELIVERED);
|
|
314
|
+
next.undelivered = kept;
|
|
315
|
+
return { state: next, dropped };
|
|
316
|
+
}
|
|
317
|
+
function holdAlerts(state, alerts, until, deferred) {
|
|
318
|
+
if (alerts.length === 0) return { state, dropped: 0 };
|
|
319
|
+
if (deferred) return queueUndelivered(state, alerts);
|
|
320
|
+
const next = cloneState(state);
|
|
321
|
+
const { kept, dropped } = newest([...next.sending ?? [], ...alerts.map((alert) => ({ until, alert }))], MAX_UNDELIVERED);
|
|
322
|
+
next.sending = kept;
|
|
323
|
+
return { state: next, dropped };
|
|
324
|
+
}
|
|
325
|
+
function releaseSending(state, now) {
|
|
326
|
+
const sending = state.sending ?? [];
|
|
327
|
+
const lapsed = sending.filter((entry) => !(typeof entry?.until === "number" && entry.until > now));
|
|
328
|
+
if (lapsed.length === 0) return { state, dropped: 0 };
|
|
329
|
+
const held = sending.filter((entry) => !lapsed.includes(entry));
|
|
330
|
+
return queueUndelivered(
|
|
331
|
+
{ ...state, sending: held },
|
|
332
|
+
lapsed.filter((entry) => typeof entry?.alert === "object" && entry.alert !== null).map((entry) => entry.alert)
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
function recordSent(state, delivered, failed, stale, now) {
|
|
336
|
+
const next = cloneState(state);
|
|
337
|
+
const done = new Set([...delivered, ...stale].map(alertKey));
|
|
338
|
+
next.undelivered = next.undelivered.filter((alert) => !done.has(alertKey(alert)));
|
|
339
|
+
const sent = new Set([...delivered, ...failed, ...stale].map(alertKey));
|
|
340
|
+
const held = (next.sending ?? []).filter((entry) => !(typeof entry?.alert === "object" && entry.alert !== null && sent.has(alertKey(entry.alert))));
|
|
341
|
+
if (held.length > 0) next.sending = held;
|
|
342
|
+
else delete next.sending;
|
|
343
|
+
if (delivered.length > 0) next.lastAlertAt = now;
|
|
344
|
+
return queueUndelivered(next, failed);
|
|
345
|
+
}
|
|
294
346
|
function cloneState(state) {
|
|
295
347
|
return normalizeState(state, state.job);
|
|
296
348
|
}
|
|
@@ -594,7 +646,16 @@ var OUTPUT_CAP = 16 * 1024;
|
|
|
594
646
|
function capOutput(text) {
|
|
595
647
|
const clean = stripNul(text);
|
|
596
648
|
if (clean.length <= OUTPUT_CAP) return clean;
|
|
597
|
-
return
|
|
649
|
+
return TRIMMED + clean.slice(clean.length - OUTPUT_CAP);
|
|
650
|
+
}
|
|
651
|
+
var TRIMMED = "[earlier output trimmed]\n";
|
|
652
|
+
var REDACT_EDGE = 3 * (OUTPUT_CAP + 1024);
|
|
653
|
+
function redactAndCap(text, redact) {
|
|
654
|
+
const clean = stripNul(text);
|
|
655
|
+
const from = clean.length - (OUTPUT_CAP + REDACT_EDGE);
|
|
656
|
+
if (from <= 0) return capOutput(redact(clean));
|
|
657
|
+
const redacted = stripNul(redact(clean.slice(from)));
|
|
658
|
+
return TRIMMED + redacted.slice(Math.max(redacted.length - OUTPUT_CAP, REDACT_EDGE));
|
|
598
659
|
}
|
|
599
660
|
function stripNul(text) {
|
|
600
661
|
return text.includes("\0") ? text.replace(/\u0000/g, "") : text;
|
|
@@ -602,9 +663,6 @@ function stripNul(text) {
|
|
|
602
663
|
function stripJsonNul(json2) {
|
|
603
664
|
return json2.includes("\\u0000") ? json2.replace(/\\(u0000|[\s\S])/g, (escape, next) => next === "u0000" ? "" : escape) : json2;
|
|
604
665
|
}
|
|
605
|
-
function errorMessage(error) {
|
|
606
|
-
return capOutput(describeError(error));
|
|
607
|
-
}
|
|
608
666
|
function describeError(error) {
|
|
609
667
|
if (error instanceof Error) {
|
|
610
668
|
const frames = (error.stack ?? "").split("\n").filter((line) => /^\s+at /.test(line)).slice(0, 5);
|
|
@@ -713,7 +771,7 @@ function createRecorder(run) {
|
|
|
713
771
|
};
|
|
714
772
|
return {
|
|
715
773
|
context,
|
|
716
|
-
output: () => lines.length === 0 ? null :
|
|
774
|
+
output: () => lines.length === 0 ? null : lines.join("\n"),
|
|
717
775
|
expectText: () => {
|
|
718
776
|
if (lines.length === 0) return null;
|
|
719
777
|
const all = lines.join("\n");
|
|
@@ -1454,7 +1512,7 @@ function jobPage(job, runs, now, base, complete = true) {
|
|
|
1454
1512
|
run.error ? `<details class="out error" open><summary>error</summary><pre>${h(run.error)}</pre></details>` : "",
|
|
1455
1513
|
run.output ? `<details class="out"${run.status === "ok" ? "" : " open"}><summary>output</summary><pre>${h(run.output)}</pre></details>` : ""
|
|
1456
1514
|
].join("");
|
|
1457
|
-
const metrics = Object.entries(run.metrics).map(([k, v]) => `<span><span class="k">${h(k)}</span> ${h(Number.isInteger(v) ? v : v.toFixed(4))}</span>`).join("");
|
|
1515
|
+
const metrics = Object.entries(run.metrics ?? {}).filter(([, v]) => typeof v === "number" && Number.isFinite(v)).map(([k, v]) => `<span><span class="k">${h(k)}</span> ${h(Number.isInteger(v) ? v : v.toFixed(4))}</span>`).join("");
|
|
1458
1516
|
return `<tr${detail ? ` class="has-detail"` : ""}>
|
|
1459
1517
|
<td class="nowrap">${runState(run)}</td>
|
|
1460
1518
|
<td class="nowrap">${h(when2(run.startedAt, now))} <span class="muted">UTC</span><span class="sub">${stamp(run.startedAt, now)}</span></td>
|
|
@@ -1933,11 +1991,11 @@ function memory() {
|
|
|
1933
1991
|
return true;
|
|
1934
1992
|
},
|
|
1935
1993
|
async prune(before) {
|
|
1936
|
-
const
|
|
1937
|
-
for (const run of runs.values())
|
|
1994
|
+
const newest2 = /* @__PURE__ */ new Map();
|
|
1995
|
+
for (const run of runs.values()) newest2.set(run.job, Math.max(newest2.get(run.job) ?? -Infinity, run.startedAt));
|
|
1938
1996
|
let n = 0;
|
|
1939
1997
|
for (const [id, run] of runs) {
|
|
1940
|
-
if (run.status !== "running" && run.startedAt < before && run.startedAt <
|
|
1998
|
+
if (run.status !== "running" && run.startedAt < before && run.startedAt < newest2.get(run.job)) {
|
|
1941
1999
|
runs.delete(id);
|
|
1942
2000
|
order.delete(id);
|
|
1943
2001
|
n++;
|
|
@@ -1953,7 +2011,6 @@ var NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/;
|
|
|
1953
2011
|
var TRIAGE_TIMEOUT_MS = 25e3;
|
|
1954
2012
|
var CHANNEL_TIMEOUT_MS = 15e3;
|
|
1955
2013
|
var PRUNE_INTERVAL_MS = 60 * 6e4;
|
|
1956
|
-
var MAX_UNDELIVERED = 20;
|
|
1957
2014
|
var RETRY_BUDGET_MS = 2e4;
|
|
1958
2015
|
var STATE_ATTEMPTS = 10;
|
|
1959
2016
|
var HISTORY_PAGE = BASELINE_WINDOW + 5;
|
|
@@ -1994,9 +2051,6 @@ function validateDefinition(def) {
|
|
|
1994
2051
|
function sameState(a, b) {
|
|
1995
2052
|
return JSON.stringify(a) === JSON.stringify(b);
|
|
1996
2053
|
}
|
|
1997
|
-
function alertKey(alert) {
|
|
1998
|
-
return `${alert.type}|${alert.at}|${alert.run?.id ?? ""}`;
|
|
1999
|
-
}
|
|
2000
2054
|
var CronWatch = class {
|
|
2001
2055
|
store;
|
|
2002
2056
|
alerts;
|
|
@@ -2163,19 +2217,48 @@ var CronWatch = class {
|
|
|
2163
2217
|
* that replaced it, never its own over it, and one forgotten since writes
|
|
2164
2218
|
* its own. The writes of one name take turns, in the order they were asked
|
|
2165
2219
|
* for, so one still under way cannot land after a later one; and a name
|
|
2166
|
-
* declared again while its write was under way is still to be written.
|
|
2220
|
+
* declared again while its write was under way is still to be written. A
|
|
2221
|
+
* name is marked as written only while that same declaration stands, so a
|
|
2222
|
+
* forget that lands during the write (deleting the row after it) leaves
|
|
2223
|
+
* the name to be written again, as does one forgotten before it.
|
|
2224
|
+
*
|
|
2225
|
+
* With `confirm`, as a run starts, a name already written is read back:
|
|
2226
|
+
* another process may have forgotten the job since, and a job still
|
|
2227
|
+
* declared here comes back on its next run.
|
|
2167
2228
|
*/
|
|
2168
|
-
async sync(definition) {
|
|
2229
|
+
async sync(definition, confirm = false) {
|
|
2169
2230
|
await this.ensureReady();
|
|
2170
2231
|
const name = definition.name;
|
|
2171
|
-
if (this.synced.has(name))
|
|
2232
|
+
if (this.synced.has(name)) {
|
|
2233
|
+
if (!confirm || await this.store.getJob(name)) return;
|
|
2234
|
+
this.synced.delete(name);
|
|
2235
|
+
}
|
|
2172
2236
|
await this.serial(name, async () => {
|
|
2173
2237
|
if (this.synced.has(name)) return;
|
|
2174
2238
|
const standing = this.definitions.get(name) ?? definition;
|
|
2175
2239
|
await this.store.upsertJob(toStored(standing), this.now());
|
|
2176
|
-
if (
|
|
2240
|
+
if (this.definitions.get(name) === standing) this.synced.add(name);
|
|
2177
2241
|
}, this.syncing);
|
|
2178
2242
|
}
|
|
2243
|
+
/**
|
|
2244
|
+
* Every stored job, once each declaration has been written. A job declared
|
|
2245
|
+
* here that the store no longer has was forgotten by another process after
|
|
2246
|
+
* this one wrote it: it is written again, as its next run would, so it is
|
|
2247
|
+
* checked and shown while any process still declares it.
|
|
2248
|
+
*/
|
|
2249
|
+
async storedJobs() {
|
|
2250
|
+
for (const definition of this.definitions.values()) await this.sync(definition);
|
|
2251
|
+
const jobs = await this.store.listJobs();
|
|
2252
|
+
const listed = new Set(jobs.map((job) => job.name));
|
|
2253
|
+
const missing = [...this.definitions.values()].filter((definition) => !listed.has(definition.name));
|
|
2254
|
+
if (missing.length === 0) return jobs;
|
|
2255
|
+
for (const definition of missing) {
|
|
2256
|
+
if (this.definitions.get(definition.name) !== definition) continue;
|
|
2257
|
+
this.synced.delete(definition.name);
|
|
2258
|
+
await this.sync(definition);
|
|
2259
|
+
}
|
|
2260
|
+
return this.store.listJobs();
|
|
2261
|
+
}
|
|
2179
2262
|
/**
|
|
2180
2263
|
* Runs `fn` after every earlier state update for the same job has settled,
|
|
2181
2264
|
* so two runs (or a run and a check) in this process never read and write
|
|
@@ -2251,7 +2334,7 @@ var CronWatch = class {
|
|
|
2251
2334
|
};
|
|
2252
2335
|
let recorded = false;
|
|
2253
2336
|
try {
|
|
2254
|
-
await this.sync(definition);
|
|
2337
|
+
await this.sync(definition, true);
|
|
2255
2338
|
await this.store.insertRun({ ...run });
|
|
2256
2339
|
recorded = true;
|
|
2257
2340
|
} catch (e) {
|
|
@@ -2276,7 +2359,7 @@ var CronWatch = class {
|
|
|
2276
2359
|
run.finishedAt = finishedAt;
|
|
2277
2360
|
run.durationMs = runDuration(startedAt, finishedAt);
|
|
2278
2361
|
run.metrics = recorder.metrics();
|
|
2279
|
-
run.output = recorder.output() ?? (typeof result === "string" ?
|
|
2362
|
+
run.output = recorder.output() ?? (typeof result === "string" ? result : null);
|
|
2280
2363
|
this.conclude(definition, run, result, error, threw, recorder.expectText() ?? (typeof result === "string" ? result : null));
|
|
2281
2364
|
await started;
|
|
2282
2365
|
try {
|
|
@@ -2289,12 +2372,13 @@ var CronWatch = class {
|
|
|
2289
2372
|
}
|
|
2290
2373
|
/**
|
|
2291
2374
|
* Sets a finished run's status and error from how it ended, then redacts
|
|
2292
|
-
* its output and error. Shared by execute()
|
|
2375
|
+
* its output and error and caps them, in that order. Shared by execute()
|
|
2376
|
+
* and RunHandle.finish().
|
|
2293
2377
|
*/
|
|
2294
2378
|
conclude(definition, run, result, error, threw, expectText) {
|
|
2295
2379
|
if (threw) {
|
|
2296
2380
|
run.status = "failed";
|
|
2297
|
-
run.error =
|
|
2381
|
+
run.error = describeError(error);
|
|
2298
2382
|
} else if (result instanceof Response && result.status >= 400) {
|
|
2299
2383
|
run.status = "failed";
|
|
2300
2384
|
run.error = `HTTP ${result.status}${result.statusText ? ` ${result.statusText}` : ""}`;
|
|
@@ -2307,8 +2391,8 @@ var CronWatch = class {
|
|
|
2307
2391
|
run.status = "ok";
|
|
2308
2392
|
}
|
|
2309
2393
|
}
|
|
2310
|
-
if (run.output !== null) run.output =
|
|
2311
|
-
if (run.error !== null) run.error =
|
|
2394
|
+
if (run.output !== null) run.output = redactAndCap(run.output, this.redact);
|
|
2395
|
+
if (run.error !== null) run.error = redactAndCap(run.error, this.redact);
|
|
2312
2396
|
}
|
|
2313
2397
|
/**
|
|
2314
2398
|
* Writes a finished run and evaluates it. `recorded` says whether its
|
|
@@ -2403,7 +2487,7 @@ ${id}`;
|
|
|
2403
2487
|
};
|
|
2404
2488
|
let recorded = false;
|
|
2405
2489
|
try {
|
|
2406
|
-
await this.sync(definition);
|
|
2490
|
+
await this.sync(definition, true);
|
|
2407
2491
|
await this.store.insertRun({ ...run });
|
|
2408
2492
|
recorded = true;
|
|
2409
2493
|
} catch (e) {
|
|
@@ -2503,14 +2587,15 @@ ${id}`;
|
|
|
2503
2587
|
const result = typeof outcome === "string" ? outcome : failed ? void 0 : outcome?.result;
|
|
2504
2588
|
const error = failed ? outcome.error : void 0;
|
|
2505
2589
|
const finishedAt = self.now();
|
|
2506
|
-
const added = recorder.output() ?? (typeof result === "string" ?
|
|
2590
|
+
const added = recorder.output() ?? (typeof result === "string" ? result : null);
|
|
2507
2591
|
const run = {
|
|
2508
2592
|
...from,
|
|
2509
2593
|
status: "running",
|
|
2510
2594
|
finishedAt,
|
|
2511
2595
|
durationMs: runDuration(from.startedAt, finishedAt),
|
|
2512
2596
|
error: null,
|
|
2513
|
-
|
|
2597
|
+
// Capped by conclude(), after it is redacted.
|
|
2598
|
+
output: joinLines(from.output, added),
|
|
2514
2599
|
metrics: { ...from.metrics, ...recorder.metrics() }
|
|
2515
2600
|
};
|
|
2516
2601
|
const expectText = joinLines(head, joinLines(from.output, recorder.expectText() ?? (typeof result === "string" ? result : null)));
|
|
@@ -2559,7 +2644,7 @@ ${id}`;
|
|
|
2559
2644
|
self.report(new Error(`run ${id} of ${name} belongs to job "${stored.job}"; ignored`), `flushing ${name}`);
|
|
2560
2645
|
return;
|
|
2561
2646
|
}
|
|
2562
|
-
const output = lines === null ? stored.output : joinOutput(stored.output,
|
|
2647
|
+
const output = lines === null ? stored.output : joinOutput(stored.output, redactAndCap(lines, self.redact));
|
|
2563
2648
|
if (!await self.writeRunIf({ ...stored, output, metrics: { ...stored.metrics, ...metrics } }, ["running"])) return putBack();
|
|
2564
2649
|
const text = taken.expectText();
|
|
2565
2650
|
if (text !== null && (head === null || head.length < OUTPUT_CAP)) head = (joinLines(head, text) ?? "").slice(0, OUTPUT_CAP);
|
|
@@ -2584,13 +2669,19 @@ ${id}`;
|
|
|
2584
2669
|
* alone and reported. A finished run is judged as if it had been wrapped
|
|
2585
2670
|
* here (expect, failures, duration, budgets) and its output and error are
|
|
2586
2671
|
* redacted the same way; one finishing after a check marked it timeout is
|
|
2587
|
-
* judged only when it succeeded, as RunHandle.finish() does.
|
|
2588
|
-
*
|
|
2672
|
+
* judged only when it succeeded, as RunHandle.finish() does. A metric that
|
|
2673
|
+
* is not a finite number throws before anything is written, as
|
|
2674
|
+
* job.metric() does. Returns the alerts it sent.
|
|
2589
2675
|
*/
|
|
2590
2676
|
async recordRun(input, options = {}) {
|
|
2591
2677
|
const declared = this.definitions.get(input.job);
|
|
2592
2678
|
if (!declared) throw new Error(`recordRun: job "${input.job}" is not declared; call job() first`);
|
|
2593
2679
|
if (input.id.includes("\0")) throw new Error(`recordRun: run ids cannot contain a NUL character (job "${input.job}")`);
|
|
2680
|
+
for (const [metric, value] of Object.entries(input.metrics ?? {})) {
|
|
2681
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
2682
|
+
throw new Error(`recordRun: metric "${metric}" must be a finite number (job "${input.job}", run "${input.id}")`);
|
|
2683
|
+
}
|
|
2684
|
+
}
|
|
2594
2685
|
await this.sync(declared);
|
|
2595
2686
|
const run = { ...input, metrics: { ...input.metrics } };
|
|
2596
2687
|
if (run.status === "ok") {
|
|
@@ -2600,8 +2691,8 @@ ${id}`;
|
|
|
2600
2691
|
run.error = unmet;
|
|
2601
2692
|
}
|
|
2602
2693
|
}
|
|
2603
|
-
if (run.output !== null) run.output =
|
|
2604
|
-
if (run.error !== null) run.error =
|
|
2694
|
+
if (run.output !== null) run.output = redactAndCap(run.output, this.redact);
|
|
2695
|
+
if (run.error !== null) run.error = redactAndCap(run.error, this.redact);
|
|
2605
2696
|
const evaluate = options.evaluate !== false;
|
|
2606
2697
|
const definition = toStored(declared);
|
|
2607
2698
|
const stored = await this.store.getRun(run.id);
|
|
@@ -2635,22 +2726,42 @@ ${id}`;
|
|
|
2635
2726
|
}
|
|
2636
2727
|
/**
|
|
2637
2728
|
* Evaluate a finished run (ok, failed, or timed out by a check), already
|
|
2638
|
-
* written, against the job's state and send what that produces.
|
|
2729
|
+
* written, against the job's state and send what that produces. The
|
|
2730
|
+
* alerts are written with that state (see outbox()). Never throws.
|
|
2639
2731
|
*/
|
|
2640
2732
|
async finishRun(definition, run, now) {
|
|
2641
|
-
let
|
|
2733
|
+
let held;
|
|
2642
2734
|
try {
|
|
2643
2735
|
let history = null;
|
|
2644
|
-
({ result:
|
|
2736
|
+
({ result: held } = await this.updateState(run.job, async (previous) => {
|
|
2645
2737
|
history ??= await this.history(run);
|
|
2646
|
-
|
|
2647
|
-
return { state: settled.state, result: settled.alerts };
|
|
2738
|
+
return this.outbox(applySilence(previous, onRunFinish(definition, run, previous, history, now), now), definition, now);
|
|
2648
2739
|
}));
|
|
2649
2740
|
} catch (e) {
|
|
2650
2741
|
this.onError(e, `evaluating ${run.job}`);
|
|
2651
2742
|
return [];
|
|
2652
2743
|
}
|
|
2653
|
-
|
|
2744
|
+
this.reportDropped(run.job, held.dropped);
|
|
2745
|
+
return this.dispatch(run.job, held.alerts, now);
|
|
2746
|
+
}
|
|
2747
|
+
/**
|
|
2748
|
+
* An evaluation as it is written: its drafts composed into alerts and held
|
|
2749
|
+
* in the same state (holdAlerts), so the write that opens a condition also
|
|
2750
|
+
* keeps its alerts, and a process that stops before sending them does not
|
|
2751
|
+
* lose them. Called inside updateState(), so it only computes.
|
|
2752
|
+
*/
|
|
2753
|
+
outbox(settled, definition, now) {
|
|
2754
|
+
const alerts = settled.alerts.map((draft) => composeAlert(draft, definition, now));
|
|
2755
|
+
const { state, dropped } = holdAlerts(settled.state, alerts, this.now() + SEND_LEASE_MS, this.deferDelivery);
|
|
2756
|
+
return { state, result: { alerts, dropped } };
|
|
2757
|
+
}
|
|
2758
|
+
/** Reports alerts let go because a job's queue was full. */
|
|
2759
|
+
reportDropped(name, dropped) {
|
|
2760
|
+
if (dropped <= 0) return;
|
|
2761
|
+
this.report(
|
|
2762
|
+
new Error(`${dropped} undelivered alert${dropped === 1 ? "" : "s"} for ${name} dropped: only the newest ${MAX_UNDELIVERED} are kept for retry`),
|
|
2763
|
+
`alert queue for ${name}`
|
|
2764
|
+
);
|
|
2654
2765
|
}
|
|
2655
2766
|
/**
|
|
2656
2767
|
* The runs before `run`, newest first, with up to BASELINE_WINDOW
|
|
@@ -2690,11 +2801,13 @@ ${id}`;
|
|
|
2690
2801
|
}
|
|
2691
2802
|
for (const definition of this.definitions.values()) await this.sync(definition);
|
|
2692
2803
|
const now = this.now();
|
|
2693
|
-
for (const
|
|
2804
|
+
for (const listed of await this.store.runningRuns()) {
|
|
2694
2805
|
try {
|
|
2695
|
-
const declared = this.definitions.get(
|
|
2696
|
-
const definition = declared ? toStored(declared) : (await this.store.getJob(
|
|
2697
|
-
if (!definition || !isStuck(definition,
|
|
2806
|
+
const declared = this.definitions.get(listed.job);
|
|
2807
|
+
const definition = declared ? toStored(declared) : (await this.store.getJob(listed.job))?.definition;
|
|
2808
|
+
if (!definition || !isStuck(definition, listed, now)) continue;
|
|
2809
|
+
const run = await this.store.getRun(listed.id);
|
|
2810
|
+
if (!run || run.status !== "running" || run.job !== listed.job) continue;
|
|
2698
2811
|
const timeout = timeoutMs(definition);
|
|
2699
2812
|
run.status = "timeout";
|
|
2700
2813
|
run.finishedAt = now;
|
|
@@ -2703,23 +2816,26 @@ ${id}`;
|
|
|
2703
2816
|
if (!await this.writeRunIf(run, ["running"])) continue;
|
|
2704
2817
|
alerts.push(...await this.finishRun(definition, run, now));
|
|
2705
2818
|
} catch (e) {
|
|
2706
|
-
this.onError(e, `checking ${
|
|
2819
|
+
this.onError(e, `checking ${listed.job}`);
|
|
2707
2820
|
}
|
|
2708
2821
|
}
|
|
2709
2822
|
const jobs = [];
|
|
2710
2823
|
const retries = { spentMs: 0 };
|
|
2711
|
-
for (const stored of await this.
|
|
2824
|
+
for (const stored of await this.storedJobs()) {
|
|
2712
2825
|
try {
|
|
2713
2826
|
const recent = await this.store.listRuns(stored.name, BASELINE_WINDOW);
|
|
2714
2827
|
let nextExpectedAt = null;
|
|
2715
|
-
const { state, result:
|
|
2828
|
+
const { state, result: held } = await this.updateState(stored.name, (previous) => {
|
|
2716
2829
|
const evaluation = onCheck(stored.definition, stored, recent[0] ?? null, previous, now);
|
|
2717
2830
|
nextExpectedAt = evaluation.nextExpectedAt;
|
|
2718
2831
|
const settled = applySilence(previous, evaluation, now);
|
|
2719
|
-
|
|
2832
|
+
const released = releaseSending(settled.state, this.now());
|
|
2833
|
+
const out = this.outbox({ state: released.state, alerts: settled.alerts }, stored.definition, now);
|
|
2834
|
+
return { state: out.state, result: { alerts: out.result.alerts, dropped: released.dropped + out.result.dropped } };
|
|
2720
2835
|
});
|
|
2836
|
+
this.reportDropped(stored.name, held.dropped);
|
|
2721
2837
|
alerts.push(...await this.retryUndelivered(stored.name, state, now, retries));
|
|
2722
|
-
alerts.push(...await this.dispatch(
|
|
2838
|
+
alerts.push(...await this.dispatch(stored.name, held.alerts, now));
|
|
2723
2839
|
jobs.push(summarize(stored, recent, state, nextExpectedAt, now));
|
|
2724
2840
|
} catch (e) {
|
|
2725
2841
|
this.onError(e, `checking ${stored.name}`);
|
|
@@ -2763,16 +2879,17 @@ ${id}`;
|
|
|
2763
2879
|
/** Every job's summary with its newest `limit` runs, read together. What the dashboard shows. */
|
|
2764
2880
|
async jobsWithRuns(limit = 20) {
|
|
2765
2881
|
await this.ensureReady();
|
|
2766
|
-
|
|
2882
|
+
const jobs = await this.storedJobs();
|
|
2767
2883
|
const now = this.now();
|
|
2768
2884
|
const out = [];
|
|
2769
|
-
for (const stored of
|
|
2885
|
+
for (const stored of jobs) out.push(await this.snapshot(stored, now, clampLimit(limit, 20, 0)));
|
|
2770
2886
|
return out;
|
|
2771
2887
|
}
|
|
2888
|
+
/** A job's summary, or null for one the store does not have. One declared here and forgotten elsewhere is written again, as storedJobs() does. */
|
|
2772
2889
|
async jobSummary(name) {
|
|
2773
2890
|
await this.ensureReady();
|
|
2774
2891
|
const definition = this.definitions.get(name);
|
|
2775
|
-
if (definition) await this.sync(definition);
|
|
2892
|
+
if (definition) await this.sync(definition, true);
|
|
2776
2893
|
const stored = await this.store.getJob(name);
|
|
2777
2894
|
if (!stored) return null;
|
|
2778
2895
|
return (await this.snapshot(stored, this.now(), 0)).job;
|
|
@@ -2786,11 +2903,14 @@ ${id}`;
|
|
|
2786
2903
|
await this.ensureReady();
|
|
2787
2904
|
return this.store.getRun(id);
|
|
2788
2905
|
}
|
|
2789
|
-
/**
|
|
2906
|
+
/**
|
|
2907
|
+
* Stop alerts for a job for a while. State keeps updating underneath. The
|
|
2908
|
+
* end is a whole millisecond, held at 2^53 - 1 (see silenceEnd).
|
|
2909
|
+
*/
|
|
2790
2910
|
async silence(name, duration) {
|
|
2791
2911
|
const ms = parseDuration(duration, "silence duration");
|
|
2792
2912
|
return this.patchState(name, (state) => {
|
|
2793
|
-
state.silencedUntil = this.now()
|
|
2913
|
+
state.silencedUntil = silenceEnd(this.now(), ms);
|
|
2794
2914
|
});
|
|
2795
2915
|
}
|
|
2796
2916
|
async unsilence(name) {
|
|
@@ -2808,7 +2928,11 @@ ${id}`;
|
|
|
2808
2928
|
});
|
|
2809
2929
|
return state;
|
|
2810
2930
|
}
|
|
2811
|
-
/**
|
|
2931
|
+
/**
|
|
2932
|
+
* Remove a job and its runs from the store. A job still declared in code
|
|
2933
|
+
* comes back: here on its next run, and in any other process that declares
|
|
2934
|
+
* it on its next run there, or at that process's next check or dashboard read.
|
|
2935
|
+
*/
|
|
2812
2936
|
async forget(name) {
|
|
2813
2937
|
await this.ensureReady();
|
|
2814
2938
|
this.definitions.delete(name);
|
|
@@ -2846,32 +2970,37 @@ ${id}`;
|
|
|
2846
2970
|
this.timer = null;
|
|
2847
2971
|
this.firstTick = null;
|
|
2848
2972
|
}
|
|
2973
|
+
/**
|
|
2974
|
+
* Stop the interval, wait for a check already under way (bounded by its
|
|
2975
|
+
* own channel, triage and retry timeouts; what it throws was reported to
|
|
2976
|
+
* whoever started it), then close the store, so that check neither writes
|
|
2977
|
+
* after the store is closed nor loses the alerts it would queue.
|
|
2978
|
+
*/
|
|
2849
2979
|
async close() {
|
|
2850
2980
|
this.stop();
|
|
2981
|
+
if (this.checking) await this.checking.catch(() => {
|
|
2982
|
+
});
|
|
2851
2983
|
if (this.store.close) await this.store.close();
|
|
2852
2984
|
}
|
|
2853
2985
|
/**
|
|
2854
|
-
*
|
|
2855
|
-
*
|
|
2856
|
-
* the delivery fields are written back, onto
|
|
2986
|
+
* Triage and send each alert the outbox holds (see outbox()). The state,
|
|
2987
|
+
* with the alerts in it, was saved before this, so a slow channel holds up
|
|
2988
|
+
* nothing else; afterwards only the delivery fields are written back, onto
|
|
2989
|
+
* a fresh read of the state, and the alerts leave `sending`. Triage is made
|
|
2990
|
+
* here, never stored with the held alert: the write that opens a condition
|
|
2991
|
+
* cannot wait for it, and a retry triages an alert that has none. With
|
|
2992
|
+
* deliver: "check" the alerts were queued for a check elsewhere instead.
|
|
2857
2993
|
*/
|
|
2858
|
-
async dispatch(
|
|
2859
|
-
if (
|
|
2860
|
-
const composed = [];
|
|
2994
|
+
async dispatch(name, alerts, now) {
|
|
2995
|
+
if (alerts.length === 0 || this.deferDelivery) return alerts;
|
|
2861
2996
|
const delivered = [];
|
|
2862
2997
|
const failed = [];
|
|
2863
|
-
for (const
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
failed.push(alert);
|
|
2867
|
-
} else {
|
|
2868
|
-
if (this.triage && alert.type !== "recovered") await this.addTriage(alert, TRIAGE_TIMEOUT_MS);
|
|
2869
|
-
(await this.deliver(alert) ? delivered : failed).push(alert);
|
|
2870
|
-
}
|
|
2871
|
-
composed.push(alert);
|
|
2998
|
+
for (const alert of alerts) {
|
|
2999
|
+
if (this.triage && alert.type !== "recovered") await this.addTriage(alert, TRIAGE_TIMEOUT_MS);
|
|
3000
|
+
(await this.deliver(alert) ? delivered : failed).push(alert);
|
|
2872
3001
|
}
|
|
2873
|
-
await this.recordDelivery(
|
|
2874
|
-
return
|
|
3002
|
+
await this.recordDelivery(name, delivered, failed, [], now);
|
|
3003
|
+
return alerts;
|
|
2875
3004
|
}
|
|
2876
3005
|
/**
|
|
2877
3006
|
* Send the alerts that no channel accepted last time, once each, oldest
|
|
@@ -2900,28 +3029,18 @@ ${id}`;
|
|
|
2900
3029
|
}
|
|
2901
3030
|
/**
|
|
2902
3031
|
* Mark delivered alerts done, drop stale ones, and keep failed ones for the
|
|
2903
|
-
* next check
|
|
2904
|
-
*
|
|
3032
|
+
* next check, taking them all out of `sending` (recordSent). A failed alert
|
|
3033
|
+
* replaces its stored copy, so a triage made on this attempt is kept.
|
|
3034
|
+
* lastAlertAt moves only on a delivery. When this write fails, alerts
|
|
3035
|
+
* still in `sending` are retried once their lease runs out.
|
|
2905
3036
|
*/
|
|
2906
3037
|
async recordDelivery(name, delivered, failed, dropped, now) {
|
|
2907
3038
|
try {
|
|
2908
3039
|
const { result: trimmed } = await this.updateState(name, (previous) => {
|
|
2909
|
-
const state = normalizeState(previous, name);
|
|
2910
|
-
|
|
2911
|
-
const retried = new Map(failed.map((a) => [alertKey(a), a]));
|
|
2912
|
-
const kept = state.undelivered.filter((a) => !done.has(alertKey(a))).map((a) => retried.get(alertKey(a)) ?? a);
|
|
2913
|
-
const known = new Set(kept.map(alertKey));
|
|
2914
|
-
kept.push(...failed.filter((a) => !known.has(alertKey(a))));
|
|
2915
|
-
state.undelivered = kept.slice(-MAX_UNDELIVERED);
|
|
2916
|
-
if (delivered.length > 0) state.lastAlertAt = now;
|
|
2917
|
-
return { state, result: Math.max(0, kept.length - MAX_UNDELIVERED) };
|
|
3040
|
+
const { state, dropped: letGo } = recordSent(normalizeState(previous, name), delivered, failed, dropped, now);
|
|
3041
|
+
return { state, result: letGo };
|
|
2918
3042
|
});
|
|
2919
|
-
|
|
2920
|
-
this.onError(
|
|
2921
|
-
new Error(`${trimmed} undelivered alert${trimmed === 1 ? "" : "s"} for ${name} dropped: only the newest ${MAX_UNDELIVERED} are kept for retry`),
|
|
2922
|
-
`alert queue for ${name}`
|
|
2923
|
-
);
|
|
2924
|
-
}
|
|
3043
|
+
this.reportDropped(name, trimmed);
|
|
2925
3044
|
} catch (e) {
|
|
2926
3045
|
this.onError(e, `recording alert delivery for ${name}`);
|
|
2927
3046
|
}
|