Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,133 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { EventBusLike, LoggerLike } from "@suaveplan/types";
import type { ExecutionTimers } from "../src/execution/job-execution.js";
import { createJobScheduler } from "../src/scheduler/job-scheduler.js";
import type { JobSchedulerEventMap } from "../src/scheduler/job-scheduler.types.js";
interface TimerState {
now: number;
nextId: number;
pending: Map<number, { fireAt: number; cb: () => void }>;
}
function harness(start: number): {
state: TimerState;
timers: ExecutionTimers;
advance: (delta: number) => Promise<void>;
bus: EventBusLike<JobSchedulerEventMap>;
logger: LoggerLike;
} {
const state: TimerState = { now: start, nextId: 1, pending: new Map() };
const timers: ExecutionTimers = {
setTimer: (cb, ms) => {
const id = state.nextId++;
state.pending.set(id, { fireAt: state.now + ms, cb });
return id;
},
clearTimer: (h) => {
state.pending.delete(h as number);
},
};
const advance = async (delta: number): Promise<void> => {
const target = state.now + delta;
for (;;) {
let id: number | undefined;
let at = Number.POSITIVE_INFINITY;
for (const [k, v] of state.pending) {
if (v.fireAt <= target && v.fireAt < at) {
id = k;
at = v.fireAt;
}
}
if (id === undefined) break;
const entry = state.pending.get(id);
if (entry === undefined) break;
state.pending.delete(id);
state.now = entry.fireAt;
entry.cb();
await Promise.resolve();
await Promise.resolve();
}
state.now = target;
};
const bus: EventBusLike<JobSchedulerEventMap> = {
on: () => () => undefined,
emit: () => undefined,
};
const logger: LoggerLike = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
return { state, timers, advance, bus, logger };
}
describe("integration: three cron jobs over 10 simulated minutes", () => {
it("fires every-minute, every-2-minutes, every-5-minutes the expected counts", {
timeout: 30_000,
}, async () => {
const startAt = Date.UTC(2026, 4, 12, 9, 0, 0);
const h = harness(startAt);
const s = createJobScheduler({
clock: () => h.state.now,
timers: h.timers,
events: h.bus,
logger: h.logger,
tickIntervalMs: 100,
});
const counts = { every1: 0, every2: 0, every5: 0 };
s.schedule("* * * * *", async () => {
counts.every1 += 1;
});
s.schedule("*/2 * * * *", async () => {
counts.every2 += 1;
});
s.schedule("*/5 * * * *", async () => {
counts.every5 += 1;
});
s.start();
await h.advance(10 * 60_000 + 500);
await s.stop();
// Over a 10-minute window starting at HH:00:00.5, every-minute
// fires 10 times (HH:01 through HH:10), every-2-minutes fires 5
// times (02, 04, 06, 08, 10), every-5-minutes fires 2 times
// (05, 10).
expect(counts.every1).toBe(10);
expect(counts.every2).toBe(5);
expect(counts.every5).toBe(2);
});
});
describe("integration: stop() drains a mid-run job", () => {
it("waits for a slow handler to finish before resolving", {
timeout: 30_000,
}, async () => {
const h = harness(Date.UTC(2026, 4, 12, 9, 0, 0));
const s = createJobScheduler({
clock: () => h.state.now,
timers: h.timers,
events: h.bus,
logger: h.logger,
tickIntervalMs: 50,
});
let resolved = false;
let resolver: () => void = () => undefined;
s.queue(
() =>
new Promise<void>((res) => {
resolver = () => {
resolved = true;
res();
};
}),
{ delayMs: 0 }
);
s.start();
await h.advance(100);
const stopP = s.stop();
resolver();
await stopP;
expect(resolved).toBe(true);
});
});

View File

@@ -0,0 +1,103 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import { JobQueue } from "../src/queue/job-queue.js";
describe("property: JobQueue monotonic dequeue", () => {
it("always returns entries in non-decreasing runAt order", {
timeout: 30_000,
}, () => {
fc.assert(
fc.property(
fc.array(
fc.record({
jobId: fc.string({ minLength: 1, maxLength: 8 }),
runAt: fc.integer({ min: 0, max: 1_000_000 }),
}),
{ minLength: 0, maxLength: 200 }
),
(entries) => {
const q = new JobQueue();
for (let i = 0; i < entries.length; i++) {
const e = entries[i] as {
jobId: string;
runAt: number;
};
q.enqueue({
jobId: `${e.jobId}-${i}`,
runAt: e.runAt,
});
}
let prev = Number.NEGATIVE_INFINITY;
while (q.size() > 0) {
const out = q.dequeue();
if (out === undefined) break;
expect(out.runAt).toBeGreaterThanOrEqual(prev);
prev = out.runAt;
}
}
),
{ numRuns: 100 }
);
});
});
describe("property: enqueue+remove leaves consistent heap", () => {
it("after removing every inserted id, queue is empty", {
timeout: 30_000,
}, () => {
fc.assert(
fc.property(
fc.array(
fc.record({
jobId: fc.uuid(),
runAt: fc.integer({ min: 0, max: 1_000_000 }),
}),
{ minLength: 0, maxLength: 100 }
),
(entries) => {
const q = new JobQueue();
const ids = new Set<string>();
for (const e of entries) {
if (ids.has(e.jobId)) continue;
ids.add(e.jobId);
q.enqueue({ jobId: e.jobId, runAt: e.runAt });
}
for (const id of ids) {
expect(q.remove(id)).toBe(true);
}
expect(q.size()).toBe(0);
expect(q.peek()).toBeUndefined();
}
),
{ numRuns: 100 }
);
});
});
describe("property: enqueue followed by N dequeues equals sorted input", () => {
it("produces a sorted output prefix", { timeout: 30_000 }, () => {
fc.assert(
fc.property(
fc.array(fc.integer({ min: 0, max: 1_000_000 }), {
minLength: 1,
maxLength: 50,
}),
(runAts) => {
const q = new JobQueue();
runAts.forEach((runAt, i) => {
q.enqueue({ jobId: `j-${i}`, runAt });
});
const got: number[] = [];
while (q.size() > 0) {
const e = q.dequeue();
if (e === undefined) break;
got.push(e.runAt);
}
const expected = [...runAts].sort((a, b) => a - b);
expect(got).toEqual(expected);
}
),
{ numRuns: 100 }
);
});
});

View File

@@ -0,0 +1 @@
export {};