feat: implement cron window spread backend (#4566)
- New DB fields on Schedule and ScheduleInstance - Use `queueTimestamp` for the "effectiveAt" delayed start time, propagate it to Clickhouse TaskRun table - Disable fastpath for delayed jobs - Add schedule timing logic, API endpoints with windows, persistence - Calculate phase for every schedule, only persist when window is non-null - Additional o11y for phased rollout
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
area: webapp
|
||||
type: feature
|
||||
---
|
||||
|
||||
Add backend support for delaying cron schedules within a specified window with a minimum of 60 seconds.
|
||||
@@ -1684,6 +1684,11 @@ const EnvironmentSchema = z
|
||||
SCHEDULE_WORKER_CONCURRENCY_LIMIT: z.coerce.number().int().default(50),
|
||||
SCHEDULE_WORKER_SHUTDOWN_TIMEOUT_MS: z.coerce.number().int().default(30_000),
|
||||
SCHEDULE_WORKER_DISTRIBUTION_WINDOW_SECONDS: z.coerce.number().int().default(30),
|
||||
SCHEDULE_WORKER_CRON_SPREAD_FRACTION: z.coerce
|
||||
.number()
|
||||
.catch(0)
|
||||
.default(0)
|
||||
.transform((value) => (Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 0)),
|
||||
|
||||
SCHEDULE_WORKER_REDIS_HOST: z
|
||||
.string()
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getTaskIdentifiers } from "~/models/task.server";
|
||||
import { getCurrentPlan, getPlans } from "~/services/platform.v3.server";
|
||||
import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import { formatScheduleWindow } from "~/v3/scheduleWindow.server";
|
||||
import { CheckScheduleService } from "~/v3/services/checkSchedule.server";
|
||||
import {
|
||||
calculateNextScheduledTimestampFromNow,
|
||||
@@ -31,6 +32,7 @@ export type ScheduleListItem = {
|
||||
cron: string;
|
||||
cronDescription: string;
|
||||
timezone: string;
|
||||
window?: string;
|
||||
externalId: string | null;
|
||||
nextRun: Date;
|
||||
lastRun: Date | undefined;
|
||||
@@ -215,6 +217,8 @@ export class ScheduleListPresenter extends BasePresenter {
|
||||
generatorExpression: true,
|
||||
generatorDescription: true,
|
||||
timezone: true,
|
||||
windowDurationSeconds: true,
|
||||
windowPercentage: true,
|
||||
externalId: true,
|
||||
instances: {
|
||||
select: {
|
||||
@@ -306,6 +310,7 @@ export class ScheduleListPresenter extends BasePresenter {
|
||||
cron: schedule.generatorExpression,
|
||||
cronDescription: schedule.generatorDescription,
|
||||
timezone: schedule.timezone,
|
||||
window: formatScheduleWindow(schedule),
|
||||
active: schedule.active,
|
||||
externalId: schedule.externalId,
|
||||
lastRun,
|
||||
|
||||
@@ -6,12 +6,14 @@ import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstan
|
||||
import { nextScheduledTimestamps } from "~/v3/utils/calculateNextSchedule.server";
|
||||
import { NextRunListPresenter } from "./NextRunListPresenter.server";
|
||||
import { scheduleWhereClause } from "~/models/schedules.server";
|
||||
import { formatScheduleWindow } from "~/v3/scheduleWindow.server";
|
||||
|
||||
type ViewScheduleOptions = {
|
||||
userId?: string;
|
||||
projectId: string;
|
||||
friendlyId: string;
|
||||
environmentId: string;
|
||||
includeRunHistory?: boolean;
|
||||
};
|
||||
|
||||
export class ViewSchedulePresenter {
|
||||
@@ -21,7 +23,13 @@ export class ViewSchedulePresenter {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call({ userId, projectId, friendlyId, environmentId }: ViewScheduleOptions) {
|
||||
public async call({
|
||||
userId,
|
||||
projectId,
|
||||
friendlyId,
|
||||
environmentId,
|
||||
includeRunHistory = true,
|
||||
}: ViewScheduleOptions) {
|
||||
const schedule = await this.#prismaClient.taskSchedule.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
@@ -30,6 +38,8 @@ export class ViewSchedulePresenter {
|
||||
generatorExpression: true,
|
||||
generatorDescription: true,
|
||||
timezone: true,
|
||||
windowDurationSeconds: true,
|
||||
windowPercentage: true,
|
||||
externalId: true,
|
||||
deduplicationKey: true,
|
||||
userProvidedDeduplicationKey: true,
|
||||
@@ -76,17 +86,14 @@ export class ViewSchedulePresenter {
|
||||
? nextScheduledTimestamps(schedule.generatorExpression, schedule.timezone, new Date(), 5)
|
||||
: [];
|
||||
|
||||
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
|
||||
schedule.project.organizationId,
|
||||
"standard"
|
||||
);
|
||||
const runPresenter = new NextRunListPresenter(this.#prismaClient, clickhouse);
|
||||
const { runs } = await runPresenter.call(schedule.project.organizationId, environmentId, {
|
||||
projectId: schedule.project.id,
|
||||
scheduleId: schedule.id,
|
||||
pageSize: 5,
|
||||
period: "31d",
|
||||
});
|
||||
const runs = includeRunHistory
|
||||
? await this.#getRunHistory({
|
||||
organizationId: schedule.project.organizationId,
|
||||
environmentId,
|
||||
projectId: schedule.project.id,
|
||||
scheduleId: schedule.id,
|
||||
})
|
||||
: [];
|
||||
|
||||
return {
|
||||
schedule: {
|
||||
@@ -107,6 +114,32 @@ export class ViewSchedulePresenter {
|
||||
};
|
||||
}
|
||||
|
||||
async #getRunHistory({
|
||||
organizationId,
|
||||
environmentId,
|
||||
projectId,
|
||||
scheduleId,
|
||||
}: {
|
||||
organizationId: string;
|
||||
environmentId: string;
|
||||
projectId: string;
|
||||
scheduleId: string;
|
||||
}) {
|
||||
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
|
||||
organizationId,
|
||||
"standard"
|
||||
);
|
||||
const runPresenter = new NextRunListPresenter(this.#prismaClient, clickhouse);
|
||||
const { runs } = await runPresenter.call(organizationId, environmentId, {
|
||||
projectId,
|
||||
scheduleId,
|
||||
pageSize: 5,
|
||||
period: "31d",
|
||||
});
|
||||
|
||||
return runs;
|
||||
}
|
||||
|
||||
public toJSONResponse(result: NonNullable<Awaited<ReturnType<ViewSchedulePresenter["call"]>>>) {
|
||||
const response: ScheduleObject = {
|
||||
id: result.schedule.friendlyId,
|
||||
@@ -120,6 +153,7 @@ export class ViewSchedulePresenter {
|
||||
description: result.schedule.cronDescription,
|
||||
},
|
||||
timezone: result.schedule.timezone,
|
||||
window: formatScheduleWindow(result.schedule),
|
||||
externalId: result.schedule.externalId ?? undefined,
|
||||
deduplicationKey: result.schedule.userProvidedDeduplicationKey
|
||||
? (result.schedule.deduplicationKey ?? undefined)
|
||||
|
||||
@@ -64,6 +64,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
projectId: authenticationResult.environment.projectId,
|
||||
friendlyId: parsedParams.data.scheduleId,
|
||||
environmentId: authenticationResult.environment.id,
|
||||
includeRunHistory: false,
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
|
||||
@@ -64,6 +64,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
projectId: authenticationResult.environment.projectId,
|
||||
friendlyId: parsedParams.data.scheduleId,
|
||||
environmentId: authenticationResult.environment.id,
|
||||
includeRunHistory: false,
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
|
||||
@@ -107,6 +107,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
taskIdentifier: body.data.task,
|
||||
cron: body.data.cron,
|
||||
timezone: body.data.timezone,
|
||||
window: body.data.window,
|
||||
environments: [authenticationResult.environment.id],
|
||||
externalId: body.data.externalId,
|
||||
};
|
||||
@@ -124,6 +125,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
description: schedule.cronDescription,
|
||||
},
|
||||
timezone: schedule.timezone,
|
||||
window: schedule.window,
|
||||
externalId: schedule.externalId ?? undefined,
|
||||
deduplicationKey: schedule.deduplicationKey,
|
||||
environments: schedule.environments,
|
||||
@@ -176,6 +178,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
projectId: authenticationResult.environment.projectId,
|
||||
friendlyId: parsedParams.data.scheduleId,
|
||||
environmentId: authenticationResult.environment.id,
|
||||
includeRunHistory: false,
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
|
||||
@@ -51,6 +51,7 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
externalId: body.data.externalId,
|
||||
deduplicationKey: body.data.deduplicationKey,
|
||||
timezone: body.data.timezone,
|
||||
window: body.data.window,
|
||||
};
|
||||
|
||||
const schedule = await service.call(authenticationResult.environment.projectId, options);
|
||||
@@ -66,6 +67,7 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
description: schedule.cronDescription,
|
||||
},
|
||||
timezone: schedule.timezone,
|
||||
window: schedule.window,
|
||||
externalId: schedule.externalId ?? undefined,
|
||||
deduplicationKey: schedule.deduplicationKey,
|
||||
environments: schedule.environments,
|
||||
@@ -121,6 +123,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
description: schedule.cronDescription,
|
||||
},
|
||||
timezone: schedule.timezone,
|
||||
window: schedule.window,
|
||||
deduplicationKey: schedule.userProvidedDeduplicationKey
|
||||
? schedule.deduplicationKey
|
||||
: undefined,
|
||||
|
||||
@@ -1307,6 +1307,7 @@ export class RunsReplicationService {
|
||||
run.id, // run_id
|
||||
run.updatedAt.getTime(), // updated_at
|
||||
run.createdAt.getTime(), // created_at
|
||||
run.queueTimestamp?.getTime() ?? null, // queue_timestamp
|
||||
run.status, // status
|
||||
environmentType, // environment_type
|
||||
run.friendlyId, // friendly_id
|
||||
|
||||
@@ -72,6 +72,8 @@ function createScheduleEngine() {
|
||||
distributionWindow: {
|
||||
seconds: env.SCHEDULE_WORKER_DISTRIBUTION_WINDOW_SECONDS,
|
||||
},
|
||||
schedulePhaseSecret: env.ENCRYPTION_KEY,
|
||||
cronSpreadFraction: env.SCHEDULE_WORKER_CRON_SPREAD_FRACTION,
|
||||
tracer,
|
||||
meter,
|
||||
onTriggerScheduledTask: async ({
|
||||
@@ -81,6 +83,7 @@ function createScheduleEngine() {
|
||||
scheduleInstanceId,
|
||||
scheduleId,
|
||||
exactScheduleTime,
|
||||
effectiveScheduleTime,
|
||||
}) => {
|
||||
try {
|
||||
// v3 (engine V1) is retired: skip firing V1 schedules instead of triggering into a guaranteed rejection every tick.
|
||||
@@ -104,6 +107,7 @@ function createScheduleEngine() {
|
||||
scheduleInstanceId,
|
||||
scheduleId,
|
||||
exactScheduleTime,
|
||||
effectiveScheduleTime,
|
||||
});
|
||||
|
||||
const result = await triggerService.call(
|
||||
@@ -114,7 +118,7 @@ function createScheduleEngine() {
|
||||
customIcon: "scheduled",
|
||||
scheduleId,
|
||||
scheduleInstanceId,
|
||||
queueTimestamp: exactScheduleTime,
|
||||
queueTimestamp: effectiveScheduleTime,
|
||||
overrideCreatedAt: exactScheduleTime,
|
||||
triggerSource: "schedule",
|
||||
triggerAction: "trigger",
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { parseScheduleWindow } from "@internal/schedule-engine";
|
||||
import type { ScheduleWindow } from "@trigger.dev/core/v3";
|
||||
|
||||
const SECONDS_PER_UNIT = {
|
||||
m: 60,
|
||||
h: 3_600,
|
||||
} as const;
|
||||
|
||||
export type ScheduleWindowDatabaseFields = {
|
||||
windowDurationSeconds: number | null;
|
||||
windowPercentage: number | null;
|
||||
};
|
||||
|
||||
export function normalizeScheduleWindow(
|
||||
window: ScheduleWindow | undefined
|
||||
): ScheduleWindowDatabaseFields {
|
||||
if (window === undefined) {
|
||||
return {
|
||||
windowDurationSeconds: null,
|
||||
windowPercentage: null,
|
||||
};
|
||||
}
|
||||
|
||||
const parsedWindow = parseScheduleWindow(window);
|
||||
|
||||
if (parsedWindow.type === "percentage") {
|
||||
return {
|
||||
windowDurationSeconds: null,
|
||||
windowPercentage: parsedWindow.percentage,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
windowDurationSeconds: parsedWindow.durationSeconds,
|
||||
windowPercentage: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatScheduleWindow({
|
||||
windowDurationSeconds,
|
||||
windowPercentage,
|
||||
}: ScheduleWindowDatabaseFields): ScheduleWindow | undefined {
|
||||
if (windowPercentage !== null) {
|
||||
return `${windowPercentage}%`;
|
||||
}
|
||||
|
||||
if (windowDurationSeconds === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (windowDurationSeconds === 0) {
|
||||
return "0m";
|
||||
}
|
||||
|
||||
if (windowDurationSeconds % SECONDS_PER_UNIT.h === 0) {
|
||||
return `${windowDurationSeconds / SECONDS_PER_UNIT.h}h`;
|
||||
}
|
||||
|
||||
return `${windowDurationSeconds / SECONDS_PER_UNIT.m}m`;
|
||||
}
|
||||
|
||||
export function validateScheduleWindowSyntax(
|
||||
window: ScheduleWindow | undefined
|
||||
): { valid: true } | { valid: false; message: string } {
|
||||
if (window === undefined) {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
try {
|
||||
parseScheduleWindow(window);
|
||||
return { valid: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ScheduleWindow } from "@trigger.dev/core/v3";
|
||||
import { parseExpression } from "cron-parser";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -56,6 +57,7 @@ export const UpsertSchedule = z.object({
|
||||
externalId: z.string().optional(),
|
||||
deduplicationKey: z.string().optional(),
|
||||
timezone: z.string().optional(),
|
||||
window: ScheduleWindow.optional(),
|
||||
});
|
||||
|
||||
export type UpsertSchedule = z.infer<typeof UpsertSchedule>;
|
||||
|
||||
@@ -5,13 +5,16 @@ import { resolveProjectScopedEnvironments } from "./resolveProjectScopedEnvironm
|
||||
import { getLimit } from "~/services/platform.v3.server";
|
||||
import { getTimezones } from "~/utils/timezones.server";
|
||||
import { env } from "~/env.server";
|
||||
import type { ScheduleWindow } from "@trigger.dev/core/v3";
|
||||
import { type PrismaClientOrTransaction } from "@trigger.dev/database";
|
||||
import { validateScheduleWindowSyntax } from "../scheduleWindow.server";
|
||||
|
||||
type Schedule = {
|
||||
cron: string;
|
||||
timezone?: string;
|
||||
taskIdentifier: string;
|
||||
friendlyId?: string;
|
||||
window?: ScheduleWindow;
|
||||
};
|
||||
|
||||
export class CheckScheduleService extends BaseService {
|
||||
@@ -39,6 +42,11 @@ export class CheckScheduleService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
const windowValidation = validateScheduleWindowSyntax(schedule.window);
|
||||
if (!windowValidation.valid) {
|
||||
throw new ServiceValidationError(windowValidation.message);
|
||||
}
|
||||
|
||||
//check the task exists
|
||||
const task = await this._prisma.backgroundWorkerTask.findFirst({
|
||||
where: {
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
updateQueueConcurrencyLimits,
|
||||
} from "../runQueue.server";
|
||||
import { scheduleEngine } from "../scheduleEngine.server";
|
||||
import { normalizeScheduleWindow } from "../scheduleWindow.server";
|
||||
import { calculateNextBuildVersion } from "../utils/calculateNextBuildVersion";
|
||||
import { clampMaxDuration } from "../utils/maxDuration";
|
||||
import { BaseService, ServiceValidationError } from "./baseService.server";
|
||||
@@ -665,6 +666,10 @@ export async function syncDeclarativeSchedules(
|
||||
id: true,
|
||||
friendlyId: true,
|
||||
taskIdentifier: true,
|
||||
generatorExpression: true,
|
||||
timezone: true,
|
||||
windowDurationSeconds: true,
|
||||
windowPercentage: true,
|
||||
instances: {
|
||||
select: {
|
||||
environmentId: true,
|
||||
@@ -710,11 +715,18 @@ export async function syncDeclarativeSchedules(
|
||||
timezone: task.schedule.timezone,
|
||||
taskIdentifier: task.id,
|
||||
friendlyId: existingSchedule?.friendlyId,
|
||||
window: task.schedule.window,
|
||||
},
|
||||
[environment.id]
|
||||
);
|
||||
|
||||
if (existingSchedule) {
|
||||
const normalizedWindow = normalizeScheduleWindow(task.schedule.window);
|
||||
const timingChanged =
|
||||
existingSchedule.generatorExpression !== task.schedule.cron ||
|
||||
existingSchedule.timezone !== task.schedule.timezone ||
|
||||
existingSchedule.windowDurationSeconds !== normalizedWindow.windowDurationSeconds ||
|
||||
existingSchedule.windowPercentage !== normalizedWindow.windowPercentage;
|
||||
const schedule = await prisma.taskSchedule.update({
|
||||
where: {
|
||||
id: existingSchedule.id,
|
||||
@@ -723,6 +735,7 @@ export async function syncDeclarativeSchedules(
|
||||
generatorExpression: task.schedule.cron,
|
||||
generatorDescription: cronstrue.toString(task.schedule.cron),
|
||||
timezone: task.schedule.timezone,
|
||||
...normalizedWindow,
|
||||
},
|
||||
include: {
|
||||
instances: true,
|
||||
@@ -732,7 +745,10 @@ export async function syncDeclarativeSchedules(
|
||||
missingSchedules.delete(existingSchedule.id);
|
||||
const instance = schedule.instances.at(0);
|
||||
if (instance) {
|
||||
await scheduleEngine.registerNextTaskScheduleInstance({ instanceId: instance.id });
|
||||
await scheduleEngine.registerNextTaskScheduleInstance({
|
||||
instanceId: instance.id,
|
||||
preserveExistingJob: !timingChanged,
|
||||
});
|
||||
} else {
|
||||
throw new CreateDeclarativeScheduleError(
|
||||
`Missing instance for declarative schedule ${schedule.id}`
|
||||
@@ -748,6 +764,7 @@ export async function syncDeclarativeSchedules(
|
||||
generatorDescription: cronstrue.toString(task.schedule.cron),
|
||||
timezone: task.schedule.timezone,
|
||||
type: "DECLARATIVE",
|
||||
...normalizeScheduleWindow(task.schedule.window),
|
||||
instances: {
|
||||
create: [
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ import { calculateNextScheduledTimestampFromNow } from "../utils/calculateNextSc
|
||||
import { BaseService, ServiceValidationError } from "./baseService.server";
|
||||
import { CheckScheduleService } from "./checkSchedule.server";
|
||||
import { scheduleEngine } from "../scheduleEngine.server";
|
||||
import { formatScheduleWindow, normalizeScheduleWindow } from "../scheduleWindow.server";
|
||||
import { scheduleWhereClause } from "~/models/schedules.server";
|
||||
|
||||
export type UpsertTaskScheduleServiceOptions = UpsertSchedule;
|
||||
@@ -100,6 +101,7 @@ export class UpsertTaskScheduleService extends BaseService {
|
||||
generatorDescription: cronstrue.toString(options.cron),
|
||||
timezone: options.timezone ?? "UTC",
|
||||
externalId: options.externalId ? options.externalId : undefined,
|
||||
...normalizeScheduleWindow(options.window),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -161,12 +163,15 @@ export class UpsertTaskScheduleService extends BaseService {
|
||||
generatorDescription: cronstrue.toString(options.cron),
|
||||
timezone: options.timezone ?? "UTC",
|
||||
externalId: options.externalId ? options.externalId : null,
|
||||
...normalizeScheduleWindow(options.window),
|
||||
},
|
||||
});
|
||||
|
||||
const scheduleHasChanged =
|
||||
scheduleRecord.generatorExpression !== existingSchedule.generatorExpression ||
|
||||
scheduleRecord.timezone !== existingSchedule.timezone;
|
||||
scheduleRecord.timezone !== existingSchedule.timezone ||
|
||||
scheduleRecord.windowDurationSeconds !== existingSchedule.windowDurationSeconds ||
|
||||
scheduleRecord.windowPercentage !== existingSchedule.windowPercentage;
|
||||
|
||||
// create the new instances
|
||||
const newInstances: InstanceWithEnvironment[] = [];
|
||||
@@ -245,6 +250,7 @@ export class UpsertTaskScheduleService extends BaseService {
|
||||
cron: taskSchedule.generatorExpression,
|
||||
cronDescription: taskSchedule.generatorDescription,
|
||||
timezone: taskSchedule.timezone,
|
||||
window: formatScheduleWindow(taskSchedule),
|
||||
nextRun: calculateNextScheduledTimestampFromNow(
|
||||
taskSchedule.generatorExpression,
|
||||
taskSchedule.timezone
|
||||
|
||||
@@ -124,6 +124,78 @@ describe("RunEngineTriggerTaskService", () => {
|
||||
expect(queueLength).toBe(1);
|
||||
});
|
||||
|
||||
containerTest(
|
||||
"persists distinct nominal and effective schedule times",
|
||||
async ({ prisma, redisOptions }) => {
|
||||
const engine = new RunEngine({
|
||||
prisma,
|
||||
worker: {
|
||||
redis: redisOptions,
|
||||
workers: 1,
|
||||
tasksPerWorker: 10,
|
||||
pollIntervalMs: 100,
|
||||
},
|
||||
queue: {
|
||||
redis: redisOptions,
|
||||
},
|
||||
runLock: {
|
||||
redis: redisOptions,
|
||||
},
|
||||
machines: {
|
||||
defaultMachine: "small-1x",
|
||||
machines: {
|
||||
"small-1x": {
|
||||
name: "small-1x" as const,
|
||||
cpu: 0.5,
|
||||
memory: 0.5,
|
||||
centsPerMs: 0.0001,
|
||||
},
|
||||
},
|
||||
baseCostInCents: 0.0005,
|
||||
},
|
||||
tracer: trace.getTracer("test", "0.0.0"),
|
||||
});
|
||||
onTestFinished(() => engine.quit());
|
||||
|
||||
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
||||
const taskIdentifier = "scheduled-task";
|
||||
await setupBackgroundWorker(engine, authenticatedEnvironment, taskIdentifier);
|
||||
|
||||
const traceEventConcern = new MockTraceEventConcern();
|
||||
const triggerTaskService = new RunEngineTriggerTaskService({
|
||||
engine,
|
||||
prisma,
|
||||
payloadProcessor: new MockPayloadProcessor(),
|
||||
queueConcern: new DefaultQueueManager(prisma, engine),
|
||||
idempotencyKeyConcern: new IdempotencyKeyConcern(prisma, engine, traceEventConcern),
|
||||
validator: new MockTriggerTaskValidator(),
|
||||
traceEventConcern,
|
||||
tracer: trace.getTracer("test", "0.0.0"),
|
||||
metadataMaximumSize: 1024 * 1024,
|
||||
});
|
||||
|
||||
const nominalAt = new Date(Date.now() - 30_000);
|
||||
const effectiveAt = new Date(Date.now() + 60_000);
|
||||
const result = await triggerTaskService.call({
|
||||
taskId: taskIdentifier,
|
||||
environment: authenticatedEnvironment,
|
||||
body: { payload: { timestamp: nominalAt } },
|
||||
options: {
|
||||
overrideCreatedAt: nominalAt,
|
||||
queueTimestamp: effectiveAt,
|
||||
triggerSource: "schedule",
|
||||
triggerAction: "trigger",
|
||||
},
|
||||
});
|
||||
|
||||
const run = await prisma.taskRun.findUniqueOrThrow({
|
||||
where: { id: result!.run.id },
|
||||
});
|
||||
expect(run.createdAt).toEqual(nominalAt);
|
||||
expect(run.queueTimestamp).toEqual(effectiveAt);
|
||||
}
|
||||
);
|
||||
|
||||
containerTest(
|
||||
"routes scheduled-lineage runs to a separate worker queue that dequeues independently",
|
||||
async ({ prisma, redisOptions }) => {
|
||||
|
||||
@@ -73,6 +73,7 @@ describe("RunsReplicationService (part 1/7)", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const queueTimestamp = new Date("2026-08-11T12:34:56.789Z");
|
||||
const taskRun = await prisma.taskRun.create({
|
||||
data: {
|
||||
friendlyId: "run_1234",
|
||||
@@ -81,6 +82,7 @@ describe("RunsReplicationService (part 1/7)", () => {
|
||||
traceId: "1234",
|
||||
spanId: "1234",
|
||||
queue: "test",
|
||||
queueTimestamp,
|
||||
workerQueue: "us-east-1-next",
|
||||
region: "us-east-1",
|
||||
planType: "free",
|
||||
@@ -100,7 +102,8 @@ describe("RunsReplicationService (part 1/7)", () => {
|
||||
|
||||
const queryRuns = clickhouse.reader.query({
|
||||
name: "runs-replication",
|
||||
query: "SELECT * FROM trigger_dev.task_runs_v2",
|
||||
query:
|
||||
"SELECT *, toString(toUnixTimestamp64Milli(queue_timestamp)) AS queue_timestamp_ms FROM trigger_dev.task_runs_v2",
|
||||
schema: z.any(),
|
||||
});
|
||||
|
||||
@@ -125,6 +128,7 @@ describe("RunsReplicationService (part 1/7)", () => {
|
||||
organization_id: organization.id,
|
||||
environment_type: "DEVELOPMENT",
|
||||
engine: "V2",
|
||||
queue_timestamp_ms: queueTimestamp.getTime().toString(),
|
||||
trigger_source: "api",
|
||||
root_trigger_source: "dashboard",
|
||||
is_warm_start: 1,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatScheduleWindow,
|
||||
normalizeScheduleWindow,
|
||||
validateScheduleWindowSyntax,
|
||||
} from "~/v3/scheduleWindow.server";
|
||||
|
||||
describe("schedule window persistence", () => {
|
||||
it("normalizes duration and percentage windows", () => {
|
||||
expect(normalizeScheduleWindow("30m")).toEqual({
|
||||
windowDurationSeconds: 1_800,
|
||||
windowPercentage: null,
|
||||
});
|
||||
expect(normalizeScheduleWindow("0m")).toEqual({
|
||||
windowDurationSeconds: 0,
|
||||
windowPercentage: null,
|
||||
});
|
||||
expect(normalizeScheduleWindow("30%")).toEqual({
|
||||
windowDurationSeconds: null,
|
||||
windowPercentage: 30,
|
||||
});
|
||||
expect(normalizeScheduleWindow(undefined)).toEqual({
|
||||
windowDurationSeconds: null,
|
||||
windowPercentage: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("formats stored windows canonically", () => {
|
||||
expect(
|
||||
formatScheduleWindow({
|
||||
windowDurationSeconds: 0,
|
||||
windowPercentage: null,
|
||||
})
|
||||
).toBe("0m");
|
||||
expect(
|
||||
formatScheduleWindow({
|
||||
windowDurationSeconds: 86_400,
|
||||
windowPercentage: null,
|
||||
})
|
||||
).toBe("24h");
|
||||
expect(
|
||||
formatScheduleWindow({
|
||||
windowDurationSeconds: 7_200,
|
||||
windowPercentage: null,
|
||||
})
|
||||
).toBe("2h");
|
||||
expect(
|
||||
formatScheduleWindow({
|
||||
windowDurationSeconds: null,
|
||||
windowPercentage: 30,
|
||||
})
|
||||
).toBe("30%");
|
||||
});
|
||||
|
||||
it.each(["30.5%", "1d", "25h"])(
|
||||
"rejects invalid syntax through the authoritative timing parser: %s",
|
||||
(window) => {
|
||||
expect(validateScheduleWindowSyntax(window)).toMatchObject({ valid: false });
|
||||
}
|
||||
);
|
||||
|
||||
it("accepts an absolute window independently of the cron interval", () => {
|
||||
expect(validateScheduleWindowSyntax("30m")).toEqual({ valid: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { PrismaClient } from "@trigger.dev/database";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { seedTestEnvironment } from "./helpers/seedTestEnvironment";
|
||||
import { getTestServer } from "./helpers/sharedTestServer";
|
||||
|
||||
const TASK_IDENTIFIER = "scheduled-task";
|
||||
|
||||
describe("Schedules API windows", () => {
|
||||
it("creates, retrieves, updates, and clears a window", async () => {
|
||||
const server = getTestServer();
|
||||
const { apiKey, project, environment } = await seedTestEnvironment(server.prisma);
|
||||
await seedScheduledTask(server.prisma, project.id, environment.id);
|
||||
|
||||
const createResponse = await server.webapp.fetch("/api/v1/schedules", {
|
||||
method: "POST",
|
||||
headers: authHeaders(apiKey),
|
||||
body: JSON.stringify({
|
||||
task: TASK_IDENTIFIER,
|
||||
cron: "0 * * * *",
|
||||
deduplicationKey: "window-lifecycle",
|
||||
window: "30%",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(createResponse.status).toBe(200);
|
||||
const created = await createResponse.json();
|
||||
expect(created).toMatchObject({
|
||||
task: TASK_IDENTIFIER,
|
||||
timezone: "UTC",
|
||||
window: "30%",
|
||||
});
|
||||
|
||||
const retrieveResponse = await server.webapp.fetch(`/api/v1/schedules/${created.id}`, {
|
||||
headers: authHeaders(apiKey),
|
||||
});
|
||||
expect(retrieveResponse.status).toBe(200);
|
||||
await expect(retrieveResponse.json()).resolves.toMatchObject({
|
||||
id: created.id,
|
||||
window: "30%",
|
||||
});
|
||||
|
||||
const updateResponse = await server.webapp.fetch(`/api/v1/schedules/${created.id}`, {
|
||||
method: "PUT",
|
||||
headers: authHeaders(apiKey),
|
||||
body: JSON.stringify({
|
||||
task: TASK_IDENTIFIER,
|
||||
cron: "0 0 * * *",
|
||||
window: "2h",
|
||||
}),
|
||||
});
|
||||
expect(updateResponse.status).toBe(200);
|
||||
await expect(updateResponse.json()).resolves.toMatchObject({
|
||||
id: created.id,
|
||||
window: "2h",
|
||||
});
|
||||
|
||||
const clearResponse = await server.webapp.fetch(`/api/v1/schedules/${created.id}`, {
|
||||
method: "PUT",
|
||||
headers: authHeaders(apiKey),
|
||||
body: JSON.stringify({
|
||||
task: TASK_IDENTIFIER,
|
||||
cron: "0 0 * * *",
|
||||
}),
|
||||
});
|
||||
expect(clearResponse.status).toBe(200);
|
||||
const cleared = await clearResponse.json();
|
||||
expect(cleared.id).toBe(created.id);
|
||||
expect(cleared).not.toHaveProperty("window");
|
||||
|
||||
const stored = await server.prisma.taskSchedule.findUniqueOrThrow({
|
||||
where: { friendlyId: created.id },
|
||||
select: { windowDurationSeconds: true, windowPercentage: true },
|
||||
});
|
||||
expect(stored).toEqual({
|
||||
windowDurationSeconds: null,
|
||||
windowPercentage: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts zero windows and absolute windows longer than the cron interval", async () => {
|
||||
const server = getTestServer();
|
||||
const { apiKey, project, environment } = await seedTestEnvironment(server.prisma);
|
||||
await seedScheduledTask(server.prisma, project.id, environment.id);
|
||||
|
||||
const windows = [
|
||||
["0m", "0m"],
|
||||
["0h", "0m"],
|
||||
["0%", "0%"],
|
||||
["2h", "2h"],
|
||||
] as const;
|
||||
|
||||
for (const [index, [window, expectedWindow]] of windows.entries()) {
|
||||
const response = await server.webapp.fetch("/api/v1/schedules", {
|
||||
method: "POST",
|
||||
headers: authHeaders(apiKey),
|
||||
body: JSON.stringify({
|
||||
task: TASK_IDENTIFIER,
|
||||
cron: "0 * * * *",
|
||||
deduplicationKey: `zero-window-${index}`,
|
||||
window,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toMatchObject({ window: expectedWindow });
|
||||
}
|
||||
});
|
||||
|
||||
it("returns safe errors for invalid windows", async () => {
|
||||
const server = getTestServer();
|
||||
const { apiKey, project, environment } = await seedTestEnvironment(server.prisma);
|
||||
await seedScheduledTask(server.prisma, project.id, environment.id);
|
||||
|
||||
const invalidRequests = [
|
||||
{ window: 30, expectedStatus: 400 },
|
||||
{ window: "30.5%", expectedStatus: 422 },
|
||||
{ window: "1d", expectedStatus: 422 },
|
||||
{ window: "25h", expectedStatus: 422 },
|
||||
];
|
||||
|
||||
for (const [index, { window, expectedStatus }] of invalidRequests.entries()) {
|
||||
const response = await server.webapp.fetch("/api/v1/schedules", {
|
||||
method: "POST",
|
||||
headers: authHeaders(apiKey),
|
||||
body: JSON.stringify({
|
||||
task: TASK_IDENTIFIER,
|
||||
cron: "0 * * * *",
|
||||
deduplicationKey: `invalid-window-${index}`,
|
||||
window,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(expectedStatus);
|
||||
await expect(response.json()).resolves.toHaveProperty("error");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function authHeaders(apiKey: string) {
|
||||
return {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
}
|
||||
|
||||
async function seedScheduledTask(
|
||||
prisma: PrismaClient,
|
||||
projectId: string,
|
||||
runtimeEnvironmentId: string
|
||||
) {
|
||||
const worker = await prisma.backgroundWorker.create({
|
||||
data: {
|
||||
friendlyId: `worker_${runtimeEnvironmentId}`,
|
||||
contentHash: `hash_${runtimeEnvironmentId}`,
|
||||
version: "20260811.1",
|
||||
metadata: {},
|
||||
projectId,
|
||||
runtimeEnvironmentId,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.backgroundWorkerTask.create({
|
||||
data: {
|
||||
friendlyId: `task_${runtimeEnvironmentId}`,
|
||||
slug: TASK_IDENTIFIER,
|
||||
filePath: "src/trigger/scheduled-task.ts",
|
||||
workerId: worker.id,
|
||||
projectId,
|
||||
runtimeEnvironmentId,
|
||||
triggerSource: "SCHEDULED",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -4,8 +4,17 @@ import { describe, expect, vi } from "vitest";
|
||||
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { syncDeclarativeSchedules } from "~/v3/services/createBackgroundWorker.server";
|
||||
|
||||
const { registerNextTaskScheduleInstance } = vi.hoisted(() => ({
|
||||
registerNextTaskScheduleInstance: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("~/v3/scheduleEngine.server", () => ({
|
||||
scheduleEngine: { registerNextTaskScheduleInstance },
|
||||
}));
|
||||
|
||||
vi.setConfig({ testTimeout: 60_000 });
|
||||
|
||||
type TasksArg = Parameters<typeof syncDeclarativeSchedules>[0];
|
||||
type WorkerArg = Parameters<typeof syncDeclarativeSchedules>[1];
|
||||
const noWorker = {} as unknown as WorkerArg;
|
||||
|
||||
@@ -82,6 +91,82 @@ function countingPrisma(prisma: PrismaClient) {
|
||||
const asEnv = (env: { id: string; projectId: string; type: string }) =>
|
||||
env as unknown as AuthenticatedEnvironment;
|
||||
|
||||
function declarativeTasks(schedule: { cron: string; timezone: string; window?: string }): TasksArg {
|
||||
return [{ id: "my-task", schedule }] as TasksArg;
|
||||
}
|
||||
|
||||
async function seedScheduledTask(
|
||||
prisma: PrismaClient,
|
||||
projectId: string,
|
||||
runtimeEnvironmentId: string
|
||||
) {
|
||||
const worker = await prisma.backgroundWorker.create({
|
||||
data: {
|
||||
friendlyId: `worker_${runtimeEnvironmentId}`,
|
||||
contentHash: `hash_${runtimeEnvironmentId}`,
|
||||
version: "20260811.1",
|
||||
metadata: {},
|
||||
projectId,
|
||||
runtimeEnvironmentId,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.backgroundWorkerTask.create({
|
||||
data: {
|
||||
friendlyId: `task_${runtimeEnvironmentId}`,
|
||||
slug: "my-task",
|
||||
filePath: "src/trigger/my-task.ts",
|
||||
workerId: worker.id,
|
||||
projectId,
|
||||
runtimeEnvironmentId,
|
||||
triggerSource: "SCHEDULED",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("syncDeclarativeSchedules registration", () => {
|
||||
containerTest(
|
||||
"preserves an existing Redis job when declarative timing is unchanged",
|
||||
async ({ prisma }) => {
|
||||
registerNextTaskScheduleInstance.mockClear();
|
||||
const { project, prodEnv } = await seedProjectWithEnvs(prisma);
|
||||
const schedule = await makeDeclarativeSchedule(prisma, project.id, [prodEnv.id]);
|
||||
await seedScheduledTask(prisma, project.id, prodEnv.id);
|
||||
|
||||
await syncDeclarativeSchedules(
|
||||
declarativeTasks({ cron: "0 * * * *", timezone: "UTC" }),
|
||||
noWorker,
|
||||
asEnv(prodEnv),
|
||||
prisma
|
||||
);
|
||||
|
||||
expect(registerNextTaskScheduleInstance).toHaveBeenCalledWith({
|
||||
instanceId: schedule.instances[0].id,
|
||||
preserveExistingJob: true,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
containerTest("replaces the Redis job when declarative timing changes", async ({ prisma }) => {
|
||||
registerNextTaskScheduleInstance.mockClear();
|
||||
const { project, prodEnv } = await seedProjectWithEnvs(prisma);
|
||||
const schedule = await makeDeclarativeSchedule(prisma, project.id, [prodEnv.id]);
|
||||
await seedScheduledTask(prisma, project.id, prodEnv.id);
|
||||
|
||||
await syncDeclarativeSchedules(
|
||||
declarativeTasks({ cron: "30 * * * *", timezone: "UTC", window: "30m" }),
|
||||
noWorker,
|
||||
asEnv(prodEnv),
|
||||
prisma
|
||||
);
|
||||
|
||||
expect(registerNextTaskScheduleInstance).toHaveBeenCalledWith({
|
||||
instanceId: schedule.instances[0].id,
|
||||
preserveExistingJob: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("syncDeclarativeSchedules deletion path", () => {
|
||||
containerTest(
|
||||
"does not issue any instance delete when the env owns no instance of the missing schedules",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE trigger_dev.task_runs_v2
|
||||
ADD COLUMN IF NOT EXISTS queue_timestamp Nullable(DateTime64(3)) AFTER created_at;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE trigger_dev.task_runs_v2
|
||||
DROP COLUMN IF EXISTS queue_timestamp;
|
||||
@@ -29,6 +29,7 @@ describe("Task Runs V2", () => {
|
||||
});
|
||||
|
||||
const now = Date.now();
|
||||
const queueTimestamp = now + 30_000;
|
||||
const taskRunData: TaskRunInsertArray = [
|
||||
"env_1234", // environment_id
|
||||
"org_1234", // organization_id
|
||||
@@ -36,6 +37,7 @@ describe("Task Runs V2", () => {
|
||||
"run_1234", // run_id
|
||||
now, // updated_at
|
||||
now, // created_at
|
||||
queueTimestamp, // queue_timestamp
|
||||
"PENDING", // status
|
||||
"DEVELOPMENT", // environment_type
|
||||
"friendly_1234", // friendly_id
|
||||
@@ -105,6 +107,7 @@ describe("Task Runs V2", () => {
|
||||
schema: z.object({
|
||||
environment_id: z.string(),
|
||||
run_id: z.string(),
|
||||
queue_timestamp: z.coerce.date().nullable(),
|
||||
concurrency_key: z.string(),
|
||||
bulk_action_group_ids: z.array(z.string()),
|
||||
}),
|
||||
@@ -121,6 +124,7 @@ describe("Task Runs V2", () => {
|
||||
expect.objectContaining({
|
||||
environment_id: "env_1234",
|
||||
run_id: "run_1234",
|
||||
queue_timestamp: new Date(queueTimestamp),
|
||||
concurrency_key: "concurrency_key_1234",
|
||||
bulk_action_group_ids: ["bulk_action_group_id_1234", "bulk_action_group_id_1235"],
|
||||
}),
|
||||
@@ -183,6 +187,7 @@ describe("Task Runs V2", () => {
|
||||
"run_mixed", // run_id
|
||||
now, // updated_at
|
||||
now, // created_at
|
||||
null, // queue_timestamp
|
||||
"COMPLETED_SUCCESSFULLY", // status
|
||||
"DEVELOPMENT", // environment_type
|
||||
"friendly_mixed", // friendly_id
|
||||
@@ -282,6 +287,7 @@ describe("Task Runs V2", () => {
|
||||
"cma45oli70002qrdy47w0j4n7", // run_id
|
||||
createdAt, // updated_at
|
||||
createdAt, // created_at
|
||||
null, // queue_timestamp
|
||||
"PENDING", // status
|
||||
"PRODUCTION", // environment_type
|
||||
"run_cma45oli70002qrdy47w0j4n7", // friendly_id
|
||||
@@ -339,6 +345,7 @@ describe("Task Runs V2", () => {
|
||||
"cma45oli70002qrdy47w0j4n7", // run_id
|
||||
createdAt, // updated_at
|
||||
createdAt, // created_at
|
||||
null, // queue_timestamp
|
||||
"COMPLETED_SUCCESSFULLY", // status
|
||||
"PRODUCTION", // environment_type
|
||||
"run_cma45oli70002qrdy47w0j4n7", // friendly_id
|
||||
@@ -443,6 +450,7 @@ describe("Task Runs V2", () => {
|
||||
"cma45oli70002qrdy47w0j4n7", // run_id
|
||||
createdAt, // updated_at
|
||||
createdAt, // created_at
|
||||
null, // queue_timestamp
|
||||
"PENDING", // status
|
||||
"PRODUCTION", // environment_type
|
||||
"run_cma45oli70002qrdy47w0j4n7", // friendly_id
|
||||
@@ -555,6 +563,7 @@ describe("Task Runs V2", () => {
|
||||
"root_run_1", // run_id
|
||||
baseCreatedAt, // updated_at
|
||||
baseCreatedAt, // created_at
|
||||
null, // queue_timestamp
|
||||
"EXECUTING", // status
|
||||
"DEVELOPMENT", // environment_type
|
||||
"run_root_1", // friendly_id
|
||||
@@ -612,6 +621,7 @@ describe("Task Runs V2", () => {
|
||||
"child_a",
|
||||
baseCreatedAt + 1_000,
|
||||
baseCreatedAt + 1_000,
|
||||
null, // queue_timestamp
|
||||
"PENDING",
|
||||
"DEVELOPMENT",
|
||||
"run_child_a",
|
||||
@@ -673,6 +683,7 @@ describe("Task Runs V2", () => {
|
||||
"child_b",
|
||||
baseCreatedAt + 2_000,
|
||||
baseCreatedAt + 2_000,
|
||||
null, // queue_timestamp
|
||||
"EXECUTING",
|
||||
"DEVELOPMENT",
|
||||
"run_child_b",
|
||||
@@ -730,6 +741,7 @@ describe("Task Runs V2", () => {
|
||||
"child_deleted",
|
||||
baseCreatedAt + 3_000,
|
||||
baseCreatedAt + 3_000,
|
||||
null, // queue_timestamp
|
||||
"PENDING",
|
||||
"DEVELOPMENT",
|
||||
"run_child_deleted",
|
||||
@@ -907,6 +919,7 @@ describe("Task Runs V2", () => {
|
||||
"cma45oli70002qrdy47w0j4n7",
|
||||
createdAt,
|
||||
createdAt,
|
||||
null, // queue_timestamp
|
||||
"PENDING",
|
||||
"PRODUCTION",
|
||||
"run_cma45oli70002qrdy47w0j4n7",
|
||||
@@ -1010,6 +1023,7 @@ describe("Task Runs V2", () => {
|
||||
"cma45oli70002qrdy47w0j4n7",
|
||||
createdAt,
|
||||
createdAt,
|
||||
null, // queue_timestamp
|
||||
"PENDING",
|
||||
"PRODUCTION",
|
||||
"run_cma45oli70002qrdy47w0j4n7",
|
||||
@@ -1113,6 +1127,7 @@ describe("Task Runs V2", () => {
|
||||
"cma45oli70002qrdy47w0j4n7",
|
||||
createdAt,
|
||||
createdAt,
|
||||
null, // queue_timestamp
|
||||
"PENDING",
|
||||
"PRODUCTION",
|
||||
"run_cma45oli70002qrdy47w0j4n7",
|
||||
|
||||
@@ -9,6 +9,7 @@ export const TaskRunV2 = z.object({
|
||||
run_id: z.string(),
|
||||
updated_at: z.number().int(),
|
||||
created_at: z.number().int(),
|
||||
queue_timestamp: z.number().int().nullish(),
|
||||
status: z.string(),
|
||||
environment_type: z.string(),
|
||||
friendly_id: z.string(),
|
||||
@@ -69,6 +70,7 @@ export const TASK_RUN_COLUMNS = [
|
||||
"run_id",
|
||||
"updated_at",
|
||||
"created_at",
|
||||
"queue_timestamp",
|
||||
"status",
|
||||
"environment_type",
|
||||
"friendly_id",
|
||||
@@ -138,6 +140,7 @@ export type TaskRunFieldTypes = {
|
||||
run_id: string;
|
||||
updated_at: number;
|
||||
created_at: number;
|
||||
queue_timestamp: number | null;
|
||||
status: string;
|
||||
environment_type: string;
|
||||
friendly_id: string;
|
||||
@@ -306,6 +309,7 @@ export type TaskRunInsertArray = [
|
||||
run_id: string,
|
||||
updated_at: number,
|
||||
created_at: number,
|
||||
queue_timestamp: number | null,
|
||||
status: string,
|
||||
environment_type: string,
|
||||
friendly_id: string,
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."TaskSchedule"
|
||||
ADD COLUMN "windowDurationSeconds" INTEGER,
|
||||
ADD COLUMN "windowPercentage" INTEGER;
|
||||
|
||||
ALTER TABLE "public"."TaskSchedule"
|
||||
ADD CONSTRAINT "TaskSchedule_window_exclusive"
|
||||
CHECK (
|
||||
"windowDurationSeconds" IS NULL
|
||||
OR "windowPercentage" IS NULL
|
||||
) NOT VALID;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."TaskScheduleInstance"
|
||||
ADD COLUMN "schedulePhase" INTEGER;
|
||||
@@ -2270,6 +2270,10 @@ model TaskSchedule {
|
||||
/// These are IANA format string, or the default "UTC". E.g. "America/New_York"
|
||||
timezone String @default("UTC")
|
||||
|
||||
// Cron spread
|
||||
windowDurationSeconds Int?
|
||||
windowPercentage Int?
|
||||
|
||||
///Can be provided by the user then accessed inside a run
|
||||
externalId String?
|
||||
|
||||
@@ -2317,6 +2321,9 @@ model TaskScheduleInstance {
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||
projectId String
|
||||
|
||||
// Durable cron spread phase
|
||||
schedulePhase Int?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
||||
@@ -156,6 +156,10 @@ export class DequeueSystem {
|
||||
|
||||
const orgId = message.message.orgId;
|
||||
const runId = message.messageId;
|
||||
const queueWaitMs =
|
||||
typeof message.message.eligibleAtMs === "number"
|
||||
? Math.max(0, Date.now() - message.message.eligibleAtMs)
|
||||
: undefined;
|
||||
|
||||
this.$.logger.info("DequeueSystem.dequeueFromWorkerQueue dequeued message", {
|
||||
runId,
|
||||
@@ -174,6 +178,9 @@ export class DequeueSystem {
|
||||
span.setAttribute("consumer_id", consumerId);
|
||||
span.setAttribute("worker_queue", workerQueue);
|
||||
span.setAttribute("blocking_pop", blockingPop ?? true);
|
||||
if (queueWaitMs !== undefined) {
|
||||
span.setAttribute("queue_wait_ms", queueWaitMs);
|
||||
}
|
||||
|
||||
//lock the run so nothing else can modify it
|
||||
try {
|
||||
|
||||
@@ -2133,8 +2133,10 @@ export class RunQueue {
|
||||
const messageId = message.runId;
|
||||
const messageData = JSON.stringify(message);
|
||||
const messageScore = String(message.timestamp);
|
||||
const currentTime = String(Date.now());
|
||||
const enableFastPathArg = enableFastPath ? "1" : "0";
|
||||
const currentTimeMs = Date.now();
|
||||
const shouldEnableFastPath = enableFastPath && message.timestamp <= currentTimeMs;
|
||||
const currentTime = String(currentTimeMs);
|
||||
const enableFastPathArg = shouldEnableFastPath ? "1" : "0";
|
||||
const metricsGaugeArg = this.#queueMetricsGaugeArg();
|
||||
const defaultEnvConcurrencyLimit = String(this.options.defaultEnvConcurrency);
|
||||
const defaultEnvConcurrencyBurstFactor = String(
|
||||
@@ -2155,6 +2157,7 @@ export class RunQueue {
|
||||
messageScore,
|
||||
masterQueueKey,
|
||||
enableFastPath,
|
||||
shouldEnableFastPath,
|
||||
ttlInfo,
|
||||
service: this.name,
|
||||
});
|
||||
|
||||
@@ -179,6 +179,53 @@ describe("RunQueue.enqueueMessage fast path", () => {
|
||||
}
|
||||
);
|
||||
|
||||
redisTest("should not fast-path a future-scored message", async ({ redisContainer }) => {
|
||||
const queue = createQueue(redisContainer, "runqueue:fp-future-score:");
|
||||
|
||||
try {
|
||||
await queue.updateEnvConcurrencyLimits(authenticatedEnvDev);
|
||||
|
||||
const futureMessage: InputPayload = {
|
||||
...messageDev,
|
||||
runId: "r_future_score",
|
||||
timestamp: Date.now() + 60_000,
|
||||
};
|
||||
|
||||
await queue.enqueueMessage({
|
||||
env: authenticatedEnvDev,
|
||||
message: futureMessage,
|
||||
workerQueue: authenticatedEnvDev.id,
|
||||
enableFastPath: true,
|
||||
});
|
||||
|
||||
const queueLength = await queue.lengthOfQueue(authenticatedEnvDev, futureMessage.queue);
|
||||
const queueConcurrency = await queue.currentConcurrencyOfQueue(
|
||||
authenticatedEnvDev,
|
||||
futureMessage.queue
|
||||
);
|
||||
const dequeued = await queue.dequeueMessageFromWorkerQueue(
|
||||
"test_12345",
|
||||
authenticatedEnvDev.id,
|
||||
{ blockingPop: false }
|
||||
);
|
||||
|
||||
expect({
|
||||
// A future-scored message must remain in the sorted set until it is eligible.
|
||||
queueLength,
|
||||
// It must not claim concurrency before it becomes eligible.
|
||||
queueConcurrency,
|
||||
// It must not be visible to a worker before its timestamp.
|
||||
dequeuedMessageId: dequeued?.messageId,
|
||||
}).toEqual({
|
||||
queueLength: 1,
|
||||
queueConcurrency: 0,
|
||||
dequeuedMessageId: undefined,
|
||||
});
|
||||
} finally {
|
||||
await queue.quit();
|
||||
}
|
||||
});
|
||||
|
||||
redisTest("should take slow path when enableFastPath is false", async ({ redisContainer }) => {
|
||||
const queue = createQueue(redisContainer, "runqueue:fp2:");
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
/**
|
||||
* Calculates a distributed execution time for a scheduled task.
|
||||
* Tasks are distributed across a time window before the exact schedule time
|
||||
* Tasks are distributed across a time window before their target time
|
||||
* to prevent thundering herd issues while maintaining schedule accuracy.
|
||||
*/
|
||||
export function calculateDistributedExecutionTime(
|
||||
exactScheduleTime: Date,
|
||||
targetTime: Date,
|
||||
distributionWindowSeconds: number = 30,
|
||||
instanceId?: string
|
||||
): Date {
|
||||
// Create seed by combining ISO timestamp with optional instanceId
|
||||
// This ensures different instances get different distributions even with same schedule time
|
||||
const timeSeed = exactScheduleTime.toISOString();
|
||||
const timeSeed = targetTime.toISOString();
|
||||
const seed = instanceId ? `${timeSeed}:${instanceId}` : timeSeed;
|
||||
|
||||
// Use a better hash function (FNV-1a variant) for more uniform distribution
|
||||
@@ -30,6 +30,6 @@ export function calculateDistributedExecutionTime(
|
||||
// Calculate offset in milliseconds (0 to distributionWindowSeconds * 1000)
|
||||
const offsetMs = Math.floor(normalized * distributionWindowSeconds * 1000);
|
||||
|
||||
// Return time that's offsetMs before the exact schedule time
|
||||
return new Date(exactScheduleTime.getTime() - offsetMs);
|
||||
// Return time that's offsetMs before the target time
|
||||
return new Date(targetTime.getTime() - offsetMs);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { PrismaClient } from "@trigger.dev/database";
|
||||
import { Worker, type JobHandlerParams } from "@trigger.dev/redis-worker";
|
||||
import { calculateDistributedExecutionTime } from "./distributedScheduling.js";
|
||||
import {
|
||||
calculateNextScheduledTimestamp,
|
||||
calculateNextSchedulableOccurrence,
|
||||
nextScheduledTimestamps,
|
||||
previousScheduledTimestamp,
|
||||
} from "./scheduleCalculation.js";
|
||||
@@ -15,6 +15,11 @@ import type {
|
||||
TriggerScheduledTaskCallback,
|
||||
TriggerScheduleParams,
|
||||
} from "./types.js";
|
||||
import {
|
||||
calculateSchedulePhase,
|
||||
SCHEDULE_PHASE_DENOMINATOR,
|
||||
type NormalizedScheduleWindow,
|
||||
} from "./scheduleTiming.js";
|
||||
import { scheduleWorkerCatalog } from "./workerCatalog.js";
|
||||
import { tryCatch } from "@trigger.dev/core/utils";
|
||||
|
||||
@@ -31,6 +36,8 @@ export class ScheduleEngine {
|
||||
private scheduleExecutionDuration: Histogram;
|
||||
private scheduleExecutionFailureCounter: Counter;
|
||||
private distributionOffsetHistogram: Histogram;
|
||||
private scheduleWindowCappedCounter: Counter;
|
||||
private schedulePhasePersistedCounter: Counter;
|
||||
private devEnvironmentCheckCounter: Counter;
|
||||
|
||||
prisma: PrismaClient;
|
||||
@@ -71,11 +78,22 @@ export class ScheduleEngine {
|
||||
this.distributionOffsetHistogram = this.meter.createHistogram(
|
||||
"schedule_distribution_offset_ms",
|
||||
{
|
||||
description: "Distribution offset from exact schedule time in milliseconds",
|
||||
description: "Distribution offset from effective schedule time in milliseconds",
|
||||
unit: "ms",
|
||||
}
|
||||
);
|
||||
|
||||
this.scheduleWindowCappedCounter = this.meter.createCounter("schedule_windows_capped_total", {
|
||||
description: "Total number of absolute schedule windows capped at the next nominal interval",
|
||||
});
|
||||
|
||||
this.schedulePhasePersistedCounter = this.meter.createCounter(
|
||||
"schedule_phase_persisted_total",
|
||||
{
|
||||
description: "Total number of schedule phases persisted during registration",
|
||||
}
|
||||
);
|
||||
|
||||
this.devEnvironmentCheckCounter = this.meter.createCounter("dev_environment_checks_total", {
|
||||
description: "Total number of development environment connectivity checks",
|
||||
});
|
||||
@@ -168,24 +186,113 @@ export class ScheduleEngine {
|
||||
instance.taskSchedule.generatorExpression
|
||||
);
|
||||
|
||||
const fromTimestamp = params.fromTimestamp ?? new Date();
|
||||
const scheduleWindow = normalizedScheduleWindow(instance.taskSchedule);
|
||||
const schedulePhase =
|
||||
instance.schedulePhase ??
|
||||
calculateSchedulePhase({
|
||||
secret: this.options.schedulePhaseSecret,
|
||||
environmentId: instance.environmentId,
|
||||
deduplicationKey: instance.taskSchedule.deduplicationKey,
|
||||
});
|
||||
|
||||
const cronSpreadActive = this.#isCronSpreadActive(schedulePhase);
|
||||
|
||||
let persisted = false;
|
||||
if (cronSpreadActive && instance.schedulePhase === null) {
|
||||
await this.prisma.taskScheduleInstance.updateMany({
|
||||
where: {
|
||||
id: instance.id,
|
||||
schedulePhase: null,
|
||||
},
|
||||
data: {
|
||||
schedulePhase,
|
||||
},
|
||||
});
|
||||
persisted = true;
|
||||
this.schedulePhasePersistedCounter.add(1, {
|
||||
environment_type: instance.environment.type,
|
||||
schedule_type: instance.taskSchedule.type,
|
||||
});
|
||||
}
|
||||
|
||||
span.setAttribute(
|
||||
"schedule_phase_source",
|
||||
instance.schedulePhase !== null ? "db" : persisted ? "persisted" : "ephemeral"
|
||||
);
|
||||
span.setAttribute("schedule_phase", schedulePhase);
|
||||
|
||||
const registrationTime = new Date();
|
||||
const fromTimestamp = params.fromTimestamp ?? registrationTime;
|
||||
span.setAttribute("from_timestamp", fromTimestamp.toISOString());
|
||||
|
||||
const nextScheduledTimestamp = calculateNextScheduledTimestamp(
|
||||
instance.taskSchedule.generatorExpression,
|
||||
instance.taskSchedule.timezone,
|
||||
fromTimestamp
|
||||
);
|
||||
const {
|
||||
nominalAt,
|
||||
candidateEffectiveAt,
|
||||
effectiveAt,
|
||||
effectiveRangeMs,
|
||||
windowMs,
|
||||
offsetMs: candidateDelayMs,
|
||||
intervalMs,
|
||||
windowWasCappedToInterval,
|
||||
skippedExpiredOccurrences,
|
||||
} = calculateNextSchedulableOccurrence({
|
||||
schedule: instance.taskSchedule.generatorExpression,
|
||||
timezone: instance.taskSchedule.timezone,
|
||||
afterNominal: fromTimestamp,
|
||||
now: registrationTime,
|
||||
schedulePhase,
|
||||
window: scheduleWindow,
|
||||
cronSpreadEnabled: cronSpreadActive,
|
||||
});
|
||||
const appliedDelayMs = effectiveAt.getTime() - nominalAt.getTime();
|
||||
|
||||
span.setAttribute("next_scheduled_timestamp", nextScheduledTimestamp.toISOString());
|
||||
span.setAttribute("cron_spread_fraction", this.options.cronSpreadFraction);
|
||||
span.setAttribute("cron_spread_active", cronSpreadActive);
|
||||
span.setAttribute("schedule_window_type", scheduleWindow?.type ?? "none");
|
||||
span.setAttribute("next_scheduled_timestamp", nominalAt.toISOString());
|
||||
span.setAttribute("candidate_effective_schedule_time", candidateEffectiveAt.toISOString());
|
||||
span.setAttribute("effective_schedule_time", effectiveAt.toISOString());
|
||||
span.setAttribute("candidate_delay_ms", candidateDelayMs);
|
||||
span.setAttribute("applied_delay_ms", appliedDelayMs);
|
||||
span.setAttribute("schedule_window_ms", windowMs);
|
||||
span.setAttribute("effective_range_ms", effectiveRangeMs);
|
||||
span.setAttribute("schedule_window_was_capped_to_interval", windowWasCappedToInterval);
|
||||
span.setAttribute("schedule_expired_occurrences_skipped", skippedExpiredOccurrences);
|
||||
|
||||
const schedulingDelayMs = nextScheduledTimestamp.getTime() - Date.now();
|
||||
if (skippedExpiredOccurrences) {
|
||||
span.addEvent("schedule_expired_occurrences_skipped", {
|
||||
from_nominal_time: fromTimestamp.toISOString(),
|
||||
selected_nominal_time: nominalAt.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
if (windowWasCappedToInterval) {
|
||||
span.addEvent("schedule_window_capped_to_interval", {
|
||||
requested_window_ms: windowMs,
|
||||
nominal_interval_ms: intervalMs,
|
||||
});
|
||||
this.scheduleWindowCappedCounter.add(1, {
|
||||
environment_type: instance.environment.type,
|
||||
schedule_type: instance.taskSchedule.type,
|
||||
});
|
||||
}
|
||||
|
||||
const schedulingDelayMs = effectiveAt.getTime() - registrationTime.getTime();
|
||||
span.setAttribute("scheduling_delay_ms", schedulingDelayMs);
|
||||
|
||||
this.logger.debug("Calculated next schedule timestamp", {
|
||||
this.logger.debug("Calculated next schedule timestamps", {
|
||||
instanceId: params.instanceId,
|
||||
taskIdentifier: instance.taskSchedule.taskIdentifier,
|
||||
nextScheduledTimestamp: nextScheduledTimestamp.toISOString(),
|
||||
nominalAt: nominalAt.toISOString(),
|
||||
candidateEffectiveAt: candidateEffectiveAt.toISOString(),
|
||||
effectiveAt: effectiveAt.toISOString(),
|
||||
cronSpreadActive,
|
||||
scheduleWindowType: scheduleWindow?.type ?? "none",
|
||||
candidateDelayMs,
|
||||
appliedDelayMs,
|
||||
effectiveRangeMs,
|
||||
windowWasCappedToInterval,
|
||||
skippedExpiredOccurrences,
|
||||
schedulingDelayMs,
|
||||
generatorExpression: instance.taskSchedule.generatorExpression,
|
||||
timezone: instance.taskSchedule.timezone,
|
||||
@@ -224,11 +331,13 @@ export class ScheduleEngine {
|
||||
}
|
||||
}
|
||||
|
||||
await this.enqueueScheduledTask(
|
||||
params.instanceId,
|
||||
nextScheduledTimestamp,
|
||||
lastScheduleTime
|
||||
);
|
||||
await this.enqueueScheduledTask({
|
||||
instanceId: params.instanceId,
|
||||
exactScheduleTime: nominalAt,
|
||||
effectiveScheduleTime: effectiveAt,
|
||||
lastScheduleTime,
|
||||
preserveExistingJob: params.preserveExistingJob,
|
||||
});
|
||||
|
||||
// Record metrics
|
||||
this.scheduleRegistrationCounter.add(1, {
|
||||
@@ -268,6 +377,7 @@ export class ScheduleEngine {
|
||||
instanceId: payload.instanceId,
|
||||
finalAttempt: false, // TODO: implement retry logic
|
||||
exactScheduleTime: payload.exactScheduleTime,
|
||||
effectiveScheduleTime: payload.effectiveScheduleTime,
|
||||
lastScheduleTime: payload.lastScheduleTime,
|
||||
});
|
||||
}
|
||||
@@ -281,14 +391,17 @@ export class ScheduleEngine {
|
||||
|
||||
span.setAttribute("instanceId", params.instanceId);
|
||||
span.setAttribute("finalAttempt", params.finalAttempt);
|
||||
if (params.exactScheduleTime) {
|
||||
span.setAttribute("exactScheduleTime", params.exactScheduleTime.toISOString());
|
||||
}
|
||||
const exactScheduleTime = params.exactScheduleTime ?? new Date();
|
||||
const effectiveScheduleTime = params.effectiveScheduleTime ?? exactScheduleTime;
|
||||
|
||||
span.setAttribute("exactScheduleTime", exactScheduleTime.toISOString());
|
||||
span.setAttribute("effectiveScheduleTime", effectiveScheduleTime.toISOString());
|
||||
|
||||
this.logger.debug("Starting scheduled task trigger", {
|
||||
instanceId: params.instanceId,
|
||||
finalAttempt: params.finalAttempt,
|
||||
exactScheduleTime: params.exactScheduleTime?.toISOString(),
|
||||
exactScheduleTime: exactScheduleTime.toISOString(),
|
||||
effectiveScheduleTime: effectiveScheduleTime.toISOString(),
|
||||
});
|
||||
|
||||
let taskIdentifier: string | undefined;
|
||||
@@ -412,9 +525,6 @@ export class ScheduleEngine {
|
||||
span.setAttribute("skip_reason", skipReason);
|
||||
}
|
||||
|
||||
// Calculate the schedule timestamp that will be used (regardless of whether we trigger or not)
|
||||
const scheduleTimestamp = params.exactScheduleTime ?? new Date();
|
||||
|
||||
if (shouldTrigger) {
|
||||
// payload.lastTimestamp is the actual previous fire time. Sources, in
|
||||
// order:
|
||||
@@ -427,25 +537,48 @@ export class ScheduleEngine {
|
||||
// 3. undefined — first-ever fire (no previous fire to point at).
|
||||
const lastTimestamp =
|
||||
params.lastScheduleTime ?? instance.lastScheduledTimestamp ?? undefined;
|
||||
const actualExecutionTime = new Date();
|
||||
const scheduleWindow = normalizedScheduleWindow(instance.taskSchedule);
|
||||
const schedulePhase =
|
||||
instance.schedulePhase ??
|
||||
calculateSchedulePhase({
|
||||
secret: this.options.schedulePhaseSecret,
|
||||
environmentId: instance.environmentId,
|
||||
deduplicationKey: instance.taskSchedule.deduplicationKey,
|
||||
});
|
||||
const cronSpreadActive = this.#isCronSpreadActive(schedulePhase);
|
||||
span.setAttribute("cron_spread_active", cronSpreadActive);
|
||||
const nextOccurrence = calculateNextSchedulableOccurrence({
|
||||
schedule: instance.taskSchedule.generatorExpression,
|
||||
timezone: instance.taskSchedule.timezone,
|
||||
afterNominal: exactScheduleTime,
|
||||
now: actualExecutionTime,
|
||||
schedulePhase,
|
||||
window: scheduleWindow,
|
||||
cronSpreadEnabled: cronSpreadActive,
|
||||
});
|
||||
const upcoming = [
|
||||
nextOccurrence.nominalAt,
|
||||
...nextScheduledTimestamps(
|
||||
instance.taskSchedule.generatorExpression,
|
||||
instance.taskSchedule.timezone,
|
||||
nextOccurrence.nominalAt,
|
||||
9
|
||||
),
|
||||
];
|
||||
|
||||
const payload = {
|
||||
scheduleId: instance.taskSchedule.friendlyId,
|
||||
type: instance.taskSchedule.type as "DECLARATIVE" | "IMPERATIVE",
|
||||
timestamp: scheduleTimestamp,
|
||||
timestamp: exactScheduleTime,
|
||||
lastTimestamp,
|
||||
externalId: instance.taskSchedule.externalId ?? undefined,
|
||||
timezone: instance.taskSchedule.timezone,
|
||||
upcoming: nextScheduledTimestamps(
|
||||
instance.taskSchedule.generatorExpression,
|
||||
instance.taskSchedule.timezone,
|
||||
scheduleTimestamp,
|
||||
10
|
||||
),
|
||||
upcoming,
|
||||
};
|
||||
|
||||
// Calculate execution timing metrics
|
||||
const actualExecutionTime = new Date();
|
||||
const schedulingAccuracyMs = actualExecutionTime.getTime() - scheduleTimestamp.getTime();
|
||||
const schedulingAccuracyMs = actualExecutionTime.getTime() - exactScheduleTime.getTime();
|
||||
|
||||
span.setAttribute("scheduling_accuracy_ms", schedulingAccuracyMs);
|
||||
span.setAttribute("actual_execution_time", actualExecutionTime.toISOString());
|
||||
@@ -453,7 +586,8 @@ export class ScheduleEngine {
|
||||
this.logger.debug("Triggering scheduled task", {
|
||||
instanceId: params.instanceId,
|
||||
taskIdentifier: instance.taskSchedule.taskIdentifier,
|
||||
scheduleTimestamp: scheduleTimestamp.toISOString(),
|
||||
exactScheduleTime: exactScheduleTime.toISOString(),
|
||||
effectiveScheduleTime: effectiveScheduleTime.toISOString(),
|
||||
actualExecutionTime: actualExecutionTime.toISOString(),
|
||||
schedulingAccuracyMs,
|
||||
lastTimestamp: lastTimestamp?.toISOString(),
|
||||
@@ -469,7 +603,8 @@ export class ScheduleEngine {
|
||||
payload,
|
||||
scheduleInstanceId: instance.id,
|
||||
scheduleId: instance.taskSchedule.id,
|
||||
exactScheduleTime: scheduleTimestamp,
|
||||
exactScheduleTime,
|
||||
effectiveScheduleTime,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -573,21 +708,22 @@ export class ScheduleEngine {
|
||||
});
|
||||
}
|
||||
|
||||
// Register the next run. `fromTimestamp` advances on every tick so
|
||||
// the next cron slot keeps marching forward even through skips.
|
||||
// Register the next run. `fromTimestamp` anchors nominal chaining;
|
||||
// registration preserves an upcoming effective occurrence and skips
|
||||
// expired intermediate ticks after downtime.
|
||||
// `lastScheduleTime` is the actual previous fire time the next job
|
||||
// will report as `payload.lastTimestamp` — only advance it when we
|
||||
// actually triggered, otherwise carry forward the existing value so
|
||||
// a long pause/disconnect doesn't quietly overwrite the real
|
||||
// last-fire timestamp with a series of skipped slots.
|
||||
const carriedLastScheduleTime = shouldTrigger
|
||||
? scheduleTimestamp
|
||||
? exactScheduleTime
|
||||
: (params.lastScheduleTime ?? instance.lastScheduledTimestamp ?? undefined);
|
||||
|
||||
const [nextRunError] = await tryCatch(
|
||||
this.registerNextTaskScheduleInstance({
|
||||
instanceId: params.instanceId,
|
||||
fromTimestamp: scheduleTimestamp,
|
||||
fromTimestamp: exactScheduleTime,
|
||||
lastScheduleTime: carriedLastScheduleTime,
|
||||
})
|
||||
);
|
||||
@@ -641,28 +777,49 @@ export class ScheduleEngine {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-schedule rollout gate for cron spread. The schedule's deterministic
|
||||
* phase doubles as a stable sampling key: raising the fraction is strictly
|
||||
* additive (a schedule never leaves the rollout once included), and 0/1 map
|
||||
* to fully off/on.
|
||||
*/
|
||||
#isCronSpreadActive(schedulePhase: number): boolean {
|
||||
return schedulePhase < this.options.cronSpreadFraction * SCHEDULE_PHASE_DENOMINATOR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueues a scheduled task with distributed execution timing
|
||||
*/
|
||||
private async enqueueScheduledTask(
|
||||
instanceId: string,
|
||||
exactScheduleTime: Date,
|
||||
lastScheduleTime?: Date
|
||||
) {
|
||||
private async enqueueScheduledTask({
|
||||
instanceId,
|
||||
exactScheduleTime,
|
||||
effectiveScheduleTime,
|
||||
lastScheduleTime,
|
||||
preserveExistingJob = false,
|
||||
}: {
|
||||
instanceId: string;
|
||||
exactScheduleTime: Date;
|
||||
effectiveScheduleTime: Date;
|
||||
lastScheduleTime?: Date;
|
||||
preserveExistingJob?: boolean;
|
||||
}) {
|
||||
return startSpan(this.tracer, "enqueueScheduledTask", async (span) => {
|
||||
span.setAttribute("instanceId", instanceId);
|
||||
span.setAttribute("exactScheduleTime", exactScheduleTime.toISOString());
|
||||
span.setAttribute("effectiveScheduleTime", effectiveScheduleTime.toISOString());
|
||||
span.setAttribute("preserveExistingJob", preserveExistingJob);
|
||||
if (lastScheduleTime) {
|
||||
span.setAttribute("lastScheduleTime", lastScheduleTime.toISOString());
|
||||
}
|
||||
|
||||
const distributedExecutionTime = calculateDistributedExecutionTime(
|
||||
exactScheduleTime,
|
||||
effectiveScheduleTime,
|
||||
this.distributionWindowSeconds,
|
||||
instanceId
|
||||
);
|
||||
|
||||
const distributionOffsetMs = exactScheduleTime.getTime() - distributedExecutionTime.getTime();
|
||||
const distributionOffsetMs =
|
||||
effectiveScheduleTime.getTime() - distributedExecutionTime.getTime();
|
||||
|
||||
span.setAttribute("distributedExecutionTime", distributedExecutionTime.toISOString());
|
||||
span.setAttribute("distributionOffsetMs", distributionOffsetMs);
|
||||
@@ -675,29 +832,42 @@ export class ScheduleEngine {
|
||||
this.logger.debug("Enqueuing scheduled task with distributed execution", {
|
||||
instanceId,
|
||||
exactScheduleTime: exactScheduleTime.toISOString(),
|
||||
effectiveScheduleTime: effectiveScheduleTime.toISOString(),
|
||||
distributedExecutionTime: distributedExecutionTime.toISOString(),
|
||||
distributionOffsetMs,
|
||||
distributionWindowSeconds: this.distributionWindowSeconds,
|
||||
preserveExistingJob,
|
||||
});
|
||||
|
||||
try {
|
||||
await this.worker.enqueue({
|
||||
const job = {
|
||||
id: `scheduled-task-instance:${instanceId}`,
|
||||
job: "schedule.triggerScheduledTask",
|
||||
job: "schedule.triggerScheduledTask" as const,
|
||||
payload: {
|
||||
instanceId,
|
||||
exactScheduleTime,
|
||||
effectiveScheduleTime,
|
||||
lastScheduleTime,
|
||||
},
|
||||
availableAt: distributedExecutionTime,
|
||||
});
|
||||
};
|
||||
let enqueued = true;
|
||||
if (preserveExistingJob) {
|
||||
enqueued = await this.worker.enqueueOnce(job);
|
||||
} else {
|
||||
await this.worker.enqueue(job);
|
||||
}
|
||||
|
||||
span.setAttribute("enqueue_success", true);
|
||||
span.setAttribute("existing_job_preserved", !enqueued);
|
||||
|
||||
this.logger.debug("Successfully enqueued scheduled task", {
|
||||
instanceId,
|
||||
jobId: `scheduled-task-instance:${instanceId}`,
|
||||
});
|
||||
this.logger.debug(
|
||||
enqueued ? "Successfully enqueued scheduled task" : "Preserved existing scheduled task",
|
||||
{
|
||||
instanceId,
|
||||
jobId: job.id,
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to enqueue scheduled task", {
|
||||
instanceId,
|
||||
@@ -865,3 +1035,21 @@ export class ScheduleEngine {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizedScheduleWindow({
|
||||
windowDurationSeconds,
|
||||
windowPercentage,
|
||||
}: {
|
||||
windowDurationSeconds: number | null;
|
||||
windowPercentage: number | null;
|
||||
}): NormalizedScheduleWindow | undefined {
|
||||
if (windowPercentage !== null) {
|
||||
return { type: "percentage", percentage: windowPercentage };
|
||||
}
|
||||
|
||||
if (windowDurationSeconds !== null) {
|
||||
return { type: "duration", durationSeconds: windowDurationSeconds };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
calculateNextNominalTimestamp,
|
||||
calculateNextSchedulableOccurrence,
|
||||
nextScheduledTimestamps,
|
||||
} from "./scheduleCalculation.js";
|
||||
import { SCHEDULE_PHASE_DENOMINATOR } from "./scheduleTiming.js";
|
||||
|
||||
describe("calculateNextNominalTimestamp", () => {
|
||||
it("advances from the previous nominal tick instead of wall-clock time", () => {
|
||||
const next = calculateNextNominalTimestamp(
|
||||
"* * * * *",
|
||||
"UTC",
|
||||
new Date("2024-01-01T09:00:00.000Z")
|
||||
);
|
||||
|
||||
expect(next).toEqual(new Date("2024-01-01T09:01:00.000Z"));
|
||||
});
|
||||
|
||||
it("uses the 23-hour elapsed interval across spring DST", () => {
|
||||
const nominalAt = new Date("2026-03-08T05:00:00.000Z");
|
||||
const next = calculateNextNominalTimestamp("0 0 * * *", "America/New_York", nominalAt);
|
||||
|
||||
expect(next).toEqual(new Date("2026-03-09T04:00:00.000Z"));
|
||||
expect(next.getTime() - nominalAt.getTime()).toBe(23 * 60 * 60 * 1_000);
|
||||
});
|
||||
|
||||
it("uses the 25-hour elapsed interval across autumn DST", () => {
|
||||
const nominalAt = new Date("2026-11-01T04:00:00.000Z");
|
||||
const next = calculateNextNominalTimestamp("0 0 * * *", "America/New_York", nominalAt);
|
||||
|
||||
expect(next).toEqual(new Date("2026-11-02T05:00:00.000Z"));
|
||||
expect(next.getTime() - nominalAt.getTime()).toBe(25 * 60 * 60 * 1_000);
|
||||
});
|
||||
|
||||
it("preserves cron-parser calendar semantics across month boundaries", () => {
|
||||
const next = calculateNextNominalTimestamp(
|
||||
"0 23 L * *",
|
||||
"UTC",
|
||||
new Date("2027-01-31T23:00:00.000Z")
|
||||
);
|
||||
|
||||
expect(next).toEqual(new Date("2027-02-28T23:00:00.000Z"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateNextSchedulableOccurrence", () => {
|
||||
const hourlySchedule = "0 * * * *";
|
||||
const window = { type: "percentage", percentage: 100 } as const;
|
||||
|
||||
it("restores wall-clock catch-up behavior when spreading is disabled", () => {
|
||||
const occurrence = calculateNextSchedulableOccurrence({
|
||||
schedule: hourlySchedule,
|
||||
timezone: "UTC",
|
||||
afterNominal: new Date("2026-08-11T09:00:00.000Z"),
|
||||
now: new Date("2026-08-11T12:30:00.000Z"),
|
||||
schedulePhase: (SCHEDULE_PHASE_DENOMINATOR * 3) / 4,
|
||||
window,
|
||||
cronSpreadEnabled: false,
|
||||
});
|
||||
|
||||
expect(occurrence.nominalAt).toEqual(new Date("2026-08-11T13:00:00.000Z"));
|
||||
expect(occurrence.effectiveAt).toEqual(occurrence.nominalAt);
|
||||
expect(occurrence.skippedExpiredOccurrences).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps strict nominal chaining when the next effective time is upcoming", () => {
|
||||
const occurrence = calculateNextSchedulableOccurrence({
|
||||
schedule: hourlySchedule,
|
||||
timezone: "UTC",
|
||||
afterNominal: new Date("2026-08-11T09:00:00.000Z"),
|
||||
now: new Date("2026-08-11T10:00:01.000Z"),
|
||||
schedulePhase: (SCHEDULE_PHASE_DENOMINATOR * 3) / 4,
|
||||
window,
|
||||
cronSpreadEnabled: true,
|
||||
});
|
||||
|
||||
expect(occurrence.nominalAt).toEqual(new Date("2026-08-11T10:00:00.000Z"));
|
||||
expect(occurrence.effectiveAt).toEqual(new Date("2026-08-11T10:45:00.000Z"));
|
||||
expect(occurrence.skippedExpiredOccurrences).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the latest nominal occurrence when its effective time is upcoming", () => {
|
||||
const occurrence = calculateNextSchedulableOccurrence({
|
||||
schedule: hourlySchedule,
|
||||
timezone: "UTC",
|
||||
afterNominal: new Date("2026-08-11T09:00:00.000Z"),
|
||||
now: new Date("2026-08-11T12:30:00.000Z"),
|
||||
schedulePhase: (SCHEDULE_PHASE_DENOMINATOR * 3) / 4,
|
||||
window,
|
||||
cronSpreadEnabled: true,
|
||||
});
|
||||
|
||||
expect(occurrence.nominalAt).toEqual(new Date("2026-08-11T12:00:00.000Z"));
|
||||
expect(occurrence.effectiveAt).toEqual(new Date("2026-08-11T12:45:00.000Z"));
|
||||
expect(occurrence.skippedExpiredOccurrences).toBe(true);
|
||||
});
|
||||
|
||||
it("skips to the next future nominal occurrence when the latest effective time expired", () => {
|
||||
const occurrence = calculateNextSchedulableOccurrence({
|
||||
schedule: hourlySchedule,
|
||||
timezone: "UTC",
|
||||
afterNominal: new Date("2026-08-11T09:00:00.000Z"),
|
||||
now: new Date("2026-08-11T12:30:00.000Z"),
|
||||
schedulePhase: SCHEDULE_PHASE_DENOMINATOR / 4,
|
||||
window,
|
||||
cronSpreadEnabled: true,
|
||||
});
|
||||
|
||||
expect(occurrence.nominalAt).toEqual(new Date("2026-08-11T13:00:00.000Z"));
|
||||
expect(occurrence.effectiveAt).toEqual(new Date("2026-08-11T13:15:00.000Z"));
|
||||
expect(occurrence.skippedExpiredOccurrences).toBe(true);
|
||||
});
|
||||
|
||||
it("includes a nominal occurrence exactly at now when it is still eligible", () => {
|
||||
const occurrence = calculateNextSchedulableOccurrence({
|
||||
schedule: hourlySchedule,
|
||||
timezone: "UTC",
|
||||
afterNominal: new Date("2026-08-11T09:00:00.000Z"),
|
||||
now: new Date("2026-08-11T12:00:00.000Z"),
|
||||
schedulePhase: 0,
|
||||
window,
|
||||
cronSpreadEnabled: true,
|
||||
});
|
||||
|
||||
expect(occurrence.nominalAt).toEqual(new Date("2026-08-11T12:00:00.000Z"));
|
||||
expect(occurrence.effectiveAt).toEqual(new Date("2026-08-11T12:00:00.000Z"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("nextScheduledTimestamps", () => {
|
||||
it("advances every timestamp from the preceding nominal tick", () => {
|
||||
const upcoming = nextScheduledTimestamps(
|
||||
"* * * * *",
|
||||
"UTC",
|
||||
new Date("2024-01-01T09:00:00.000Z"),
|
||||
3
|
||||
);
|
||||
|
||||
expect(upcoming).toEqual([
|
||||
new Date("2024-01-01T09:01:00.000Z"),
|
||||
new Date("2024-01-01T09:02:00.000Z"),
|
||||
new Date("2024-01-01T09:03:00.000Z"),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,22 @@
|
||||
import { parseExpression } from "cron-parser";
|
||||
import {
|
||||
calculateEffectiveScheduleTime,
|
||||
type EffectiveScheduleTime,
|
||||
type NormalizedScheduleWindow,
|
||||
} from "./scheduleTiming.js";
|
||||
|
||||
export function calculateNextScheduledTimestampFromNow(schedule: string, timezone: string | null) {
|
||||
return calculateNextScheduledTimestamp(schedule, timezone, new Date());
|
||||
}
|
||||
|
||||
export function calculateNextNominalTimestamp(
|
||||
schedule: string,
|
||||
timezone: string | null,
|
||||
nominalTimestamp: Date
|
||||
) {
|
||||
return calculateNextStep(schedule, timezone, nominalTimestamp);
|
||||
}
|
||||
|
||||
export function calculateNextScheduledTimestamp(
|
||||
schedule: string,
|
||||
timezone: string | null,
|
||||
@@ -29,6 +42,82 @@ function calculateNextStep(schedule: string, timezone: string | null, currentDat
|
||||
.toDate();
|
||||
}
|
||||
|
||||
type SchedulableOccurrence = Omit<EffectiveScheduleTime, "effectiveAt"> & {
|
||||
candidateEffectiveAt: Date;
|
||||
effectiveAt: Date;
|
||||
skippedExpiredOccurrences: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Selects the next occurrence that has not passed its actual eligibility time.
|
||||
*
|
||||
* The usual path advances strictly from the preceding nominal tick. If that occurrence expired
|
||||
* during downtime, selection jumps directly to the latest nominal tick that could still be
|
||||
* eligible, or to the first future nominal tick. This preserves one late catch-up without
|
||||
* replaying every missed occurrence.
|
||||
*/
|
||||
export function calculateNextSchedulableOccurrence({
|
||||
schedule,
|
||||
timezone,
|
||||
afterNominal,
|
||||
now,
|
||||
schedulePhase,
|
||||
window,
|
||||
cronSpreadEnabled,
|
||||
}: {
|
||||
schedule: string;
|
||||
timezone: string | null;
|
||||
afterNominal: Date;
|
||||
now: Date;
|
||||
schedulePhase: number;
|
||||
window?: NormalizedScheduleWindow;
|
||||
cronSpreadEnabled: boolean;
|
||||
}): SchedulableOccurrence {
|
||||
const occurrenceAt = (
|
||||
nominalAt: Date
|
||||
): Omit<SchedulableOccurrence, "skippedExpiredOccurrences"> => {
|
||||
const nextNominalAt = calculateNextNominalTimestamp(schedule, timezone, nominalAt);
|
||||
const { effectiveAt: candidateEffectiveAt, ...timing } = calculateEffectiveScheduleTime({
|
||||
nominalAt,
|
||||
nextNominalAt,
|
||||
schedulePhase,
|
||||
window,
|
||||
});
|
||||
|
||||
return {
|
||||
...timing,
|
||||
candidateEffectiveAt,
|
||||
effectiveAt: cronSpreadEnabled ? candidateEffectiveAt : nominalAt,
|
||||
};
|
||||
};
|
||||
|
||||
const firstNominalAt = calculateNextNominalTimestamp(schedule, timezone, afterNominal);
|
||||
const firstOccurrence = occurrenceAt(firstNominalAt);
|
||||
|
||||
if (firstOccurrence.effectiveAt.getTime() >= now.getTime()) {
|
||||
return { ...firstOccurrence, skippedExpiredOccurrences: false };
|
||||
}
|
||||
|
||||
// `prev()` is strictly before its current date. Advancing by one millisecond includes a cron
|
||||
// tick exactly at `now`, whose effective time may still be upcoming.
|
||||
const latestNominalAt = previousScheduledTimestamp(
|
||||
schedule,
|
||||
timezone,
|
||||
new Date(now.getTime() + 1)
|
||||
);
|
||||
|
||||
if (latestNominalAt.getTime() > afterNominal.getTime()) {
|
||||
const latestOccurrence = occurrenceAt(latestNominalAt);
|
||||
|
||||
if (latestOccurrence.effectiveAt.getTime() >= now.getTime()) {
|
||||
return { ...latestOccurrence, skippedExpiredOccurrences: true };
|
||||
}
|
||||
}
|
||||
|
||||
const nextOccurrence = occurrenceAt(calculateNextNominalTimestamp(schedule, timezone, now));
|
||||
return { ...nextOccurrence, skippedExpiredOccurrences: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron's previous slot relative to `fromTimestamp`. For a continuously-
|
||||
* running schedule this equals the actual last fire time; for paused or
|
||||
@@ -59,11 +148,7 @@ export function nextScheduledTimestamps(
|
||||
let nextScheduledTimestamp = lastScheduledTimestamp;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
nextScheduledTimestamp = calculateNextScheduledTimestamp(
|
||||
cron,
|
||||
timezone,
|
||||
nextScheduledTimestamp
|
||||
);
|
||||
nextScheduledTimestamp = calculateNextNominalTimestamp(cron, timezone, nextScheduledTimestamp);
|
||||
|
||||
result.push(nextScheduledTimestamp);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import {
|
||||
MAX_ABSOLUTE_SCHEDULE_WINDOW_SECONDS,
|
||||
MAX_SCHEDULE_PHASE,
|
||||
MINIMUM_SCHEDULE_RANGE_MS,
|
||||
SCHEDULE_PHASE_DENOMINATOR,
|
||||
calculateEffectiveScheduleTime,
|
||||
calculateSchedulePhase,
|
||||
parseScheduleWindow,
|
||||
resolveScheduleWindowMs,
|
||||
validateScheduleWindow,
|
||||
} from "./scheduleTiming.js";
|
||||
|
||||
describe("parseScheduleWindow", () => {
|
||||
it.each([
|
||||
["30m", { type: "duration", durationSeconds: 1_800 }],
|
||||
["2h", { type: "duration", durationSeconds: 7_200 }],
|
||||
["24h", { type: "duration", durationSeconds: 86_400 }],
|
||||
["0m", { type: "duration", durationSeconds: 0 }],
|
||||
["0h", { type: "duration", durationSeconds: 0 }],
|
||||
["0%", { type: "percentage", percentage: 0 }],
|
||||
["12%", { type: "percentage", percentage: 12 }],
|
||||
["100%", { type: "percentage", percentage: 100 }],
|
||||
] as const)("normalizes %s", (input, expected) => {
|
||||
expect(parseScheduleWindow(input)).toEqual(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"",
|
||||
"00m",
|
||||
"01m",
|
||||
"1.5h",
|
||||
"0d",
|
||||
"1d",
|
||||
"25h",
|
||||
"1441m",
|
||||
"30s",
|
||||
"0.01%",
|
||||
"1.0%",
|
||||
"12.3%",
|
||||
"100.01%",
|
||||
"101%",
|
||||
"1.234%",
|
||||
"1e2%",
|
||||
" 30m",
|
||||
"30m ",
|
||||
])("rejects %j", (input) => {
|
||||
expect(() => parseScheduleWindow(input)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects normalized durations over 24 hours", () => {
|
||||
expect(() =>
|
||||
validateScheduleWindow({
|
||||
type: "duration",
|
||||
durationSeconds: MAX_ABSOLUTE_SCHEDULE_WINDOW_SECONDS + 1,
|
||||
})
|
||||
).toThrow("up to 24 hours");
|
||||
});
|
||||
});
|
||||
|
||||
describe("schedule window validation", () => {
|
||||
it.each([0, 100])("allows %s percent", (percentage) => {
|
||||
expect(() => validateScheduleWindow({ type: "percentage", percentage })).not.toThrow();
|
||||
});
|
||||
|
||||
it("allows a zero-duration window", () => {
|
||||
expect(() => validateScheduleWindow({ type: "duration", durationSeconds: 0 })).not.toThrow();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ type: "duration", durationSeconds: -1 },
|
||||
{ type: "duration", durationSeconds: 1.5 },
|
||||
{ type: "percentage", percentage: -100 },
|
||||
{ type: "percentage", percentage: 101 },
|
||||
{ type: "percentage", percentage: 1.5 },
|
||||
] as const)("rejects an invalid normalized window: %o", (window) => {
|
||||
expect(() => validateScheduleWindow(window)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveScheduleWindowMs", () => {
|
||||
it("returns zero when no window was configured", () => {
|
||||
expect(resolveScheduleWindowMs(undefined, 5 * 60_000)).toBe(0);
|
||||
});
|
||||
|
||||
it("resolves percentage windows using integer arithmetic", () => {
|
||||
expect(resolveScheduleWindowMs({ type: "percentage", percentage: 33 }, 5 * 60_000)).toBe(
|
||||
99_000
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateEffectiveScheduleTime", () => {
|
||||
const nominalAt = new Date("2026-08-10T10:00:00.000Z");
|
||||
|
||||
it("uses the 60-second baseline when no window was configured", () => {
|
||||
const timing = calculateEffectiveScheduleTime({
|
||||
nominalAt,
|
||||
nextNominalAt: new Date("2026-08-10T10:05:00.000Z"),
|
||||
schedulePhase: SCHEDULE_PHASE_DENOMINATOR / 2,
|
||||
});
|
||||
|
||||
expect(timing).toEqual({
|
||||
nominalAt,
|
||||
nextNominalAt: new Date("2026-08-10T10:05:00.000Z"),
|
||||
effectiveAt: new Date("2026-08-10T10:00:30.000Z"),
|
||||
intervalMs: 300_000,
|
||||
windowMs: 0,
|
||||
effectiveRangeMs: MINIMUM_SCHEDULE_RANGE_MS,
|
||||
offsetMs: 30_000,
|
||||
windowWasCappedToInterval: false,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[0, 0],
|
||||
[10, 30_000],
|
||||
])("uses the 60-second baseline when %s percent resolves to %sms", (percentage, windowMs) => {
|
||||
const timing = calculateEffectiveScheduleTime({
|
||||
nominalAt,
|
||||
nextNominalAt: new Date("2026-08-10T10:05:00.000Z"),
|
||||
schedulePhase: SCHEDULE_PHASE_DENOMINATOR / 2,
|
||||
window: { type: "percentage", percentage },
|
||||
});
|
||||
|
||||
expect(timing.windowMs).toBe(windowMs);
|
||||
expect(timing.effectiveRangeMs).toBe(60_000);
|
||||
expect(timing.offsetMs).toBe(30_000);
|
||||
});
|
||||
|
||||
it("uses 30% of a five-minute interval", () => {
|
||||
const timing = calculateEffectiveScheduleTime({
|
||||
nominalAt,
|
||||
nextNominalAt: new Date("2026-08-10T10:05:00.000Z"),
|
||||
schedulePhase: SCHEDULE_PHASE_DENOMINATOR / 2,
|
||||
window: { type: "percentage", percentage: 30 },
|
||||
});
|
||||
|
||||
expect(timing.windowMs).toBe(90_000);
|
||||
expect(timing.effectiveRangeMs).toBe(90_000);
|
||||
expect(timing.offsetMs).toBe(45_000);
|
||||
expect(timing.effectiveAt).toEqual(new Date("2026-08-10T10:00:45.000Z"));
|
||||
});
|
||||
|
||||
it("keeps a 100% window half-open at the maximum phase", () => {
|
||||
const nextNominalAt = new Date("2026-08-10T10:05:00.000Z");
|
||||
const timing = calculateEffectiveScheduleTime({
|
||||
nominalAt,
|
||||
nextNominalAt,
|
||||
schedulePhase: MAX_SCHEDULE_PHASE,
|
||||
window: { type: "percentage", percentage: 100 },
|
||||
});
|
||||
|
||||
expect(timing.effectiveRangeMs).toBe(300_000);
|
||||
expect(timing.offsetMs).toBe(299_999);
|
||||
expect(timing.effectiveAt).toEqual(new Date(nextNominalAt.getTime() - 1));
|
||||
expect(timing.effectiveAt.getTime()).toBeLessThan(nextNominalAt.getTime());
|
||||
});
|
||||
|
||||
it("preserves cadence for consecutive occurrences with a stable 100% phase", () => {
|
||||
const phase = 1_610_612_735;
|
||||
const first = calculateEffectiveScheduleTime({
|
||||
nominalAt,
|
||||
nextNominalAt: new Date("2026-08-10T10:05:00.000Z"),
|
||||
schedulePhase: phase,
|
||||
window: { type: "percentage", percentage: 100 },
|
||||
});
|
||||
const second = calculateEffectiveScheduleTime({
|
||||
nominalAt: new Date("2026-08-10T10:05:00.000Z"),
|
||||
nextNominalAt: new Date("2026-08-10T10:10:00.000Z"),
|
||||
schedulePhase: phase,
|
||||
window: { type: "percentage", percentage: 100 },
|
||||
});
|
||||
|
||||
expect(second.effectiveAt.getTime() - first.effectiveAt.getTime()).toBe(5 * 60_000);
|
||||
});
|
||||
|
||||
it("allows an effective time to cross a calendar boundary", () => {
|
||||
const timing = calculateEffectiveScheduleTime({
|
||||
nominalAt: new Date("2026-12-31T23:00:00.000Z"),
|
||||
nextNominalAt: new Date("2027-01-01T23:00:00.000Z"),
|
||||
schedulePhase: SCHEDULE_PHASE_DENOMINATOR / 2,
|
||||
window: { type: "duration", durationSeconds: 3 * 60 * 60 },
|
||||
});
|
||||
|
||||
expect(timing.effectiveAt).toEqual(new Date("2027-01-01T00:30:00.000Z"));
|
||||
});
|
||||
|
||||
it("caps an absolute window at the interval to the next nominal tick", () => {
|
||||
const timing = calculateEffectiveScheduleTime({
|
||||
nominalAt,
|
||||
nextNominalAt: new Date("2026-08-10T10:05:00.000Z"),
|
||||
schedulePhase: SCHEDULE_PHASE_DENOMINATOR / 2,
|
||||
window: { type: "duration", durationSeconds: 30 * 60 },
|
||||
});
|
||||
|
||||
expect(timing.windowMs).toBe(1_800_000);
|
||||
expect(timing.effectiveRangeMs).toBe(300_000);
|
||||
expect(timing.windowWasCappedToInterval).toBe(true);
|
||||
expect(timing.effectiveAt).toEqual(new Date("2026-08-10T10:02:30.000Z"));
|
||||
});
|
||||
|
||||
it.each([-1, 1.5, SCHEDULE_PHASE_DENOMINATOR])(
|
||||
"rejects invalid schedule phase %s",
|
||||
(schedulePhase) => {
|
||||
expect(() =>
|
||||
calculateEffectiveScheduleTime({
|
||||
nominalAt,
|
||||
nextNominalAt: new Date("2026-08-10T10:05:00.000Z"),
|
||||
schedulePhase,
|
||||
})
|
||||
).toThrow("Schedule phase must be an integer");
|
||||
}
|
||||
);
|
||||
|
||||
it("rejects a non-positive nominal interval", () => {
|
||||
expect(() =>
|
||||
calculateEffectiveScheduleTime({
|
||||
nominalAt,
|
||||
nextNominalAt: nominalAt,
|
||||
schedulePhase: 0,
|
||||
})
|
||||
).toThrow("Nominal schedule interval must be a positive integer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateSchedulePhase", () => {
|
||||
const input = {
|
||||
secret: "test-secret",
|
||||
environmentId: "env_789",
|
||||
deduplicationKey: "daily-report",
|
||||
};
|
||||
|
||||
it("uses the agreed domain-separated HMAC input", () => {
|
||||
expect(calculateSchedulePhase(input)).toBe(43_063_717);
|
||||
});
|
||||
|
||||
it("is stable for the same logical schedule instance", () => {
|
||||
expect(calculateSchedulePhase(input)).toBe(calculateSchedulePhase(input));
|
||||
});
|
||||
|
||||
it.each(["environmentId", "deduplicationKey"] as const)("changes when %s changes", (field) => {
|
||||
expect(calculateSchedulePhase({ ...input, [field]: `${input[field]}_other` })).not.toBe(
|
||||
calculateSchedulePhase(input)
|
||||
);
|
||||
});
|
||||
|
||||
it("changes when the secret changes", () => {
|
||||
expect(calculateSchedulePhase({ ...input, secret: "other-secret" })).not.toBe(
|
||||
calculateSchedulePhase(input)
|
||||
);
|
||||
});
|
||||
|
||||
it("always returns a non-negative signed 31-bit integer", () => {
|
||||
for (let index = 0; index < 1_000; index++) {
|
||||
const phase = calculateSchedulePhase({ ...input, deduplicationKey: `schedule-${index}` });
|
||||
expect(phase).toBeGreaterThanOrEqual(0);
|
||||
expect(phase).toBeLessThan(SCHEDULE_PHASE_DENOMINATOR);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an empty secret", () => {
|
||||
expect(() => calculateSchedulePhase({ ...input, secret: "" })).toThrow(
|
||||
"secret must not be empty"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import { createHmac } from "node:crypto";
|
||||
|
||||
export const SCHEDULE_PHASE_DENOMINATOR = 2_147_483_648;
|
||||
export const MAX_SCHEDULE_PHASE = SCHEDULE_PHASE_DENOMINATOR - 1;
|
||||
export const MINIMUM_SCHEDULE_RANGE_MS = 60_000;
|
||||
export const MAX_ABSOLUTE_SCHEDULE_WINDOW_SECONDS = 24 * 60 * 60;
|
||||
|
||||
const PERCENTAGE_DENOMINATOR = 100;
|
||||
|
||||
export type NormalizedScheduleWindow =
|
||||
| { type: "duration"; durationSeconds: number }
|
||||
| { type: "percentage"; percentage: number };
|
||||
|
||||
export type SchedulePhaseInput = {
|
||||
secret: string | Buffer;
|
||||
environmentId: string;
|
||||
deduplicationKey: string;
|
||||
};
|
||||
|
||||
export type EffectiveScheduleTime = {
|
||||
nominalAt: Date;
|
||||
nextNominalAt: Date;
|
||||
effectiveAt: Date;
|
||||
intervalMs: number;
|
||||
windowMs: number;
|
||||
effectiveRangeMs: number;
|
||||
offsetMs: number;
|
||||
windowWasCappedToInterval: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses the public schedule-window syntax.
|
||||
*
|
||||
* Durations are non-negative whole minutes or hours up to 24 hours.
|
||||
* Percentages are whole numbers from 0% through 100%.
|
||||
*/
|
||||
export function parseScheduleWindow(value: string): NormalizedScheduleWindow {
|
||||
const durationMatch = /^(0|[1-9]\d*)([mh])$/.exec(value);
|
||||
|
||||
if (durationMatch) {
|
||||
const amount = Number(durationMatch[1]);
|
||||
const unit = durationMatch[2] as "m" | "h";
|
||||
const unitSeconds = unit === "m" ? 60 : 3_600;
|
||||
const durationSeconds = amount * unitSeconds;
|
||||
|
||||
if (
|
||||
!Number.isSafeInteger(durationSeconds) ||
|
||||
durationSeconds > MAX_ABSOLUTE_SCHEDULE_WINDOW_SECONDS
|
||||
) {
|
||||
throw new RangeError("Schedule window duration cannot exceed 24 hours");
|
||||
}
|
||||
|
||||
return { type: "duration", durationSeconds };
|
||||
}
|
||||
|
||||
const percentageMatch = /^(0|[1-9]\d?|100)%$/.exec(value);
|
||||
|
||||
if (percentageMatch) {
|
||||
return { type: "percentage", percentage: Number(percentageMatch[1]) };
|
||||
}
|
||||
|
||||
throw new TypeError(
|
||||
'Schedule window must be a whole duration such as "0m", "30m", or "24h", or a percentage such as "30%"'
|
||||
);
|
||||
}
|
||||
|
||||
export function validateScheduleWindow(window: NormalizedScheduleWindow): void {
|
||||
if (window.type === "duration") {
|
||||
if (
|
||||
!Number.isSafeInteger(window.durationSeconds) ||
|
||||
window.durationSeconds < 0 ||
|
||||
window.durationSeconds > MAX_ABSOLUTE_SCHEDULE_WINDOW_SECONDS
|
||||
) {
|
||||
throw new RangeError(
|
||||
"Schedule window duration must be a non-negative integer up to 24 hours"
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!Number.isInteger(window.percentage) ||
|
||||
window.percentage < 0 ||
|
||||
window.percentage > PERCENTAGE_DENOMINATOR
|
||||
) {
|
||||
throw new RangeError(
|
||||
"Schedule window percentage must be a whole percentage from 0% through 100%"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveScheduleWindowMs(
|
||||
window: NormalizedScheduleWindow | undefined,
|
||||
intervalMs: number
|
||||
): number {
|
||||
assertPositiveInterval(intervalMs);
|
||||
|
||||
if (!window) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
validateScheduleWindow(window);
|
||||
|
||||
if (window.type === "duration") {
|
||||
return window.durationSeconds * 1_000;
|
||||
}
|
||||
|
||||
return Number((BigInt(intervalMs) * BigInt(window.percentage)) / BigInt(PERCENTAGE_DENOMINATOR));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the stable effective time for one nominal occurrence using integer arithmetic.
|
||||
*
|
||||
* An absolute window is a maximum. Each occurrence caps it at the interval to its next nominal
|
||||
* tick, guaranteeing that the effective time never reaches or passes the next occurrence.
|
||||
*/
|
||||
export function calculateEffectiveScheduleTime({
|
||||
nominalAt,
|
||||
nextNominalAt,
|
||||
schedulePhase,
|
||||
window,
|
||||
}: {
|
||||
nominalAt: Date;
|
||||
nextNominalAt: Date;
|
||||
schedulePhase: number;
|
||||
window?: NormalizedScheduleWindow;
|
||||
}): EffectiveScheduleTime {
|
||||
assertValidDate(nominalAt, "nominalAt");
|
||||
assertValidDate(nextNominalAt, "nextNominalAt");
|
||||
assertValidSchedulePhase(schedulePhase);
|
||||
|
||||
const intervalMs = nextNominalAt.getTime() - nominalAt.getTime();
|
||||
assertPositiveInterval(intervalMs);
|
||||
|
||||
const windowMs = resolveScheduleWindowMs(window, intervalMs);
|
||||
const requestedRangeMs = Math.max(MINIMUM_SCHEDULE_RANGE_MS, windowMs);
|
||||
const effectiveRangeMs = Math.min(intervalMs, requestedRangeMs);
|
||||
const windowWasCappedToInterval = effectiveRangeMs !== requestedRangeMs;
|
||||
const offsetMs = Number(
|
||||
(BigInt(schedulePhase) * BigInt(effectiveRangeMs)) / BigInt(SCHEDULE_PHASE_DENOMINATOR)
|
||||
);
|
||||
const effectiveAtMs = nominalAt.getTime() + offsetMs;
|
||||
|
||||
if (!Number.isSafeInteger(effectiveAtMs)) {
|
||||
throw new RangeError("Calculated effective schedule time is outside the safe date range");
|
||||
}
|
||||
|
||||
return {
|
||||
nominalAt,
|
||||
nextNominalAt,
|
||||
effectiveAt: new Date(effectiveAtMs),
|
||||
intervalMs,
|
||||
windowMs,
|
||||
effectiveRangeMs,
|
||||
offsetMs,
|
||||
windowWasCappedToInterval,
|
||||
};
|
||||
}
|
||||
|
||||
/** Calculates the durable, domain-separated phase stored on a schedule instance. */
|
||||
export function calculateSchedulePhase({
|
||||
secret,
|
||||
environmentId,
|
||||
deduplicationKey,
|
||||
}: SchedulePhaseInput): number {
|
||||
if (
|
||||
(typeof secret === "string" && secret.length === 0) ||
|
||||
(Buffer.isBuffer(secret) && !secret.length)
|
||||
) {
|
||||
throw new RangeError("Schedule phase secret must not be empty");
|
||||
}
|
||||
|
||||
const input = JSON.stringify(["cron-phase-v1", environmentId, deduplicationKey]);
|
||||
const digest = createHmac("sha256", secret).update(input).digest();
|
||||
|
||||
return digest.readUInt32BE(0) & MAX_SCHEDULE_PHASE;
|
||||
}
|
||||
|
||||
function assertValidSchedulePhase(schedulePhase: number): void {
|
||||
if (!Number.isInteger(schedulePhase) || schedulePhase < 0 || schedulePhase > MAX_SCHEDULE_PHASE) {
|
||||
throw new RangeError(`Schedule phase must be an integer from 0 to ${MAX_SCHEDULE_PHASE}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertPositiveInterval(intervalMs: number): void {
|
||||
if (!Number.isSafeInteger(intervalMs) || intervalMs <= 0) {
|
||||
throw new RangeError(
|
||||
"Nominal schedule interval must be a positive integer number of milliseconds"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertValidDate(value: Date, name: string): void {
|
||||
if (!Number.isFinite(value.getTime())) {
|
||||
throw new RangeError(`${name} must be a valid date`);
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,8 @@ export type TriggerScheduledTaskParams = {
|
||||
};
|
||||
scheduleInstanceId: string;
|
||||
scheduleId: string;
|
||||
exactScheduleTime?: Date;
|
||||
exactScheduleTime: Date;
|
||||
effectiveScheduleTime: Date;
|
||||
};
|
||||
|
||||
export type TriggerScheduledTaskErrorType = "QUEUE_LIMIT" | "OUT_OF_ENTITLEMENTS" | "SYSTEM_ERROR";
|
||||
@@ -50,6 +51,14 @@ export interface ScheduleEngineOptions {
|
||||
distributionWindow?: {
|
||||
seconds: number;
|
||||
};
|
||||
schedulePhaseSecret: string | Buffer;
|
||||
/**
|
||||
* Fraction of schedules (0 to 1) with cron spread active, gated on each
|
||||
* schedule's deterministic phase. 0 disables spreading entirely; 1 enables
|
||||
* it for every schedule. Raising the fraction is strictly additive — phases
|
||||
* are stable, so a schedule never leaves the rollout once included.
|
||||
*/
|
||||
cronSpreadFraction: number;
|
||||
tracer?: Tracer;
|
||||
meter?: Meter;
|
||||
onTriggerScheduledTask: TriggerScheduledTaskCallback;
|
||||
@@ -74,15 +83,16 @@ export interface TriggerScheduleParams {
|
||||
instanceId: string;
|
||||
finalAttempt: boolean;
|
||||
exactScheduleTime?: Date;
|
||||
effectiveScheduleTime?: Date;
|
||||
lastScheduleTime?: Date;
|
||||
}
|
||||
|
||||
export interface RegisterScheduleInstanceParams {
|
||||
instanceId: string;
|
||||
/**
|
||||
* Anchor for computing the next cron slot. Defaults to now() when omitted.
|
||||
* This advances on every tick (fired or skipped) so the next slot keeps
|
||||
* marching forward regardless of skip reasons.
|
||||
* Nominal anchor for selecting the next non-expired cron occurrence. Defaults
|
||||
* to now() when omitted. The engine advances from this timestamp when the
|
||||
* next occurrence is still eligible and skips expired intermediate ticks.
|
||||
*/
|
||||
fromTimestamp?: Date;
|
||||
/**
|
||||
@@ -92,4 +102,9 @@ export interface RegisterScheduleInstanceParams {
|
||||
* disconnected, etc.) do NOT advance this — only real fires do.
|
||||
*/
|
||||
lastScheduleTime?: Date;
|
||||
/**
|
||||
* Keep an existing stable-ID Redis job unchanged, while still creating it
|
||||
* when missing. Intended for no-op reconciliation of unchanged schedules.
|
||||
*/
|
||||
preserveExistingJob?: boolean;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { scheduleWorkerCatalog } from "./workerCatalog.js";
|
||||
|
||||
const schema = scheduleWorkerCatalog["schedule.triggerScheduledTask"].schema;
|
||||
|
||||
describe("scheduleWorkerCatalog", () => {
|
||||
it("accepts legacy payloads without an effective schedule time", () => {
|
||||
const exactScheduleTime = "2026-08-11T10:00:00.000Z";
|
||||
|
||||
const payload = schema.parse({
|
||||
instanceId: "instance_123",
|
||||
exactScheduleTime,
|
||||
});
|
||||
|
||||
expect(payload.exactScheduleTime).toEqual(new Date(exactScheduleTime));
|
||||
expect(payload.effectiveScheduleTime).toBeUndefined();
|
||||
});
|
||||
|
||||
it("coerces nominal and effective schedule times for new payloads", () => {
|
||||
const exactScheduleTime = "2026-08-11T10:00:00.000Z";
|
||||
const effectiveScheduleTime = "2026-08-11T10:00:42.123Z";
|
||||
|
||||
const payload = schema.parse({
|
||||
instanceId: "instance_123",
|
||||
exactScheduleTime,
|
||||
effectiveScheduleTime,
|
||||
});
|
||||
|
||||
expect(payload.exactScheduleTime).toEqual(new Date(exactScheduleTime));
|
||||
expect(payload.effectiveScheduleTime).toEqual(new Date(effectiveScheduleTime));
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,12 @@ export const scheduleWorkerCatalog = {
|
||||
"schedule.triggerScheduledTask": {
|
||||
schema: z.object({
|
||||
instanceId: z.string(),
|
||||
// The nominal cron occurrence. Keep this field name for compatibility
|
||||
// with jobs enqueued before effective schedule times were introduced.
|
||||
exactScheduleTime: z.coerce.date(),
|
||||
// Optional for compatibility with in-flight jobs. Missing means the
|
||||
// effective time is the nominal exactScheduleTime.
|
||||
effectiveScheduleTime: z.coerce.date().optional(),
|
||||
// Optional for backward compat with in-flight jobs enqueued by older
|
||||
// engines. After deploy, every newly-enqueued job populates this with
|
||||
// the just-fired schedule time so the next dequeue can report
|
||||
|
||||
@@ -1,4 +1,21 @@
|
||||
export { ScheduleEngine } from "./engine/index.js";
|
||||
export { calculateNextNominalTimestamp } from "./engine/scheduleCalculation.js";
|
||||
export {
|
||||
MAX_ABSOLUTE_SCHEDULE_WINDOW_SECONDS,
|
||||
MAX_SCHEDULE_PHASE,
|
||||
MINIMUM_SCHEDULE_RANGE_MS,
|
||||
SCHEDULE_PHASE_DENOMINATOR,
|
||||
calculateEffectiveScheduleTime,
|
||||
calculateSchedulePhase,
|
||||
parseScheduleWindow,
|
||||
resolveScheduleWindowMs,
|
||||
validateScheduleWindow,
|
||||
} from "./engine/scheduleTiming.js";
|
||||
export type {
|
||||
EffectiveScheduleTime,
|
||||
NormalizedScheduleWindow,
|
||||
SchedulePhaseInput,
|
||||
} from "./engine/scheduleTiming.js";
|
||||
export type {
|
||||
ScheduleEngineOptions,
|
||||
TriggerScheduleParams,
|
||||
|
||||
@@ -22,6 +22,8 @@ describe("ScheduleEngine Integration", () => {
|
||||
prisma,
|
||||
redis: redisOptions,
|
||||
distributionWindow: { seconds: 10 },
|
||||
schedulePhaseSecret: "test-schedule-phase-secret",
|
||||
cronSpreadFraction: 1,
|
||||
worker: {
|
||||
concurrency: 1,
|
||||
disabled: false, // Enable worker for full integration test
|
||||
@@ -95,6 +97,9 @@ describe("ScheduleEngine Integration", () => {
|
||||
environmentId: environment.id,
|
||||
projectId: project.id,
|
||||
active: true,
|
||||
// Keep the lifecycle test fast and deterministic. Non-zero phase
|
||||
// behavior is covered by the focused registration tests.
|
||||
schedulePhase: 0,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -209,6 +214,7 @@ describe("ScheduleEngine Integration", () => {
|
||||
scheduleInstanceId: scheduleInstance.id,
|
||||
scheduleId: taskSchedule.id,
|
||||
exactScheduleTime: firstScheduledTime,
|
||||
effectiveScheduleTime: firstScheduledTime,
|
||||
});
|
||||
|
||||
// Verify the second execution parameters
|
||||
@@ -232,6 +238,7 @@ describe("ScheduleEngine Integration", () => {
|
||||
scheduleInstanceId: scheduleInstance.id,
|
||||
scheduleId: taskSchedule.id,
|
||||
exactScheduleTime: secondScheduledTime,
|
||||
effectiveScheduleTime: secondScheduledTime,
|
||||
});
|
||||
} finally {
|
||||
// Clean up: stop the worker
|
||||
|
||||
@@ -2,7 +2,14 @@ import { containerTest } from "@internal/testcontainers";
|
||||
import { trace } from "@internal/tracing";
|
||||
import { describe, expect, vi } from "vitest";
|
||||
import type { TriggerScheduledTaskParams } from "../src/engine/types.js";
|
||||
import { ScheduleEngine } from "../src/index.js";
|
||||
import {
|
||||
calculateEffectiveScheduleTime,
|
||||
calculateNextNominalTimestamp,
|
||||
calculateSchedulePhase,
|
||||
SCHEDULE_PHASE_DENOMINATOR,
|
||||
ScheduleEngine,
|
||||
} from "../src/index.js";
|
||||
import { calculateDistributedExecutionTime } from "../src/engine/distributedScheduling.js";
|
||||
|
||||
describe("ScheduleEngine Integration (part 2)", () => {
|
||||
// Deploy-moment backward compatibility. At deploy time, in-flight Redis jobs
|
||||
@@ -20,6 +27,8 @@ describe("ScheduleEngine Integration (part 2)", () => {
|
||||
prisma,
|
||||
redis: redisOptions,
|
||||
distributionWindow: { seconds: 10 },
|
||||
schedulePhaseSecret: "test-schedule-phase-secret",
|
||||
cronSpreadFraction: 0,
|
||||
worker: {
|
||||
concurrency: 1,
|
||||
disabled: true, // Don't actually run the worker — calling triggerScheduledTask directly
|
||||
@@ -72,6 +81,7 @@ describe("ScheduleEngine Integration (part 2)", () => {
|
||||
type: "DECLARATIVE",
|
||||
active: true,
|
||||
externalId: "legacy-ext",
|
||||
windowDurationSeconds: 60,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -88,24 +98,395 @@ describe("ScheduleEngine Integration (part 2)", () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Call triggerScheduledTask directly without lastScheduleTime,
|
||||
// simulating an in-flight Redis job enqueued by the old engine.
|
||||
// Call triggerScheduledTask directly without lastScheduleTime or an
|
||||
// effective time, simulating an in-flight Redis job from the old engine.
|
||||
const exactScheduleTime = new Date("2026-04-30T10:05:00.000Z");
|
||||
const beforeTrigger = new Date();
|
||||
await engine.triggerScheduledTask({
|
||||
instanceId: scheduleInstance.id,
|
||||
finalAttempt: false,
|
||||
exactScheduleTime,
|
||||
// lastScheduleTime intentionally omitted — legacy payload shape
|
||||
// effectiveScheduleTime and lastScheduleTime intentionally omitted
|
||||
});
|
||||
|
||||
expect(triggerCalls.length).toBe(1);
|
||||
expect(triggerCalls[0].payload.timestamp).toEqual(exactScheduleTime);
|
||||
expect(triggerCalls[0].exactScheduleTime).toEqual(exactScheduleTime);
|
||||
expect(triggerCalls[0].effectiveScheduleTime).toEqual(exactScheduleTime);
|
||||
// Falls back to instance.lastScheduledTimestamp from the DB rather
|
||||
// than reporting undefined for this one transitional fire.
|
||||
expect(triggerCalls[0].payload.lastTimestamp).toEqual(preDeployLastFire);
|
||||
expect(triggerCalls[0].payload.upcoming).toHaveLength(10);
|
||||
expect(
|
||||
triggerCalls[0].payload.upcoming.every(
|
||||
(timestamp) => timestamp.getTime() > beforeTrigger.getTime()
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
const nextJob = await engine.getJob(`scheduled-task-instance:${scheduleInstance.id}`);
|
||||
const nextJobPayload = nextJob!.item as unknown as {
|
||||
exactScheduleTime: string;
|
||||
effectiveScheduleTime: string;
|
||||
};
|
||||
const nextNominalAt = new Date(nextJobPayload.exactScheduleTime);
|
||||
|
||||
// The legacy occurrence fires once, then expired intermediate ticks are
|
||||
// skipped instead of being replayed. With spread disabled, eligibility
|
||||
// remains nominal and the next job is in the future.
|
||||
expect(nextNominalAt.getTime()).toBeGreaterThan(beforeTrigger.getTime());
|
||||
expect(new Date(nextJobPayload.effectiveScheduleTime)).toEqual(nextNominalAt);
|
||||
expect(nextJob!.timestamp).toEqual(
|
||||
calculateDistributedExecutionTime(nextNominalAt, 10, scheduleInstance.id)
|
||||
);
|
||||
expect(new Date((nextJob!.item as { lastScheduleTime: string }).lastScheduleTime)).toEqual(
|
||||
exactScheduleTime
|
||||
);
|
||||
|
||||
const updatedInstance = await prisma.taskScheduleInstance.findUniqueOrThrow({
|
||||
where: { id: scheduleInstance.id },
|
||||
select: { schedulePhase: true },
|
||||
});
|
||||
expect(updatedInstance.schedulePhase).toBeNull();
|
||||
} finally {
|
||||
await engine.quit();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
containerTest(
|
||||
"should assign a stable schedule phase once when spreading is active",
|
||||
{ timeout: 30_000 },
|
||||
async ({ prisma, redisOptions }) => {
|
||||
const schedulePhaseSecret = "test-schedule-phase-secret";
|
||||
const triggerCalls: TriggerScheduledTaskParams[] = [];
|
||||
const engine = new ScheduleEngine({
|
||||
prisma,
|
||||
redis: redisOptions,
|
||||
distributionWindow: { seconds: 10 },
|
||||
schedulePhaseSecret,
|
||||
cronSpreadFraction: 1,
|
||||
worker: {
|
||||
concurrency: 1,
|
||||
disabled: true,
|
||||
pollIntervalMs: 1000,
|
||||
},
|
||||
tracer: trace.getTracer("test", "0.0.0"),
|
||||
onTriggerScheduledTask: async (params) => {
|
||||
triggerCalls.push(params);
|
||||
return { success: true };
|
||||
},
|
||||
isDevEnvironmentConnectedHandler: vi.fn().mockResolvedValue(true),
|
||||
});
|
||||
|
||||
try {
|
||||
const organization = await prisma.organization.create({
|
||||
data: { title: "Schedule Phase Org", slug: "schedule-phase-org" },
|
||||
});
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
name: "Schedule Phase Project",
|
||||
slug: "schedule-phase-project",
|
||||
externalRef: "schedule-phase-ref",
|
||||
organizationId: organization.id,
|
||||
},
|
||||
});
|
||||
const environment = await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
slug: "schedule-phase-env",
|
||||
type: "PRODUCTION",
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
apiKey: "tr_schedule_phase",
|
||||
pkApiKey: "pk_schedule_phase",
|
||||
shortcode: "phase",
|
||||
},
|
||||
});
|
||||
const taskSchedule = await prisma.taskSchedule.create({
|
||||
data: {
|
||||
friendlyId: "sched_phase",
|
||||
taskIdentifier: "schedule-phase-task",
|
||||
projectId: project.id,
|
||||
deduplicationKey: "schedule-phase-dedup",
|
||||
generatorExpression: "*/5 * * * *",
|
||||
generatorDescription: "Every 5 minutes",
|
||||
timezone: "UTC",
|
||||
type: "DECLARATIVE",
|
||||
},
|
||||
});
|
||||
const scheduleInstance = await prisma.taskScheduleInstance.create({
|
||||
data: {
|
||||
taskScheduleId: taskSchedule.id,
|
||||
environmentId: environment.id,
|
||||
projectId: project.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Atomic preserve mode still creates the stable-ID job when it is missing.
|
||||
await engine.registerNextTaskScheduleInstance({
|
||||
instanceId: scheduleInstance.id,
|
||||
preserveExistingJob: true,
|
||||
});
|
||||
|
||||
const unwindowedInstance = await prisma.taskScheduleInstance.findUniqueOrThrow({
|
||||
where: { id: scheduleInstance.id },
|
||||
select: { schedulePhase: true },
|
||||
});
|
||||
expect(unwindowedInstance.schedulePhase).toBe(
|
||||
calculateSchedulePhase({
|
||||
secret: schedulePhaseSecret,
|
||||
environmentId: environment.id,
|
||||
deduplicationKey: taskSchedule.deduplicationKey,
|
||||
})
|
||||
);
|
||||
|
||||
const unwindowedJob = await engine.getJob(`scheduled-task-instance:${scheduleInstance.id}`);
|
||||
const unwindowedPayload = unwindowedJob!.item as unknown as {
|
||||
exactScheduleTime: string;
|
||||
effectiveScheduleTime: string;
|
||||
};
|
||||
const unwindowedNominalAt = new Date(unwindowedPayload.exactScheduleTime);
|
||||
const unwindowedNextNominalAt = calculateNextNominalTimestamp(
|
||||
taskSchedule.generatorExpression,
|
||||
taskSchedule.timezone,
|
||||
unwindowedNominalAt
|
||||
);
|
||||
const unwindowedPhase = calculateSchedulePhase({
|
||||
secret: schedulePhaseSecret,
|
||||
environmentId: environment.id,
|
||||
deduplicationKey: taskSchedule.deduplicationKey,
|
||||
});
|
||||
const { effectiveAt: unwindowedEffectiveAt } = calculateEffectiveScheduleTime({
|
||||
nominalAt: unwindowedNominalAt,
|
||||
nextNominalAt: unwindowedNextNominalAt,
|
||||
schedulePhase: unwindowedPhase,
|
||||
});
|
||||
expect(new Date(unwindowedPayload.effectiveScheduleTime)).toEqual(unwindowedEffectiveAt);
|
||||
expect(unwindowedJob!.timestamp).toEqual(
|
||||
calculateDistributedExecutionTime(unwindowedEffectiveAt, 10, scheduleInstance.id)
|
||||
);
|
||||
|
||||
await prisma.taskSchedule.update({
|
||||
where: { id: taskSchedule.id },
|
||||
data: { windowDurationSeconds: 60 },
|
||||
});
|
||||
|
||||
const expectedPhase = calculateSchedulePhase({
|
||||
secret: schedulePhaseSecret,
|
||||
environmentId: environment.id,
|
||||
deduplicationKey: taskSchedule.deduplicationKey,
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
engine.registerNextTaskScheduleInstance({ instanceId: scheduleInstance.id }),
|
||||
engine.registerNextTaskScheduleInstance({ instanceId: scheduleInstance.id }),
|
||||
engine.registerNextTaskScheduleInstance({ instanceId: scheduleInstance.id }),
|
||||
]);
|
||||
|
||||
const assignedInstance = await prisma.taskScheduleInstance.findUniqueOrThrow({
|
||||
where: { id: scheduleInstance.id },
|
||||
select: { schedulePhase: true },
|
||||
});
|
||||
expect(assignedInstance.schedulePhase).toBe(expectedPhase);
|
||||
|
||||
const pinnedPhase = 1_234_567_890;
|
||||
await prisma.taskScheduleInstance.update({
|
||||
where: { id: scheduleInstance.id },
|
||||
data: { schedulePhase: pinnedPhase },
|
||||
});
|
||||
|
||||
await engine.registerNextTaskScheduleInstance({ instanceId: scheduleInstance.id });
|
||||
|
||||
const preservedInstance = await prisma.taskScheduleInstance.findUniqueOrThrow({
|
||||
where: { id: scheduleInstance.id },
|
||||
select: { schedulePhase: true },
|
||||
});
|
||||
expect(preservedInstance.schedulePhase).toBe(pinnedPhase);
|
||||
|
||||
const pendingBeforeNoop = await engine.getJob(
|
||||
`scheduled-task-instance:${scheduleInstance.id}`
|
||||
);
|
||||
|
||||
// No-op reconciliation preserves the existing payload and score atomically.
|
||||
await engine.registerNextTaskScheduleInstance({
|
||||
instanceId: scheduleInstance.id,
|
||||
preserveExistingJob: true,
|
||||
});
|
||||
const pendingAfterNoop = await engine.getJob(
|
||||
`scheduled-task-instance:${scheduleInstance.id}`
|
||||
);
|
||||
expect(pendingAfterNoop).toEqual(pendingBeforeNoop);
|
||||
|
||||
await prisma.taskSchedule.update({
|
||||
where: { id: taskSchedule.id },
|
||||
data: { windowDurationSeconds: 120 },
|
||||
});
|
||||
|
||||
// Normal registration still replaces the job when timing changed.
|
||||
await engine.registerNextTaskScheduleInstance({ instanceId: scheduleInstance.id });
|
||||
const pendingAfterTimingChange = await engine.getJob(
|
||||
`scheduled-task-instance:${scheduleInstance.id}`
|
||||
);
|
||||
expect(pendingAfterTimingChange).not.toEqual(pendingBeforeNoop);
|
||||
|
||||
const intervalMs = 5 * 60_000;
|
||||
const exactScheduleTime = new Date(Math.floor(Date.now() / intervalMs) * intervalMs);
|
||||
const effectiveScheduleTime = new Date(exactScheduleTime.getTime() + 45_000);
|
||||
await engine.triggerScheduledTask({
|
||||
instanceId: scheduleInstance.id,
|
||||
finalAttempt: false,
|
||||
exactScheduleTime,
|
||||
effectiveScheduleTime,
|
||||
});
|
||||
|
||||
expect(triggerCalls).toHaveLength(1);
|
||||
expect(triggerCalls[0].payload.timestamp).toEqual(exactScheduleTime);
|
||||
expect(triggerCalls[0].exactScheduleTime).toEqual(exactScheduleTime);
|
||||
expect(triggerCalls[0].effectiveScheduleTime).toEqual(effectiveScheduleTime);
|
||||
|
||||
const nextJob = await engine.getJob(`scheduled-task-instance:${scheduleInstance.id}`);
|
||||
const nextJobPayload = nextJob!.item as unknown as {
|
||||
exactScheduleTime: string;
|
||||
effectiveScheduleTime: string;
|
||||
};
|
||||
const nextNominalAt = new Date(exactScheduleTime.getTime() + intervalMs);
|
||||
const followingNominalAt = new Date(nextNominalAt.getTime() + intervalMs);
|
||||
const { effectiveAt: nextEffectiveAt } = calculateEffectiveScheduleTime({
|
||||
nominalAt: nextNominalAt,
|
||||
nextNominalAt: followingNominalAt,
|
||||
schedulePhase: pinnedPhase,
|
||||
window: { type: "duration", durationSeconds: 120 },
|
||||
});
|
||||
|
||||
expect(new Date(nextJobPayload.exactScheduleTime)).toEqual(nextNominalAt);
|
||||
expect(new Date(nextJobPayload.effectiveScheduleTime)).toEqual(nextEffectiveAt);
|
||||
expect(nextJob!.timestamp).toEqual(
|
||||
calculateDistributedExecutionTime(nextEffectiveAt, 10, scheduleInstance.id)
|
||||
);
|
||||
} finally {
|
||||
await engine.quit();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
containerTest(
|
||||
"gates cron spread per schedule via the rollout fraction",
|
||||
{ timeout: 30_000 },
|
||||
async ({ prisma, redisOptions }) => {
|
||||
const schedulePhaseSecret = "test-schedule-phase-secret";
|
||||
|
||||
const organization = await prisma.organization.create({
|
||||
data: { title: "Spread Fraction Org", slug: "spread-fraction-org" },
|
||||
});
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
name: "Spread Fraction Project",
|
||||
slug: "spread-fraction-project",
|
||||
externalRef: "spread-fraction-ref",
|
||||
organizationId: organization.id,
|
||||
},
|
||||
});
|
||||
const environment = await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
slug: "spread-fraction-env",
|
||||
type: "PRODUCTION",
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
apiKey: "tr_spread_fraction",
|
||||
pkApiKey: "pk_spread_fraction",
|
||||
shortcode: "spread",
|
||||
},
|
||||
});
|
||||
const taskSchedule = await prisma.taskSchedule.create({
|
||||
data: {
|
||||
friendlyId: "sched_spread_fraction",
|
||||
taskIdentifier: "spread-fraction-task",
|
||||
projectId: project.id,
|
||||
deduplicationKey: "spread-fraction-dedup",
|
||||
generatorExpression: "*/5 * * * *",
|
||||
generatorDescription: "Every 5 minutes",
|
||||
timezone: "UTC",
|
||||
type: "DECLARATIVE",
|
||||
},
|
||||
});
|
||||
const scheduleInstance = await prisma.taskScheduleInstance.create({
|
||||
data: {
|
||||
taskScheduleId: taskSchedule.id,
|
||||
environmentId: environment.id,
|
||||
projectId: project.id,
|
||||
},
|
||||
});
|
||||
|
||||
const phase = calculateSchedulePhase({
|
||||
secret: schedulePhaseSecret,
|
||||
environmentId: environment.id,
|
||||
deduplicationKey: taskSchedule.deduplicationKey,
|
||||
});
|
||||
|
||||
// The gate is `phase < fraction * DENOMINATOR`. Dividing and multiplying
|
||||
// by 2^31 is exact in floating point, so `phase / DENOMINATOR` excludes
|
||||
// this schedule and `(phase + 1) / DENOMINATOR` includes it.
|
||||
const excludingFraction = phase / SCHEDULE_PHASE_DENOMINATOR;
|
||||
const includingFraction = (phase + 1) / SCHEDULE_PHASE_DENOMINATOR;
|
||||
|
||||
const createEngine = (cronSpreadFraction: number) =>
|
||||
new ScheduleEngine({
|
||||
prisma,
|
||||
redis: redisOptions,
|
||||
distributionWindow: { seconds: 10 },
|
||||
schedulePhaseSecret,
|
||||
cronSpreadFraction,
|
||||
worker: {
|
||||
concurrency: 1,
|
||||
disabled: true,
|
||||
pollIntervalMs: 1000,
|
||||
},
|
||||
tracer: trace.getTracer("test", "0.0.0"),
|
||||
onTriggerScheduledTask: async () => ({ success: true }),
|
||||
isDevEnvironmentConnectedHandler: vi.fn().mockResolvedValue(true),
|
||||
});
|
||||
|
||||
const jobId = `scheduled-task-instance:${scheduleInstance.id}`;
|
||||
|
||||
const excludedEngine = createEngine(excludingFraction);
|
||||
try {
|
||||
await excludedEngine.registerNextTaskScheduleInstance({ instanceId: scheduleInstance.id });
|
||||
const job = await excludedEngine.getJob(jobId);
|
||||
const payload = job!.item as unknown as {
|
||||
exactScheduleTime: string;
|
||||
effectiveScheduleTime: string;
|
||||
};
|
||||
// Spread inactive: the effective time is the nominal tick.
|
||||
expect(new Date(payload.effectiveScheduleTime)).toEqual(
|
||||
new Date(payload.exactScheduleTime)
|
||||
);
|
||||
} finally {
|
||||
await excludedEngine.quit();
|
||||
}
|
||||
|
||||
const includedEngine = createEngine(includingFraction);
|
||||
try {
|
||||
await includedEngine.registerNextTaskScheduleInstance({ instanceId: scheduleInstance.id });
|
||||
const job = await includedEngine.getJob(jobId);
|
||||
const payload = job!.item as unknown as {
|
||||
exactScheduleTime: string;
|
||||
effectiveScheduleTime: string;
|
||||
};
|
||||
const nominalAt = new Date(payload.exactScheduleTime);
|
||||
const nextNominalAt = calculateNextNominalTimestamp(
|
||||
taskSchedule.generatorExpression,
|
||||
taskSchedule.timezone,
|
||||
nominalAt
|
||||
);
|
||||
// Spread active with no window configured: the 60s baseline applies.
|
||||
const { effectiveAt } = calculateEffectiveScheduleTime({
|
||||
nominalAt,
|
||||
nextNominalAt,
|
||||
schedulePhase: phase,
|
||||
});
|
||||
expect(new Date(payload.effectiveScheduleTime)).toEqual(effectiveAt);
|
||||
} finally {
|
||||
await includedEngine.quit();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -16,6 +16,8 @@ describe("Schedule Recovery", () => {
|
||||
prisma,
|
||||
redis: redisOptions,
|
||||
distributionWindow: { seconds: 10 },
|
||||
schedulePhaseSecret: "test-schedule-phase-secret",
|
||||
cronSpreadFraction: 1,
|
||||
worker: {
|
||||
concurrency: 1,
|
||||
disabled: true, // Disable worker to prevent automatic execution
|
||||
@@ -118,6 +120,8 @@ describe("Schedule Recovery", () => {
|
||||
prisma,
|
||||
redis: redisOptions,
|
||||
distributionWindow: { seconds: 10 },
|
||||
schedulePhaseSecret: "test-schedule-phase-secret",
|
||||
cronSpreadFraction: 1,
|
||||
worker: {
|
||||
concurrency: 1,
|
||||
disabled: true, // Disable worker to prevent automatic execution
|
||||
@@ -223,6 +227,8 @@ describe("Schedule Recovery", () => {
|
||||
prisma,
|
||||
redis: redisOptions,
|
||||
distributionWindow: { seconds: 10 },
|
||||
schedulePhaseSecret: "test-schedule-phase-secret",
|
||||
cronSpreadFraction: 1,
|
||||
worker: {
|
||||
concurrency: 1,
|
||||
disabled: true, // Disable worker to prevent automatic execution
|
||||
@@ -334,6 +340,8 @@ describe("Schedule Recovery", () => {
|
||||
prisma,
|
||||
redis: redisOptions,
|
||||
distributionWindow: { seconds: 10 },
|
||||
schedulePhaseSecret: "test-schedule-phase-secret",
|
||||
cronSpreadFraction: 1,
|
||||
worker: {
|
||||
concurrency: 1,
|
||||
disabled: true, // Disable worker to prevent automatic execution
|
||||
@@ -404,6 +412,8 @@ describe("Schedule Recovery", () => {
|
||||
prisma,
|
||||
redis: redisOptions,
|
||||
distributionWindow: { seconds: 10 },
|
||||
schedulePhaseSecret: "test-schedule-phase-secret",
|
||||
cronSpreadFraction: 1,
|
||||
worker: { concurrency: 1, disabled: true, pollIntervalMs: 1000 },
|
||||
tracer: trace.getTracer("test", "0.0.0"),
|
||||
onTriggerScheduledTask: async () => ({ success: true }),
|
||||
@@ -505,6 +515,8 @@ describe("Schedule Recovery", () => {
|
||||
prisma,
|
||||
redis: redisOptions,
|
||||
distributionWindow: { seconds: 10 },
|
||||
schedulePhaseSecret: "test-schedule-phase-secret",
|
||||
cronSpreadFraction: 1,
|
||||
worker: { concurrency: 1, disabled: true, pollIntervalMs: 1000 },
|
||||
tracer: trace.getTracer("test", "0.0.0"),
|
||||
onTriggerScheduledTask: async () => ({ success: true }),
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { BackgroundWorkerMetadata } from "./resources.js";
|
||||
import { DequeuedMessage, MachineResources } from "./runEngine.js";
|
||||
import { QueueTypeName } from "./queues.js";
|
||||
import { ScheduleWindow } from "./schemas.js";
|
||||
|
||||
export const RunEngineVersion = z.union([z.literal("V1"), z.literal("V2")]);
|
||||
|
||||
@@ -1045,6 +1046,13 @@ export const CreateScheduleOptions = z.object({
|
||||
*
|
||||
*/
|
||||
timezone: z.string().optional(),
|
||||
/** Optionally delay each occurrence by a stable amount within this window.
|
||||
* Absolute windows use whole minutes or hours up to 24 hours and are capped at the next
|
||||
* nominal interval. Percentages are relative to each nominal interval.
|
||||
*
|
||||
* @example "30m", "2h", "24h", "30%", "100%"
|
||||
*/
|
||||
window: ScheduleWindow.optional(),
|
||||
});
|
||||
|
||||
export type CreateScheduleOptions = z.infer<typeof CreateScheduleOptions>;
|
||||
@@ -1070,6 +1078,7 @@ export const ScheduleObject = z.object({
|
||||
externalId: z.string().nullish(),
|
||||
generator: ScheduleGenerator,
|
||||
timezone: z.string(),
|
||||
window: ScheduleWindow.optional(),
|
||||
nextRun: z.coerce.date().nullish(),
|
||||
environments: z.array(
|
||||
z.object({
|
||||
|
||||
@@ -174,10 +174,20 @@ export const QueueManifest = z.object({
|
||||
|
||||
export type QueueManifest = z.infer<typeof QueueManifest>;
|
||||
|
||||
/**
|
||||
* A delay window after a nominal cron tick.
|
||||
*
|
||||
* The server's schedule timing domain validates and normalizes the public syntax.
|
||||
*/
|
||||
export const ScheduleWindow = z.string().min(1);
|
||||
|
||||
export type ScheduleWindow = z.infer<typeof ScheduleWindow>;
|
||||
|
||||
export const ScheduleMetadata = z.object({
|
||||
cron: z.string(),
|
||||
timezone: z.string(),
|
||||
environments: z.array(EnvironmentType).optional(),
|
||||
window: ScheduleWindow.optional(),
|
||||
});
|
||||
|
||||
const AgentConfig = z.object({
|
||||
|
||||
Reference in New Issue
Block a user