summaryrefslogtreecommitdiffhomepage
path: root/packages/heartbeat/src/scheduler.test.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/heartbeat/src/scheduler.test.ts')
-rw-r--r--packages/heartbeat/src/scheduler.test.ts343
1 files changed, 343 insertions, 0 deletions
diff --git a/packages/heartbeat/src/scheduler.test.ts b/packages/heartbeat/src/scheduler.test.ts
new file mode 100644
index 0000000..82e5c4d
--- /dev/null
+++ b/packages/heartbeat/src/scheduler.test.ts
@@ -0,0 +1,343 @@
+import { describe, expect, it } from "vitest";
+import { HeartbeatScheduler, type Timers } from "./scheduler.js";
+
+interface FakeTimer {
+ readonly fn: () => void;
+ readonly firesAt: number;
+}
+
+/**
+ * A controllable fake clock: `advance(ms)` moves virtual time forward and runs
+ * any timers that became due. The injected `fire` returns a deferred the test
+ * resolves manually, so we can assert the "running" state and the re-arm that
+ * happens only AFTER the run completes.
+ */
+function createFakeTimers() {
+ let now = 0;
+ let nextId = 1;
+ const timers = new Map<number, FakeTimer>();
+ const timersApi: Timers = {
+ now: () => now,
+ setTimeout: (fn, ms) => {
+ const id = nextId++;
+ timers.set(id, { fn, firesAt: now + ms });
+ return id as unknown as ReturnType<typeof setTimeout>;
+ },
+ clearTimeout: (handle) => {
+ if (handle !== undefined) timers.delete(handle as unknown as number);
+ },
+ };
+ return {
+ timers: timersApi,
+ advance(ms: number): number {
+ now += ms;
+ let fired = 0;
+ const due = [...timers.entries()]
+ .filter(([, t]) => t.firesAt <= now)
+ .sort((a, b) => a[0] - b[0]);
+ for (const [id, t] of due) {
+ timers.delete(id);
+ t.fn();
+ fired++;
+ }
+ return fired;
+ },
+ pendingCount(): number {
+ return timers.size;
+ },
+ };
+}
+
+/** A deferred promise the test resolves to signal run completion. */
+function createDeferred(): { promise: Promise<void>; resolve: () => void } {
+ let resolve!: () => void;
+ const promise = new Promise<void>((r) => {
+ resolve = r;
+ });
+ return { promise, resolve };
+}
+
+// Flush pending microtasks (the scheduler's .finally re-arm runs as a microtask
+// after the fire promise resolves).
+const flush = async (): Promise<void> => {
+ await new Promise((r) => queueMicrotask(r));
+};
+
+describe("HeartbeatScheduler", () => {
+ it("arms a timer that fires after intervalMinutes", () => {
+ const fake = createFakeTimers();
+ const fires: string[] = [];
+ const deferreds: Array<{ resolve: () => void }> = [];
+ const scheduler = new HeartbeatScheduler({
+ timers: fake.timers,
+ fire: (ws) => {
+ fires.push(ws);
+ const d = createDeferred();
+ deferreds.push(d);
+ return d.promise;
+ },
+ });
+
+ scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 });
+ expect(scheduler.isArmed("ws-1")).toBe(true);
+ expect(fake.pendingCount()).toBe(1);
+
+ // Just shy of the interval → no fire.
+ expect(fake.advance(59_999)).toBe(0);
+ expect(fires).toEqual([]);
+
+ // Exactly the interval (1 minute = 60_000ms) → fires.
+ expect(fake.advance(1)).toBe(1);
+ expect(fires).toEqual(["ws-1"]);
+ // While the run is in progress: running, no pending timer.
+ expect(scheduler.isRunning("ws-1")).toBe(true);
+ expect(fake.pendingCount()).toBe(0);
+ });
+
+ it("re-arms only after the run completes (reset timer after each run)", async () => {
+ const fake = createFakeTimers();
+ const fires: string[] = [];
+ const deferreds: Array<{ resolve: () => void }> = [];
+ const scheduler = new HeartbeatScheduler({
+ timers: fake.timers,
+ fire: (ws) => {
+ fires.push(ws);
+ const d = createDeferred();
+ deferreds.push(d);
+ return d.promise;
+ },
+ });
+
+ scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 });
+ fake.advance(60_000); // first fire
+ expect(fires).toEqual(["ws-1"]);
+ // Run in progress — no new timer yet.
+ expect(fake.pendingCount()).toBe(0);
+
+ // Completing the run schedules the next fire.
+ deferreds[0]?.resolve();
+ await flush();
+ expect(scheduler.isRunning("ws-1")).toBe(false);
+ expect(fake.pendingCount()).toBe(1);
+
+ // Next fire after another interval.
+ fake.advance(60_000);
+ expect(fires).toEqual(["ws-1", "ws-1"]);
+ });
+
+ it("uses a longer interval for a higher intervalMinutes", () => {
+ const fake = createFakeTimers();
+ let fireCount = 0;
+ const scheduler = new HeartbeatScheduler({
+ timers: fake.timers,
+ fire: () => {
+ fireCount++;
+ return createDeferred().promise;
+ },
+ });
+ scheduler.arm("ws-1", { enabled: true, intervalMinutes: 30 });
+ // 1 minute wouldn't fire a 30-minute schedule.
+ expect(fake.advance(60_000)).toBe(0);
+ // 28 more minutes (29 total) still wouldn't fire.
+ expect(fake.advance(28 * 60_000)).toBe(0);
+ // The remaining minute completes the 30 minutes → fires.
+ expect(fake.advance(60_000)).toBe(1);
+ expect(fireCount).toBe(1);
+ });
+
+ it("disarms (clears the pending timer) when disabled", () => {
+ const fake = createFakeTimers();
+ const scheduler = new HeartbeatScheduler({
+ timers: fake.timers,
+ fire: () => createDeferred().promise,
+ });
+ scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 });
+ expect(fake.pendingCount()).toBe(1);
+ scheduler.arm("ws-1", { enabled: false, intervalMinutes: 1 });
+ expect(scheduler.isArmed("ws-1")).toBe(false);
+ expect(fake.pendingCount()).toBe(0);
+ });
+
+ it("disarmAll stops every schedule", () => {
+ const fake = createFakeTimers();
+ const scheduler = new HeartbeatScheduler({
+ timers: fake.timers,
+ fire: () => createDeferred().promise,
+ });
+ scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 });
+ scheduler.arm("ws-2", { enabled: true, intervalMinutes: 1 });
+ expect(fake.pendingCount()).toBe(2);
+ scheduler.disarmAll();
+ expect(fake.pendingCount()).toBe(0);
+ expect(scheduler.isArmed("ws-1")).toBe(false);
+ expect(scheduler.isArmed("ws-2")).toBe(false);
+ });
+
+ it("does not re-arm after a disarm during a run", async () => {
+ const fake = createFakeTimers();
+ const deferreds: Array<{ resolve: () => void }> = [];
+ const scheduler = new HeartbeatScheduler({
+ timers: fake.timers,
+ fire: () => {
+ const d = createDeferred();
+ deferreds.push(d);
+ return d.promise;
+ },
+ });
+ scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 });
+ fake.advance(60_000); // fire in progress
+ scheduler.disarm("ws-1"); // disarm mid-run
+ deferreds[0]?.resolve();
+ await flush();
+ // Disarmed → no re-arm scheduled.
+ expect(fake.pendingCount()).toBe(0);
+ expect(scheduler.isArmed("ws-1")).toBe(false);
+ });
+
+ it("a config update during a run applies the new interval on the next re-arm", async () => {
+ const fake = createFakeTimers();
+ const deferreds: Array<{ resolve: () => void }> = [];
+ const scheduler = new HeartbeatScheduler({
+ timers: fake.timers,
+ fire: () => {
+ const d = createDeferred();
+ deferreds.push(d);
+ return d.promise;
+ },
+ });
+ scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 });
+ fake.advance(60_000); // first fire (interval=1m)
+ // Update interval to 5m while the run is in progress.
+ scheduler.arm("ws-1", { enabled: true, intervalMinutes: 5 });
+ expect(scheduler.isRunning("ws-1")).toBe(true);
+ expect(fake.pendingCount()).toBe(0); // no new timer while running
+ deferreds[0]?.resolve();
+ await flush();
+ // Re-armed with the NEW 5-minute interval.
+ expect(fake.advance(60_000)).toBe(0); // 1 minute isn't enough now
+ expect(fake.advance(4 * 60_000)).toBe(1); // completes 5 minutes → fires
+ });
+
+ it("a thrown fire is swallowed and the loop continues", async () => {
+ const fake = createFakeTimers();
+ let fireCount = 0;
+ const scheduler = new HeartbeatScheduler({
+ timers: fake.timers,
+ fire: () => {
+ fireCount++;
+ return Promise.reject(new Error("boom"));
+ },
+ });
+ scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 });
+ fake.advance(60_000);
+ await flush();
+ expect(fireCount).toBe(1);
+ // Loop continues: next fire scheduled.
+ expect(fake.pendingCount()).toBe(1);
+ fake.advance(60_000);
+ expect(fireCount).toBe(2);
+ });
+
+ // ─── nextFireAt (CR-HB-3: server-authoritative next-run time) ──────────────
+
+ it("nextFireAt returns null for a workspace with no schedule", () => {
+ const fake = createFakeTimers();
+ const scheduler = new HeartbeatScheduler({
+ timers: fake.timers,
+ fire: () => createDeferred().promise,
+ });
+ expect(scheduler.nextFireAt("unknown")).toBeNull();
+ });
+
+ it("nextFireAt returns the absolute fire time when armed (now + intervalMs)", () => {
+ const fake = createFakeTimers();
+ const scheduler = new HeartbeatScheduler({
+ timers: fake.timers,
+ fire: () => createDeferred().promise,
+ });
+ // now=0, interval=1m → next fire at epoch-ms 60_000.
+ scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 });
+ expect(scheduler.nextFireAt("ws-1")).toBe(60_000);
+ });
+
+ it("nextFireAt reflects a longer interval", () => {
+ const fake = createFakeTimers();
+ const scheduler = new HeartbeatScheduler({
+ timers: fake.timers,
+ fire: () => createDeferred().promise,
+ });
+ scheduler.arm("ws-1", { enabled: true, intervalMinutes: 30 });
+ expect(scheduler.nextFireAt("ws-1")).toBe(30 * 60_000);
+ });
+
+ it("nextFireAt reflects a new interval on re-arm (not running)", () => {
+ const fake = createFakeTimers();
+ const scheduler = new HeartbeatScheduler({
+ timers: fake.timers,
+ fire: () => createDeferred().promise,
+ });
+ scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 });
+ expect(scheduler.nextFireAt("ws-1")).toBe(60_000);
+ // Re-arm with a 5-minute interval (armed, not running) → recomputed.
+ scheduler.arm("ws-1", { enabled: true, intervalMinutes: 5 });
+ expect(scheduler.nextFireAt("ws-1")).toBe(5 * 60_000);
+ });
+
+ it("nextFireAt returns null when disarmed", () => {
+ const fake = createFakeTimers();
+ const scheduler = new HeartbeatScheduler({
+ timers: fake.timers,
+ fire: () => createDeferred().promise,
+ });
+ scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 });
+ expect(scheduler.nextFireAt("ws-1")).toBe(60_000);
+ scheduler.disarm("ws-1");
+ expect(scheduler.nextFireAt("ws-1")).toBeNull();
+ });
+
+ it("nextFireAt returns null while a run is in progress, then the next fire after it completes", async () => {
+ const fake = createFakeTimers();
+ const deferreds: Array<{ resolve: () => void }> = [];
+ const scheduler = new HeartbeatScheduler({
+ timers: fake.timers,
+ fire: () => {
+ const d = createDeferred();
+ deferreds.push(d);
+ return d.promise;
+ },
+ });
+ scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 });
+ expect(scheduler.nextFireAt("ws-1")).toBe(60_000);
+
+ fake.advance(60_000); // fire → run in progress
+ expect(scheduler.isRunning("ws-1")).toBe(true);
+ // In flight → no next run queued yet.
+ expect(scheduler.nextFireAt("ws-1")).toBeNull();
+
+ deferreds[0]?.resolve();
+ await flush();
+ // Re-armed at completion-time (60_000) + interval (60_000) = 120_000.
+ expect(scheduler.nextFireAt("ws-1")).toBe(120_000);
+ });
+
+ it("nextFireAt returns null after disarming mid-run (no re-arm)", async () => {
+ const fake = createFakeTimers();
+ const deferreds: Array<{ resolve: () => void }> = [];
+ const scheduler = new HeartbeatScheduler({
+ timers: fake.timers,
+ fire: () => {
+ const d = createDeferred();
+ deferreds.push(d);
+ return d.promise;
+ },
+ });
+ scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 });
+ fake.advance(60_000); // fire in progress
+ expect(scheduler.nextFireAt("ws-1")).toBeNull(); // running
+ scheduler.disarm("ws-1"); // disarm mid-run
+ deferreds[0]?.resolve();
+ await flush();
+ // Disarmed → no re-arm, no fire time.
+ expect(scheduler.nextFireAt("ws-1")).toBeNull();
+ });
+});