g1t/deploy/self-host/scheduler.mjs

97 lines4,319 bytesCodeBlame
1#!/usr/bin/env node
2// Runs the services' cron triggers in a self-hosted g1t. `wrangler dev`
3// (workerd) never fires a Worker's `scheduled` handler on its own, so this
4// does: once a minute it asks Wrangler's local API to run the handler of
5// each service whose cron matches the time (UTC), the same handler hosted
6// g1t's Cron Triggers run.
7//
8// Which services, and their crons, are in schedules.json, written by
9// configs.mjs from each service's wrangler.jsonc (`triggers.crons`), for
10// the services whose crons are safe to run here (SELF_HOST_CRONS there).
11// Wrangler's local API answers only requests addressed to localhost, so
12// this runs inside the g1t container, beside it (start.sh).
13//
14// Usage: node scheduler.mjs <schedules.json> [base URL, default http://127.0.0.1:8787]
15
16import { readFileSync } from "node:fs";
17
18const FIELDS = [
19 { min: 0, max: 59 }, // minute
20 { min: 0, max: 23 }, // hour
21 { min: 1, max: 31 }, // day of the month
22 { min: 1, max: 12 }, // month
23 { min: 0, max: 7 }, // day of the week, 0 and 7 both Sunday
24];
25
26/** The values one cron field allows, or null when it is malformed. */
27export function fieldValues(text, { min, max }) {
28 const values = new Set();
29 for (const part of text.split(",")) {
30 const [range, stepText] = part.split("/");
31 const step = stepText === undefined ? 1 : Number(stepText);
32 if (!Number.isInteger(step) || step < 1) return null;
33 let from;
34 let to;
35 if (range === "*") {
36 [from, to] = [min, max];
37 } else if (range.includes("-")) {
38 [from, to] = range.split("-").map(Number);
39 } else {
40 from = Number(range);
41 to = stepText === undefined ? from : max;
42 }
43 if (!Number.isInteger(from) || !Number.isInteger(to) || from < min || to > max || from > to) return null;
44 for (let value = from; value <= to; value += step) values.add(value);
45 }
46 return values;
47}
48
49/** Whether a five-field cron expression matches `date` (UTC, to the minute). */
50export function matches(cron, date) {
51 const fields = cron.trim().split(/\s+/);
52 if (fields.length !== 5) return false;
53 const sets = fields.map((field, i) => fieldValues(field, FIELDS[i]));
54 if (sets.some((set) => set === null)) return false;
55 const [minutes, hours, days, months, weekdays] = sets;
56 const weekday = date.getUTCDay();
57 const dayMatches = days.has(date.getUTCDate());
58 const weekdayMatches = weekdays.has(weekday) || (weekday === 0 && weekdays.has(7));
59 // As cron does: when both day fields are restricted, either may match.
60 const day =
61 fields[2] !== "*" && fields[4] !== "*" ? dayMatches || weekdayMatches : dayMatches && weekdayMatches;
62 return minutes.has(date.getUTCMinutes()) && hours.has(date.getUTCHours()) && months.has(date.getUTCMonth() + 1) && day;
63}
64
65/** The handlers due at `date`: `{ worker, cron }` for each cron that matches. */
66export function due(schedules, date) {
67 return schedules.flatMap(({ worker, crons }) => crons.filter((cron) => matches(cron, date)).map((cron) => ({ worker, cron })));
68}
69
70async function run(base, { worker, cron }) {
71 try {
72 const answer = await fetch(`${base}/cdn-cgi/local/explorer/api/local/scheduled?worker=${encodeURIComponent(worker)}`, {
73 method: "POST",
74 headers: { "content-type": "application/json" },
75 body: JSON.stringify({ cron }),
76 });
77 const body = await answer.json().catch(() => ({}));
78 if (!answer.ok || !body.success || body.result?.outcome !== "ok") {
79 console.error(`scheduler: ${worker} (${cron}): ${answer.status} ${JSON.stringify(body.errors ?? body.result ?? body)}`);
80 }
81 } catch (error) {
82 console.error(`scheduler: ${worker} (${cron}) could not be run: ${error.message}`);
83 }
84}
85
86if (process.argv[1]?.replaceAll("\\", "/").endsWith("deploy/self-host/scheduler.mjs")) {
87 const schedules = JSON.parse(readFileSync(process.argv[2], "utf8"));
88 const base = (process.argv[3] ?? "http://127.0.0.1:8787").replace(/\/$/, "");
89 console.log(`scheduler: ${schedules.map((s) => `${s.worker} ${s.crons.join(", ")}`).join("; ")}`);
90 const tick = () => {
91 const now = new Date();
92 for (const job of due(schedules, now)) run(base, job);
93 // The next whole minute, a second in.
94 setTimeout(tick, 60_000 - (now.getTime() % 60_000) + 1_000);
95 };
96 setTimeout(tick, 60_000 - (Date.now() % 60_000) + 1_000);
97}