@cronwatch/sdk 0.8.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-B5hM4l-D.d.ts → client-BcIjVBiZ.d.ts} +79 -13
- package/dist/{client-Di5HAWQz.d.cts → client-DUoSea7X.d.cts} +79 -13
- package/dist/d1.cjs +29 -19
- 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 +29 -19
- 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-DX6gkd5H.d.cts → email-JZs3vxwq.d.cts} +1 -1
- package/dist/{email-Ciq7GRn4.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 +253 -99
- 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 +253 -99
- 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 +47 -5
- package/dist/pg-cron.cjs.map +1 -1
- package/dist/pg-cron.d.cts +5 -2
- package/dist/pg-cron.d.ts +5 -2
- package/dist/pg-cron.js +47 -5
- package/dist/pg-cron.js.map +1 -1
- package/dist/postgres.cjs +22 -14
- 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 +22 -14
- 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 +26 -18
- 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 +26 -18
- package/dist/sqlite.js.map +1 -1
- package/dist/twilio.d.cts +1 -1
- package/dist/twilio.d.ts +1 -1
- package/dist/{types-BjBbCGeJ.d.cts → types-DyWCxlSi.d.cts} +20 -1
- package/dist/{types-BjBbCGeJ.d.ts → types-DyWCxlSi.d.ts} +20 -1
- package/dist/webhook.d.cts +1 -1
- package/dist/webhook.d.ts +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -266,23 +266,81 @@ function runDuration(startedAt, finishedAt) {
|
|
|
266
266
|
const ms = finishedAt - startedAt;
|
|
267
267
|
return ms > 0 ? Math.min(ms, MAX_DURATION_MS) : 0;
|
|
268
268
|
}
|
|
269
|
+
function silenceEnd(now, ms) {
|
|
270
|
+
return Math.min(now + Math.floor(Math.min(ms, MAX_DURATION_MS)), MAX_DURATION_MS);
|
|
271
|
+
}
|
|
269
272
|
function stateVersion(state) {
|
|
270
273
|
const version = state?.version;
|
|
271
274
|
return typeof version === "number" && Number.isSafeInteger(version) && version >= 0 ? version + 0 : 0;
|
|
272
275
|
}
|
|
276
|
+
function failureCount(state) {
|
|
277
|
+
const count = state?.consecutiveFailures;
|
|
278
|
+
if (typeof count !== "number" || !Number.isInteger(count) || count <= 0) return 0;
|
|
279
|
+
return Math.min(count, MAX_DURATION_MS);
|
|
280
|
+
}
|
|
273
281
|
function emptyState(job) {
|
|
274
282
|
return { job, open: {}, consecutiveFailures: 0, silencedUntil: null, lastAlertAt: null, pendingRecovery: [], undelivered: [] };
|
|
275
283
|
}
|
|
276
284
|
function normalizeState(state, job) {
|
|
277
285
|
if (!state) return emptyState(job);
|
|
286
|
+
const { sending, ...rest } = state;
|
|
278
287
|
return {
|
|
279
288
|
...emptyState(job),
|
|
280
|
-
...
|
|
289
|
+
...rest,
|
|
281
290
|
open: { ...state.open },
|
|
291
|
+
consecutiveFailures: failureCount(state),
|
|
282
292
|
pendingRecovery: [...state.pendingRecovery ?? []],
|
|
283
|
-
undelivered: [...state.undelivered ?? []]
|
|
293
|
+
undelivered: [...state.undelivered ?? []],
|
|
294
|
+
...Array.isArray(sending) && sending.length > 0 ? { sending: [...sending] } : {}
|
|
284
295
|
};
|
|
285
296
|
}
|
|
297
|
+
var MAX_UNDELIVERED = 20;
|
|
298
|
+
var SEND_LEASE_MS = 5 * 6e4;
|
|
299
|
+
function alertKey(alert) {
|
|
300
|
+
return `${alert.type}|${alert.at}|${alert.run?.id ?? ""}`;
|
|
301
|
+
}
|
|
302
|
+
function newest(list, max) {
|
|
303
|
+
return { kept: list.slice(-max), dropped: Math.max(0, list.length - max) };
|
|
304
|
+
}
|
|
305
|
+
function queueUndelivered(state, alerts) {
|
|
306
|
+
const next = cloneState(state);
|
|
307
|
+
const byKey = new Map(alerts.map((alert) => [alertKey(alert), alert]));
|
|
308
|
+
const queue = next.undelivered.map((alert) => byKey.get(alertKey(alert)) ?? alert);
|
|
309
|
+
const known = new Set(queue.map(alertKey));
|
|
310
|
+
queue.push(...alerts.filter((alert) => !known.has(alertKey(alert))));
|
|
311
|
+
const { kept, dropped } = newest(queue, MAX_UNDELIVERED);
|
|
312
|
+
next.undelivered = kept;
|
|
313
|
+
return { state: next, dropped };
|
|
314
|
+
}
|
|
315
|
+
function holdAlerts(state, alerts, until, deferred) {
|
|
316
|
+
if (alerts.length === 0) return { state, dropped: 0 };
|
|
317
|
+
if (deferred) return queueUndelivered(state, alerts);
|
|
318
|
+
const next = cloneState(state);
|
|
319
|
+
const { kept, dropped } = newest([...next.sending ?? [], ...alerts.map((alert) => ({ until, alert }))], MAX_UNDELIVERED);
|
|
320
|
+
next.sending = kept;
|
|
321
|
+
return { state: next, dropped };
|
|
322
|
+
}
|
|
323
|
+
function releaseSending(state, now) {
|
|
324
|
+
const sending = state.sending ?? [];
|
|
325
|
+
const lapsed = sending.filter((entry) => !(typeof entry?.until === "number" && entry.until > now));
|
|
326
|
+
if (lapsed.length === 0) return { state, dropped: 0 };
|
|
327
|
+
const held = sending.filter((entry) => !lapsed.includes(entry));
|
|
328
|
+
return queueUndelivered(
|
|
329
|
+
{ ...state, sending: held },
|
|
330
|
+
lapsed.filter((entry) => typeof entry?.alert === "object" && entry.alert !== null).map((entry) => entry.alert)
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
function recordSent(state, delivered, failed, stale, now) {
|
|
334
|
+
const next = cloneState(state);
|
|
335
|
+
const done = new Set([...delivered, ...stale].map(alertKey));
|
|
336
|
+
next.undelivered = next.undelivered.filter((alert) => !done.has(alertKey(alert)));
|
|
337
|
+
const sent = new Set([...delivered, ...failed, ...stale].map(alertKey));
|
|
338
|
+
const held = (next.sending ?? []).filter((entry) => !(typeof entry?.alert === "object" && entry.alert !== null && sent.has(alertKey(entry.alert))));
|
|
339
|
+
if (held.length > 0) next.sending = held;
|
|
340
|
+
else delete next.sending;
|
|
341
|
+
if (delivered.length > 0) next.lastAlertAt = now;
|
|
342
|
+
return queueUndelivered(next, failed);
|
|
343
|
+
}
|
|
286
344
|
function cloneState(state) {
|
|
287
345
|
return normalizeState(state, state.job);
|
|
288
346
|
}
|
|
@@ -377,7 +435,7 @@ function onRunFinish(def, run, state, history, now) {
|
|
|
377
435
|
}
|
|
378
436
|
return { state: next, alerts };
|
|
379
437
|
}
|
|
380
|
-
next.consecutiveFailures
|
|
438
|
+
next.consecutiveFailures = Math.min(next.consecutiveFailures + 1, MAX_DURATION_MS);
|
|
381
439
|
closeCondition(next, "missed");
|
|
382
440
|
const threshold = Math.max(1, def.failuresBeforeAlert ?? 1);
|
|
383
441
|
const condition = run.status === "timeout" ? "stuck" : "failed";
|
|
@@ -586,13 +644,22 @@ var OUTPUT_CAP = 16 * 1024;
|
|
|
586
644
|
function capOutput(text) {
|
|
587
645
|
const clean = stripNul(text);
|
|
588
646
|
if (clean.length <= OUTPUT_CAP) return clean;
|
|
589
|
-
return
|
|
647
|
+
return TRIMMED + clean.slice(clean.length - OUTPUT_CAP);
|
|
648
|
+
}
|
|
649
|
+
var TRIMMED = "[earlier output trimmed]\n";
|
|
650
|
+
var REDACT_EDGE = 3 * (OUTPUT_CAP + 1024);
|
|
651
|
+
function redactAndCap(text, redact) {
|
|
652
|
+
const clean = stripNul(text);
|
|
653
|
+
const from = clean.length - (OUTPUT_CAP + REDACT_EDGE);
|
|
654
|
+
if (from <= 0) return capOutput(redact(clean));
|
|
655
|
+
const redacted = stripNul(redact(clean.slice(from)));
|
|
656
|
+
return TRIMMED + redacted.slice(Math.max(redacted.length - OUTPUT_CAP, REDACT_EDGE));
|
|
590
657
|
}
|
|
591
658
|
function stripNul(text) {
|
|
592
659
|
return text.includes("\0") ? text.replace(/\u0000/g, "") : text;
|
|
593
660
|
}
|
|
594
|
-
function
|
|
595
|
-
return
|
|
661
|
+
function stripJsonNul(json2) {
|
|
662
|
+
return json2.includes("\\u0000") ? json2.replace(/\\(u0000|[\s\S])/g, (escape, next) => next === "u0000" ? "" : escape) : json2;
|
|
596
663
|
}
|
|
597
664
|
function describeError(error) {
|
|
598
665
|
if (error instanceof Error) {
|
|
@@ -702,7 +769,7 @@ function createRecorder(run) {
|
|
|
702
769
|
};
|
|
703
770
|
return {
|
|
704
771
|
context,
|
|
705
|
-
output: () => lines.length === 0 ? null :
|
|
772
|
+
output: () => lines.length === 0 ? null : lines.join("\n"),
|
|
706
773
|
expectText: () => {
|
|
707
774
|
if (lines.length === 0) return null;
|
|
708
775
|
const all = lines.join("\n");
|
|
@@ -1443,7 +1510,7 @@ function jobPage(job, runs, now, base, complete = true) {
|
|
|
1443
1510
|
run.error ? `<details class="out error" open><summary>error</summary><pre>${h(run.error)}</pre></details>` : "",
|
|
1444
1511
|
run.output ? `<details class="out"${run.status === "ok" ? "" : " open"}><summary>output</summary><pre>${h(run.output)}</pre></details>` : ""
|
|
1445
1512
|
].join("");
|
|
1446
|
-
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("");
|
|
1513
|
+
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("");
|
|
1447
1514
|
return `<tr${detail ? ` class="has-detail"` : ""}>
|
|
1448
1515
|
<td class="nowrap">${runState(run)}</td>
|
|
1449
1516
|
<td class="nowrap">${h(when2(run.startedAt, now))} <span class="muted">UTC</span><span class="sub">${stamp(run.startedAt, now)}</span></td>
|
|
@@ -1843,13 +1910,21 @@ function memory() {
|
|
|
1843
1910
|
const states = /* @__PURE__ */ new Map();
|
|
1844
1911
|
let seq = 0;
|
|
1845
1912
|
const clone = (v) => JSON.parse(JSON.stringify(v));
|
|
1913
|
+
const kept = (v) => JSON.parse(stripJsonNul(JSON.stringify(v)));
|
|
1914
|
+
const keptRun = (run) => ({
|
|
1915
|
+
...clone(run),
|
|
1916
|
+
trigger: stripNul(run.trigger),
|
|
1917
|
+
output: run.output === null ? null : stripNul(run.output),
|
|
1918
|
+
error: run.error === null ? null : stripNul(run.error),
|
|
1919
|
+
metrics: kept(run.metrics)
|
|
1920
|
+
});
|
|
1846
1921
|
const byName = (a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
|
|
1847
1922
|
return {
|
|
1848
1923
|
async upsertJob(definition, now) {
|
|
1849
1924
|
const existing = jobs.get(definition.name);
|
|
1850
1925
|
jobs.set(definition.name, {
|
|
1851
1926
|
name: definition.name,
|
|
1852
|
-
definition:
|
|
1927
|
+
definition: kept(definition),
|
|
1853
1928
|
createdAt: existing?.createdAt ?? now,
|
|
1854
1929
|
updatedAt: now
|
|
1855
1930
|
});
|
|
@@ -1871,19 +1946,19 @@ function memory() {
|
|
|
1871
1946
|
},
|
|
1872
1947
|
async insertRun(run) {
|
|
1873
1948
|
if (runs.has(run.id)) throw new Error(`run ${run.id} already exists`);
|
|
1874
|
-
runs.set(run.id,
|
|
1949
|
+
runs.set(run.id, keptRun(run));
|
|
1875
1950
|
order.set(run.id, ++seq);
|
|
1876
1951
|
},
|
|
1877
1952
|
async updateRun(run) {
|
|
1878
1953
|
const existing = runs.get(run.id);
|
|
1879
1954
|
if (!existing) return;
|
|
1880
|
-
const { status, finishedAt, durationMs, error, output, metrics } =
|
|
1955
|
+
const { status, finishedAt, durationMs, error, output, metrics } = keptRun(run);
|
|
1881
1956
|
runs.set(run.id, { ...existing, status, finishedAt, durationMs, error, output, metrics });
|
|
1882
1957
|
},
|
|
1883
1958
|
async updateRunIf(run, fromStatuses) {
|
|
1884
1959
|
const existing = runs.get(run.id);
|
|
1885
1960
|
if (!existing || !fromStatuses.includes(existing.status)) return false;
|
|
1886
|
-
const { status, finishedAt, durationMs, error, output, metrics } =
|
|
1961
|
+
const { status, finishedAt, durationMs, error, output, metrics } = keptRun(run);
|
|
1887
1962
|
runs.set(run.id, { ...existing, status, finishedAt, durationMs, error, output, metrics });
|
|
1888
1963
|
return true;
|
|
1889
1964
|
},
|
|
@@ -1906,19 +1981,19 @@ function memory() {
|
|
|
1906
1981
|
return s ? clone(s) : null;
|
|
1907
1982
|
},
|
|
1908
1983
|
async setState(state) {
|
|
1909
|
-
states.set(state.job,
|
|
1984
|
+
states.set(state.job, kept(state));
|
|
1910
1985
|
},
|
|
1911
1986
|
async compareAndSetState(state, expectedVersion) {
|
|
1912
1987
|
if (stateVersion(states.get(state.job)) !== expectedVersion) return false;
|
|
1913
|
-
states.set(state.job,
|
|
1988
|
+
states.set(state.job, kept(state));
|
|
1914
1989
|
return true;
|
|
1915
1990
|
},
|
|
1916
1991
|
async prune(before) {
|
|
1917
|
-
const
|
|
1918
|
-
for (const run of runs.values())
|
|
1992
|
+
const newest2 = /* @__PURE__ */ new Map();
|
|
1993
|
+
for (const run of runs.values()) newest2.set(run.job, Math.max(newest2.get(run.job) ?? -Infinity, run.startedAt));
|
|
1919
1994
|
let n = 0;
|
|
1920
1995
|
for (const [id, run] of runs) {
|
|
1921
|
-
if (run.status !== "running" && run.startedAt < before && run.startedAt <
|
|
1996
|
+
if (run.status !== "running" && run.startedAt < before && run.startedAt < newest2.get(run.job)) {
|
|
1922
1997
|
runs.delete(id);
|
|
1923
1998
|
order.delete(id);
|
|
1924
1999
|
n++;
|
|
@@ -1934,7 +2009,6 @@ var NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/;
|
|
|
1934
2009
|
var TRIAGE_TIMEOUT_MS = 25e3;
|
|
1935
2010
|
var CHANNEL_TIMEOUT_MS = 15e3;
|
|
1936
2011
|
var PRUNE_INTERVAL_MS = 60 * 6e4;
|
|
1937
|
-
var MAX_UNDELIVERED = 20;
|
|
1938
2012
|
var RETRY_BUDGET_MS = 2e4;
|
|
1939
2013
|
var STATE_ATTEMPTS = 10;
|
|
1940
2014
|
var HISTORY_PAGE = BASELINE_WINDOW + 5;
|
|
@@ -1975,9 +2049,6 @@ function validateDefinition(def) {
|
|
|
1975
2049
|
function sameState(a, b) {
|
|
1976
2050
|
return JSON.stringify(a) === JSON.stringify(b);
|
|
1977
2051
|
}
|
|
1978
|
-
function alertKey(alert) {
|
|
1979
|
-
return `${alert.type}|${alert.at}|${alert.run?.id ?? ""}`;
|
|
1980
|
-
}
|
|
1981
2052
|
var CronWatch = class {
|
|
1982
2053
|
store;
|
|
1983
2054
|
alerts;
|
|
@@ -1998,6 +2069,8 @@ var CronWatch = class {
|
|
|
1998
2069
|
synced = /* @__PURE__ */ new Set();
|
|
1999
2070
|
/** The tail of each job's queue of state updates. See serial(). */
|
|
2000
2071
|
queues = /* @__PURE__ */ new Map();
|
|
2072
|
+
/** The tail of each job's queue of declaration writes. See sync(). */
|
|
2073
|
+
syncing = /* @__PURE__ */ new Map();
|
|
2001
2074
|
/** start() calls with an id still in flight, so two at once in this process record one run. */
|
|
2002
2075
|
starting = /* @__PURE__ */ new Map();
|
|
2003
2076
|
ready = null;
|
|
@@ -2136,26 +2209,69 @@ var CronWatch = class {
|
|
|
2136
2209
|
}
|
|
2137
2210
|
await this.ready;
|
|
2138
2211
|
}
|
|
2139
|
-
|
|
2212
|
+
/**
|
|
2213
|
+
* Writes the declaration of `definition`'s name as it stands, unless the
|
|
2214
|
+
* store has it. A handle kept from an earlier declaration writes the one
|
|
2215
|
+
* that replaced it, never its own over it, and one forgotten since writes
|
|
2216
|
+
* its own. The writes of one name take turns, in the order they were asked
|
|
2217
|
+
* for, so one still under way cannot land after a later one; and a name
|
|
2218
|
+
* declared again while its write was under way is still to be written. A
|
|
2219
|
+
* name is marked as written only while that same declaration stands, so a
|
|
2220
|
+
* forget that lands during the write (deleting the row after it) leaves
|
|
2221
|
+
* the name to be written again, as does one forgotten before it.
|
|
2222
|
+
*
|
|
2223
|
+
* With `confirm`, as a run starts, a name already written is read back:
|
|
2224
|
+
* another process may have forgotten the job since, and a job still
|
|
2225
|
+
* declared here comes back on its next run.
|
|
2226
|
+
*/
|
|
2227
|
+
async sync(definition, confirm = false) {
|
|
2140
2228
|
await this.ensureReady();
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2229
|
+
const name = definition.name;
|
|
2230
|
+
if (this.synced.has(name)) {
|
|
2231
|
+
if (!confirm || await this.store.getJob(name)) return;
|
|
2232
|
+
this.synced.delete(name);
|
|
2233
|
+
}
|
|
2234
|
+
await this.serial(name, async () => {
|
|
2235
|
+
if (this.synced.has(name)) return;
|
|
2236
|
+
const standing = this.definitions.get(name) ?? definition;
|
|
2237
|
+
await this.store.upsertJob(toStored(standing), this.now());
|
|
2238
|
+
if (this.definitions.get(name) === standing) this.synced.add(name);
|
|
2239
|
+
}, this.syncing);
|
|
2240
|
+
}
|
|
2241
|
+
/**
|
|
2242
|
+
* Every stored job, once each declaration has been written. A job declared
|
|
2243
|
+
* here that the store no longer has was forgotten by another process after
|
|
2244
|
+
* this one wrote it: it is written again, as its next run would, so it is
|
|
2245
|
+
* checked and shown while any process still declares it.
|
|
2246
|
+
*/
|
|
2247
|
+
async storedJobs() {
|
|
2248
|
+
for (const definition of this.definitions.values()) await this.sync(definition);
|
|
2249
|
+
const jobs = await this.store.listJobs();
|
|
2250
|
+
const listed = new Set(jobs.map((job) => job.name));
|
|
2251
|
+
const missing = [...this.definitions.values()].filter((definition) => !listed.has(definition.name));
|
|
2252
|
+
if (missing.length === 0) return jobs;
|
|
2253
|
+
for (const definition of missing) {
|
|
2254
|
+
if (this.definitions.get(definition.name) !== definition) continue;
|
|
2255
|
+
this.synced.delete(definition.name);
|
|
2256
|
+
await this.sync(definition);
|
|
2257
|
+
}
|
|
2258
|
+
return this.store.listJobs();
|
|
2144
2259
|
}
|
|
2145
2260
|
/**
|
|
2146
2261
|
* Runs `fn` after every earlier state update for the same job has settled,
|
|
2147
2262
|
* so two runs (or a run and a check) in this process never read and write
|
|
2148
2263
|
* the job's state over each other. Other processes are coordinated by
|
|
2149
|
-
* updateState() instead.
|
|
2264
|
+
* updateState() instead. sync() queues a job's declaration writes the same
|
|
2265
|
+
* way, in `syncing`.
|
|
2150
2266
|
*/
|
|
2151
|
-
serial(job, fn) {
|
|
2152
|
-
const result = (
|
|
2267
|
+
serial(job, fn, queues = this.queues) {
|
|
2268
|
+
const result = (queues.get(job) ?? Promise.resolve()).then(fn);
|
|
2153
2269
|
const tail2 = result.then(() => {
|
|
2154
2270
|
}, () => {
|
|
2155
2271
|
});
|
|
2156
|
-
|
|
2272
|
+
queues.set(job, tail2);
|
|
2157
2273
|
void tail2.then(() => {
|
|
2158
|
-
if (
|
|
2274
|
+
if (queues.get(job) === tail2) queues.delete(job);
|
|
2159
2275
|
});
|
|
2160
2276
|
return result;
|
|
2161
2277
|
}
|
|
@@ -2216,7 +2332,7 @@ var CronWatch = class {
|
|
|
2216
2332
|
};
|
|
2217
2333
|
let recorded = false;
|
|
2218
2334
|
try {
|
|
2219
|
-
await this.sync(definition);
|
|
2335
|
+
await this.sync(definition, true);
|
|
2220
2336
|
await this.store.insertRun({ ...run });
|
|
2221
2337
|
recorded = true;
|
|
2222
2338
|
} catch (e) {
|
|
@@ -2241,7 +2357,7 @@ var CronWatch = class {
|
|
|
2241
2357
|
run.finishedAt = finishedAt;
|
|
2242
2358
|
run.durationMs = runDuration(startedAt, finishedAt);
|
|
2243
2359
|
run.metrics = recorder.metrics();
|
|
2244
|
-
run.output = recorder.output() ?? (typeof result === "string" ?
|
|
2360
|
+
run.output = recorder.output() ?? (typeof result === "string" ? result : null);
|
|
2245
2361
|
this.conclude(definition, run, result, error, threw, recorder.expectText() ?? (typeof result === "string" ? result : null));
|
|
2246
2362
|
await started;
|
|
2247
2363
|
try {
|
|
@@ -2254,12 +2370,13 @@ var CronWatch = class {
|
|
|
2254
2370
|
}
|
|
2255
2371
|
/**
|
|
2256
2372
|
* Sets a finished run's status and error from how it ended, then redacts
|
|
2257
|
-
* its output and error. Shared by execute()
|
|
2373
|
+
* its output and error and caps them, in that order. Shared by execute()
|
|
2374
|
+
* and RunHandle.finish().
|
|
2258
2375
|
*/
|
|
2259
2376
|
conclude(definition, run, result, error, threw, expectText) {
|
|
2260
2377
|
if (threw) {
|
|
2261
2378
|
run.status = "failed";
|
|
2262
|
-
run.error =
|
|
2379
|
+
run.error = describeError(error);
|
|
2263
2380
|
} else if (result instanceof Response && result.status >= 400) {
|
|
2264
2381
|
run.status = "failed";
|
|
2265
2382
|
run.error = `HTTP ${result.status}${result.statusText ? ` ${result.statusText}` : ""}`;
|
|
@@ -2272,8 +2389,8 @@ var CronWatch = class {
|
|
|
2272
2389
|
run.status = "ok";
|
|
2273
2390
|
}
|
|
2274
2391
|
}
|
|
2275
|
-
if (run.output !== null) run.output =
|
|
2276
|
-
if (run.error !== null) run.error =
|
|
2392
|
+
if (run.output !== null) run.output = redactAndCap(run.output, this.redact);
|
|
2393
|
+
if (run.error !== null) run.error = redactAndCap(run.error, this.redact);
|
|
2277
2394
|
}
|
|
2278
2395
|
/**
|
|
2279
2396
|
* Writes a finished run and evaluates it. `recorded` says whether its
|
|
@@ -2368,7 +2485,7 @@ ${id}`;
|
|
|
2368
2485
|
};
|
|
2369
2486
|
let recorded = false;
|
|
2370
2487
|
try {
|
|
2371
|
-
await this.sync(definition);
|
|
2488
|
+
await this.sync(definition, true);
|
|
2372
2489
|
await this.store.insertRun({ ...run });
|
|
2373
2490
|
recorded = true;
|
|
2374
2491
|
} catch (e) {
|
|
@@ -2468,14 +2585,15 @@ ${id}`;
|
|
|
2468
2585
|
const result = typeof outcome === "string" ? outcome : failed ? void 0 : outcome?.result;
|
|
2469
2586
|
const error = failed ? outcome.error : void 0;
|
|
2470
2587
|
const finishedAt = self.now();
|
|
2471
|
-
const added = recorder.output() ?? (typeof result === "string" ?
|
|
2588
|
+
const added = recorder.output() ?? (typeof result === "string" ? result : null);
|
|
2472
2589
|
const run = {
|
|
2473
2590
|
...from,
|
|
2474
2591
|
status: "running",
|
|
2475
2592
|
finishedAt,
|
|
2476
2593
|
durationMs: runDuration(from.startedAt, finishedAt),
|
|
2477
2594
|
error: null,
|
|
2478
|
-
|
|
2595
|
+
// Capped by conclude(), after it is redacted.
|
|
2596
|
+
output: joinLines(from.output, added),
|
|
2479
2597
|
metrics: { ...from.metrics, ...recorder.metrics() }
|
|
2480
2598
|
};
|
|
2481
2599
|
const expectText = joinLines(head, joinLines(from.output, recorder.expectText() ?? (typeof result === "string" ? result : null)));
|
|
@@ -2524,7 +2642,7 @@ ${id}`;
|
|
|
2524
2642
|
self.report(new Error(`run ${id} of ${name} belongs to job "${stored.job}"; ignored`), `flushing ${name}`);
|
|
2525
2643
|
return;
|
|
2526
2644
|
}
|
|
2527
|
-
const output = lines === null ? stored.output : joinOutput(stored.output,
|
|
2645
|
+
const output = lines === null ? stored.output : joinOutput(stored.output, redactAndCap(lines, self.redact));
|
|
2528
2646
|
if (!await self.writeRunIf({ ...stored, output, metrics: { ...stored.metrics, ...metrics } }, ["running"])) return putBack();
|
|
2529
2647
|
const text = taken.expectText();
|
|
2530
2648
|
if (text !== null && (head === null || head.length < OUTPUT_CAP)) head = (joinLines(head, text) ?? "").slice(0, OUTPUT_CAP);
|
|
@@ -2549,12 +2667,19 @@ ${id}`;
|
|
|
2549
2667
|
* alone and reported. A finished run is judged as if it had been wrapped
|
|
2550
2668
|
* here (expect, failures, duration, budgets) and its output and error are
|
|
2551
2669
|
* redacted the same way; one finishing after a check marked it timeout is
|
|
2552
|
-
* judged only when it succeeded, as RunHandle.finish() does.
|
|
2553
|
-
*
|
|
2670
|
+
* judged only when it succeeded, as RunHandle.finish() does. A metric that
|
|
2671
|
+
* is not a finite number throws before anything is written, as
|
|
2672
|
+
* job.metric() does. Returns the alerts it sent.
|
|
2554
2673
|
*/
|
|
2555
2674
|
async recordRun(input, options = {}) {
|
|
2556
2675
|
const declared = this.definitions.get(input.job);
|
|
2557
2676
|
if (!declared) throw new Error(`recordRun: job "${input.job}" is not declared; call job() first`);
|
|
2677
|
+
if (input.id.includes("\0")) throw new Error(`recordRun: run ids cannot contain a NUL character (job "${input.job}")`);
|
|
2678
|
+
for (const [metric, value] of Object.entries(input.metrics ?? {})) {
|
|
2679
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
2680
|
+
throw new Error(`recordRun: metric "${metric}" must be a finite number (job "${input.job}", run "${input.id}")`);
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2558
2683
|
await this.sync(declared);
|
|
2559
2684
|
const run = { ...input, metrics: { ...input.metrics } };
|
|
2560
2685
|
if (run.status === "ok") {
|
|
@@ -2564,8 +2689,8 @@ ${id}`;
|
|
|
2564
2689
|
run.error = unmet;
|
|
2565
2690
|
}
|
|
2566
2691
|
}
|
|
2567
|
-
if (run.output !== null) run.output =
|
|
2568
|
-
if (run.error !== null) run.error =
|
|
2692
|
+
if (run.output !== null) run.output = redactAndCap(run.output, this.redact);
|
|
2693
|
+
if (run.error !== null) run.error = redactAndCap(run.error, this.redact);
|
|
2569
2694
|
const evaluate = options.evaluate !== false;
|
|
2570
2695
|
const definition = toStored(declared);
|
|
2571
2696
|
const stored = await this.store.getRun(run.id);
|
|
@@ -2599,22 +2724,42 @@ ${id}`;
|
|
|
2599
2724
|
}
|
|
2600
2725
|
/**
|
|
2601
2726
|
* Evaluate a finished run (ok, failed, or timed out by a check), already
|
|
2602
|
-
* written, against the job's state and send what that produces.
|
|
2727
|
+
* written, against the job's state and send what that produces. The
|
|
2728
|
+
* alerts are written with that state (see outbox()). Never throws.
|
|
2603
2729
|
*/
|
|
2604
2730
|
async finishRun(definition, run, now) {
|
|
2605
|
-
let
|
|
2731
|
+
let held;
|
|
2606
2732
|
try {
|
|
2607
2733
|
let history = null;
|
|
2608
|
-
({ result:
|
|
2734
|
+
({ result: held } = await this.updateState(run.job, async (previous) => {
|
|
2609
2735
|
history ??= await this.history(run);
|
|
2610
|
-
|
|
2611
|
-
return { state: settled.state, result: settled.alerts };
|
|
2736
|
+
return this.outbox(applySilence(previous, onRunFinish(definition, run, previous, history, now), now), definition, now);
|
|
2612
2737
|
}));
|
|
2613
2738
|
} catch (e) {
|
|
2614
2739
|
this.onError(e, `evaluating ${run.job}`);
|
|
2615
2740
|
return [];
|
|
2616
2741
|
}
|
|
2617
|
-
|
|
2742
|
+
this.reportDropped(run.job, held.dropped);
|
|
2743
|
+
return this.dispatch(run.job, held.alerts, now);
|
|
2744
|
+
}
|
|
2745
|
+
/**
|
|
2746
|
+
* An evaluation as it is written: its drafts composed into alerts and held
|
|
2747
|
+
* in the same state (holdAlerts), so the write that opens a condition also
|
|
2748
|
+
* keeps its alerts, and a process that stops before sending them does not
|
|
2749
|
+
* lose them. Called inside updateState(), so it only computes.
|
|
2750
|
+
*/
|
|
2751
|
+
outbox(settled, definition, now) {
|
|
2752
|
+
const alerts = settled.alerts.map((draft) => composeAlert(draft, definition, now));
|
|
2753
|
+
const { state, dropped } = holdAlerts(settled.state, alerts, this.now() + SEND_LEASE_MS, this.deferDelivery);
|
|
2754
|
+
return { state, result: { alerts, dropped } };
|
|
2755
|
+
}
|
|
2756
|
+
/** Reports alerts let go because a job's queue was full. */
|
|
2757
|
+
reportDropped(name, dropped) {
|
|
2758
|
+
if (dropped <= 0) return;
|
|
2759
|
+
this.report(
|
|
2760
|
+
new Error(`${dropped} undelivered alert${dropped === 1 ? "" : "s"} for ${name} dropped: only the newest ${MAX_UNDELIVERED} are kept for retry`),
|
|
2761
|
+
`alert queue for ${name}`
|
|
2762
|
+
);
|
|
2618
2763
|
}
|
|
2619
2764
|
/**
|
|
2620
2765
|
* The runs before `run`, newest first, with up to BASELINE_WINDOW
|
|
@@ -2654,11 +2799,13 @@ ${id}`;
|
|
|
2654
2799
|
}
|
|
2655
2800
|
for (const definition of this.definitions.values()) await this.sync(definition);
|
|
2656
2801
|
const now = this.now();
|
|
2657
|
-
for (const
|
|
2802
|
+
for (const listed of await this.store.runningRuns()) {
|
|
2658
2803
|
try {
|
|
2659
|
-
const declared = this.definitions.get(
|
|
2660
|
-
const definition = declared ? toStored(declared) : (await this.store.getJob(
|
|
2661
|
-
if (!definition || !isStuck(definition,
|
|
2804
|
+
const declared = this.definitions.get(listed.job);
|
|
2805
|
+
const definition = declared ? toStored(declared) : (await this.store.getJob(listed.job))?.definition;
|
|
2806
|
+
if (!definition || !isStuck(definition, listed, now)) continue;
|
|
2807
|
+
const run = await this.store.getRun(listed.id);
|
|
2808
|
+
if (!run || run.status !== "running" || run.job !== listed.job) continue;
|
|
2662
2809
|
const timeout = timeoutMs(definition);
|
|
2663
2810
|
run.status = "timeout";
|
|
2664
2811
|
run.finishedAt = now;
|
|
@@ -2667,23 +2814,26 @@ ${id}`;
|
|
|
2667
2814
|
if (!await this.writeRunIf(run, ["running"])) continue;
|
|
2668
2815
|
alerts.push(...await this.finishRun(definition, run, now));
|
|
2669
2816
|
} catch (e) {
|
|
2670
|
-
this.onError(e, `checking ${
|
|
2817
|
+
this.onError(e, `checking ${listed.job}`);
|
|
2671
2818
|
}
|
|
2672
2819
|
}
|
|
2673
2820
|
const jobs = [];
|
|
2674
2821
|
const retries = { spentMs: 0 };
|
|
2675
|
-
for (const stored of await this.
|
|
2822
|
+
for (const stored of await this.storedJobs()) {
|
|
2676
2823
|
try {
|
|
2677
2824
|
const recent = await this.store.listRuns(stored.name, BASELINE_WINDOW);
|
|
2678
2825
|
let nextExpectedAt = null;
|
|
2679
|
-
const { state, result:
|
|
2826
|
+
const { state, result: held } = await this.updateState(stored.name, (previous) => {
|
|
2680
2827
|
const evaluation = onCheck(stored.definition, stored, recent[0] ?? null, previous, now);
|
|
2681
2828
|
nextExpectedAt = evaluation.nextExpectedAt;
|
|
2682
2829
|
const settled = applySilence(previous, evaluation, now);
|
|
2683
|
-
|
|
2830
|
+
const released = releaseSending(settled.state, this.now());
|
|
2831
|
+
const out = this.outbox({ state: released.state, alerts: settled.alerts }, stored.definition, now);
|
|
2832
|
+
return { state: out.state, result: { alerts: out.result.alerts, dropped: released.dropped + out.result.dropped } };
|
|
2684
2833
|
});
|
|
2834
|
+
this.reportDropped(stored.name, held.dropped);
|
|
2685
2835
|
alerts.push(...await this.retryUndelivered(stored.name, state, now, retries));
|
|
2686
|
-
alerts.push(...await this.dispatch(
|
|
2836
|
+
alerts.push(...await this.dispatch(stored.name, held.alerts, now));
|
|
2687
2837
|
jobs.push(summarize(stored, recent, state, nextExpectedAt, now));
|
|
2688
2838
|
} catch (e) {
|
|
2689
2839
|
this.onError(e, `checking ${stored.name}`);
|
|
@@ -2727,16 +2877,17 @@ ${id}`;
|
|
|
2727
2877
|
/** Every job's summary with its newest `limit` runs, read together. What the dashboard shows. */
|
|
2728
2878
|
async jobsWithRuns(limit = 20) {
|
|
2729
2879
|
await this.ensureReady();
|
|
2730
|
-
|
|
2880
|
+
const jobs = await this.storedJobs();
|
|
2731
2881
|
const now = this.now();
|
|
2732
2882
|
const out = [];
|
|
2733
|
-
for (const stored of
|
|
2883
|
+
for (const stored of jobs) out.push(await this.snapshot(stored, now, clampLimit(limit, 20, 0)));
|
|
2734
2884
|
return out;
|
|
2735
2885
|
}
|
|
2886
|
+
/** 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. */
|
|
2736
2887
|
async jobSummary(name) {
|
|
2737
2888
|
await this.ensureReady();
|
|
2738
2889
|
const definition = this.definitions.get(name);
|
|
2739
|
-
if (definition) await this.sync(definition);
|
|
2890
|
+
if (definition) await this.sync(definition, true);
|
|
2740
2891
|
const stored = await this.store.getJob(name);
|
|
2741
2892
|
if (!stored) return null;
|
|
2742
2893
|
return (await this.snapshot(stored, this.now(), 0)).job;
|
|
@@ -2750,11 +2901,14 @@ ${id}`;
|
|
|
2750
2901
|
await this.ensureReady();
|
|
2751
2902
|
return this.store.getRun(id);
|
|
2752
2903
|
}
|
|
2753
|
-
/**
|
|
2904
|
+
/**
|
|
2905
|
+
* Stop alerts for a job for a while. State keeps updating underneath. The
|
|
2906
|
+
* end is a whole millisecond, held at 2^53 - 1 (see silenceEnd).
|
|
2907
|
+
*/
|
|
2754
2908
|
async silence(name, duration) {
|
|
2755
2909
|
const ms = parseDuration(duration, "silence duration");
|
|
2756
2910
|
return this.patchState(name, (state) => {
|
|
2757
|
-
state.silencedUntil = this.now()
|
|
2911
|
+
state.silencedUntil = silenceEnd(this.now(), ms);
|
|
2758
2912
|
});
|
|
2759
2913
|
}
|
|
2760
2914
|
async unsilence(name) {
|
|
@@ -2772,7 +2926,11 @@ ${id}`;
|
|
|
2772
2926
|
});
|
|
2773
2927
|
return state;
|
|
2774
2928
|
}
|
|
2775
|
-
/**
|
|
2929
|
+
/**
|
|
2930
|
+
* Remove a job and its runs from the store. A job still declared in code
|
|
2931
|
+
* comes back: here on its next run, and in any other process that declares
|
|
2932
|
+
* it on its next run there, or at that process's next check or dashboard read.
|
|
2933
|
+
*/
|
|
2776
2934
|
async forget(name) {
|
|
2777
2935
|
await this.ensureReady();
|
|
2778
2936
|
this.definitions.delete(name);
|
|
@@ -2810,32 +2968,37 @@ ${id}`;
|
|
|
2810
2968
|
this.timer = null;
|
|
2811
2969
|
this.firstTick = null;
|
|
2812
2970
|
}
|
|
2971
|
+
/**
|
|
2972
|
+
* Stop the interval, wait for a check already under way (bounded by its
|
|
2973
|
+
* own channel, triage and retry timeouts; what it throws was reported to
|
|
2974
|
+
* whoever started it), then close the store, so that check neither writes
|
|
2975
|
+
* after the store is closed nor loses the alerts it would queue.
|
|
2976
|
+
*/
|
|
2813
2977
|
async close() {
|
|
2814
2978
|
this.stop();
|
|
2979
|
+
if (this.checking) await this.checking.catch(() => {
|
|
2980
|
+
});
|
|
2815
2981
|
if (this.store.close) await this.store.close();
|
|
2816
2982
|
}
|
|
2817
2983
|
/**
|
|
2818
|
-
*
|
|
2819
|
-
*
|
|
2820
|
-
* the delivery fields are written back, onto
|
|
2984
|
+
* Triage and send each alert the outbox holds (see outbox()). The state,
|
|
2985
|
+
* with the alerts in it, was saved before this, so a slow channel holds up
|
|
2986
|
+
* nothing else; afterwards only the delivery fields are written back, onto
|
|
2987
|
+
* a fresh read of the state, and the alerts leave `sending`. Triage is made
|
|
2988
|
+
* here, never stored with the held alert: the write that opens a condition
|
|
2989
|
+
* cannot wait for it, and a retry triages an alert that has none. With
|
|
2990
|
+
* deliver: "check" the alerts were queued for a check elsewhere instead.
|
|
2821
2991
|
*/
|
|
2822
|
-
async dispatch(
|
|
2823
|
-
if (
|
|
2824
|
-
const composed = [];
|
|
2992
|
+
async dispatch(name, alerts, now) {
|
|
2993
|
+
if (alerts.length === 0 || this.deferDelivery) return alerts;
|
|
2825
2994
|
const delivered = [];
|
|
2826
2995
|
const failed = [];
|
|
2827
|
-
for (const
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
failed.push(alert);
|
|
2831
|
-
} else {
|
|
2832
|
-
if (this.triage && alert.type !== "recovered") await this.addTriage(alert, TRIAGE_TIMEOUT_MS);
|
|
2833
|
-
(await this.deliver(alert) ? delivered : failed).push(alert);
|
|
2834
|
-
}
|
|
2835
|
-
composed.push(alert);
|
|
2996
|
+
for (const alert of alerts) {
|
|
2997
|
+
if (this.triage && alert.type !== "recovered") await this.addTriage(alert, TRIAGE_TIMEOUT_MS);
|
|
2998
|
+
(await this.deliver(alert) ? delivered : failed).push(alert);
|
|
2836
2999
|
}
|
|
2837
|
-
await this.recordDelivery(
|
|
2838
|
-
return
|
|
3000
|
+
await this.recordDelivery(name, delivered, failed, [], now);
|
|
3001
|
+
return alerts;
|
|
2839
3002
|
}
|
|
2840
3003
|
/**
|
|
2841
3004
|
* Send the alerts that no channel accepted last time, once each, oldest
|
|
@@ -2864,28 +3027,18 @@ ${id}`;
|
|
|
2864
3027
|
}
|
|
2865
3028
|
/**
|
|
2866
3029
|
* Mark delivered alerts done, drop stale ones, and keep failed ones for the
|
|
2867
|
-
* next check
|
|
2868
|
-
*
|
|
3030
|
+
* next check, taking them all out of `sending` (recordSent). A failed alert
|
|
3031
|
+
* replaces its stored copy, so a triage made on this attempt is kept.
|
|
3032
|
+
* lastAlertAt moves only on a delivery. When this write fails, alerts
|
|
3033
|
+
* still in `sending` are retried once their lease runs out.
|
|
2869
3034
|
*/
|
|
2870
3035
|
async recordDelivery(name, delivered, failed, dropped, now) {
|
|
2871
3036
|
try {
|
|
2872
3037
|
const { result: trimmed } = await this.updateState(name, (previous) => {
|
|
2873
|
-
const state = normalizeState(previous, name);
|
|
2874
|
-
|
|
2875
|
-
const retried = new Map(failed.map((a) => [alertKey(a), a]));
|
|
2876
|
-
const kept = state.undelivered.filter((a) => !done.has(alertKey(a))).map((a) => retried.get(alertKey(a)) ?? a);
|
|
2877
|
-
const known = new Set(kept.map(alertKey));
|
|
2878
|
-
kept.push(...failed.filter((a) => !known.has(alertKey(a))));
|
|
2879
|
-
state.undelivered = kept.slice(-MAX_UNDELIVERED);
|
|
2880
|
-
if (delivered.length > 0) state.lastAlertAt = now;
|
|
2881
|
-
return { state, result: Math.max(0, kept.length - MAX_UNDELIVERED) };
|
|
3038
|
+
const { state, dropped: letGo } = recordSent(normalizeState(previous, name), delivered, failed, dropped, now);
|
|
3039
|
+
return { state, result: letGo };
|
|
2882
3040
|
});
|
|
2883
|
-
|
|
2884
|
-
this.onError(
|
|
2885
|
-
new Error(`${trimmed} undelivered alert${trimmed === 1 ? "" : "s"} for ${name} dropped: only the newest ${MAX_UNDELIVERED} are kept for retry`),
|
|
2886
|
-
`alert queue for ${name}`
|
|
2887
|
-
);
|
|
2888
|
-
}
|
|
3041
|
+
this.reportDropped(name, trimmed);
|
|
2889
3042
|
} catch (e) {
|
|
2890
3043
|
this.onError(e, `recording alert delivery for ${name}`);
|
|
2891
3044
|
}
|
|
@@ -2925,6 +3078,7 @@ function checkRunId(job, id, method) {
|
|
|
2925
3078
|
if (typeof id !== "string" || id.length === 0 || id.length > 200) {
|
|
2926
3079
|
throw new Error(`job "${job}": ${method}() needs a run id of 1 to 200 characters (got ${typeof id === "string" ? `${id.length} characters` : typeof id})`);
|
|
2927
3080
|
}
|
|
3081
|
+
if (id.includes("\0")) throw new Error(`job "${job}": ${method}() cannot take a run id containing a NUL character`);
|
|
2928
3082
|
if (id.startsWith(RESERVED_RUN_ID_PREFIX)) {
|
|
2929
3083
|
throw new Error(`job "${job}": ${method}() cannot take a run id starting with "${RESERVED_RUN_ID_PREFIX}", which the pg_cron source uses for its runs`);
|
|
2930
3084
|
}
|