UNPKG

@convex-dev/workpool

Version:

A Convex component for managing async work.

304 lines (275 loc) 10.1 kB
import { convexTest } from "convex-test"; import { afterEach, assert, beforeEach, describe, expect, test, vi, } from "vitest"; import { internal } from "./_generated/api"; import { kickMainLoop } from "./kick.js"; import { DEFAULT_LOG_LEVEL } from "./logging.js"; import schema from "./schema.js"; import { modules } from "./setup.test.js"; import { fromSegment, getCurrentSegment, getNextSegment, toSegment, } from "./shared"; import { DEFAULT_MAX_PARALLELISM } from "./shared.js"; describe("kickMainLoop", () => { beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date(1765432101234)); // Set to a known time }); afterEach(() => { vi.useRealTimers(); }); test("ensures it creates globals on first call", async () => { const t = convexTest(schema, modules); await t.run(async (ctx) => { await kickMainLoop(ctx, "enqueue"); const globals = await ctx.db.query("globals").unique(); expect(globals).not.toBeNull(); const runStatus = await ctx.db.query("runStatus").unique(); expect(runStatus).not.toBeNull(); assert(runStatus); expect(runStatus.state.kind).toBe("running"); const internalState = await ctx.db.query("internalState").unique(); expect(internalState).not.toBeNull(); assert(internalState); expect(internalState.generation).toBe(0n); }); }); test("it updates the globals when they change", async () => { const t = convexTest(schema, modules); await t.run(async (ctx) => { await kickMainLoop(ctx, "enqueue"); const globals = await ctx.db.query("globals").unique(); expect(globals).not.toBeNull(); assert(globals); expect(globals.maxParallelism).toBe(DEFAULT_MAX_PARALLELISM); expect(globals.logLevel).toBe(DEFAULT_LOG_LEVEL); await kickMainLoop(ctx, "enqueue", { maxParallelism: DEFAULT_MAX_PARALLELISM + 1, logLevel: "ERROR", }); const after = await ctx.db.query("globals").unique(); expect(after).not.toBeNull(); assert(after); expect(after.maxParallelism).toBe(DEFAULT_MAX_PARALLELISM + 1); expect(after.logLevel).toBe("ERROR"); }); }); test("does not kick when already running", async () => { const t = convexTest(schema, modules); await t.run(async (ctx) => { // First kick to set up initial state await kickMainLoop(ctx, "enqueue"); const runStatus = await ctx.db.query("runStatus").unique(); assert(runStatus); expect(runStatus.state.kind).toBe("running"); // Second kick should not change state const segment = await kickMainLoop(ctx, "enqueue"); const afterStatus = await ctx.db.query("runStatus").unique(); assert(afterStatus); expect(afterStatus.state.kind).toBe("running"); expect(afterStatus._id).toBe(runStatus._id); expect(segment).toBe(getNextSegment()); }); }); test("kicks when scheduled with later segment", async () => { const t = convexTest(schema, modules); await t.run(async (ctx) => { // Set up initial scheduled state await kickMainLoop(ctx, "enqueue"); const runStatus = await ctx.db.query("runStatus").unique(); assert(runStatus); // Get current segment and schedule for future const now = Date.now(); const futureTime = now + 10000; // 10 seconds in future const futureSegment = toSegment(futureTime); // Manually set to scheduled state with future segment const scheduledId = await ctx.scheduler.runAfter( fromSegment(futureSegment) - now, internal.loop.main, { generation: 0n, segment: futureSegment, } ); await ctx.db.patch(runStatus._id, { state: { kind: "scheduled", scheduledId, saturated: false, generation: 0n, segment: futureSegment, }, }); // Kick should reschedule to run sooner const segment = await kickMainLoop(ctx, "enqueue"); expect(segment).toBe(getCurrentSegment()); const afterStatus = await ctx.db.query("runStatus").unique(); assert(afterStatus); expect(afterStatus.state.kind).toBe("running"); }); }); test("does not kick when scheduled and saturated", async () => { const t = convexTest(schema, modules); await t.run(async (ctx) => { // Set up initial scheduled state await kickMainLoop(ctx, "enqueue"); const runStatus = await ctx.db.query("runStatus").unique(); assert(runStatus); // Get current segment const now = Date.now(); const nearFutureTime = now + 1000; // 1 second in future const nearFutureSegment = toSegment(nearFutureTime); // Manually set to scheduled saturated state const scheduledId = await ctx.scheduler.runAfter( fromSegment(nearFutureSegment) - now, internal.loop.main, { generation: 0n, segment: nearFutureSegment, } ); await ctx.db.patch(runStatus._id, { state: { kind: "scheduled", scheduledId, saturated: true, generation: 0n, segment: nearFutureSegment, }, }); // Kick should not change state when saturated const segment = await kickMainLoop(ctx, "enqueue"); expect(segment).toBe(getNextSegment()); const afterStatus = await ctx.db.query("runStatus").unique(); assert(afterStatus); expect(afterStatus.state.kind).toBe("scheduled"); assert(afterStatus.state.kind === "scheduled"); expect(afterStatus.state.saturated).toBe(true); }); }); test("recovers if runStatus is deleted but other state exists", async () => { const t = convexTest(schema, modules); await t.run(async (ctx) => { // First create all state await kickMainLoop(ctx, "enqueue"); // Delete runStatus const runStatus = await ctx.db.query("runStatus").unique(); assert(runStatus); await ctx.db.delete(runStatus._id); // Kick should recreate runStatus await kickMainLoop(ctx, "complete"); const newRunStatus = await ctx.db.query("runStatus").unique(); expect(newRunStatus).not.toBeNull(); assert(newRunStatus); expect(newRunStatus.state.kind).toBe("running"); }); }); test("recovers if globals is deleted but other state exists", async () => { const t = convexTest(schema, modules); await t.run(async (ctx) => { // First create all state await kickMainLoop(ctx, "enqueue"); // Delete globals const globals = await ctx.db.query("globals").unique(); assert(globals); await ctx.db.delete(globals._id); // Kick should recreate globals await kickMainLoop(ctx, "complete"); const newGlobals = await ctx.db.query("globals").unique(); expect(newGlobals).not.toBeNull(); assert(newGlobals); expect(newGlobals.maxParallelism).toBe(DEFAULT_MAX_PARALLELISM); expect(newGlobals.logLevel).toBe(DEFAULT_LOG_LEVEL); }); }); test("handles race conditions between multiple kicks", async () => { const t = convexTest(schema, modules); // Run kicks in separate transactions to simulate concurrent access const segments = await Promise.all( Array.from({ length: 10 }, () => t.run(async (ctx) => { const segment = await kickMainLoop(ctx, "enqueue"); return segment; }) ) ); expect(segments.filter((s) => s === getCurrentSegment())).toHaveLength(1); // Check final state in a new transaction await t.run(async (ctx) => { // Should end up with single consistent state const runStatus = await ctx.db.query("runStatus").unique(); const internalState = await ctx.db.query("internalState").unique(); const globals = await ctx.db.query("globals").unique(); expect(runStatus).not.toBeNull(); expect(internalState).not.toBeNull(); expect(globals).not.toBeNull(); assert(runStatus); assert(internalState); assert(globals); expect(runStatus.state.kind).toBe("running"); expect(internalState.generation).toBe(0n); expect(globals.maxParallelism).toBe(DEFAULT_MAX_PARALLELISM); }); }); test("preserves state between kicks with different sources", async () => { const t = convexTest(schema, modules); await t.run(async (ctx) => { // Initial kick with custom config await kickMainLoop(ctx, "enqueue", { maxParallelism: 5, logLevel: "ERROR", }); // Kick from different sources await kickMainLoop(ctx, "cancel"); await kickMainLoop(ctx, "complete"); // Config should be preserved const globals = await ctx.db.query("globals").unique(); expect(globals).not.toBeNull(); assert(globals); expect(globals.maxParallelism).toBe(5); expect(globals.logLevel).toBe("ERROR"); }); }); test("cancels and starts running when scheduled", async () => { const t = convexTest(schema, modules); await t.run(async (ctx) => { await kickMainLoop(ctx, "enqueue"); const runStatus = await ctx.db.query("runStatus").unique(); assert(runStatus); const segment = getNextSegment() + 10n; const scheduledId = await ctx.scheduler.runAfter( 10_000, internal.loop.main, { generation: 0n, segment } ); await ctx.db.patch(runStatus._id, { state: { generation: 0n, saturated: false, kind: "scheduled", segment, scheduledId, }, }); // await all scheduled functions to run await kickMainLoop(ctx, "enqueue"); const afterStatus = await ctx.db.query("runStatus").unique(); assert(afterStatus); expect(afterStatus.state.kind).toBe("running"); assert(afterStatus.state.kind === "running"); const scheduledJob = await ctx.db.system.get(scheduledId); assert(scheduledJob); expect(scheduledJob.state.kind).toBe("canceled"); }); }); });