@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.
Files changed (67) hide show
  1. package/dist/anthropic.d.cts +1 -1
  2. package/dist/anthropic.d.ts +1 -1
  3. package/dist/bugsnag.d.cts +1 -1
  4. package/dist/bugsnag.d.ts +1 -1
  5. package/dist/{client-CQm-8-mR.d.ts → client-BcIjVBiZ.d.ts} +68 -13
  6. package/dist/{client-D35cfnNV.d.cts → client-DUoSea7X.d.cts} +68 -13
  7. package/dist/d1.cjs.map +1 -1
  8. package/dist/d1.d.cts +1 -1
  9. package/dist/d1.d.ts +1 -1
  10. package/dist/d1.js.map +1 -1
  11. package/dist/datadog.d.cts +1 -1
  12. package/dist/datadog.d.ts +1 -1
  13. package/dist/discord.cjs +17 -2
  14. package/dist/discord.cjs.map +1 -1
  15. package/dist/discord.d.cts +11 -2
  16. package/dist/discord.d.ts +11 -2
  17. package/dist/discord.js +16 -3
  18. package/dist/discord.js.map +1 -1
  19. package/dist/{email-D1d5niMy.d.cts → email-JZs3vxwq.d.cts} +1 -1
  20. package/dist/{email-BxCp9RQV.d.ts → email-VrGsVz43.d.ts} +1 -1
  21. package/dist/honeybadger.d.cts +1 -1
  22. package/dist/honeybadger.d.ts +1 -1
  23. package/dist/index.cjs +207 -88
  24. package/dist/index.cjs.map +1 -1
  25. package/dist/index.d.cts +4 -4
  26. package/dist/index.d.ts +4 -4
  27. package/dist/index.js +207 -88
  28. package/dist/index.js.map +1 -1
  29. package/dist/mailgun.d.cts +2 -2
  30. package/dist/mailgun.d.ts +2 -2
  31. package/dist/newrelic.d.cts +1 -1
  32. package/dist/newrelic.d.ts +1 -1
  33. package/dist/pg-cron.cjs +2 -1
  34. package/dist/pg-cron.cjs.map +1 -1
  35. package/dist/pg-cron.d.cts +2 -2
  36. package/dist/pg-cron.d.ts +2 -2
  37. package/dist/pg-cron.js +2 -1
  38. package/dist/pg-cron.js.map +1 -1
  39. package/dist/postgres.cjs.map +1 -1
  40. package/dist/postgres.d.cts +1 -1
  41. package/dist/postgres.d.ts +1 -1
  42. package/dist/postgres.js.map +1 -1
  43. package/dist/postmark.d.cts +2 -2
  44. package/dist/postmark.d.ts +2 -2
  45. package/dist/resend.d.cts +2 -2
  46. package/dist/resend.d.ts +2 -2
  47. package/dist/rollbar.d.cts +1 -1
  48. package/dist/rollbar.d.ts +1 -1
  49. package/dist/sendgrid.d.cts +2 -2
  50. package/dist/sendgrid.d.ts +2 -2
  51. package/dist/sentry.d.cts +1 -1
  52. package/dist/sentry.d.ts +1 -1
  53. package/dist/ses.d.cts +2 -2
  54. package/dist/ses.d.ts +2 -2
  55. package/dist/slack.d.cts +1 -1
  56. package/dist/slack.d.ts +1 -1
  57. package/dist/sqlite.cjs.map +1 -1
  58. package/dist/sqlite.d.cts +1 -1
  59. package/dist/sqlite.d.ts +1 -1
  60. package/dist/sqlite.js.map +1 -1
  61. package/dist/twilio.d.cts +1 -1
  62. package/dist/twilio.d.ts +1 -1
  63. package/dist/{types-Cz47Boks.d.cts → types-DyWCxlSi.d.cts} +14 -1
  64. package/dist/{types-Cz47Boks.d.ts → types-DyWCxlSi.d.ts} +14 -1
  65. package/dist/webhook.d.cts +1 -1
  66. package/dist/webhook.d.ts +1 -1
  67. 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
- ...state,
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 "[earlier output trimmed]\n" + clean.slice(clean.length - OUTPUT_CAP);
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 : capOutput(lines.join("\n")),
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 newest = /* @__PURE__ */ new Map();
1937
- for (const run of runs.values()) newest.set(run.job, Math.max(newest.get(run.job) ?? -Infinity, run.startedAt));
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 < newest.get(run.job)) {
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)) return;
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 ((this.definitions.get(name) ?? standing) === standing) this.synced.add(name);
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" ? capOutput(result) : null);
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() and RunHandle.finish().
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 = errorMessage(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 = stripNul(this.redact(run.output));
2311
- if (run.error !== null) run.error = stripNul(this.redact(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" ? capOutput(result) : null);
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
- output: joinOutput(from.output, added),
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, stripNul(self.redact(lines)));
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. Returns the
2588
- * alerts it sent.
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 = stripNul(this.redact(capOutput(run.output)));
2604
- if (run.error !== null) run.error = stripNul(this.redact(capOutput(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. Never throws.
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 drafts;
2733
+ let held;
2642
2734
  try {
2643
2735
  let history = null;
2644
- ({ result: drafts } = await this.updateState(run.job, async (previous) => {
2736
+ ({ result: held } = await this.updateState(run.job, async (previous) => {
2645
2737
  history ??= await this.history(run);
2646
- const settled = applySilence(previous, onRunFinish(definition, run, previous, history, now), now);
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
- return this.dispatch(drafts, definition, now);
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 run of await this.store.runningRuns()) {
2804
+ for (const listed of await this.store.runningRuns()) {
2694
2805
  try {
2695
- const declared = this.definitions.get(run.job);
2696
- const definition = declared ? toStored(declared) : (await this.store.getJob(run.job))?.definition;
2697
- if (!definition || !isStuck(definition, run, now)) continue;
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 ${run.job}`);
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.store.listJobs()) {
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: drafts } = await this.updateState(stored.name, (previous) => {
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
- return { state: settled.state, result: settled.alerts };
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(drafts, stored.definition, now));
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
- for (const definition of this.definitions.values()) await this.sync(definition);
2882
+ const jobs = await this.storedJobs();
2767
2883
  const now = this.now();
2768
2884
  const out = [];
2769
- for (const stored of await this.store.listJobs()) out.push(await this.snapshot(stored, now, clampLimit(limit, 20, 0)));
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
- /** Stop alerts for a job for a while. State keeps updating underneath. */
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() + ms;
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
- /** Remove a job and its runs from the store. A job still declared in code comes back on its next run. */
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
- * Compose, triage and send each draft. The state was saved before this
2855
- * (updateState), so a slow channel holds up nothing else; afterwards only
2856
- * the delivery fields are written back, onto a fresh read of the state.
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(drafts, definition, now) {
2859
- if (drafts.length === 0) return [];
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 draft of drafts) {
2864
- const alert = composeAlert(draft, definition, now);
2865
- if (this.deferDelivery) {
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(definition.name, delivered, failed, [], now);
2874
- return composed;
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. A failed alert replaces its stored copy, so a triage made on
2904
- * this attempt is kept. lastAlertAt moves only on a delivery.
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
- const done = new Set([...delivered, ...dropped].map(alertKey));
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
- if (trimmed > 0) {
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
  }