mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 06:42:06 +01:00
Dispatch scheduled work from saved deadlines
Routine and issue-monitor work can wait for a heartbeat scan even when the app wakes before a saved deadline. Register independent deadline workers with the existing delivery coordinator and notify them after producer transactions settle. Preserve the interval recovery scan, execution checks, atomic claims, and idle/standby/shutdown gates. Parse each cron expression once per next-run search to avoid repeating that work for every minute of a sparse schedule. Verified focused scheduler, routine, SQL eligibility, and notification tests, workspace typecheck, and full build. Full-suite and real local dispatch acceptance are tracked in the PR before final readiness. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
c7ea5c0216
commit
da062a3971
10 files changed
+344
-30
No files matched your search
@@ -1,3 +1,5 @@
|
||||
import { subscribeDatabaseWork } from "@paperclipai/db";
|
||||
import { DELIVERY_QUEUES } from "../services/delivery-work-notifications.js";
|
||||
import { createHmac, randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
@@ -214,6 +216,40 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
||||
return { companyId, agentId, issueSvc, projectId, routine, svc, wakeups };
|
||||
}
|
||||
|
||||
it("notifies scheduled routine work after trigger transactions settle", async () => {
|
||||
const f = await seedFixture();
|
||||
const events: string[] = [];
|
||||
const unsubscribe = subscribeDatabaseWork(db, DELIVERY_QUEUES.routineSchedules, event => { events.push(event); });
|
||||
try {
|
||||
const created = await f.svc.createTrigger(f.routine.id,
|
||||
{ kind: "schedule", cronExpression: "0 * * * *", timezone: "UTC" }, {});
|
||||
expect(events).toEqual(["intent", "settled"]);
|
||||
events.length = 0;
|
||||
await f.svc.updateTrigger(created.trigger.id, { cronExpression: "30 * * * *" }, {});
|
||||
expect(events).toEqual(["intent", "settled"]);
|
||||
events.length = 0;
|
||||
await f.svc.deleteTrigger(created.trigger.id, {});
|
||||
expect(events).toEqual(["intent", "settled"]);
|
||||
} finally { unsubscribe(); }
|
||||
});
|
||||
|
||||
it("notifies monitor work when an issue deadline is created and changed", async () => {
|
||||
const f = await seedFixture();
|
||||
const events: string[] = [];
|
||||
const unsubscribe = subscribeDatabaseWork(db, DELIVERY_QUEUES.issueMonitors, event => { events.push(event); });
|
||||
const next = new Date(Date.now() + 600_000);
|
||||
try {
|
||||
const issue = await f.issueSvc.create(f.companyId, {
|
||||
title: "Scheduled check", status: "in_progress", assigneeAgentId: f.agentId,
|
||||
executionPolicy: { mode: "normal", commentRequired: true, stages: [], monitor: { nextCheckAt: next.toISOString() } },
|
||||
});
|
||||
expect(events).toEqual(["intent", "settled"]);
|
||||
events.length = 0;
|
||||
await f.issueSvc.update(issue.id, { monitorNextCheckAt: new Date(next.getTime() + 60_000) });
|
||||
expect(events).toEqual(["intent", "settled"]);
|
||||
} finally { unsubscribe(); }
|
||||
});
|
||||
|
||||
async function armWorktreeExecution(cutoff: Date, instanceId = "worktree-routines-test") {
|
||||
await db.insert(instanceSettings).values({
|
||||
singletonKey: "default",
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { agents, companies, createDb, issues, routines, routineTriggers } from "@paperclipai/db";
|
||||
import { sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import type { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
|
||||
import { DELIVERY_QUEUES, type DeliveryQueue } from "../services/delivery-work-notifications.js";
|
||||
import { registerScheduledWork } from "../services/scheduled-work.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
describe.skipIf(!support.supported)("scheduled deadline database queries", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: Db;
|
||||
beforeAll(async () => {
|
||||
database = await startEmbeddedPostgresTestDatabase("paperclip-scheduled-deadlines");
|
||||
db = createDb(database.connectionString);
|
||||
}, 30_000);
|
||||
afterAll(async () => { await db?.$client.end(); await database?.cleanup(); });
|
||||
|
||||
it("selects future eligible deadlines and excludes inactive, mismatched and past work", async () => {
|
||||
const now = Date.now();
|
||||
const at = (minutes: number) => new Date(now + minutes * 60_000);
|
||||
const [company, pausedCompany] = await db.insert(companies).values([
|
||||
{ name: "Active schedules", issuePrefix: "SCH" },
|
||||
{ name: "Paused schedules", issuePrefix: "PAU", status: "paused" },
|
||||
]).returning();
|
||||
const [agent] = await db.insert(agents).values({ companyId: company!.id, name: "Scheduled agent" }).returning();
|
||||
const [active, paused, otherCompany] = await db.insert(routines).values([
|
||||
{ companyId: company!.id, title: "Active routine" },
|
||||
{ companyId: company!.id, title: "Paused routine", status: "paused" },
|
||||
{ companyId: pausedCompany!.id, title: "Paused company routine" },
|
||||
]).returning();
|
||||
const trigger = { companyId: company!.id, routineId: active!.id, kind: "schedule" };
|
||||
await db.insert(routineTriggers).values([
|
||||
{ ...trigger, nextRunAt: at(10) },
|
||||
{ ...trigger, enabled: false, nextRunAt: at(1) },
|
||||
{ ...trigger, archived: true, nextRunAt: at(2) },
|
||||
{ ...trigger, routineId: paused!.id, nextRunAt: at(3) },
|
||||
{ ...trigger, companyId: pausedCompany!.id, nextRunAt: at(4) },
|
||||
{ ...trigger, companyId: pausedCompany!.id, routineId: otherCompany!.id, nextRunAt: at(5) },
|
||||
{ ...trigger, nextRunAt: at(-1) },
|
||||
]);
|
||||
const monitor = { companyId: company!.id, title: "Monitor", status: "in_progress", assigneeAgentId: agent!.id };
|
||||
await db.insert(issues).values([
|
||||
{ ...monitor, monitorNextCheckAt: at(15) },
|
||||
{ ...monitor, status: "done", monitorNextCheckAt: at(1) },
|
||||
{ ...monitor, assigneeUserId: "fixture-user", monitorNextCheckAt: at(2) },
|
||||
{ ...monitor, assigneeAgentId: null, monitorNextCheckAt: at(3) },
|
||||
{ ...monitor, companyId: pausedCompany!.id, monitorNextCheckAt: at(4) },
|
||||
{ ...monitor, monitorNextCheckAt: at(-1) },
|
||||
]);
|
||||
type Task = Parameters<ReturnType<typeof createDeliveryWorkCoordinator>["register"]>[1];
|
||||
const tasks = new Map<DeliveryQueue, Task>();
|
||||
registerScheduledWork({ register: (queue, task) => {
|
||||
tasks.set(queue, task);
|
||||
return { ready: Promise.resolve(), wake: () => {} };
|
||||
} }, db, { retryMs: 60_000, tickTimers: async () => {}, tickRoutines: async () => {} });
|
||||
for (const task of tasks.values()) await task.run(new AbortController().signal);
|
||||
expect(tasks.get(DELIVERY_QUEUES.routineSchedules)!.nextRunAt!()).toBe(at(10).getTime());
|
||||
expect(tasks.get(DELIVERY_QUEUES.issueMonitors)!.nextRunAt!()).toBe(at(15).getTime());
|
||||
await db.execute(sql`truncate table companies restart identity cascade`);
|
||||
for (const task of tasks.values()) await task.run(new AbortController().signal);
|
||||
for (const task of tasks.values()) expect(task.nextRunAt!()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { PgDialect } from "drizzle-orm/pg-core";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { installDatabaseWorkSignals } from "../../../packages/db/src/work-signals.js";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "../services/delivery-work-notifications.js";
|
||||
import { registerScheduledWork } from "../services/scheduled-work.js";
|
||||
import { idleWorkSnapshot } from "../services/task-admission.js";
|
||||
|
||||
const coordinators: ReturnType<typeof createDeliveryWorkCoordinator>[] = [];
|
||||
afterEach(async () => {
|
||||
for (const coordinator of coordinators.splice(0)) await coordinator.stop();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function fixture(input: { routine?: number | null; monitor?: number | null; tickRoutines?: () => Promise<void> } = {}) {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2020-01-01T00:00:00.000Z"));
|
||||
const dialect = new PgDialect();
|
||||
const deadlines = { routine: input.routine ?? null, monitor: input.monitor ?? null };
|
||||
let canRun = true;
|
||||
let failRead = false;
|
||||
const execute = vi.fn(async (query: any) => {
|
||||
const text = dialect.sqlToQuery(query).sql;
|
||||
if (text.includes("pg_current_xact_id")) return [{ xid: "42" }];
|
||||
if (failRead) throw new Error("database unavailable");
|
||||
const at = deadlines[text.includes("routine_triggers") ? "routine" : "monitor"];
|
||||
return [{ next_at: at !== null && at > Date.now() ? new Date(at) : null }];
|
||||
});
|
||||
const owner = installDatabaseWorkSignals({ execute, async transaction(callback: (tx: any) => Promise<any>) {
|
||||
return callback({ execute, transaction: owner.transaction });
|
||||
} });
|
||||
const errors = vi.fn();
|
||||
const coordinator = createDeliveryWorkCoordinator({ owner, canRun: () => canRun, onError: errors });
|
||||
coordinators.push(coordinator);
|
||||
const tickTimers = vi.fn(async () => {});
|
||||
const tickRoutines = vi.fn(input.tickRoutines ?? (async () => {}));
|
||||
const workers = registerScheduledWork(coordinator, owner as unknown as Db,
|
||||
{ retryMs: 60_000, tickTimers, tickRoutines });
|
||||
return { coordinator, workers, owner, deadlines, execute, tickTimers, tickRoutines, errors,
|
||||
ready: () => Promise.all([workers.routines.ready, workers.timers.ready]),
|
||||
canRun: (value: boolean) => { canRun = value; }, failRead: (value: boolean) => { failRead = value; } };
|
||||
}
|
||||
|
||||
describe("scheduled routine and monitor deadlines", () => {
|
||||
it("dispatches at each saved deadline without waiting for a periodic tick", async () => {
|
||||
const now = Date.UTC(2020, 0, 1);
|
||||
const f = fixture({ routine: now + 10_000, monitor: now + 20_000 });
|
||||
await f.ready();
|
||||
expect(f.coordinator.nextWakeAt()).toBe(now + 10_000);
|
||||
expect(idleWorkSnapshot().active).toBe(0);
|
||||
await vi.advanceTimersByTimeAsync(9999);
|
||||
expect(f.tickRoutines).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(f.tickRoutines).toHaveBeenCalledTimes(2);
|
||||
expect(f.tickTimers).toHaveBeenCalledTimes(1);
|
||||
expect(f.coordinator.nextWakeAt()).toBe(now + 20_000);
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(f.tickTimers).toHaveBeenCalledTimes(2);
|
||||
expect(f.coordinator.nextWakeAt()).toBeNull();
|
||||
expect(f.execute).toHaveBeenCalledTimes(4);
|
||||
expect(idleWorkSnapshot().active).toBe(0);
|
||||
await vi.advanceTimersByTimeAsync(3_600_000);
|
||||
expect(f.execute).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it("rearms an earlier deadline only after the producer transaction settles", async () => {
|
||||
const now = Date.UTC(2020, 0, 1);
|
||||
const f = fixture({ routine: now + 600_000 });
|
||||
await f.ready();
|
||||
let commit!: () => void;
|
||||
const held = new Promise<void>(resolve => { commit = resolve; });
|
||||
const writing = f.owner.transaction(async tx => {
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.routineSchedules);
|
||||
f.deadlines.routine = now + 10_000;
|
||||
await held;
|
||||
});
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(f.tickRoutines).toHaveBeenCalledTimes(1);
|
||||
expect(idleWorkSnapshot().active).toBe(1);
|
||||
} finally { commit(); await writing; }
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(f.coordinator.nextWakeAt()).toBe(now + 10_000);
|
||||
expect(idleWorkSnapshot().active).toBe(0);
|
||||
await vi.advanceTimersByTimeAsync(8999);
|
||||
expect(f.tickRoutines).toHaveBeenCalledTimes(3);
|
||||
expect(f.tickTimers).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps monitor dispatch responsive while routine work is slow", async () => {
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>(resolve => { release = resolve; });
|
||||
const f = fixture({ monitor: Date.UTC(2020, 0, 1) + 10_000, tickRoutines: () => held });
|
||||
try {
|
||||
await f.workers.timers.ready;
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(f.tickTimers).toHaveBeenCalledTimes(2);
|
||||
expect(f.tickRoutines).toHaveBeenCalledTimes(1);
|
||||
} finally { release(); await f.workers.routines.ready; }
|
||||
});
|
||||
|
||||
it("does not dispatch during idle drain or standby and cancels deadlines on stop", async () => {
|
||||
const f = fixture({ routine: Date.UTC(2020, 0, 1) + 10_000 });
|
||||
await f.ready();
|
||||
f.canRun(false);
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(f.tickRoutines).toHaveBeenCalledTimes(1);
|
||||
expect(idleWorkSnapshot().active).toBe(0);
|
||||
await f.coordinator.stop();
|
||||
f.canRun(true);
|
||||
await vi.advanceTimersByTimeAsync(3_600_000);
|
||||
expect(f.tickRoutines).toHaveBeenCalledTimes(1);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("retries failed deadline reads at the fallback interval without a tight loop", async () => {
|
||||
const f = fixture();
|
||||
await f.ready();
|
||||
f.failRead(true);
|
||||
f.workers.routines.wake();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(f.errors).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(59_000);
|
||||
expect(f.tickRoutines).toHaveBeenCalledTimes(2);
|
||||
f.failRead(false);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(f.tickRoutines).toHaveBeenCalledTimes(3);
|
||||
expect(f.coordinator.nextWakeAt()).toBeNull();
|
||||
expect(idleWorkSnapshot().active).toBe(0);
|
||||
});
|
||||
});
|
||||
+18
-24
@@ -7,6 +7,7 @@ import {
|
||||
} from "./services/skill-version-retention.js";
|
||||
import { subscribeAllCompanyLiveEvents } from "./services/live-events.js";
|
||||
import { DELIVERY_QUEUES } from "./services/delivery-work-notifications.js";
|
||||
import { registerScheduledWork } from "./services/scheduled-work.js";
|
||||
import type { createDeliveryWorkCoordinator } from "./services/delivery-work-coordinator.js";
|
||||
import { registerTerminalWorkspaceWork, TERMINAL_WORKSPACE_RETRY_MS } from "./services/terminal-workspace-work.js";
|
||||
import { isIdleTaskDrainActive, beginIdleTrackedWork, trackIdleWork } from "./services/task-admission.js";
|
||||
@@ -1718,7 +1719,7 @@ async function startServerWithDatabaseTeardown(
|
||||
await step("retention_sweep", runRetentionSweep);
|
||||
};
|
||||
|
||||
const startHeartbeatScheduler = () => startHeartbeatSchedulerInterval(() => {
|
||||
const createHeartbeatSchedulerTick = (scheduled: ReturnType<typeof registerScheduledWork>) => () => {
|
||||
// Track the outer async callback as well as the work it starts. Shutdown
|
||||
// can then wait through an already-running suppression check before it
|
||||
// captures the authoritative set of running heartbeat rows.
|
||||
@@ -1738,18 +1739,7 @@ async function startServerWithDatabaseTeardown(
|
||||
);
|
||||
}
|
||||
|
||||
if (!(await heartbeat.resolveSchedulingSuppression()).suppressed) {
|
||||
trackHeartbeatSchedulerWork(heartbeat
|
||||
.tickTimers(new Date())
|
||||
.then((result) => {
|
||||
if (result.enqueued > 0) {
|
||||
logger.info({ ...result }, "heartbeat timer tick enqueued runs");
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error({ err }, "heartbeat timer tick failed");
|
||||
}));
|
||||
}
|
||||
scheduled.timers.wake();
|
||||
|
||||
if (isIdleTaskDrainActive() || heartbeatSchedulerStopped) return;
|
||||
scheduleExternalObjectRefreshSweep(new Date());
|
||||
@@ -1763,16 +1753,7 @@ async function startServerWithDatabaseTeardown(
|
||||
scheduleEnvironmentLeaseCleanupSweep();
|
||||
|
||||
if (isIdleTaskDrainActive() || heartbeatSchedulerStopped) return;
|
||||
trackHeartbeatSchedulerWork(routines
|
||||
.tickScheduledTriggers(new Date())
|
||||
.then((result) => {
|
||||
if (result.triggered > 0) {
|
||||
logger.info({ ...result }, "routine scheduler tick enqueued runs");
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error({ err }, "routine scheduler tick failed");
|
||||
}));
|
||||
scheduled.routines.wake();
|
||||
|
||||
if (isIdleTaskDrainActive() || heartbeatSchedulerStopped) return;
|
||||
trackHeartbeatSchedulerWork((async () => {
|
||||
@@ -1896,7 +1877,20 @@ async function startServerWithDatabaseTeardown(
|
||||
})().catch((err) => {
|
||||
logger.error({ err }, "heartbeat scheduler tick failed");
|
||||
}));
|
||||
});
|
||||
};
|
||||
const startHeartbeatScheduler = () => {
|
||||
const scheduled = registerScheduledWork(deliveryWork, db, {
|
||||
retryMs: config.heartbeatSchedulerIntervalMs,
|
||||
tickTimers: () => heartbeat.tickTimers(new Date()).then(result => {
|
||||
if (result.enqueued > 0) logger.info({ ...result }, "heartbeat timer tick enqueued runs");
|
||||
}),
|
||||
tickRoutines: () => routines.tickScheduledTriggers(new Date()).then(result => {
|
||||
if (result.triggered > 0) logger.info({ ...result }, "routine scheduler tick enqueued runs");
|
||||
}),
|
||||
});
|
||||
trackHeartbeatSchedulerWork(Promise.all([scheduled.timers.ready, scheduled.routines.ready]));
|
||||
startHeartbeatSchedulerInterval(createHeartbeatSchedulerTick(scheduled));
|
||||
};
|
||||
trackHeartbeatSchedulerWork(runStartupHousekeeping().then(() => {
|
||||
if (!heartbeatSchedulerStopped) startHeartbeatScheduler();
|
||||
}));
|
||||
|
||||
@@ -27,6 +27,8 @@ export const DELIVERY_QUEUES = {
|
||||
pluginLifecycle: "plugin-resource-lifecycle",
|
||||
toolAction: "tool-action-receipts",
|
||||
terminalWorkspaces: "terminal-workspaces",
|
||||
routineSchedules: "routine-schedules",
|
||||
issueMonitors: "issue-monitors",
|
||||
} as const;
|
||||
export type DeliveryQueue = typeof DELIVERY_QUEUES[keyof typeof DELIVERY_QUEUES];
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { type TaskWorkspaceSelection, type TaskWorkspaceIntent } from "@papercli
|
||||
import { executionWorkspaceService } from "./execution-workspaces.js";
|
||||
import { notifyChatPublicationWork } from "./chat-work-notifications.js";
|
||||
import { notifyTerminalWorkspaceWork } from "./terminal-workspace-work.js";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
import { recordChatHandoff, recordChatCompletion, existingChatCompletionReply, acknowledgeChatCompletionReply } from "./chat-completion-delivery.js";
|
||||
import { mirrorSlackBoardComment, slackBoardReplyBindings } from "./slack-board-messages.js";
|
||||
import { assertAgentRunWriteAllowed } from "../agent-run-cancellation.js";
|
||||
@@ -10383,6 +10384,7 @@ export function issueService(db: Db) {
|
||||
await notifyTerminalWorkspaceWork(tx, values.executionWorkspaceId ? `workspace:${values.executionWorkspaceId}`
|
||||
: values.parentId ? `issue:${values.parentId}` : undefined);
|
||||
}
|
||||
if (values.monitorNextCheckAt) await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
|
||||
const [issue] = await tx.insert(issues).values(values).returning();
|
||||
await attachOwnedDraftImages(tx, issue, issue.description, { userId: issueData.createdByUserId, agentId: issueData.createdByAgentId });
|
||||
if (issue.visibility === "private" && !inheritedPrivateSubtree && issueData.createdByAgentId) {
|
||||
@@ -11205,6 +11207,10 @@ export function issueService(db: Db) {
|
||||
|| issueData.parentId !== undefined || issueData.executionWorkspaceId !== undefined) {
|
||||
await notifyTerminalWorkspaceWork(tx, `issue:${id}`);
|
||||
}
|
||||
if (patch.monitorNextCheckAt !== undefined || (receiptExisting.monitorNextCheckAt &&
|
||||
(patch.status !== undefined || patch.assigneeAgentId !== undefined || patch.assigneeUserId !== undefined))) {
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
|
||||
}
|
||||
const updated = await tx
|
||||
.update(issues)
|
||||
.set(patch)
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { nextCronTickInTimeZone } from "./routines.js";
|
||||
import * as cron from "./cron.js";
|
||||
|
||||
describe("nextCronTickInTimeZone formatter caching", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("parses a sparse schedule once instead of once per searched minute", () => {
|
||||
const parse = cron.parseCron;
|
||||
let parses = 0;
|
||||
vi.spyOn(cron, "parseCron").mockImplementation(expression => {
|
||||
parses++;
|
||||
if (parses > 1) throw new Error("Repeated cron parsing blocks unrelated scheduled work");
|
||||
return parse(expression);
|
||||
});
|
||||
const next = nextCronTickInTimeZone("0 12 1 1 *", "UTC", new Date("2020-01-01T12:00:00Z"));
|
||||
expect(next?.toISOString()).toBe("2021-01-01T12:00:00.000Z");
|
||||
expect(parses).toBe(1);
|
||||
});
|
||||
|
||||
it("computes the next occurrence of a sparse monthly cron correctly", () => {
|
||||
const next = nextCronTickInTimeZone(
|
||||
"30 6 1 * *",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
import { verifyAppWebhook } from "./app-webhook.js";
|
||||
import crypto from "node:crypto";
|
||||
import { verifyFirefliesWebhook } from "./fireflies-webhook.js";
|
||||
@@ -253,8 +254,7 @@ function getZonedMinuteParts(date: Date, timeZone: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function matchesCronMinute(expression: string, timeZone: string, date: Date) {
|
||||
const cron = parseCron(expression);
|
||||
function matchesCronMinute(cron: ReturnType<typeof parseCron>, timeZone: string, date: Date) {
|
||||
const parts = getZonedMinuteParts(date, timeZone);
|
||||
return (
|
||||
cron.minutes.includes(parts.minute) &&
|
||||
@@ -268,16 +268,18 @@ function matchesCronMinute(expression: string, timeZone: string, date: Date) {
|
||||
export function nextCronTickInTimeZone(expression: string, timeZone: string, after: Date) {
|
||||
const trimmed = expression.trim();
|
||||
assertTimeZone(timeZone);
|
||||
const error = validateCron(trimmed);
|
||||
if (error) {
|
||||
throw unprocessable(error);
|
||||
let cron: ReturnType<typeof parseCron>;
|
||||
try {
|
||||
cron = parseCron(trimmed);
|
||||
} catch (error) {
|
||||
throw unprocessable(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
||||
const cursor = floorToMinute(after);
|
||||
cursor.setUTCMinutes(cursor.getUTCMinutes() + 1);
|
||||
const limit = 366 * 24 * 60 * 5;
|
||||
for (let i = 0; i < limit; i += 1) {
|
||||
if (matchesCronMinute(trimmed, timeZone, cursor)) {
|
||||
if (matchesCronMinute(cron, timeZone, cursor)) {
|
||||
return new Date(cursor.getTime());
|
||||
}
|
||||
cursor.setUTCMinutes(cursor.getUTCMinutes() + 1);
|
||||
@@ -2230,6 +2232,7 @@ export function routineService(
|
||||
}
|
||||
const createdRoutine = await db.transaction(async (tx) => {
|
||||
const txDb = tx as unknown as Db;
|
||||
await notifyDeliveryWork(txDb, DELIVERY_QUEUES.routineSchedules);
|
||||
const [created] = await txDb
|
||||
.insert(routines)
|
||||
.values({
|
||||
@@ -2334,6 +2337,7 @@ export function routineService(
|
||||
}
|
||||
const updatedRoutine = await db.transaction(async (tx) => {
|
||||
const txDb = tx as unknown as Db;
|
||||
await notifyDeliveryWork(txDb, DELIVERY_QUEUES.routineSchedules);
|
||||
await tx.execute(sql`select id from ${routines} where ${routines.id} = ${id} for update`);
|
||||
const locked = await txDb
|
||||
.select()
|
||||
@@ -2488,6 +2492,7 @@ export function routineService(
|
||||
|
||||
const { trigger, revision } = await db.transaction(async (tx) => {
|
||||
const txDb = tx as unknown as Db;
|
||||
await notifyDeliveryWork(txDb, DELIVERY_QUEUES.routineSchedules);
|
||||
await tx.execute(sql`select id from ${routines} where ${routines.id} = ${routine.id} for update`);
|
||||
const [createdTrigger] = await txDb
|
||||
.insert(routineTriggers)
|
||||
@@ -2562,6 +2567,7 @@ export function routineService(
|
||||
|
||||
const result = await db.transaction(async (tx) => {
|
||||
const txDb = tx as unknown as Db;
|
||||
await notifyDeliveryWork(txDb, DELIVERY_QUEUES.routineSchedules);
|
||||
await tx.execute(sql`select id from ${routines} where ${routines.id} = ${existing.routineId} for update`);
|
||||
const [updated] = await txDb
|
||||
.update(routineTriggers)
|
||||
@@ -2601,6 +2607,7 @@ export function routineService(
|
||||
if (!existing) return { deleted: false, revision: null };
|
||||
const result = await db.transaction(async (tx) => {
|
||||
const txDb = tx as unknown as Db;
|
||||
await notifyDeliveryWork(txDb, DELIVERY_QUEUES.routineSchedules);
|
||||
await tx.execute(sql`select id from ${routines} where ${routines.id} = ${existing.routineId} for update`);
|
||||
await txDb.delete(routineTriggers).where(eq(routineTriggers.id, id));
|
||||
const routine = await txDb
|
||||
@@ -2719,6 +2726,7 @@ export function routineService(
|
||||
|
||||
const result = await db.transaction(async (tx) => {
|
||||
const txDb = tx as unknown as Db;
|
||||
await notifyDeliveryWork(txDb, DELIVERY_QUEUES.routineSchedules);
|
||||
await tx.execute(sql`select id from ${routines} where ${routines.id} = ${existingRoutine.id} for update`);
|
||||
const locked = await txDb
|
||||
.select()
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { sql, type SQL } from "drizzle-orm";
|
||||
import type { createDeliveryWorkCoordinator } from "./delivery-work-coordinator.js";
|
||||
import { DELIVERY_QUEUES, type DeliveryQueue } from "./delivery-work-notifications.js";
|
||||
|
||||
export function registerScheduledWork(
|
||||
coordinator: Pick<ReturnType<typeof createDeliveryWorkCoordinator>, "register">,
|
||||
db: Pick<Db, "execute">,
|
||||
options: {
|
||||
retryMs: number;
|
||||
tickTimers: () => Promise<unknown>;
|
||||
tickRoutines: () => Promise<unknown>;
|
||||
},
|
||||
) {
|
||||
function register(queue: DeliveryQueue, work: () => Promise<unknown>, query: (now: Date) => SQL) {
|
||||
let next: number | null = null;
|
||||
return coordinator.register(queue, {
|
||||
retryMs: options.retryMs,
|
||||
run: async () => {
|
||||
await work();
|
||||
const [row] = await db.execute<{ next_at: Date | string | null }>(query(new Date()));
|
||||
if (!row || row.next_at === undefined) throw new Error("Scheduled deadline query returned no result");
|
||||
next = row.next_at === null ? null
|
||||
: row.next_at instanceof Date ? row.next_at.getTime() : Date.parse(row.next_at);
|
||||
if (next !== null && !Number.isFinite(next)) throw new Error("Scheduled deadline is invalid");
|
||||
},
|
||||
hasPending: async () => false,
|
||||
nextRunAt: () => next,
|
||||
});
|
||||
}
|
||||
|
||||
const routines = register(DELIVERY_QUEUES.routineSchedules, options.tickRoutines, now => sql`
|
||||
SELECT min(t.next_run_at) AS next_at
|
||||
FROM routine_triggers t JOIN routines r ON r.id = t.routine_id AND r.company_id = t.company_id
|
||||
JOIN companies c ON c.id = r.company_id
|
||||
WHERE t.kind = 'schedule' AND t.enabled AND NOT t.archived
|
||||
AND r.status = 'active' AND c.status = 'active' AND t.next_run_at > ${now.toISOString()}::timestamptz`);
|
||||
const timers = register(DELIVERY_QUEUES.issueMonitors, options.tickTimers, now => sql`
|
||||
SELECT min(i.monitor_next_check_at) AS next_at
|
||||
FROM issues i JOIN companies c ON c.id = i.company_id
|
||||
WHERE c.status = 'active' AND i.status IN ('in_progress', 'in_review')
|
||||
AND i.assignee_agent_id IS NOT NULL AND i.assignee_user_id IS NULL
|
||||
AND i.monitor_next_check_at > ${now.toISOString()}::timestamptz`);
|
||||
return { routines, timers };
|
||||
}
|
||||
Reference in new issue
Block a user