33 lines · 1.2 KB
import { DurableObject } from "cloudflare:workers";

/** Even with no rings, a scope re-checks D1 this often, bounding any missed-ring delay. */
const RECHECK_MS = 30_000;

export type Doorbell = { ringing: boolean; version: number };

/**
 * One per runner scope (`repo:<id>` or `owner:<id>`). Idle self-hosted runners long-poll every couple of
 * seconds; without this every poll scans queued jobs in D1. Queuing a job rings the scope's bell, and
 * pollers only query D1 while it rings. A poll that finds no candidate jobs silences it, unless a ring
 * arrived meanwhile (the version check). State is memory-only: a fresh or evicted object starts ringing,
 * so losing it only costs one extra D1 query.
 */
export class JobDoorbell extends DurableObject<Env> {
  private version = 1;
  private silencedAt: number | null = null;

  async ring(): Promise<void> {
    this.version++;
    this.silencedAt = null;
  }

  async check(): Promise<Doorbell> {
    const stale = this.silencedAt === null || Date.now() - this.silencedAt > RECHECK_MS;
    return { ringing: stale, version: this.version };
  }

  async silence(version: number): Promise<void> {
    if (version === this.version) this.silencedAt = Date.now();
  }
}