Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

60 lines · 3.2 KBRaw History
1import { DurableObject } from "cloudflare:workers";
2import type { Env } from "./env.js";
3import { dueSchedules, eachLimited, nextTick, tickMinutes, tickWindow } from "./lib/schedules.js";
4
5/** Repository objects asked to start scheduled runs at the same time. */
6const FAN_OUT = 16;
7
8/**
9 * Fires workflow `schedule` triggers. One instance ("global") ticks on its own alarm, which
10 * survives deploys and restarts, instead of on the Worker's cron trigger: Cloudflare can take
11 * minutes to apply a cron trigger after a deploy (every deploy re-sends it), and an invocation
12 * that never comes, or fails, would drop that window's schedules. Each tick fires what is due
13 * since the last tick that finished, so a late or failed tick is caught up by the next one.
14 * The cron trigger and every repository that registers schedules only make sure the alarm is set.
15 */
16export class SchedulerDO extends DurableObject<Env> {
17 /** Sets the alarm when none is due by the next tick (a first start, or a lost alarm). */
18 async ensure(): Promise<void> {
19 const next = nextTick(Date.now(), this.tick());
20 const at = await this.ctx.storage.getAlarm();
21 if (at === null || at > next) await this.ctx.storage.setAlarm(next);
22 }
23
24 /** When the last tick finished, the minutes it covered up to, and when the next is due (ISO times, or null). */
25 async status(): Promise<{ tick_minutes: number; last_tick_at: string | null; covered_until: string | null; next_tick_at: string | null }> {
26 const [finished, last, next] = await Promise.all([this.ctx.storage.get<number>("finished"), this.ctx.storage.get<number>("last"), this.ctx.storage.getAlarm()]);
27 const iso = (t: number | null | undefined) => (typeof t === "number" ? new Date(t).toISOString() : null);
28 return { tick_minutes: this.tick(), last_tick_at: iso(finished), covered_until: iso(last), next_tick_at: iso(next) };
29 }
30
31 override async alarm(): Promise<void> {
32 const now = Date.now();
33 // The next tick first, so nothing below can end the chain.
34 await this.ctx.storage.setAlarm(nextTick(now, this.tick()));
35 const minutes = tickWindow(now, await this.ctx.storage.get<number>("last"), this.tick());
36 if (!minutes.length) return;
37 try {
38 await fireSchedules(this.env, minutes);
39 await this.ctx.storage.put({ last: minutes[minutes.length - 1]!.getTime(), finished: Date.now() });
40 } catch (e) {
41 console.error("schedule tick failed; the next tick catches it up", e);
42 }
43 }
44
45 private tick(): number {
46 return tickMinutes(this.env.SCHEDULE_TICK_MINUTES);
47 }
48}
49
50/** Starts a run for every schedule due in `minutes`. Throws only when the schedules can't be read. */
51export async function fireSchedules(env: Env, minutes: Date[]): Promise<void> {
52 const { results } = await env.DB.prepare("SELECT s.repo_id, s.workflow_path, s.cron FROM schedules s JOIN repos r ON r.id = s.repo_id WHERE r.archived = 0").all<{ repo_id: string; workflow_path: string; cron: string }>();
53 await eachLimited(dueSchedules(results, minutes), FAN_OUT, async (s) => {
54 try {
55 await env.REPO.get(env.REPO.idFromName(s.repo_id)).scheduleTick(s.workflow_path, s.cron);
56 } catch (e) {
57 console.error("schedule failed", s.repo_id, s.workflow_path, e);
58 }
59 });
60}