Initial Commit
This commit is contained in:
133
packages/foundation/flow/job-scheduler/tests/integration.test.ts
Normal file
133
packages/foundation/flow/job-scheduler/tests/integration.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
103
packages/foundation/flow/job-scheduler/tests/property.test.ts
Normal file
103
packages/foundation/flow/job-scheduler/tests/property.test.ts
Normal 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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
Reference in New Issue
Block a user