@framers/agentos-ext-triggers 0.0.0-stage → 0.1.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/LICENSE +98 -0
- package/README.md +198 -2
- package/dist/contract.d.ts +262 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +2 -0
- package/dist/contract.js.map +1 -0
- package/dist/cron.d.ts +36 -0
- package/dist/cron.d.ts.map +1 -0
- package/dist/cron.js +125 -0
- package/dist/cron.js.map +1 -0
- package/dist/housekeeping.d.ts +20 -0
- package/dist/housekeeping.d.ts.map +1 -0
- package/dist/housekeeping.js +33 -0
- package/dist/housekeeping.js.map +1 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/names.d.ts +9 -0
- package/dist/names.d.ts.map +1 -0
- package/dist/names.js +9 -0
- package/dist/names.js.map +1 -0
- package/dist/report.d.ts +27 -0
- package/dist/report.d.ts.map +1 -0
- package/dist/report.js +94 -0
- package/dist/report.js.map +1 -0
- package/dist/rows.d.ts +87 -0
- package/dist/rows.d.ts.map +1 -0
- package/dist/rows.js +52 -0
- package/dist/rows.js.map +1 -0
- package/dist/scheduler.d.ts +44 -0
- package/dist/scheduler.d.ts.map +1 -0
- package/dist/scheduler.js +743 -0
- package/dist/scheduler.js.map +1 -0
- package/dist/store/ids.d.ts +3 -0
- package/dist/store/ids.d.ts.map +1 -0
- package/dist/store/ids.js +6 -0
- package/dist/store/ids.js.map +1 -0
- package/dist/store/migrations.d.ts +30 -0
- package/dist/store/migrations.d.ts.map +1 -0
- package/dist/store/migrations.js +26 -0
- package/dist/store/migrations.js.map +1 -0
- package/dist/store/prefix.d.ts +23 -0
- package/dist/store/prefix.d.ts.map +1 -0
- package/dist/store/prefix.js +37 -0
- package/dist/store/prefix.js.map +1 -0
- package/dist/store/sql/index.d.ts +21 -0
- package/dist/store/sql/index.d.ts.map +1 -0
- package/dist/store/sql/index.js +157 -0
- package/dist/store/sql/index.js.map +1 -0
- package/dist/watcher.d.ts +28 -0
- package/dist/watcher.d.ts.map +1 -0
- package/dist/watcher.js +279 -0
- package/dist/watcher.js.map +1 -0
- package/dist/words.d.ts +12 -0
- package/dist/words.d.ts.map +1 -0
- package/dist/words.js +57 -0
- package/dist/words.js.map +1 -0
- package/manifest.json +39 -0
- package/package.json +68 -3
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
import { computeNextRunAtMs, requireSchedule } from './cron.js';
|
|
2
|
+
import { housekeep, requireKeep } from './housekeeping.js';
|
|
3
|
+
import { NAME, WATCH_ROW } from './names.js';
|
|
4
|
+
import { createReporter } from './report.js';
|
|
5
|
+
import { missedViewOf, num, runViewOf, scheduleOf, viewOf } from './rows.js';
|
|
6
|
+
import { randomHex } from './store/ids.js';
|
|
7
|
+
import { dialectOf } from './store/migrations.js';
|
|
8
|
+
import { DEFAULT_PREFIX, requireDialectPrefix, requirePrefix, tableName } from './store/prefix.js';
|
|
9
|
+
import { scheduleWords } from './words.js';
|
|
10
|
+
/** The tick when the host gives none, and the smallest `every`. */
|
|
11
|
+
export const DEFAULT_TICK_MS = 10_000;
|
|
12
|
+
/** The gap after which a job counts as stalled when its definition gives none. */
|
|
13
|
+
export const DEFAULT_STALL_AFTER_MS = 900_000;
|
|
14
|
+
/** Tries before a job pauses itself when its definition gives none. */
|
|
15
|
+
export const DEFAULT_RETRIES = 5;
|
|
16
|
+
/** A run's bound when its definition gives none. */
|
|
17
|
+
export const DEFAULT_TIMEOUT_MS = 600_000;
|
|
18
|
+
/** Hand runs an hour on one instance when the host gives none. */
|
|
19
|
+
export const DEFAULT_HAND_RUNS_PER_HOUR = 20;
|
|
20
|
+
/** How long `stop()` waits for the runs in flight when the host gives no wait. */
|
|
21
|
+
export const DEFAULT_STOP_WAIT_MS = 5_000;
|
|
22
|
+
/** The longest pause note. */
|
|
23
|
+
export const NOTE_MAX = 200;
|
|
24
|
+
export { NAME, WATCH_ROW } from './names.js';
|
|
25
|
+
/**
|
|
26
|
+
* The most missed due times one restore, one resume or one settlement records for a job. For `skip` and `catch_up_once`
|
|
27
|
+
* the NEWEST MISSED_MAX are recorded and the older ones only counted (one `job_missed` line with `count`); for `required`
|
|
28
|
+
* the OLDEST MISSED_MAX are recorded and the rest stay owed: the settlement of the catch-up run that served them records
|
|
29
|
+
* the next batch, so no required due time is stepped past unrecorded.
|
|
30
|
+
*/
|
|
31
|
+
export const MISSED_MAX = 1_000;
|
|
32
|
+
/** One hour, the hand runs' bound's window and the housekeeping's. */
|
|
33
|
+
const HOUR = 3_600_000;
|
|
34
|
+
/** Jobs a `list` page holds. */
|
|
35
|
+
export const LIST_PAGE = 200;
|
|
36
|
+
/** The wait before try `attempt + 1`: 30 s doubling to 10 minutes. */
|
|
37
|
+
export function defaultBackoffMs(attempt) {
|
|
38
|
+
return Math.min(30_000 * 2 ** Math.max(0, attempt - 1), 600_000);
|
|
39
|
+
}
|
|
40
|
+
/** Thrown inside the restore's transaction when its conditional claim changed no row, so the missed rows it wrote roll back with it. */
|
|
41
|
+
const RESTORE_LOST = Symbol('restore_lost');
|
|
42
|
+
let uniqueCheck = null;
|
|
43
|
+
/** Whether `error` is a unique violation, through the adapter's own `isUniqueViolation` loaded at the first call (the adapter is an optional peer, so the entry imports without it). */
|
|
44
|
+
function isUnique(error) {
|
|
45
|
+
uniqueCheck ??= import('@framers/sql-storage-adapter').then((adapter) => adapter.isUniqueViolation);
|
|
46
|
+
return uniqueCheck.then((check) => check(error));
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Builds a scheduler over `options.db` under `options.prefix`. A process that runs jobs registers them and calls
|
|
50
|
+
* `start()`; a process that only reads and commands builds one and never starts it. `internals.arm: false` arms no
|
|
51
|
+
* interval: the caller drives `tick()` (the tests, and `createSchedulerForTests`).
|
|
52
|
+
*/
|
|
53
|
+
export function createScheduler(options, internals = {}) {
|
|
54
|
+
const db = options.db;
|
|
55
|
+
const prefix = requireDialectPrefix(requirePrefix(options.prefix ?? DEFAULT_PREFIX), dialectOf(db));
|
|
56
|
+
const t = (name) => tableName(prefix, name);
|
|
57
|
+
const now = options.now ?? Date.now;
|
|
58
|
+
const tickMs = options.tickMs ?? DEFAULT_TICK_MS;
|
|
59
|
+
const logger = options.logger;
|
|
60
|
+
// A `keep` field that is not a whole number above 0 throws here, never at the first hourly housekeeping.
|
|
61
|
+
const keep = requireKeep(options.keep);
|
|
62
|
+
const reporter = createReporter(db, t, now, logger);
|
|
63
|
+
const holder = `${options.process}:${randomHex(4)}`;
|
|
64
|
+
const definitions = new Map();
|
|
65
|
+
const inFlight = new Map();
|
|
66
|
+
const runHandlers = new Set();
|
|
67
|
+
const failedHandlers = new Set();
|
|
68
|
+
const handRunsAt = [];
|
|
69
|
+
let started = false;
|
|
70
|
+
/** The start in progress: a second `start()` while it runs waits for it rather than writing, restoring and arming again. */
|
|
71
|
+
let starting = null;
|
|
72
|
+
let stopping = false;
|
|
73
|
+
let ticking = false;
|
|
74
|
+
/** The tick in progress, which `stop()` waits for before it reads the runs in flight. */
|
|
75
|
+
let currentTick = Promise.resolve();
|
|
76
|
+
let timer = null;
|
|
77
|
+
const scheduleJson = (schedule) => JSON.stringify(schedule);
|
|
78
|
+
/**
|
|
79
|
+
* The due time a new or changed definition starts from: the schedule's next one after `at`, or a one-shot's own time when
|
|
80
|
+
* it has already passed, so the restore applies the job's rule to it rather than leaving a row no claim can take.
|
|
81
|
+
*/
|
|
82
|
+
function firstDue(schedule, at) {
|
|
83
|
+
return computeNextRunAtMs(schedule, at) ?? (schedule.kind === 'at' && Number.isFinite(schedule.at) ? schedule.at : null);
|
|
84
|
+
}
|
|
85
|
+
/** The next due time strictly after `fromMs` that is also after `afterMs`: stepped from the due time, never from the run's end, so a slow run does not drift the schedule. */
|
|
86
|
+
function nextAfter(schedule, fromMs, afterMs) {
|
|
87
|
+
let next = computeNextRunAtMs(schedule, fromMs);
|
|
88
|
+
while (next !== undefined && next <= afterMs)
|
|
89
|
+
next = computeNextRunAtMs(schedule, next);
|
|
90
|
+
return next;
|
|
91
|
+
}
|
|
92
|
+
/** The due times from `fromMs` (exclusive) up to `untilMs` (inclusive), at most MISSED_MAX, oldest first. */
|
|
93
|
+
function dueTimes(schedule, fromMs, untilMs) {
|
|
94
|
+
const times = [];
|
|
95
|
+
let next = computeNextRunAtMs(schedule, fromMs);
|
|
96
|
+
while (next !== undefined && next <= untilMs && times.length < MISSED_MAX) {
|
|
97
|
+
times.push(next);
|
|
98
|
+
next = computeNextRunAtMs(schedule, next);
|
|
99
|
+
}
|
|
100
|
+
return times;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The missed window a rule records: `first` (a due time already owed, or undefined) and the due times after `fromMs` up to
|
|
104
|
+
* `untilMs`. `required` keeps the oldest MISSED_MAX (`more` says due times stay owed past them); the other rules keep the
|
|
105
|
+
* newest MISSED_MAX and count the older ones in `dropped`, so a catch-up serves the latest due time missed.
|
|
106
|
+
*/
|
|
107
|
+
function missedWindow(schedule, rule, first, fromMs, untilMs) {
|
|
108
|
+
const head = first === undefined ? [] : [first];
|
|
109
|
+
if (rule === 'required') {
|
|
110
|
+
const times = [...head, ...dueTimes(schedule, fromMs, untilMs)].slice(0, MISSED_MAX);
|
|
111
|
+
const after = computeNextRunAtMs(schedule, times[times.length - 1] ?? fromMs);
|
|
112
|
+
return { times, dropped: 0, more: times.length > 0 && after !== undefined && after <= untilMs };
|
|
113
|
+
}
|
|
114
|
+
let kept = [...head];
|
|
115
|
+
let dropped = 0;
|
|
116
|
+
let next = computeNextRunAtMs(schedule, fromMs);
|
|
117
|
+
while (next !== undefined && next <= untilMs) {
|
|
118
|
+
kept.push(next);
|
|
119
|
+
// Trimmed in halves, so a long outage costs one pass over its due times, never a shift per due time.
|
|
120
|
+
if (kept.length >= 2 * MISSED_MAX) {
|
|
121
|
+
dropped += kept.length - MISSED_MAX;
|
|
122
|
+
kept = kept.slice(-MISSED_MAX);
|
|
123
|
+
}
|
|
124
|
+
next = computeNextRunAtMs(schedule, next);
|
|
125
|
+
}
|
|
126
|
+
if (kept.length > MISSED_MAX) {
|
|
127
|
+
dropped += kept.length - MISSED_MAX;
|
|
128
|
+
kept = kept.slice(-MISSED_MAX);
|
|
129
|
+
}
|
|
130
|
+
return { times: kept, dropped, more: false };
|
|
131
|
+
}
|
|
132
|
+
function register(definition) {
|
|
133
|
+
// A start in progress has read, or is reading, the definitions: a job added now might get no row or no restore.
|
|
134
|
+
if (started || starting !== null)
|
|
135
|
+
throw new Error('register() runs before start()');
|
|
136
|
+
if (!NAME.test(definition.name))
|
|
137
|
+
throw new RangeError('a job name is a lower-case letter, then at most 63 lower-case letters, digits or underscores');
|
|
138
|
+
if (definitions.has(definition.name))
|
|
139
|
+
throw new Error(`duplicate job name: ${definition.name}`);
|
|
140
|
+
requireSchedule(definition.schedule, tickMs);
|
|
141
|
+
if (definition.legalDuty === true && definition.rule !== 'required')
|
|
142
|
+
throw new Error(`a legal-duty job's rule is required: ${definition.name}`);
|
|
143
|
+
definitions.set(definition.name, definition);
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* The definition's columns written in. The stored schedule is read BEFORE the write and compared: a changed schedule, or a
|
|
147
|
+
* finished one-shot (`enabled = 0`) whose time changed, is rescheduled from now and enabled again in the same transaction, so
|
|
148
|
+
* the restore below never reads the old schedule's `next_due_at` as the new one's (a weekly job changed to every five
|
|
149
|
+
* minutes would otherwise keep its weekly due time).
|
|
150
|
+
*/
|
|
151
|
+
async function upsertDefinition(def, at) {
|
|
152
|
+
const schedule = scheduleJson(def.schedule);
|
|
153
|
+
await db.transaction(async (trx) => {
|
|
154
|
+
const stored = await trx.get(`SELECT schedule, enabled FROM ${t('jobs')} WHERE name = ?`, [def.name]);
|
|
155
|
+
const changed = stored !== undefined && stored !== null && (stored.schedule === null || stored.schedule !== schedule);
|
|
156
|
+
await trx.run(`INSERT INTO ${t('jobs')} (name, title, process, kind, schedule, schedule_words, rule, stall_after_ms, safe_to_repeat, retries, timeout_ms, legal_duty, enabled, next_due_at, registered_at, updated_at)
|
|
157
|
+
VALUES (?, ?, ?, 'scheduled', ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)
|
|
158
|
+
ON CONFLICT (name) DO UPDATE SET title = excluded.title, process = excluded.process, kind = 'scheduled', schedule = excluded.schedule,
|
|
159
|
+
schedule_words = excluded.schedule_words, rule = excluded.rule, stall_after_ms = excluded.stall_after_ms, safe_to_repeat = excluded.safe_to_repeat,
|
|
160
|
+
retries = excluded.retries, timeout_ms = excluded.timeout_ms, legal_duty = excluded.legal_duty, updated_at = excluded.updated_at`, [def.name, def.title, def.process, schedule, scheduleWords(def.schedule), def.rule, def.stallAfterMs ?? DEFAULT_STALL_AFTER_MS, def.safeToRepeat ? 1 : 0,
|
|
161
|
+
def.retries ?? DEFAULT_RETRIES, def.timeoutMs ?? DEFAULT_TIMEOUT_MS, def.legalDuty === true ? 1 : 0, firstDue(def.schedule, at), at, at]);
|
|
162
|
+
if (changed) {
|
|
163
|
+
// A run in flight under the old schedule, in this process or another, settles onto the stored schedule (the settlement reads it), so it never writes the old schedule's next due time back.
|
|
164
|
+
await trx.run(`UPDATE ${t('jobs')} SET next_due_at = ?, enabled = 1, updated_at = ? WHERE name = ?`, [firstDue(def.schedule, at), at, def.name]);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Writes the missed rows for `times` by `rule` and answers whether a catch-up run is owed now. The ledger's unique index on
|
|
170
|
+
* `(job, due_at)` refuses a due time recorded twice: the insert throws, and the caller's transaction rolls back. Under
|
|
171
|
+
* `catch_up_once` an earlier catch-up still owed (a row `caught_up` with no run) is moved to `skipped`, so one catch-up is
|
|
172
|
+
* owed whatever number of restores and resumes recorded windows before it ran. `dropped` older due times are counted on
|
|
173
|
+
* the first line.
|
|
174
|
+
*/
|
|
175
|
+
async function applyRule(writer, name, rule, cause, times, at, dropped = 0) {
|
|
176
|
+
if (rule === 'catch_up_once' && times.length > 0) {
|
|
177
|
+
await writer.run(`UPDATE ${t('missed')} SET action = 'skipped', acted_at = ? WHERE job = ? AND action = 'caught_up' AND run_id IS NULL AND due_at < ?`, [at, name, times[0]]);
|
|
178
|
+
}
|
|
179
|
+
for (let i = 0; i < times.length; i += 1) {
|
|
180
|
+
const caught = rule === 'required' || (rule === 'catch_up_once' && i === times.length - 1);
|
|
181
|
+
await writer.run(`INSERT INTO ${t('missed')} (id, job, due_at, cause, action, acted_at) VALUES (?, ?, ?, ?, ?, ?)`, [randomHex(16), name, times[i], cause, caught ? 'caught_up' : 'skipped', caught ? null : at]);
|
|
182
|
+
logger.info('job_missed', { job: name, rule, cause, action: caught ? 'caught_up' : 'skipped', ...(i === 0 && dropped > 0 ? { count: dropped } : {}) });
|
|
183
|
+
}
|
|
184
|
+
return { catchUp: times.length > 0 && rule !== 'skip' };
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* The restore at start(): what was missed while no process ran a job is recorded by its rule, atomically per job (two
|
|
188
|
+
* starting processes read the same overdue `next_due_at`; one restores it and the other changes nothing). The missed rows
|
|
189
|
+
* and the move of `next_due_at` land in one transaction whose last statement is a conditional claim on the `next_due_at`
|
|
190
|
+
* this process read, skipping a row another process holds under a live lease; a claim that changes no row throws
|
|
191
|
+
* `RESTORE_LOST`, so the transaction rolls back and the other process's rows stand. A catch-up sets `next_due_at` to the
|
|
192
|
+
* LAST missed due time, never to "now": the claim then runs it at once (`next_due_at <= now`), the run's `dueAt` is a due
|
|
193
|
+
* time the schedule had, and `covers` is read from the missed rows. A `next_due_at` within the last two ticks is left to
|
|
194
|
+
* the claim: it shows nothing about whether another process is running.
|
|
195
|
+
*/
|
|
196
|
+
async function restore(at) {
|
|
197
|
+
for (const def of definitions.values()) {
|
|
198
|
+
const row = await db.get(`SELECT * FROM ${t('jobs')} WHERE name = ?`, [def.name]);
|
|
199
|
+
if (!row || row.enabled !== 1 || row.paused_by !== null)
|
|
200
|
+
continue;
|
|
201
|
+
const nextDue = num(row.next_due_at);
|
|
202
|
+
if (nextDue === null) {
|
|
203
|
+
await db.run(`UPDATE ${t('jobs')} SET next_due_at = ?, updated_at = ? WHERE name = ? AND next_due_at IS NULL`, [computeNextRunAtMs(def.schedule, at) ?? null, at, def.name]);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
// A due time of the last two ticks is the claim's, never missed: a live process claims it within a tick, and if none
|
|
207
|
+
// runs, this process's first tick does. Only a due time older than that shows that no process ran the job.
|
|
208
|
+
if (nextDue > at - 2 * tickMs)
|
|
209
|
+
continue;
|
|
210
|
+
if (row.lease_until !== null && Number(row.lease_until) > at)
|
|
211
|
+
continue; // a live process holds the job: nothing was missed
|
|
212
|
+
// A pending retry (`retry_at` set) serves `next_due_at` itself when its backoff ends: that due time is owed, never missed,
|
|
213
|
+
// and the window starts after it; the missed-run rule never reads `retry_at`. So is a due time whose run's process died
|
|
214
|
+
// holding the lease (a token under an expired lease): the claim reads that run lost first, then runs the due time again or,
|
|
215
|
+
// for a job not safe to repeat, pauses it by system.
|
|
216
|
+
const retrying = row.retry_at !== null || row.lease_token !== null;
|
|
217
|
+
const { times: window, dropped } = missedWindow(def.schedule, def.rule, retrying ? undefined : nextDue, nextDue, at);
|
|
218
|
+
// Due times an earlier restore or resume already recorded are not recorded again: a second restore before the queued
|
|
219
|
+
// catch-up ran finds its window recorded and changes nothing.
|
|
220
|
+
const recorded = window.length === 0 ? new Set() : new Set((await db.all(`SELECT due_at FROM ${t('missed')} WHERE job = ? AND due_at >= ? AND due_at <= ?`, [def.name, window[0], window[window.length - 1]])).map((m) => Number(m.due_at)));
|
|
221
|
+
const times = window.filter((due) => !recorded.has(due));
|
|
222
|
+
if (times.length === 0)
|
|
223
|
+
continue;
|
|
224
|
+
const last = times[times.length - 1];
|
|
225
|
+
try {
|
|
226
|
+
await db.transaction(async (trx) => {
|
|
227
|
+
const { catchUp } = await applyRule(trx, def.name, def.rule, 'worker_down', times, at, dropped);
|
|
228
|
+
// A pending retry keeps its due time; the catch-up it leaves owed runs after the retry settles (settle reads the ledger).
|
|
229
|
+
// A required window with more owed runs its batch now; the settlement records the next batch.
|
|
230
|
+
const next = retrying ? nextDue : catchUp ? last : (nextAfter(def.schedule, last, at) ?? null);
|
|
231
|
+
const changed = await trx.run(`UPDATE ${t('jobs')} SET next_due_at = ?, updated_at = ? WHERE name = ? AND next_due_at = ? AND (lease_until IS NULL OR lease_until < ?)`, [next, at, def.name, nextDue, at]);
|
|
232
|
+
if (Number(changed.changes) !== 1)
|
|
233
|
+
throw RESTORE_LOST;
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
// Another process restored first: its conditional claim moved the row, or its rows hold the ledger's unique index.
|
|
238
|
+
if (error !== RESTORE_LOST && !(await isUnique(error)))
|
|
239
|
+
throw error;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
function start() {
|
|
244
|
+
if (started)
|
|
245
|
+
return Promise.resolve();
|
|
246
|
+
// Cleared when it settles, so a start that threw can be called again.
|
|
247
|
+
starting ??= begin().finally(() => { starting = null; });
|
|
248
|
+
return starting;
|
|
249
|
+
}
|
|
250
|
+
async function begin() {
|
|
251
|
+
const at = now();
|
|
252
|
+
for (const def of definitions.values())
|
|
253
|
+
await upsertDefinition(def, at);
|
|
254
|
+
await restore(at);
|
|
255
|
+
started = true;
|
|
256
|
+
if (internals.arm !== false) {
|
|
257
|
+
timer = setInterval(() => void tick(), tickMs);
|
|
258
|
+
timer.unref?.();
|
|
259
|
+
void tick();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Each tick, for every run this process has in flight: the timeout read from the clock (the first tick at or past
|
|
264
|
+
* `startedAt + timeoutMs` aborts the signal with `timeout`; no real timer, so a fake clock sees it), then the renewal.
|
|
265
|
+
* A renewal that changes no row, or that the database refuses or cannot reach, is a lost lease: the run's signal is
|
|
266
|
+
* aborted with `lease_lost` at once, and its settlement stays fenced by the token.
|
|
267
|
+
*/
|
|
268
|
+
async function renewLeases(at) {
|
|
269
|
+
for (const [name, flight] of inFlight) {
|
|
270
|
+
if (!flight.controller.signal.aborted && at - flight.startedAt >= flight.timeoutMs)
|
|
271
|
+
flight.controller.abort(new Error('timeout'));
|
|
272
|
+
let changes = 0;
|
|
273
|
+
try {
|
|
274
|
+
changes = Number((await db.run(`UPDATE ${t('jobs')} SET lease_until = ?, updated_at = ? WHERE name = ? AND lease_token = ?`, [at + 2 * tickMs, at, name, flight.token])).changes);
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
changes = 0;
|
|
278
|
+
}
|
|
279
|
+
if (changes !== 1 && !flight.controller.signal.aborted)
|
|
280
|
+
flight.controller.abort(new Error('lease_lost'));
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
/** Marks a run another process left open as lost; a job not safe to repeat is paused by system, its alert owed, and not run. Answers whether the claimant may run. */
|
|
284
|
+
async function settleLost(row, lost, token, at) {
|
|
285
|
+
await db.run(`UPDATE ${t('runs')} SET outcome = 'failed', ended_at = ?, ms = ?, kind = 'lost' WHERE id = ? AND outcome = 'running'`, [at, at - Number(lost.started_at), lost.id]);
|
|
286
|
+
logger.warn('job_run', { job: row.name, outcome: 'failed', ms: at - Number(lost.started_at), tries: lost.tries });
|
|
287
|
+
if (row.safe_to_repeat === 1)
|
|
288
|
+
return true;
|
|
289
|
+
await db.run(`UPDATE ${t('jobs')} SET paused_by = 'system', paused_at = ?, failed_at = ?, failed_kind = 'lost', alerted_at = ?, tries = tries + 1, retry_at = NULL, lease_holder = NULL, lease_token = NULL, lease_until = NULL, hand_run_by = NULL, hand_run_at = NULL, updated_at = ? WHERE name = ? AND lease_token = ?`, [at, at, at, at, row.name, token]);
|
|
290
|
+
logger.warn('job_failed', { job: row.name, kind: 'lost', tries: row.tries + 1 });
|
|
291
|
+
const view = await get(row.name);
|
|
292
|
+
const run = await runById(lost.id);
|
|
293
|
+
if (view && run)
|
|
294
|
+
for (const handler of failedHandlers) {
|
|
295
|
+
try {
|
|
296
|
+
handler(view, run);
|
|
297
|
+
}
|
|
298
|
+
catch { /* a handler's throw is its own */ }
|
|
299
|
+
}
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
/** The claim: a conditional update that writes this process's lease and a fresh token on a job that is due (its retry ready) or asked for by hand, and free. */
|
|
303
|
+
async function tryClaim(def, at) {
|
|
304
|
+
const token = randomHex(16);
|
|
305
|
+
const changed = await db.run(`UPDATE ${t('jobs')} SET lease_holder = ?, lease_token = ?, lease_until = ?, updated_at = ?
|
|
306
|
+
WHERE name = ? AND kind = 'scheduled' AND (lease_until IS NULL OR lease_until < ?)
|
|
307
|
+
AND ((enabled = 1 AND paused_by IS NULL AND next_due_at IS NOT NULL AND next_due_at <= ? AND (retry_at IS NULL OR retry_at <= ?)) OR hand_run_at IS NOT NULL)`, [holder, token, at + 2 * tickMs, at, def.name, at, at, at]);
|
|
308
|
+
if (Number(changed.changes) !== 1)
|
|
309
|
+
return null;
|
|
310
|
+
const row = (await db.get(`SELECT * FROM ${t('jobs')} WHERE name = ?`, [def.name]));
|
|
311
|
+
const lost = await db.get(`SELECT * FROM ${t('runs')} WHERE job = ? AND outcome = 'running' AND lease_token <> ? ORDER BY started_at DESC LIMIT 1`, [def.name, token]);
|
|
312
|
+
if (lost && !(await settleLost(row, lost, token, at)))
|
|
313
|
+
return null;
|
|
314
|
+
return { row, token };
|
|
315
|
+
}
|
|
316
|
+
/** When the housekeeping last ran on this instance: it runs on the tick once an hour at most. */
|
|
317
|
+
let lastHousekeepingAt = null;
|
|
318
|
+
/** The retention of runs and missed runs (`housekeep`), once an hour at most, with the host's `keep` over the defaults. */
|
|
319
|
+
async function housekeeping(at) {
|
|
320
|
+
if (lastHousekeepingAt !== null && at - lastHousekeepingAt < HOUR)
|
|
321
|
+
return;
|
|
322
|
+
lastHousekeepingAt = at;
|
|
323
|
+
await housekeep(db, t, at, keep);
|
|
324
|
+
}
|
|
325
|
+
async function tick() {
|
|
326
|
+
if (ticking || stopping)
|
|
327
|
+
return;
|
|
328
|
+
ticking = true;
|
|
329
|
+
const pass = claimPass();
|
|
330
|
+
currentTick = pass;
|
|
331
|
+
await pass;
|
|
332
|
+
}
|
|
333
|
+
/** Frees a lease this process took and will not run on (a claim that landed after `stop()` began), fenced by its token. */
|
|
334
|
+
async function release(name, token) {
|
|
335
|
+
await db.run(`UPDATE ${t('jobs')} SET lease_holder = NULL, lease_token = NULL, lease_until = NULL, updated_at = ? WHERE name = ? AND lease_token = ?`, [now(), name, token]);
|
|
336
|
+
}
|
|
337
|
+
async function claimPass() {
|
|
338
|
+
try {
|
|
339
|
+
const at = now();
|
|
340
|
+
await renewLeases(at);
|
|
341
|
+
for (const def of definitions.values()) {
|
|
342
|
+
if (stopping)
|
|
343
|
+
break;
|
|
344
|
+
if (inFlight.has(def.name))
|
|
345
|
+
continue;
|
|
346
|
+
const claim = await tryClaim(def, at);
|
|
347
|
+
if (claim === null)
|
|
348
|
+
continue;
|
|
349
|
+
// `stop()` began while this claim was in the database: it starts no run, and the job is free for the next process.
|
|
350
|
+
if (stopping) {
|
|
351
|
+
await release(def.name, claim.token);
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
354
|
+
// Waits until the run's row is written and its body called, never for the body to settle.
|
|
355
|
+
await startRun(def, claim, at);
|
|
356
|
+
}
|
|
357
|
+
await housekeeping(at);
|
|
358
|
+
}
|
|
359
|
+
catch (error) {
|
|
360
|
+
logger.error('tick_failed', { kind: options.describe?.(error)?.kind ?? 'error' });
|
|
361
|
+
}
|
|
362
|
+
finally {
|
|
363
|
+
ticking = false;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Starts a claimed run: writes its row, calls the job's body and settles it when the body settles. Answers a promise that
|
|
368
|
+
* resolves once the body has been called (or the run failed before it), so a tick never returns ahead of the runs it began.
|
|
369
|
+
*/
|
|
370
|
+
function startRun(def, claim, at) {
|
|
371
|
+
const { row, token } = claim;
|
|
372
|
+
let byHand = row.hand_run_at !== null ? { accountId: row.hand_run_by ?? 'system' } : null;
|
|
373
|
+
const runId = randomHex(16);
|
|
374
|
+
const attempt = row.tries + 1;
|
|
375
|
+
const controller = new AbortController();
|
|
376
|
+
const timeoutMs = def.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
377
|
+
let begun = () => { };
|
|
378
|
+
const bodyCalled = new Promise((resolve) => { begun = resolve; });
|
|
379
|
+
const promise = (async () => {
|
|
380
|
+
// The due time this run serves: a hand run's request time; else the job's `next_due_at`, which a retry leaves as the due
|
|
381
|
+
// time it serves (its readiness is `retry_at`), so a retry serves its original due time with no lookup.
|
|
382
|
+
const dueAt = byHand ? Number(row.hand_run_at) : Number(row.next_due_at);
|
|
383
|
+
// What the run covers: the caught-up missed rows not yet served, up to this due time (the restore wrote the last of them as
|
|
384
|
+
// `next_due_at`, so it is among them), else this due time alone. Never a time the schedule did not have.
|
|
385
|
+
const pending = byHand ? [] : (await db.all(`SELECT due_at FROM ${t('missed')} WHERE job = ? AND action = 'caught_up' AND run_id IS NULL AND due_at <= ? ORDER BY due_at`, [def.name, dueAt])).map((m) => Number(m.due_at));
|
|
386
|
+
const covers = pending.length > 0 ? pending : [dueAt];
|
|
387
|
+
// The run row is written under the job row's lock, with a hand run's account read again inside that lock: an account's
|
|
388
|
+
// erasure (`forgetActor`, which nulls the job row first) either committed before it, so the run reads the account gone,
|
|
389
|
+
// or waits for it and then nulls the run row it wrote. The account read at the claim is never written after an erasure.
|
|
390
|
+
// A lock that finds the token gone means another process took the lease before this run began: that process runs the
|
|
391
|
+
// due time, so this one writes no run row and calls no body.
|
|
392
|
+
const written = await db.transaction(async (trx) => {
|
|
393
|
+
const held = Number((await trx.run(`UPDATE ${t('jobs')} SET updated_at = updated_at WHERE name = ? AND lease_token = ?`, [def.name, token])).changes);
|
|
394
|
+
if (held !== 1)
|
|
395
|
+
return null;
|
|
396
|
+
const asked = byHand === null ? null : { accountId: (await trx.get(`SELECT hand_run_by FROM ${t('jobs')} WHERE name = ?`, [def.name]))?.hand_run_by ?? 'system' };
|
|
397
|
+
await trx.run(`INSERT INTO ${t('runs')} (id, job, started_at, outcome, tries, by_hand, covers_from, covers_to, lease_token) VALUES (?, ?, ?, 'running', ?, ?, ?, ?, ?)`, [runId, def.name, at, attempt, asked?.accountId ?? null, covers[0], covers[covers.length - 1], token]);
|
|
398
|
+
return { asked };
|
|
399
|
+
});
|
|
400
|
+
if (written === null) {
|
|
401
|
+
logger.warn('claim_lost', { job: def.name });
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
byHand = written.asked;
|
|
405
|
+
let summary;
|
|
406
|
+
let error;
|
|
407
|
+
// A throw or a rejection fails the try whatever its value, `undefined` included.
|
|
408
|
+
let threw = false;
|
|
409
|
+
try {
|
|
410
|
+
const view = (await get(def.name));
|
|
411
|
+
const context = { now: at, job: view, attempt, byHand, covers, runId, dueAt, signal: controller.signal, logger };
|
|
412
|
+
const body = def.run(context);
|
|
413
|
+
begun();
|
|
414
|
+
summary = (await body);
|
|
415
|
+
}
|
|
416
|
+
catch (caught) {
|
|
417
|
+
threw = true;
|
|
418
|
+
error = caught;
|
|
419
|
+
}
|
|
420
|
+
begun();
|
|
421
|
+
const reason = controller.signal.aborted ? controller.signal.reason?.message : undefined;
|
|
422
|
+
await settle(def, row, token, runId, at, dueAt, byHand, attempt, summary, threw ? { error } : null, reason === 'timeout');
|
|
423
|
+
})().catch((caught) => {
|
|
424
|
+
logger.error('settle_failed', { job: def.name, kind: options.describe?.(caught)?.kind ?? 'error' });
|
|
425
|
+
}).finally(() => {
|
|
426
|
+
begun();
|
|
427
|
+
inFlight.delete(def.name);
|
|
428
|
+
});
|
|
429
|
+
// Set before the first await settles: `renewLeases` on the next tick reads it, and drives the timeout from the clock.
|
|
430
|
+
inFlight.set(def.name, { token, runId, controller, promise, startedAt: at, timeoutMs });
|
|
431
|
+
return bodyCalled;
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* The settlement, fenced by the token: every job write is conditional on `lease_token`, so a run whose lease another
|
|
435
|
+
* process took changes no job row, and its run is left open for that process's claim, which reads it `lost`. A success steps to the next due time (or a catch-up the
|
|
436
|
+
* ledger still owes, or a required batch now overdue); a failure below the bound sets `retry_at`; at the bound the job
|
|
437
|
+
* is paused by the system and `onFailed` fires.
|
|
438
|
+
*/
|
|
439
|
+
async function settle(def, row, token, runId, startedAt, dueAt, byHand, attempt, summary, thrown, timedOut) {
|
|
440
|
+
const ended = now();
|
|
441
|
+
const ms = ended - startedAt;
|
|
442
|
+
const outcome = thrown !== null || timedOut || summary?.outcome === 'failed' ? 'failed' : summary?.outcome === 'skipped' ? 'skipped' : 'ok';
|
|
443
|
+
const kind = timedOut ? 'timeout' : thrown !== null ? (options.describe?.(thrown.error)?.kind ?? 'error') : summary?.outcome === 'failed' ? 'failed' : null;
|
|
444
|
+
const did = summary?.did ?? null;
|
|
445
|
+
const retries = row.retries;
|
|
446
|
+
const result = await db.transaction(async (trx) => {
|
|
447
|
+
let changed;
|
|
448
|
+
let settlement = 'settled';
|
|
449
|
+
let backlog = [];
|
|
450
|
+
let dropped = 0;
|
|
451
|
+
let rule = def.rule;
|
|
452
|
+
let paused = false;
|
|
453
|
+
if (byHand) {
|
|
454
|
+
// A hand run leaves the schedule and the tries as they were; its outcome lands on the run and the last-run columns.
|
|
455
|
+
changed = Number((await trx.run(`UPDATE ${t('jobs')} SET last_run_at = ?, last_outcome = ?, last_ms = ?, last_did = ?, lease_holder = NULL, lease_token = NULL, lease_until = NULL, hand_run_by = NULL, hand_run_at = NULL, updated_at = ? WHERE name = ? AND lease_token = ?`, [ended, outcome, ms, did, ended, def.name, token])).changes);
|
|
456
|
+
}
|
|
457
|
+
else if (outcome === 'failed' && attempt < retries) {
|
|
458
|
+
const backoff = (def.backoffMs ?? defaultBackoffMs)(attempt);
|
|
459
|
+
// `next_due_at` stays the due time the retry serves; `retry_at` holds the readiness, `failed_kind` the try's kind (the view's `retrying`).
|
|
460
|
+
changed = Number((await trx.run(`UPDATE ${t('jobs')} SET tries = ?, retry_at = ?, failed_kind = ?, last_run_at = ?, last_outcome = 'failed', last_ms = ?, last_did = ?, lease_holder = NULL, lease_token = NULL, lease_until = NULL, updated_at = ? WHERE name = ? AND lease_token = ?`, [attempt, ended + backoff, kind, ended, ms, did, ended, def.name, token])).changes);
|
|
461
|
+
}
|
|
462
|
+
else if (outcome === 'failed') {
|
|
463
|
+
settlement = 'failed_out';
|
|
464
|
+
changed = Number((await trx.run(`UPDATE ${t('jobs')} SET tries = ?, retry_at = NULL, paused_by = 'system', paused_at = ?, failed_at = ?, failed_kind = ?, alerted_at = ?, last_run_at = ?, last_outcome = 'failed', last_ms = ?, last_did = ?, lease_holder = NULL, lease_token = NULL, lease_until = NULL, updated_at = ? WHERE name = ? AND lease_token = ?`, [attempt, ended, ended, kind, ended, ended, ms, did, ended, def.name, token])).changes);
|
|
465
|
+
}
|
|
466
|
+
else {
|
|
467
|
+
// The next due time never steps past a due time owed: a catch-up the ledger still holds after this one (a restore during a
|
|
468
|
+
// pending retry left it) runs next; a `required` job whose next due time has passed records the overdue due times, a batch
|
|
469
|
+
// of MISSED_MAX at most, and runs them next; else the next due time after this one and after now. The schedule and the rule
|
|
470
|
+
// are the stored ones: a process that registered a changed definition while this run was in flight wrote them, and this
|
|
471
|
+
// process's own definition may be the older one.
|
|
472
|
+
const stored = await trx.get(`SELECT schedule, rule, next_due_at, paused_by FROM ${t('jobs')} WHERE name = ?`, [def.name]);
|
|
473
|
+
const schedule = (stored ? scheduleOf(stored) : null) ?? def.schedule;
|
|
474
|
+
rule = stored?.rule ?? def.rule;
|
|
475
|
+
// A schedule changed while this run was in flight: the registration that changed it set the next due time from its own
|
|
476
|
+
// start, and that due time stands; stepping the new schedule from this run's due time would move it.
|
|
477
|
+
const changedUnder = stored !== undefined && stored !== null && stored.schedule !== row.schedule;
|
|
478
|
+
// Paused while this run was in flight: the due times that passed meanwhile are recorded by the rule, cause `paused`, as
|
|
479
|
+
// a resume would have recorded them, so the catch-up a rule owes never depends on whether the resume or this settlement came first.
|
|
480
|
+
paused = stored?.paused_by !== null && stored?.paused_by !== undefined;
|
|
481
|
+
const owed = await trx.get(`SELECT MAX(due_at) AS d FROM ${t('missed')} WHERE job = ? AND action = 'caught_up' AND run_id IS NULL AND due_at > ?`, [def.name, dueAt]);
|
|
482
|
+
let next = owed?.d !== null && owed?.d !== undefined ? Number(owed.d) : undefined;
|
|
483
|
+
if (next === undefined && changedUnder)
|
|
484
|
+
next = num(stored.next_due_at) ?? undefined;
|
|
485
|
+
if (next === undefined && (rule === 'required' || paused)) {
|
|
486
|
+
const following = computeNextRunAtMs(schedule, dueAt);
|
|
487
|
+
if (following !== undefined && following <= ended) {
|
|
488
|
+
const window = missedWindow(schedule, rule, following, following, ended);
|
|
489
|
+
backlog = window.times;
|
|
490
|
+
dropped = window.dropped;
|
|
491
|
+
if (rule !== 'skip')
|
|
492
|
+
next = backlog[backlog.length - 1];
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
next ??= nextAfter(schedule, dueAt, ended);
|
|
496
|
+
changed = Number((await trx.run(`UPDATE ${t('jobs')} SET tries = 0, retry_at = NULL, failed_at = NULL, failed_kind = NULL, next_due_at = ?, enabled = ?, last_run_at = ?, last_outcome = ?, last_ms = ?, last_did = ?, lease_holder = NULL, lease_token = NULL, lease_until = NULL, updated_at = ? WHERE name = ? AND lease_token = ?`, [next ?? null, next === undefined ? 0 : 1, ended, outcome, ms, did, ended, def.name, token])).changes);
|
|
497
|
+
}
|
|
498
|
+
if (changed !== 1)
|
|
499
|
+
return 'lost';
|
|
500
|
+
// Recorded only by the settlement that holds the token (a lost settlement writes nothing): cause `paused` for a pause that
|
|
501
|
+
// came during the run, else `worker_down`, since no run started at their time. Due times the ledger holds (a resume during
|
|
502
|
+
// the run recorded them) are not recorded again.
|
|
503
|
+
if (backlog.length > 0) {
|
|
504
|
+
const have = new Set((await trx.all(`SELECT due_at FROM ${t('missed')} WHERE job = ? AND due_at >= ? AND due_at <= ?`, [def.name, backlog[0], backlog[backlog.length - 1]])).map((m) => Number(m.due_at)));
|
|
505
|
+
const fresh = backlog.filter((due) => !have.has(due));
|
|
506
|
+
if (fresh.length > 0)
|
|
507
|
+
await applyRule(trx, def.name, rule, paused ? 'paused' : 'worker_down', fresh, ended, dropped);
|
|
508
|
+
}
|
|
509
|
+
await trx.run(`UPDATE ${t('runs')} SET ended_at = ?, outcome = ?, ms = ?, tries = ?, kind = ?, did = ? WHERE id = ?`, [ended, outcome, ms, attempt, kind, did, runId]);
|
|
510
|
+
if (outcome !== 'failed' && !byHand)
|
|
511
|
+
await trx.run(`UPDATE ${t('missed')} SET run_id = ?, acted_at = ? WHERE job = ? AND action = 'caught_up' AND run_id IS NULL AND due_at <= ?`, [runId, ended, def.name, dueAt]);
|
|
512
|
+
return settlement;
|
|
513
|
+
});
|
|
514
|
+
if (result === 'lost') {
|
|
515
|
+
// Another process took the lease, so its claim reads this run lost (and pauses a job not safe to repeat). The run is left
|
|
516
|
+
// open for that claim: closing it here, between the other claim and its look for open runs, would let that claim run a
|
|
517
|
+
// job not safe to repeat a second time.
|
|
518
|
+
logger.warn('job_run', { job: def.name, outcome: 'failed', ms, tries: attempt });
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
logger.info('job_run', { job: def.name, outcome, ms, tries: attempt });
|
|
522
|
+
const view = await get(def.name);
|
|
523
|
+
const run = await runById(runId);
|
|
524
|
+
if (!view || !run)
|
|
525
|
+
return;
|
|
526
|
+
for (const handler of runHandlers) {
|
|
527
|
+
try {
|
|
528
|
+
handler(view, run);
|
|
529
|
+
}
|
|
530
|
+
catch { /* the handler's own */ }
|
|
531
|
+
}
|
|
532
|
+
if (result === 'failed_out') {
|
|
533
|
+
logger.warn('job_failed', { job: def.name, kind: kind ?? 'error', tries: attempt });
|
|
534
|
+
for (const handler of failedHandlers) {
|
|
535
|
+
try {
|
|
536
|
+
handler(view, run);
|
|
537
|
+
}
|
|
538
|
+
catch { /* the handler's own */ }
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
const refusal = (refused, status, retryAt) => (retryAt === undefined ? { refused, status } : { refused, status, retryAt });
|
|
543
|
+
async function pause(name, actor, note) {
|
|
544
|
+
if (note !== undefined && note.length > NOTE_MAX)
|
|
545
|
+
throw new RangeError(`a pause note is at most ${NOTE_MAX} characters`);
|
|
546
|
+
const at = now();
|
|
547
|
+
return db.transaction(async (trx) => {
|
|
548
|
+
const row = await trx.get(`SELECT * FROM ${t('jobs')} WHERE name = ?`, [name]);
|
|
549
|
+
if (!row || name === WATCH_ROW)
|
|
550
|
+
return refusal('not_found', 404);
|
|
551
|
+
if (row.kind === 'reports_only')
|
|
552
|
+
return refusal('reports_only', 409);
|
|
553
|
+
if (row.paused_by !== null)
|
|
554
|
+
return refusal('already', 409);
|
|
555
|
+
// Every command is a conditional update whose changed-row count decides it: two transactions may read the same free
|
|
556
|
+
// row, and only one may write its audit entry and answer success. The read above gives the refusal its word; the write decides.
|
|
557
|
+
const changed = await trx.run(`UPDATE ${t('jobs')} SET paused_by = ?, paused_at = ?, paused_note = ?, updated_at = ? WHERE name = ? AND paused_by IS NULL AND kind = 'scheduled'`, [actor.accountId, at, note ?? null, at, name]);
|
|
558
|
+
if (Number(changed.changes) !== 1)
|
|
559
|
+
return refusal('already', 409);
|
|
560
|
+
await options.audit?.(trx, { kind: 'job_paused', staffAccountId: actor.accountId, subject: name, detail: note === undefined ? {} : { note } });
|
|
561
|
+
logger.info('job_paused', { job: name, actor: 'staff' });
|
|
562
|
+
return viewFrom(trx, name, at);
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
async function resume(name, actor) {
|
|
566
|
+
const at = now();
|
|
567
|
+
return db.transaction(async (trx) => {
|
|
568
|
+
const row = await trx.get(`SELECT * FROM ${t('jobs')} WHERE name = ?`, [name]);
|
|
569
|
+
if (!row || name === WATCH_ROW)
|
|
570
|
+
return refusal('not_found', 404);
|
|
571
|
+
if (row.kind === 'reports_only')
|
|
572
|
+
return refusal('reports_only', 409);
|
|
573
|
+
if (row.paused_by === null)
|
|
574
|
+
return refusal('already', 409);
|
|
575
|
+
const schedule = scheduleOf(row);
|
|
576
|
+
const from = num(row.next_due_at);
|
|
577
|
+
// A scheduled run claimed before the pause and still under its lease serves `next_due_at`: that due time is its, never
|
|
578
|
+
// missed, and its settlement steps the schedule on, so this resume leaves `next_due_at` to it.
|
|
579
|
+
const serving = row.lease_until !== null && Number(row.lease_until) >= at && row.hand_run_at === null;
|
|
580
|
+
// `next_due_at` is always a due time of the schedule (a retry's readiness is `retry_at`, which the resume clears), so the
|
|
581
|
+
// window starts there; due times the ledger already holds (a catch-up queued before the pause) are not recorded again.
|
|
582
|
+
const { times: window, dropped } = from !== null && from <= at ? missedWindow(schedule, row.rule, serving ? undefined : from, from, at) : { times: [], dropped: 0 };
|
|
583
|
+
const recorded = window.length === 0 ? new Set() : new Set((await trx.all(`SELECT due_at FROM ${t('missed')} WHERE job = ? AND due_at >= ? AND due_at <= ?`, [name, window[0], window[window.length - 1]])).map((m) => Number(m.due_at)));
|
|
584
|
+
const times = window.filter((due) => !recorded.has(due));
|
|
585
|
+
// The pause is lifted first, conditionally; the missed rows are written only when this transaction lifted it, so a second
|
|
586
|
+
// resume at once writes nothing and reads `already`.
|
|
587
|
+
const changed = await trx.run(`UPDATE ${t('jobs')} SET paused_by = NULL, paused_at = NULL, paused_note = NULL, failed_at = NULL, failed_kind = NULL, alerted_at = NULL, tries = 0, retry_at = NULL, updated_at = ? WHERE name = ? AND paused_by IS NOT NULL`, [at, name]);
|
|
588
|
+
if (Number(changed.changes) !== 1)
|
|
589
|
+
return refusal('already', 409);
|
|
590
|
+
const { catchUp } = await applyRule(trx, name, row.rule, 'paused', times, at, dropped);
|
|
591
|
+
// A catch-up owed (this resume's, or one queued before the pause that the ledger holds) runs at once; else a due time still
|
|
592
|
+
// ahead is kept (nothing was missed, so the resume moves nothing); else the next due time after now. A run in flight on
|
|
593
|
+
// `next_due_at` reads the ledger when it settles and runs an owed catch-up next.
|
|
594
|
+
if (!serving) {
|
|
595
|
+
const owed = await trx.get(`SELECT MAX(due_at) AS d FROM ${t('missed')} WHERE job = ? AND action = 'caught_up' AND run_id IS NULL`, [name]);
|
|
596
|
+
const next = catchUp ? times[times.length - 1] : owed?.d !== null && owed?.d !== undefined ? Number(owed.d) : from !== null && from > at ? from : (nextAfter(schedule, window[window.length - 1] ?? at, at) ?? null);
|
|
597
|
+
await trx.run(`UPDATE ${t('jobs')} SET next_due_at = ?, updated_at = ? WHERE name = ?`, [next, at, name]);
|
|
598
|
+
}
|
|
599
|
+
await options.audit?.(trx, { kind: 'job_resumed', staffAccountId: actor.accountId, subject: name, detail: { missed: times.length } });
|
|
600
|
+
logger.info('job_resumed', { job: name, actor: 'staff' });
|
|
601
|
+
return viewFrom(trx, name, at);
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
async function runNow(name, actor) {
|
|
605
|
+
const at = now();
|
|
606
|
+
// The bound counts the hand runs of this UTC clock hour, so `retryAt`, the next whole hour, is when one is taken again.
|
|
607
|
+
// The place is taken before the first await and given back on any refusal or throw, so two asks at once never both pass.
|
|
608
|
+
const hourStart = Math.floor(at / HOUR) * HOUR;
|
|
609
|
+
while (handRunsAt.length > 0 && handRunsAt[0] < hourStart)
|
|
610
|
+
handRunsAt.shift();
|
|
611
|
+
if (handRunsAt.length >= (options.handRunsPerHour ?? DEFAULT_HAND_RUNS_PER_HOUR))
|
|
612
|
+
return refusal('hand_runs_used', 429, hourStart + HOUR);
|
|
613
|
+
handRunsAt.push(at);
|
|
614
|
+
const giveBack = () => { const i = handRunsAt.lastIndexOf(at); if (i >= 0)
|
|
615
|
+
handRunsAt.splice(i, 1); };
|
|
616
|
+
let answer;
|
|
617
|
+
try {
|
|
618
|
+
answer = await queueHandRun(name, actor, at);
|
|
619
|
+
}
|
|
620
|
+
catch (error) {
|
|
621
|
+
giveBack();
|
|
622
|
+
throw error;
|
|
623
|
+
}
|
|
624
|
+
if ('refused' in answer)
|
|
625
|
+
giveBack();
|
|
626
|
+
return answer;
|
|
627
|
+
}
|
|
628
|
+
async function queueHandRun(name, actor, at) {
|
|
629
|
+
return db.transaction(async (trx) => {
|
|
630
|
+
const row = await trx.get(`SELECT * FROM ${t('jobs')} WHERE name = ?`, [name]);
|
|
631
|
+
if (!row || name === WATCH_ROW)
|
|
632
|
+
return refusal('not_found', 404);
|
|
633
|
+
if (row.kind === 'reports_only')
|
|
634
|
+
return refusal('reports_only', 409);
|
|
635
|
+
if (row.safe_to_repeat !== 1)
|
|
636
|
+
return refusal('not_safe', 409);
|
|
637
|
+
if (row.lease_until !== null && Number(row.lease_until) > at)
|
|
638
|
+
return refusal('in_flight', 409);
|
|
639
|
+
if (row.hand_run_at !== null)
|
|
640
|
+
return refusal('already', 409);
|
|
641
|
+
const changed = await trx.run(`UPDATE ${t('jobs')} SET hand_run_by = ?, hand_run_at = ?, updated_at = ? WHERE name = ? AND hand_run_at IS NULL AND (lease_until IS NULL OR lease_until < ?)`, [actor.accountId, at, at, name, at]);
|
|
642
|
+
if (Number(changed.changes) !== 1)
|
|
643
|
+
return refusal('already', 409);
|
|
644
|
+
await options.audit?.(trx, { kind: 'job_run', staffAccountId: actor.accountId, subject: name, detail: {} });
|
|
645
|
+
logger.info('job_hand_run', { job: name, actor: 'staff' });
|
|
646
|
+
return { queued: true };
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
async function missedCounts(reader, at) {
|
|
650
|
+
const rows = await reader.all(`SELECT job, COUNT(*) AS n FROM ${t('missed')} WHERE due_at >= ? GROUP BY job`, [at - 86_400_000]);
|
|
651
|
+
return new Map(rows.map((r) => [r.job, Number(r.n)]));
|
|
652
|
+
}
|
|
653
|
+
async function runningSince(reader, name) {
|
|
654
|
+
const run = await reader.get(`SELECT started_at FROM ${t('runs')} WHERE job = ? AND outcome = 'running' ORDER BY started_at DESC LIMIT 1`, [name]);
|
|
655
|
+
return run ? Number(run.started_at) : null;
|
|
656
|
+
}
|
|
657
|
+
async function viewFrom(reader, name, at) {
|
|
658
|
+
const row = await reader.get(`SELECT * FROM ${t('jobs')} WHERE name = ?`, [name]);
|
|
659
|
+
if (!row)
|
|
660
|
+
return refusal('not_found', 404);
|
|
661
|
+
return viewOf(row, at, (await missedCounts(reader, at)).get(name) ?? 0, await runningSince(reader, name));
|
|
662
|
+
}
|
|
663
|
+
async function get(name) {
|
|
664
|
+
if (name === WATCH_ROW)
|
|
665
|
+
return null;
|
|
666
|
+
const view = await viewFrom(db, name, now());
|
|
667
|
+
return 'refused' in view ? null : view;
|
|
668
|
+
}
|
|
669
|
+
const RANK = { failed: 0, stalled: 0, missed: 1, paused: 2, running: 3, no_run: 3, ok: 3 };
|
|
670
|
+
async function list(filter = {}) {
|
|
671
|
+
const at = now();
|
|
672
|
+
const rows = await db.all(`SELECT * FROM ${t('jobs')} WHERE name <> ? ORDER BY name`, [WATCH_ROW]);
|
|
673
|
+
const missed = await missedCounts(db, at);
|
|
674
|
+
const views = [];
|
|
675
|
+
for (const row of rows)
|
|
676
|
+
views.push(viewOf(row, at, missed.get(row.name) ?? 0, row.lease_until !== null && Number(row.lease_until) > at ? await runningSince(db, row.name) : null));
|
|
677
|
+
const wanted = views.filter((v) => (filter.process === undefined || v.process === filter.process) && (filter.state === undefined || (filter.state === 'attention' ? v.state === 'failed' || v.state === 'stalled' || v.state === 'missed' : v.state === filter.state)));
|
|
678
|
+
wanted.sort((a, b) => RANK[a.state] - RANK[b.state] || (a.nextDueAt ?? Number.MAX_SAFE_INTEGER) - (b.nextDueAt ?? Number.MAX_SAFE_INTEGER) || a.name.localeCompare(b.name));
|
|
679
|
+
// The cursor comes back from a host's query string: anything but a whole number from zero up reads as the first page.
|
|
680
|
+
const asked = filter.cursor === undefined ? 0 : Number(filter.cursor);
|
|
681
|
+
const offset = Number.isInteger(asked) && asked >= 0 ? asked : 0;
|
|
682
|
+
const page = wanted.slice(offset, offset + LIST_PAGE);
|
|
683
|
+
return { jobs: page, cursor: offset + LIST_PAGE < wanted.length ? String(offset + LIST_PAGE) : null };
|
|
684
|
+
}
|
|
685
|
+
async function runs(name, opts = {}) {
|
|
686
|
+
// A whole number from 1 to 100; a value that is no finite number reads as the default 20.
|
|
687
|
+
const asked = opts.limit !== undefined && Number.isFinite(opts.limit) ? Math.trunc(opts.limit) : 20;
|
|
688
|
+
const rows = await db.all(`SELECT * FROM ${t('runs')} WHERE job = ? ORDER BY started_at DESC LIMIT ?`, [name, Math.min(Math.max(asked, 1), 100)]);
|
|
689
|
+
return rows.map(runViewOf);
|
|
690
|
+
}
|
|
691
|
+
async function runById(id) {
|
|
692
|
+
const row = await db.get(`SELECT * FROM ${t('runs')} WHERE id = ?`, [id]);
|
|
693
|
+
return row ? runViewOf(row) : null;
|
|
694
|
+
}
|
|
695
|
+
async function missed(name, opts = {}) {
|
|
696
|
+
const rows = await db.all(`SELECT * FROM ${t('missed')} WHERE job = ? AND due_at >= ? ORDER BY due_at DESC LIMIT 50`, [name, opts.sinceMs ?? now() - 7 * 86_400_000]);
|
|
697
|
+
return rows.map(missedViewOf);
|
|
698
|
+
}
|
|
699
|
+
async function forgetActor(trx, accountId) {
|
|
700
|
+
// The pause stands with its actor forgotten; a hand run's actor is nulled on the job and on its runs.
|
|
701
|
+
await trx.run(`UPDATE ${t('jobs')} SET paused_by = 'system' WHERE paused_by = ?`, [accountId]);
|
|
702
|
+
await trx.run(`UPDATE ${t('jobs')} SET hand_run_by = NULL WHERE hand_run_by = ?`, [accountId]);
|
|
703
|
+
await trx.run(`UPDATE ${t('runs')} SET by_hand = NULL WHERE by_hand = ?`, [accountId]);
|
|
704
|
+
}
|
|
705
|
+
async function settled() {
|
|
706
|
+
await Promise.all([...inFlight.values()].map((f) => f.promise));
|
|
707
|
+
await reporter.pending();
|
|
708
|
+
}
|
|
709
|
+
async function stop(opts = {}) {
|
|
710
|
+
stopping = true;
|
|
711
|
+
if (timer !== null) {
|
|
712
|
+
clearInterval(timer);
|
|
713
|
+
timer = null;
|
|
714
|
+
}
|
|
715
|
+
// A tick in progress claims nothing more and frees a claim it took after this point; its runs join the ones held below.
|
|
716
|
+
await currentTick;
|
|
717
|
+
const waitMs = opts.waitMs ?? DEFAULT_STOP_WAIT_MS;
|
|
718
|
+
// No tick renews a lease once stopping, so each lease in flight is held through the wait at once (fenced by its token):
|
|
719
|
+
// another process never takes a job whose body is still running inside the wait.
|
|
720
|
+
const at = now();
|
|
721
|
+
for (const [name, flight] of inFlight) {
|
|
722
|
+
try {
|
|
723
|
+
await db.run(`UPDATE ${t('jobs')} SET lease_until = ?, updated_at = ? WHERE name = ? AND lease_token = ?`, [at + waitMs + 2 * tickMs, at, name, flight.token]);
|
|
724
|
+
}
|
|
725
|
+
catch {
|
|
726
|
+
// The database refused or could not be reached: the lease runs out as it stood, and the settlement stays fenced by the token.
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
let wait;
|
|
730
|
+
await Promise.race([settled(), new Promise((resolve) => { wait = setTimeout(resolve, waitMs); wait.unref?.(); })]);
|
|
731
|
+
if (wait !== undefined)
|
|
732
|
+
clearTimeout(wait);
|
|
733
|
+
for (const flight of inFlight.values())
|
|
734
|
+
if (!flight.controller.signal.aborted)
|
|
735
|
+
flight.controller.abort(new Error('stop'));
|
|
736
|
+
}
|
|
737
|
+
const subscribe = (set) => (handler) => { set.add(handler); return () => { set.delete(handler); }; };
|
|
738
|
+
return {
|
|
739
|
+
options, register, report: reporter.report, start, stop, pause, resume, runNow, list, get, runs, missed,
|
|
740
|
+
onRun: subscribe(runHandlers), onFailed: subscribe(failedHandlers), forgetActor, tick, settled,
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
//# sourceMappingURL=scheduler.js.map
|