Declarative schedules (#1226)
* Added type (STATIC or DYNAMIC) to TaskSchedule. Defaults to dynamic * WIP with dev indexing of static schedules * Added a code comment * First stab at deleting unused static schedules * Dashboard changes for the static schedules * Generate the description. Upsert the instances when editing. Fix for the friendlyId * Don’t allow deleting of static schedules * Don’t allow enabling/disabling of static schedules * Added filtering for schedule types * Syncing of schedule for deployed tasks * Static schedules are now created for each environment * Added a second static schedule for testing * Add the type to the schedule task run payload and the object you get back from the SDK * Changed static/dynamic to declarative/imperative * Timezone example * Changeset * Updated scheduled docs to include declarative * When you test a schedule it set the type to “IMPERATIVE” * Improved the tooltip * Fix for queue time continuing to rise when a run is canceled/expired etc * Update the info panel on a selected declarative schedule * Check if there are no instances. This should never happen but log an error if it does * Throw errors and push them through to the CLI dev command * Fail deployments if creating the background tasks or schedules fails * Format the deployment error so it gets displayed * Changed the maxed out schedules error message to remove bit about support
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Added declarative cron schedules
|
||||
@@ -1,6 +1,6 @@
|
||||
import { XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import { useNavigate } from "@remix-run/react";
|
||||
import { RuntimeEnvironment } from "@trigger.dev/database";
|
||||
import { type RuntimeEnvironment } from "@trigger.dev/database";
|
||||
import { useCallback } from "react";
|
||||
import { z } from "zod";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../../primitives/SimpleSelect";
|
||||
import { ScheduleTypeCombo } from "./ScheduleType";
|
||||
|
||||
export const ScheduleListFilters = z.object({
|
||||
page: z.coerce.number().default(1),
|
||||
@@ -28,6 +29,7 @@ export const ScheduleListFilters = z.object({
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => (value ? value.split(",") : undefined)),
|
||||
type: z.union([z.literal("declarative"), z.literal("imperative")]).optional(),
|
||||
search: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -48,7 +50,7 @@ export function ScheduleFilters({ possibleEnvironments, possibleTasks }: Schedul
|
||||
const navigate = useNavigate();
|
||||
const location = useOptimisticLocation();
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const { environments, tasks, page, search } = ScheduleListFilters.parse(
|
||||
const { environments, tasks, page, search, type } = ScheduleListFilters.parse(
|
||||
Object.fromEntries(searchParams.entries())
|
||||
);
|
||||
|
||||
@@ -73,6 +75,10 @@ export function ScheduleFilters({ possibleEnvironments, possibleTasks }: Schedul
|
||||
handleFilterChange("environments", value === "ALL" ? undefined : value);
|
||||
}, []);
|
||||
|
||||
const handleTypeChange = useCallback((value: string | typeof All) => {
|
||||
handleFilterChange("type", value === "ALL" ? undefined : value);
|
||||
}, []);
|
||||
|
||||
const handleSearchChange = useThrottle((value: string) => {
|
||||
handleFilterChange("search", value.length === 0 ? undefined : value);
|
||||
}, 300);
|
||||
@@ -97,6 +103,30 @@ export function ScheduleFilters({ possibleEnvironments, possibleTasks }: Schedul
|
||||
defaultValue={search}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
/>
|
||||
<SelectGroup>
|
||||
<Select name="type" value={type ?? "ALL"} onValueChange={handleTypeChange}>
|
||||
<SelectTrigger size="minimal" width="full">
|
||||
<SelectValue placeholder={"Select type"} className="ml-2 whitespace-nowrap p-0" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={"ALL"}>
|
||||
<Paragraph
|
||||
variant="extra-small"
|
||||
className="whitespace-nowrap pl-0.5 transition group-hover:text-text-bright"
|
||||
>
|
||||
All types
|
||||
</Paragraph>
|
||||
</SelectItem>
|
||||
<SelectItem value={"declarative"}>
|
||||
<ScheduleTypeCombo type="DECLARATIVE" className="text-xs text-text-dimmed" />
|
||||
</SelectItem>
|
||||
<SelectItem value={"imperative"}>
|
||||
<ScheduleTypeCombo type="IMPERATIVE" className="text-xs text-text-dimmed" />
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SelectGroup>
|
||||
|
||||
<SelectGroup>
|
||||
<Select
|
||||
name="environment"
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ArchiveBoxIcon, ArrowsRightLeftIcon } from "@heroicons/react/20/solid";
|
||||
import type { ScheduleType } from "@trigger.dev/database";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export function ScheduleTypeCombo({ type, className }: { type: ScheduleType; className?: string }) {
|
||||
return (
|
||||
<div className={cn("flex items-center space-x-1", className)}>
|
||||
<ScheduleTypeIcon type={type} />
|
||||
<span>{scheduleTypeName(type)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScheduleTypeIcon({ type, className }: { type: ScheduleType; className?: string }) {
|
||||
switch (type) {
|
||||
case "IMPERATIVE":
|
||||
return <ArrowsRightLeftIcon className={cn("size-4", className)} />;
|
||||
case "DECLARATIVE":
|
||||
return <ArchiveBoxIcon className={cn("size-4", className)} />;
|
||||
}
|
||||
}
|
||||
export function scheduleTypeName(type: ScheduleType) {
|
||||
switch (type) {
|
||||
case "IMPERATIVE":
|
||||
return "Imperative";
|
||||
case "DECLARATIVE":
|
||||
return "Declarative";
|
||||
}
|
||||
}
|
||||
@@ -230,8 +230,12 @@ export function TaskRunsTable({
|
||||
formatDuration(new Date(run.createdAt), new Date(run.startedAt), {
|
||||
style: "short",
|
||||
})
|
||||
) : (
|
||||
) : run.isCancellable ? (
|
||||
<LiveTimer startTime={new Date(run.createdAt)} />
|
||||
) : (
|
||||
formatDuration(new Date(run.createdAt), new Date(run.updatedAt), {
|
||||
style: "short",
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
@@ -88,6 +88,7 @@ export class EditSchedulePresenter {
|
||||
const schedule = await this.#prismaClient.taskSchedule.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
friendlyId: true,
|
||||
generatorExpression: true,
|
||||
externalId: true,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Prisma, RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import { Prisma, RuntimeEnvironmentType, ScheduleType } from "@trigger.dev/database";
|
||||
import { ScheduleListFilters } from "~/components/runs/v3/ScheduleFilters";
|
||||
import { sqlDatabaseSchema } from "~/db.server";
|
||||
import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
|
||||
@@ -16,6 +16,7 @@ const DEFAULT_PAGE_SIZE = 20;
|
||||
|
||||
export type ScheduleListItem = {
|
||||
id: string;
|
||||
type: ScheduleType;
|
||||
friendlyId: string;
|
||||
taskIdentifier: string;
|
||||
deduplicationKey: string | null;
|
||||
@@ -44,10 +45,17 @@ export class ScheduleListPresenter extends BasePresenter {
|
||||
environments,
|
||||
search,
|
||||
page,
|
||||
type,
|
||||
pageSize = DEFAULT_PAGE_SIZE,
|
||||
}: ScheduleListOptions) {
|
||||
const hasFilters =
|
||||
tasks !== undefined || environments !== undefined || (search !== undefined && search !== "");
|
||||
type !== undefined ||
|
||||
tasks !== undefined ||
|
||||
environments !== undefined ||
|
||||
(search !== undefined && search !== "");
|
||||
|
||||
const filterType =
|
||||
type === "declarative" ? "DECLARATIVE" : type === "imperative" ? "IMPERATIVE" : undefined;
|
||||
|
||||
// Find the project scoped to the organization
|
||||
const project = await this._replica.project.findFirstOrThrow({
|
||||
@@ -105,6 +113,7 @@ export class ScheduleListPresenter extends BasePresenter {
|
||||
environmentId: environments ? { in: environments } : undefined,
|
||||
},
|
||||
},
|
||||
type: filterType,
|
||||
AND: search
|
||||
? {
|
||||
OR: [
|
||||
@@ -141,6 +150,7 @@ export class ScheduleListPresenter extends BasePresenter {
|
||||
const rawSchedules = await this._replica.taskSchedule.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
friendlyId: true,
|
||||
taskIdentifier: true,
|
||||
deduplicationKey: true,
|
||||
@@ -166,6 +176,7 @@ export class ScheduleListPresenter extends BasePresenter {
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
type: filterType,
|
||||
AND: search
|
||||
? {
|
||||
OR: [
|
||||
@@ -215,11 +226,12 @@ export class ScheduleListPresenter extends BasePresenter {
|
||||
ON t."scheduleId" = r."scheduleId" AND t."createdAt" = r."LatestRun";`
|
||||
: [];
|
||||
|
||||
const schedules = rawSchedules.map((schedule) => {
|
||||
const schedules: ScheduleListItem[] = rawSchedules.map((schedule) => {
|
||||
const latestRun = latestRuns.find((r) => r.scheduleId === schedule.id);
|
||||
|
||||
return {
|
||||
id: schedule.id,
|
||||
type: schedule.type,
|
||||
friendlyId: schedule.friendlyId,
|
||||
taskIdentifier: schedule.taskIdentifier,
|
||||
deduplicationKey: schedule.deduplicationKey,
|
||||
|
||||
@@ -21,6 +21,7 @@ export class ViewSchedulePresenter {
|
||||
const schedule = await this.#prismaClient.taskSchedule.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
friendlyId: true,
|
||||
generatorExpression: true,
|
||||
generatorDescription: true,
|
||||
@@ -99,6 +100,7 @@ export class ViewSchedulePresenter {
|
||||
public toJSONResponse(result: NonNullable<Awaited<ReturnType<ViewSchedulePresenter["call"]>>>) {
|
||||
const response: ScheduleObject = {
|
||||
id: result.schedule.friendlyId,
|
||||
type: result.schedule.type,
|
||||
task: result.schedule.taskIdentifier,
|
||||
active: result.schedule.active,
|
||||
nextRun: result.schedule.nextRuns[0],
|
||||
|
||||
+104
-67
@@ -1,15 +1,21 @@
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { BoltIcon, BoltSlashIcon, PencilSquareIcon, TrashIcon } from "@heroicons/react/20/solid";
|
||||
import {
|
||||
BoltIcon,
|
||||
BoltSlashIcon,
|
||||
BookOpenIcon,
|
||||
PencilSquareIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { DialogDescription } from "@radix-ui/react-dialog";
|
||||
import { Form, useLocation } from "@remix-run/react";
|
||||
import { ActionFunctionArgs, LoaderFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { token } from "morgan";
|
||||
import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { ExitIcon } from "~/assets/icons/ExitIcon";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { EnvironmentLabel, EnvironmentLabels } from "~/components/environments/EnvironmentLabel";
|
||||
import { EnvironmentLabels } from "~/components/environments/EnvironmentLabel";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Callout, variantClasses } from "~/components/primitives/Callout";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -19,6 +25,7 @@ import {
|
||||
DialogTrigger,
|
||||
} from "~/components/primitives/Dialog";
|
||||
import { Header2, Header3 } from "~/components/primitives/Headers";
|
||||
import { InfoPanel } from "~/components/primitives/InfoPanel";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Property, PropertyTable } from "~/components/primitives/PropertyTable";
|
||||
import {
|
||||
@@ -31,11 +38,11 @@ import {
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { EnabledStatus } from "~/components/runs/v3/EnabledStatus";
|
||||
import { ScheduleTypeCombo } from "~/components/runs/v3/ScheduleType";
|
||||
import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable";
|
||||
import { prisma } from "~/db.server";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useUser } from "~/hooks/useUser";
|
||||
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { ViewSchedulePresenter } from "~/presenters/v3/ViewSchedulePresenter.server";
|
||||
@@ -197,8 +204,15 @@ export default function Page() {
|
||||
|
||||
const isUtc = schedule.timezone === "UTC";
|
||||
|
||||
const isImperative = schedule.type === "IMPERATIVE";
|
||||
|
||||
return (
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_1fr_3.25rem] overflow-hidden bg-background-bright">
|
||||
<div
|
||||
className={cn(
|
||||
"grid h-full max-h-full overflow-hidden bg-background-bright",
|
||||
isImperative ? "grid-rows-[2.5rem_1fr_3.25rem]" : "grid-rows-[2.5rem_1fr]"
|
||||
)}
|
||||
>
|
||||
<div className="mx-3 flex items-center justify-between gap-2 border-b border-grid-dimmed">
|
||||
<Header2 className={cn("whitespace-nowrap")}>{schedule.friendlyId}</Header2>
|
||||
<LinkButton
|
||||
@@ -214,6 +228,9 @@ export default function Page() {
|
||||
<PropertyTable>
|
||||
<Property label="Schedule ID">{schedule.friendlyId}</Property>
|
||||
<Property label="Task ID">{schedule.taskIdentifier}</Property>
|
||||
<Property label="Type">
|
||||
<ScheduleTypeCombo type={schedule.type} className="text-sm" />
|
||||
</Property>
|
||||
<Property label="CRON (UTC)" labelClassName="self-start">
|
||||
<div className="space-y-2">
|
||||
<InlineCode variant="extra-small">{schedule.cron}</InlineCode>
|
||||
@@ -224,15 +241,19 @@ export default function Page() {
|
||||
<Property label="Environments">
|
||||
<EnvironmentLabels size="small" environments={schedule.environments} />
|
||||
</Property>
|
||||
<Property label="External ID">
|
||||
{schedule.externalId ? schedule.externalId : "–"}
|
||||
</Property>
|
||||
<Property label="Deduplication key">
|
||||
{schedule.userProvidedDeduplicationKey ? schedule.deduplicationKey : "–"}
|
||||
</Property>
|
||||
<Property label="Status">
|
||||
<EnabledStatus enabled={schedule.active} />
|
||||
</Property>
|
||||
{isImperative && (
|
||||
<>
|
||||
<Property label="External ID">
|
||||
{schedule.externalId ? schedule.externalId : "–"}
|
||||
</Property>
|
||||
<Property label="Deduplication key">
|
||||
{schedule.userProvidedDeduplicationKey ? schedule.deduplicationKey : "–"}
|
||||
</Property>
|
||||
<Property label="Status">
|
||||
<EnabledStatus enabled={schedule.active} />
|
||||
</Property>
|
||||
</>
|
||||
)}
|
||||
</PropertyTable>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Header3>Last 5 runs</Header3>
|
||||
@@ -288,68 +309,84 @@ export default function Page() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{!isImperative && (
|
||||
<InfoPanel
|
||||
title="Editing declarative schedules"
|
||||
icon={BookOpenIcon}
|
||||
iconClassName="text-indigo-500"
|
||||
variant="info"
|
||||
buttonLabel="Docs"
|
||||
to="https://trigger.dev/docs/v3/tasks-scheduled"
|
||||
panelClassName="max-w-full"
|
||||
>
|
||||
You can only edit a declarative schedule by updating your schedules.task and then
|
||||
running the CLI dev and deploy commands.
|
||||
</InfoPanel>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 border-t border-grid-dimmed px-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<Form method="post">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="minimal/medium"
|
||||
LeadingIcon={schedule.active ? BoltSlashIcon : BoltIcon}
|
||||
leadingIconClassName={schedule.active ? "text-dimmed" : "text-success"}
|
||||
name="action"
|
||||
value={schedule.active ? "disable" : "enable"}
|
||||
>
|
||||
{schedule.active ? "Disable" : "Enable"}
|
||||
</Button>
|
||||
</Form>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
{isImperative && (
|
||||
<div className="flex items-center justify-between gap-2 border-t border-grid-dimmed px-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<Form method="post">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="minimal/medium"
|
||||
LeadingIcon={TrashIcon}
|
||||
leadingIconClassName="text-error"
|
||||
className="text-error"
|
||||
LeadingIcon={schedule.active ? BoltSlashIcon : BoltIcon}
|
||||
leadingIconClassName={schedule.active ? "text-dimmed" : "text-success"}
|
||||
name="action"
|
||||
value="delete"
|
||||
value={schedule.active ? "disable" : "enable"}
|
||||
>
|
||||
Delete
|
||||
{schedule.active ? "Disable" : "Enable"}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>Delete schedule</DialogHeader>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this schedule? This can't be reversed.
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<Form method="post">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="danger/small"
|
||||
LeadingIcon={TrashIcon}
|
||||
name="action"
|
||||
value="delete"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Form>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Form>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="minimal/medium"
|
||||
LeadingIcon={TrashIcon}
|
||||
leadingIconClassName="text-error"
|
||||
className="text-error"
|
||||
name="action"
|
||||
value="delete"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>Delete schedule</DialogHeader>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this schedule? This can't be reversed.
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<Form method="post">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="danger/small"
|
||||
LeadingIcon={TrashIcon}
|
||||
name="action"
|
||||
value="delete"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Form>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<LinkButton
|
||||
variant="tertiary/medium"
|
||||
to={`${v3EditSchedulePath(organization, project, schedule)}${location.search}`}
|
||||
LeadingIcon={PencilSquareIcon}
|
||||
>
|
||||
Edit schedule
|
||||
</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<LinkButton
|
||||
variant="tertiary/medium"
|
||||
to={`${v3EditSchedulePath(organization, project, schedule)}${location.search}`}
|
||||
LeadingIcon={PencilSquareIcon}
|
||||
>
|
||||
Edit schedule
|
||||
</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+6
-2
@@ -1,8 +1,8 @@
|
||||
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { LoaderFunctionArgs, redirect } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { EditSchedulePresenter } from "~/presenters/v3/EditSchedulePresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { ProjectParamSchema, v3ScheduleParams } from "~/utils/pathBuilder";
|
||||
import { ProjectParamSchema, v3ScheduleParams, v3SchedulesPath } from "~/utils/pathBuilder";
|
||||
import { humanToCronSupported } from "~/v3/humanToCron.server";
|
||||
import { UpsertScheduleForm } from "../resources.orgs.$organizationSlug.projects.$projectParam.schedules.new/route";
|
||||
|
||||
@@ -17,6 +17,10 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
friendlyId: scheduleParam,
|
||||
});
|
||||
|
||||
if (result.schedule?.type === "DECLARATIVE") {
|
||||
throw redirect(v3SchedulesPath({ slug: organizationSlug }, { slug: projectParam }));
|
||||
}
|
||||
|
||||
return typedjson({ ...result, showGenerateField: humanToCronSupported });
|
||||
};
|
||||
|
||||
|
||||
+68
-3
@@ -10,6 +10,11 @@ import { EnvironmentLabels } from "~/components/environments/EnvironmentLabel";
|
||||
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import {
|
||||
ScheduleTypeCombo,
|
||||
ScheduleTypeIcon,
|
||||
scheduleTypeName,
|
||||
} from "~/components/runs/v3/ScheduleType";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -334,6 +339,51 @@ function SchedulesTable({
|
||||
<TableRow>
|
||||
<TableHeaderCell>ID</TableHeaderCell>
|
||||
<TableHeaderCell>Task ID</TableHeaderCell>
|
||||
<TableHeaderCell
|
||||
tooltip={
|
||||
<div className="flex max-w-xs flex-col gap-4 p-1">
|
||||
<div>
|
||||
<div className="mb-0.5 flex items-center gap-1.5 text-sm">
|
||||
<div className={"flex items-center space-x-1"}>
|
||||
<ScheduleTypeIcon type={"DECLARATIVE"} className="text-sky-500" />
|
||||
<span className="font-medium">{scheduleTypeName("DECLARATIVE")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Paragraph variant="small" className="!text-wrap text-text-dimmed">
|
||||
Declarative schedules are defined in a{" "}
|
||||
<InlineCode variant="extra-small">schedules.task</InlineCode> with the{" "}
|
||||
<InlineCode variant="extra-small">cron</InlineCode>
|
||||
property. They sync when you update your{" "}
|
||||
<InlineCode variant="extra-small">schedules.task</InlineCode> definition and run
|
||||
the CLI dev or deploy commands.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-0.5 flex items-center gap-1.5 text-sm">
|
||||
<div className={"flex items-center space-x-1"}>
|
||||
<ScheduleTypeIcon type={"IMPERATIVE"} className="text-teal-500" />
|
||||
<span className="font-medium">{scheduleTypeName("IMPERATIVE")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Paragraph variant="small" className="!text-wrap text-text-dimmed">
|
||||
Imperative schedules are defined here in the dashboard or by using the SDK
|
||||
functions to create or delete them. They can be created, updated, disabled, and
|
||||
deleted from the dashboard or using the SDK.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div>
|
||||
<LinkButton
|
||||
variant="tertiary/medium"
|
||||
to="https://trigger.dev/docs/v3/tasks-scheduled"
|
||||
>
|
||||
View the docs
|
||||
</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
Type
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell>External ID</TableHeaderCell>
|
||||
<TableHeaderCell>CRON</TableHeaderCell>
|
||||
<TableHeaderCell hiddenLabel>CRON description</TableHeaderCell>
|
||||
@@ -362,7 +412,14 @@ function SchedulesTable({
|
||||
{schedule.taskIdentifier}
|
||||
</TableCell>
|
||||
<TableCell to={path} className={cellClass}>
|
||||
{schedule.externalId ? schedule.externalId : "–"}
|
||||
<ScheduleTypeCombo type={schedule.type} />
|
||||
</TableCell>
|
||||
<TableCell to={path} className={cellClass}>
|
||||
{schedule.type === "IMPERATIVE"
|
||||
? schedule.externalId
|
||||
? schedule.externalId
|
||||
: "–"
|
||||
: "N/A"}
|
||||
</TableCell>
|
||||
<TableCell to={path} className={cellClass}>
|
||||
{schedule.cron}
|
||||
@@ -384,13 +441,21 @@ function SchedulesTable({
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell to={path} className={cellClass}>
|
||||
{schedule.userProvidedDeduplicationKey ? schedule.deduplicationKey : "–"}
|
||||
{schedule.type === "IMPERATIVE"
|
||||
? schedule.userProvidedDeduplicationKey
|
||||
? schedule.deduplicationKey
|
||||
: "–"
|
||||
: "N/A"}
|
||||
</TableCell>
|
||||
<TableCell to={path} className={cellClass}>
|
||||
<EnvironmentLabels environments={schedule.environments} size="small" />
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
<EnabledStatus enabled={schedule.active} />
|
||||
{schedule.type === "IMPERATIVE" ? (
|
||||
<EnabledStatus enabled={schedule.active} />
|
||||
) : (
|
||||
"N/A"
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,11 @@ import { CreateBackgroundWorkerRequestBody } from "@trigger.dev/core/v3";
|
||||
import { z } from "zod";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { CreateBackgroundWorkerService } from "~/v3/services/createBackgroundWorker.server";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import {
|
||||
CreateBackgroundWorkerService,
|
||||
CreateDeclarativeScheduleError,
|
||||
} from "~/v3/services/createBackgroundWorker.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
projectRef: z.string(),
|
||||
@@ -42,14 +46,26 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
|
||||
const service = new CreateBackgroundWorkerService();
|
||||
|
||||
const backgroundWorker = await service.call(projectRef, authenticatedEnv, body.data);
|
||||
try {
|
||||
const backgroundWorker = await service.call(projectRef, authenticatedEnv, body.data);
|
||||
|
||||
return json(
|
||||
{
|
||||
id: backgroundWorker.friendlyId,
|
||||
version: backgroundWorker.version,
|
||||
contentHash: backgroundWorker.contentHash,
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
return json(
|
||||
{
|
||||
id: backgroundWorker.friendlyId,
|
||||
version: backgroundWorker.version,
|
||||
contentHash: backgroundWorker.contentHash,
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (e) {
|
||||
logger.error("Failed to create background worker", { error: e });
|
||||
|
||||
if (e instanceof ServiceValidationError) {
|
||||
return json({ error: e.message }, { status: 400 });
|
||||
} else if (e instanceof CreateDeclarativeScheduleError) {
|
||||
return json({ error: e.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return json({ error: "Failed to create background worker" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
|
||||
const responseObject: ScheduleObject = {
|
||||
id: schedule.id,
|
||||
type: schedule.type,
|
||||
task: schedule.task,
|
||||
active: schedule.active,
|
||||
generator: {
|
||||
|
||||
@@ -50,6 +50,7 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
|
||||
const responseObject: ScheduleObject = {
|
||||
id: schedule.id,
|
||||
type: schedule.type,
|
||||
task: schedule.task,
|
||||
active: schedule.active,
|
||||
generator: {
|
||||
@@ -107,6 +108,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
return {
|
||||
data: result.schedules.map((schedule) => ({
|
||||
id: schedule.friendlyId,
|
||||
type: schedule.type,
|
||||
task: schedule.taskIdentifier,
|
||||
generator: {
|
||||
type: "CRON",
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { ZodError } from "zod";
|
||||
import { CronPattern } from "../schedules";
|
||||
import { BaseService, ServiceValidationError } from "./baseService.server";
|
||||
import { getLimit } from "~/services/platform.v3.server";
|
||||
import { getTimezones } from "~/utils/timezones.server";
|
||||
import { env } from "~/env.server";
|
||||
|
||||
type Schedule = {
|
||||
cron: string;
|
||||
timezone?: string;
|
||||
taskIdentifier: string;
|
||||
friendlyId?: string;
|
||||
};
|
||||
|
||||
export class CheckScheduleService extends BaseService {
|
||||
public async call(projectId: string, schedule: Schedule) {
|
||||
//validate the cron expression
|
||||
try {
|
||||
CronPattern.parse(schedule.cron);
|
||||
} catch (e) {
|
||||
if (e instanceof ZodError) {
|
||||
throw new ServiceValidationError(`Invalid cron expression: ${e.issues[0].message}`);
|
||||
}
|
||||
|
||||
throw new ServiceValidationError(
|
||||
`Invalid cron expression: ${e instanceof Error ? e.message : JSON.stringify(e)}`
|
||||
);
|
||||
}
|
||||
|
||||
//chek it's a valid timezone
|
||||
if (schedule.timezone) {
|
||||
const possibleTimezones = getTimezones();
|
||||
if (!possibleTimezones.includes(schedule.timezone)) {
|
||||
throw new ServiceValidationError(
|
||||
`Invalid IANA timezone: '${schedule.timezone}'. View the list of valid timezones at ${env.APP_ORIGIN}/timezones`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
//check the task exists
|
||||
const task = await this._prisma.backgroundWorkerTask.findFirst({
|
||||
where: {
|
||||
slug: schedule.taskIdentifier,
|
||||
projectId: projectId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
throw new ServiceValidationError(
|
||||
`Task with identifier ${schedule.taskIdentifier} not found in project.`
|
||||
);
|
||||
}
|
||||
|
||||
if (task.triggerSource !== "SCHEDULED") {
|
||||
throw new ServiceValidationError(
|
||||
`Task with identifier ${schedule.taskIdentifier} is not a scheduled task.`
|
||||
);
|
||||
}
|
||||
|
||||
//if creating a schedule, check they're under the limits
|
||||
if (!schedule.friendlyId) {
|
||||
//check they're within their limit
|
||||
const project = await this._prisma.project.findFirst({
|
||||
where: {
|
||||
id: projectId,
|
||||
},
|
||||
select: {
|
||||
organizationId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new ServiceValidationError("Project not found");
|
||||
}
|
||||
|
||||
const limit = await getLimit(project.organizationId, "schedules", 500);
|
||||
const schedulesCount = await this._prisma.taskSchedule.count({
|
||||
where: {
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
|
||||
if (schedulesCount >= limit) {
|
||||
throw new ServiceValidationError(
|
||||
`You have created ${schedulesCount}/${limit} schedules so you'll need to increase your limits or delete some schedules.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@ import { generateFriendlyId } from "../friendlyIdentifiers";
|
||||
import { calculateNextBuildVersion } from "../utils/calculateNextBuildVersion";
|
||||
import { BaseService } from "./baseService.server";
|
||||
import { projectPubSub } from "./projectPubSub.server";
|
||||
import { RegisterNextTaskScheduleInstanceService } from "./registerNextTaskScheduleInstance.server";
|
||||
import cronstrue from "cronstrue";
|
||||
import { CheckScheduleService } from "./checkSchedule.server";
|
||||
|
||||
export class CreateBackgroundWorkerService extends BaseService {
|
||||
public async call(
|
||||
@@ -68,6 +71,12 @@ export class CreateBackgroundWorkerService extends BaseService {
|
||||
});
|
||||
|
||||
await createBackgroundTasks(body.metadata.tasks, backgroundWorker, environment, this._prisma);
|
||||
await syncDeclarativeSchedules(
|
||||
body.metadata.tasks,
|
||||
backgroundWorker,
|
||||
environment,
|
||||
this._prisma
|
||||
);
|
||||
|
||||
try {
|
||||
//send a notification that a new worker has been created
|
||||
@@ -220,3 +229,155 @@ export async function createBackgroundTasks(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//CreateDeclarativeScheduleError with a message
|
||||
export class CreateDeclarativeScheduleError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "CreateDeclarativeScheduleError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncDeclarativeSchedules(
|
||||
tasks: TaskResource[],
|
||||
worker: BackgroundWorker,
|
||||
environment: AuthenticatedEnvironment,
|
||||
prisma: PrismaClientOrTransaction
|
||||
) {
|
||||
const tasksWithDeclarativeSchedules = tasks.filter((task) => task.schedule);
|
||||
logger.info("Syncing declarative schedules", {
|
||||
tasksWithDeclarativeSchedules,
|
||||
environment,
|
||||
});
|
||||
|
||||
const existingDeclarativeSchedules = await prisma.taskSchedule.findMany({
|
||||
where: {
|
||||
type: "DECLARATIVE",
|
||||
projectId: environment.projectId,
|
||||
},
|
||||
include: {
|
||||
instances: true,
|
||||
},
|
||||
});
|
||||
|
||||
const checkSchedule = new CheckScheduleService(prisma);
|
||||
const registerNextService = new RegisterNextTaskScheduleInstanceService(prisma);
|
||||
|
||||
//start out by assuming they're all missing
|
||||
const missingSchedules = new Set<string>(
|
||||
existingDeclarativeSchedules.map((schedule) => schedule.id)
|
||||
);
|
||||
|
||||
//create/update schedules (+ instances)
|
||||
for (const task of tasksWithDeclarativeSchedules) {
|
||||
if (task.schedule === undefined) continue;
|
||||
|
||||
const existingSchedule = existingDeclarativeSchedules.find(
|
||||
(schedule) =>
|
||||
schedule.taskIdentifier === task.id &&
|
||||
schedule.instances.some((instance) => instance.environmentId === environment.id)
|
||||
);
|
||||
|
||||
//this throws errors if the schedule is invalid
|
||||
await checkSchedule.call(environment.projectId, {
|
||||
cron: task.schedule.cron,
|
||||
timezone: task.schedule.timezone,
|
||||
taskIdentifier: task.id,
|
||||
friendlyId: existingSchedule?.friendlyId,
|
||||
});
|
||||
|
||||
if (existingSchedule) {
|
||||
const schedule = await prisma.taskSchedule.update({
|
||||
where: {
|
||||
id: existingSchedule.id,
|
||||
},
|
||||
data: {
|
||||
generatorExpression: task.schedule.cron,
|
||||
generatorDescription: cronstrue.toString(task.schedule.cron),
|
||||
timezone: task.schedule.timezone,
|
||||
},
|
||||
include: {
|
||||
instances: true,
|
||||
},
|
||||
});
|
||||
|
||||
missingSchedules.delete(existingSchedule.id);
|
||||
const instance = schedule.instances.at(0);
|
||||
if (instance) {
|
||||
await registerNextService.call(instance.id);
|
||||
} else {
|
||||
throw new CreateDeclarativeScheduleError(
|
||||
`Missing instance for declarative schedule ${schedule.id}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const newSchedule = await prisma.taskSchedule.create({
|
||||
data: {
|
||||
friendlyId: generateFriendlyId("sched"),
|
||||
projectId: environment.projectId,
|
||||
taskIdentifier: task.id,
|
||||
generatorExpression: task.schedule.cron,
|
||||
generatorDescription: cronstrue.toString(task.schedule.cron),
|
||||
timezone: task.schedule.timezone,
|
||||
type: "DECLARATIVE",
|
||||
instances: {
|
||||
create: [
|
||||
{
|
||||
environmentId: environment.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
include: {
|
||||
instances: true,
|
||||
},
|
||||
});
|
||||
|
||||
const instance = newSchedule.instances.at(0);
|
||||
|
||||
if (instance) {
|
||||
await registerNextService.call(instance.id);
|
||||
} else {
|
||||
throw new CreateDeclarativeScheduleError(
|
||||
`Missing instance for declarative schedule ${newSchedule.id}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//Delete instances for this environment
|
||||
//Delete schedules that have no instances left
|
||||
const potentiallyDeletableSchedules = await prisma.taskSchedule.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: Array.from(missingSchedules),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
instances: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const schedule of potentiallyDeletableSchedules) {
|
||||
const canDeleteSchedule =
|
||||
schedule.instances.length === 0 ||
|
||||
schedule.instances.every((instance) => instance.environmentId === environment.id);
|
||||
|
||||
if (canDeleteSchedule) {
|
||||
//we can delete schedules with no instances other than ones for the current environment
|
||||
await prisma.taskSchedule.delete({
|
||||
where: {
|
||||
id: schedule.id,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
//otherwise we delete the instance (other environments remain untouched)
|
||||
await prisma.taskScheduleInstance.deleteMany({
|
||||
where: {
|
||||
taskScheduleId: schedule.id,
|
||||
environmentId: environment.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { BackgroundWorker } from "@trigger.dev/database";
|
||||
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { generateFriendlyId } from "../friendlyIdentifiers";
|
||||
import { BaseService } from "./baseService.server";
|
||||
import { createBackgroundTasks } from "./createBackgroundWorker.server";
|
||||
import { createBackgroundTasks, syncDeclarativeSchedules } from "./createBackgroundWorker.server";
|
||||
import { CURRENT_DEPLOYMENT_LABEL } from "~/consts";
|
||||
import { projectPubSub } from "./projectPubSub.server";
|
||||
import { marqs } from "~/v3/marqs/index.server";
|
||||
@@ -50,7 +50,39 @@ export class CreateDeployedBackgroundWorkerService extends BaseService {
|
||||
},
|
||||
});
|
||||
|
||||
await createBackgroundTasks(body.metadata.tasks, backgroundWorker, environment, this._prisma);
|
||||
try {
|
||||
await createBackgroundTasks(
|
||||
body.metadata.tasks,
|
||||
backgroundWorker,
|
||||
environment,
|
||||
this._prisma
|
||||
);
|
||||
await syncDeclarativeSchedules(
|
||||
body.metadata.tasks,
|
||||
backgroundWorker,
|
||||
environment,
|
||||
this._prisma
|
||||
);
|
||||
} catch (error) {
|
||||
const name = error instanceof Error ? error.name : "UnknownError";
|
||||
const message = error instanceof Error ? error.message : JSON.stringify(error);
|
||||
|
||||
await this._prisma.workerDeployment.update({
|
||||
where: {
|
||||
id: deployment.id,
|
||||
},
|
||||
data: {
|
||||
status: "FAILED",
|
||||
failedAt: new Date(),
|
||||
errorData: {
|
||||
name,
|
||||
message,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Link the deployment with the background worker
|
||||
await this._prisma.workerDeployment.update({
|
||||
|
||||
@@ -27,6 +27,20 @@ export class DeleteTaskScheduleService extends BaseService {
|
||||
}
|
||||
|
||||
try {
|
||||
const schedule = await this._prisma.taskSchedule.findFirst({
|
||||
where: {
|
||||
friendlyId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!schedule) {
|
||||
throw new Error("Schedule not found");
|
||||
}
|
||||
|
||||
if (schedule.type === "DECLARATIVE") {
|
||||
throw new Error("Cannot delete declarative schedules");
|
||||
}
|
||||
|
||||
await this._prisma.taskSchedule.delete({
|
||||
where: {
|
||||
friendlyId,
|
||||
|
||||
@@ -28,6 +28,20 @@ export class SetActiveOnTaskScheduleService extends BaseService {
|
||||
}
|
||||
|
||||
try {
|
||||
const schedule = await this._prisma.taskSchedule.findFirst({
|
||||
where: {
|
||||
friendlyId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!schedule) {
|
||||
throw new Error("Schedule not found");
|
||||
}
|
||||
|
||||
if (schedule.type === "DECLARATIVE") {
|
||||
throw new Error("Cannot enable/disable declarative schedules");
|
||||
}
|
||||
|
||||
await this._prisma.taskSchedule.update({
|
||||
where: {
|
||||
friendlyId,
|
||||
|
||||
@@ -24,6 +24,7 @@ export class TestTaskService extends BaseService {
|
||||
case "SCHEDULED": {
|
||||
const payload = {
|
||||
scheduleId: "sched_1234",
|
||||
type: "IMPERATIVE",
|
||||
timestamp: data.timestamp,
|
||||
lastTimestamp: data.lastTimestamp,
|
||||
timezone: data.timezone,
|
||||
|
||||
@@ -94,6 +94,7 @@ export class TriggerScheduledTaskService extends BaseService {
|
||||
|
||||
const payload = {
|
||||
scheduleId: instance.taskSchedule.friendlyId,
|
||||
type: instance.taskSchedule.type,
|
||||
timestamp: instance.nextScheduledTimestamp,
|
||||
lastTimestamp: instance.lastScheduledTimestamp ?? undefined,
|
||||
externalId: instance.taskSchedule.externalId ?? undefined,
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import { Prisma, TaskSchedule } from "@trigger.dev/database";
|
||||
import cronstrue from "cronstrue";
|
||||
import { nanoid } from "nanoid";
|
||||
import { ZodError } from "zod";
|
||||
import { $transaction, PrismaClientOrTransaction } from "~/db.server";
|
||||
import { generateFriendlyId } from "../friendlyIdentifiers";
|
||||
import { CronPattern, UpsertSchedule } from "../schedules";
|
||||
import { BaseService, ServiceValidationError } from "./baseService.server";
|
||||
import { RegisterNextTaskScheduleInstanceService } from "./registerNextTaskScheduleInstance.server";
|
||||
import cronstrue from "cronstrue";
|
||||
import { UpsertSchedule } from "../schedules";
|
||||
import { calculateNextScheduledTimestamp } from "../utils/calculateNextSchedule.server";
|
||||
import { getTimezones } from "~/utils/timezones.server";
|
||||
import { env } from "~/env.server";
|
||||
import { getLimit } from "~/services/platform.v3.server";
|
||||
import { BaseService, ServiceValidationError } from "./baseService.server";
|
||||
import { CheckScheduleService } from "./checkSchedule.server";
|
||||
import { RegisterNextTaskScheduleInstanceService } from "./registerNextTaskScheduleInstance.server";
|
||||
|
||||
export type UpsertTaskScheduleServiceOptions = UpsertSchedule;
|
||||
|
||||
@@ -30,79 +27,9 @@ type InstanceWithEnvironment = Prisma.TaskScheduleInstanceGetPayload<{
|
||||
|
||||
export class UpsertTaskScheduleService extends BaseService {
|
||||
public async call(projectId: string, schedule: UpsertTaskScheduleServiceOptions) {
|
||||
//validate the cron expression
|
||||
try {
|
||||
CronPattern.parse(schedule.cron);
|
||||
} catch (e) {
|
||||
if (e instanceof ZodError) {
|
||||
throw new ServiceValidationError(`Invalid cron expression: ${e.issues[0].message}`);
|
||||
}
|
||||
|
||||
throw new ServiceValidationError(
|
||||
`Invalid cron expression: ${e instanceof Error ? e.message : JSON.stringify(e)}`
|
||||
);
|
||||
}
|
||||
|
||||
const task = await this._prisma.backgroundWorkerTask.findFirst({
|
||||
where: {
|
||||
slug: schedule.taskIdentifier,
|
||||
projectId: projectId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
throw new ServiceValidationError(
|
||||
`Task with identifier ${schedule.taskIdentifier} not found in project.`
|
||||
);
|
||||
}
|
||||
|
||||
if (task.triggerSource !== "SCHEDULED") {
|
||||
throw new ServiceValidationError(
|
||||
`Task with identifier ${schedule.taskIdentifier} is not a scheduled task.`
|
||||
);
|
||||
}
|
||||
|
||||
//if creating a schedule, check they're under the limits
|
||||
if (!schedule.friendlyId) {
|
||||
//check they're within their limit
|
||||
const project = await this._prisma.project.findFirst({
|
||||
where: {
|
||||
id: projectId,
|
||||
},
|
||||
select: {
|
||||
organizationId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new ServiceValidationError("Project not found");
|
||||
}
|
||||
|
||||
const limit = await getLimit(project.organizationId, "schedules", 500);
|
||||
const schedulesCount = await this._prisma.taskSchedule.count({
|
||||
where: {
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
|
||||
if (schedulesCount >= limit) {
|
||||
throw new ServiceValidationError(
|
||||
`You have created ${schedulesCount}/${limit} schedules so you'll need to increase your limits or delete some schedules. Increase your limits by contacting support.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (schedule.timezone) {
|
||||
const possibleTimezones = getTimezones();
|
||||
if (!possibleTimezones.includes(schedule.timezone)) {
|
||||
throw new ServiceValidationError(
|
||||
`Invalid IANA timezone: "${schedule.timezone}". View the list of valid timezones at ${env.APP_ORIGIN}/timezones`
|
||||
);
|
||||
}
|
||||
}
|
||||
//this throws errors if the schedule is invalid
|
||||
const checkSchedule = new CheckScheduleService(this._prisma);
|
||||
await checkSchedule.call(projectId, schedule);
|
||||
|
||||
const result = await $transaction(this._prisma, async (tx) => {
|
||||
const deduplicationKey =
|
||||
@@ -126,6 +53,10 @@ export class UpsertTaskScheduleService extends BaseService {
|
||||
});
|
||||
|
||||
if (existingSchedule) {
|
||||
if (existingSchedule.type === "DECLARATIVE") {
|
||||
throw new ServiceValidationError("Cannot update a declarative schedule");
|
||||
}
|
||||
|
||||
return await this.#updateExistingSchedule(tx, existingSchedule, schedule, projectId);
|
||||
} else {
|
||||
return await this.#createNewSchedule(tx, schedule, projectId, deduplicationKey);
|
||||
@@ -317,6 +248,7 @@ export class UpsertTaskScheduleService extends BaseService {
|
||||
#createReturnObject(taskSchedule: TaskSchedule, instances: InstanceWithEnvironment[]) {
|
||||
return {
|
||||
id: taskSchedule.friendlyId,
|
||||
type: taskSchedule.type,
|
||||
task: taskSchedule.taskIdentifier,
|
||||
active: taskSchedule.active,
|
||||
externalId: taskSchedule.externalId,
|
||||
|
||||
@@ -16,7 +16,7 @@ paths:
|
||||
post:
|
||||
operationId: create_schedule_v1
|
||||
summary: Create a schedule
|
||||
description: Create a new schedule based on the specified options.
|
||||
description: Create a new `IMPERATIVE` schedule based on the specified options.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -127,7 +127,7 @@ paths:
|
||||
put:
|
||||
operationId: update_schedule_v1
|
||||
summary: Update Schedule
|
||||
description: Update a schedule by its ID.
|
||||
description: Update a schedule by its ID. This will only work on `IMPERATIVE` schedules that were created in the dashboard or using the imperative SDK functions like `schedules.create()`.
|
||||
parameters:
|
||||
- in: path
|
||||
name: schedule_id
|
||||
@@ -173,7 +173,7 @@ paths:
|
||||
delete:
|
||||
operationId: delete_schedule_v1
|
||||
summary: Delete Schedule
|
||||
description: Delete a schedule by its ID.
|
||||
description: Delete a schedule by its ID. This will only work on `IMPERATIVE` schedules that were created in the dashboard or using the imperative SDK functions like `schedules.create()`.
|
||||
parameters:
|
||||
- in: path
|
||||
name: schedule_id
|
||||
@@ -203,7 +203,7 @@ paths:
|
||||
post:
|
||||
operationId: deactivate_schedule_v1
|
||||
summary: Deactivate Schedule.
|
||||
description: Deactivate a schedule by its ID.
|
||||
description: Deactivate a schedule by its ID. This will only work on `IMPERATIVE` schedules that were created in the dashboard or using the imperative SDK functions like `schedules.create()`.
|
||||
parameters:
|
||||
- in: path
|
||||
name: schedule_id
|
||||
@@ -237,7 +237,7 @@ paths:
|
||||
post:
|
||||
operationId: activate_schedule_v1
|
||||
summary: Activate Schedule
|
||||
description: Activate a schedule by its ID.
|
||||
description: Activate a schedule by its ID. This will only work on `IMPERATIVE` schedules that were created in the dashboard or using the imperative SDK functions like `schedules.create()`.
|
||||
parameters:
|
||||
- in: path
|
||||
name: schedule_id
|
||||
@@ -1971,6 +1971,10 @@ components:
|
||||
example: my-scheduled-task
|
||||
description: The id of the scheduled task that will be triggered by this
|
||||
schedule
|
||||
"type":
|
||||
type: string
|
||||
example: IMPERATIVE
|
||||
description: The type of schedule, `DECLARATIVE` or `IMPERATIVE`. Declarative schedules are declared in your code by setting the `cron` property on a `schedules.task`. Imperative schedules are created in the dashboard or by using the imperative SDK functions like `schedules.create()`.
|
||||
active:
|
||||
type: boolean
|
||||
example: true
|
||||
|
||||
+62
-10
@@ -3,21 +3,13 @@ title: "Scheduled tasks"
|
||||
description: "A task that is triggered on a recurring schedule using CRON syntax."
|
||||
---
|
||||
|
||||
To use scheduled tasks you need to do two things:
|
||||
|
||||
1. Define a task in your code using `schedules.task()`.
|
||||
2. Attach a schedule to the task either using the dashboard or the SDK.
|
||||
|
||||
<Info>A task can have multiple schedules attached to it.</Info>
|
||||
|
||||
Like all tasks they don't have timeouts, they should be placed inside a [/trigger folder](/v3/trigger-folder), and you [can configure them](/v3/tasks-overview#defining-a-task).
|
||||
|
||||
## Defining a scheduled task
|
||||
|
||||
This task will run when any of the attached schedules trigger. They have a predefined payload with some useful properties:
|
||||
|
||||
```ts
|
||||
import { schedules } from "@trigger.dev/sdk/v3";
|
||||
|
||||
//this task will run when any of the attached schedules trigger
|
||||
export const firstScheduledTask = schedules.task({
|
||||
id: "first-scheduled-task",
|
||||
run: async (payload) => {
|
||||
@@ -68,6 +60,66 @@ You can see from the comments that the payload has several useful properties:
|
||||
to do that.
|
||||
</Note>
|
||||
|
||||
Like all tasks they don't have timeouts, they should be placed inside a [/trigger folder](/v3/trigger-folder), and you [can configure them](/v3/tasks-overview#defining-a-task).
|
||||
|
||||
## How to attach a schedule
|
||||
|
||||
Now that we've defined a scheduled task, we need to define when it will actually run. To do this we need to attach one or more schedules.
|
||||
|
||||
There are two ways of doing this:
|
||||
|
||||
- **Declarative:** defined on your `schedules.task`. They sync when you run the dev command or deploy.
|
||||
- **Imperative:** created from the dashboard or by using the imperative SDK functions like `schedules.create()`.
|
||||
|
||||
<Info>
|
||||
A scheduled task can have multiple schedules attached to it, including a declarative schedule
|
||||
and/or many imperative schedules.
|
||||
</Info>
|
||||
|
||||
### Declarative schedules
|
||||
|
||||
These sync when you run the [dev](/v3/cli-dev) or [deploy](/v3/cli-deploy) commands.
|
||||
|
||||
To create them you add the `cron` property to your `schedules.task()`. This property is optional and is only used if you want to add a declarative schedule to your task:
|
||||
|
||||
```ts
|
||||
export const firstScheduledTask = schedules.task({
|
||||
id: "first-scheduled-task",
|
||||
//every two hours (UTC timezone)
|
||||
cron: "0 */2 * * *",
|
||||
run: async (payload, { ctx }) => {
|
||||
//do something
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
If you use a string it will be in UTC. Alternatively, you can specify a timezone like this:
|
||||
|
||||
```ts
|
||||
export const secondScheduledTask = schedules.task({
|
||||
id: "second-scheduled-task",
|
||||
cron: {
|
||||
//5am every day Tokyo time
|
||||
pattern: "0 5 * * *",
|
||||
timezone: "Asia/Tokyo",
|
||||
},
|
||||
run: async (payload) => {},
|
||||
});
|
||||
```
|
||||
|
||||
When you run the [dev](/v3/cli-dev) or [deploy](/v3/cli-deploy) commands, declarative schedules will be synced. If you add, delete or edit the `cron` property it will be updated when you run these commands. You can view your schedules on the Schedules page in the dashboard.
|
||||
|
||||
### Imperative schedules
|
||||
|
||||
Alternatively you can explicitly attach schedules to a `schedules.task`. You can do this in the Schedules page in the dashboard by just pressing the "New schedule" button, or you can use the SDK to create schedules.
|
||||
|
||||
The advantage of imperative schedules is that they can be created dynamically, for example, you could create a schedule for each user in your database. They can also be activated, disabled, edited, and deleted without deploying new code by using the SDK or dashboard.
|
||||
|
||||
To use imperative schedules you need to do two things:
|
||||
|
||||
1. Define a task in your code using `schedules.task()`.
|
||||
2. Attach 1+ schedules to the task either using the dashboard or the SDK.
|
||||
|
||||
## Supported CRON syntax
|
||||
|
||||
```
|
||||
|
||||
@@ -226,10 +226,19 @@ export const CanceledRunResponse = z.object({
|
||||
|
||||
export type CanceledRunResponse = z.infer<typeof CanceledRunResponse>;
|
||||
|
||||
export const ScheduleType = z.union([z.literal("DECLARATIVE"), z.literal("IMPERATIVE")]);
|
||||
|
||||
export const ScheduledTaskPayload = z.object({
|
||||
/** The schedule id associated with this run (you can have many schedules for the same task).
|
||||
You can use this to remove the schedule, update it, etc */
|
||||
scheduleId: z.string(),
|
||||
/** The type of schedule – `"DECLARATIVE"` or `"IMPERATIVE"`.
|
||||
*
|
||||
* **DECLARATIVE** – defined inline on your `schedules.task` using the `cron` property. They can only be created, updated or deleted by modifying the `cron` property on your task.
|
||||
*
|
||||
* **IMPERATIVE** – created using the `schedules.create` functions or in the dashboard.
|
||||
*/
|
||||
type: ScheduleType,
|
||||
/** When the task was scheduled to run.
|
||||
* Note this will be slightly different from `new Date()` because it takes a few ms to run the task.
|
||||
*
|
||||
@@ -315,6 +324,7 @@ export type ScheduleGenerator = z.infer<typeof ScheduleGenerator>;
|
||||
|
||||
export const ScheduleObject = z.object({
|
||||
id: z.string(),
|
||||
type: ScheduleType,
|
||||
task: z.string(),
|
||||
active: z.boolean(),
|
||||
deduplicationKey: z.string().nullish(),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { QueueOptions, RetryOptions } from "./schemas";
|
||||
import { QueueOptions, RetryOptions, ScheduleMetadata } from "./schemas";
|
||||
import { MachineConfig } from "./common";
|
||||
|
||||
export const TaskResource = z.object({
|
||||
@@ -10,6 +10,7 @@ export const TaskResource = z.object({
|
||||
retry: RetryOptions.optional(),
|
||||
machine: MachineConfig.optional(),
|
||||
triggerSource: z.string().optional(),
|
||||
schedule: ScheduleMetadata.optional(),
|
||||
});
|
||||
|
||||
export type TaskResource = z.infer<typeof TaskResource>;
|
||||
|
||||
@@ -142,6 +142,11 @@ export const QueueOptions = z.object({
|
||||
|
||||
export type QueueOptions = z.infer<typeof QueueOptions>;
|
||||
|
||||
export const ScheduleMetadata = z.object({
|
||||
cron: z.string(),
|
||||
timezone: z.string(),
|
||||
});
|
||||
|
||||
export const TaskMetadata = z.object({
|
||||
id: z.string(),
|
||||
packageVersion: z.string(),
|
||||
@@ -149,6 +154,7 @@ export const TaskMetadata = z.object({
|
||||
retry: RetryOptions.optional(),
|
||||
machine: MachineConfig.optional(),
|
||||
triggerSource: z.string().optional(),
|
||||
schedule: ScheduleMetadata.optional(),
|
||||
});
|
||||
|
||||
export type TaskMetadata = z.infer<typeof TaskMetadata>;
|
||||
@@ -167,6 +173,7 @@ export const TaskMetadataWithFilePath = z.object({
|
||||
retry: RetryOptions.optional(),
|
||||
machine: MachineConfig.optional(),
|
||||
triggerSource: z.string().optional(),
|
||||
schedule: ScheduleMetadata.optional(),
|
||||
filePath: z.string(),
|
||||
exportName: z.string(),
|
||||
});
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ScheduleType" AS ENUM ('DECLARATIVE', 'IMPERATIVE');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "TaskSchedule"
|
||||
ADD COLUMN "type" "ScheduleType" NOT NULL DEFAULT 'IMPERATIVE';
|
||||
@@ -2231,6 +2231,8 @@ model WorkerDeploymentPromotion {
|
||||
model TaskSchedule {
|
||||
id String @id @default(cuid())
|
||||
|
||||
type ScheduleType @default(IMPERATIVE)
|
||||
|
||||
///users see this as `id`. They start with schedule_
|
||||
friendlyId String @unique
|
||||
///a reference to a task (not a foreign key because it's across versions)
|
||||
@@ -2267,6 +2269,13 @@ model TaskSchedule {
|
||||
@@unique([projectId, deduplicationKey])
|
||||
}
|
||||
|
||||
enum ScheduleType {
|
||||
/// defined on your task using the `cron` property
|
||||
DECLARATIVE
|
||||
/// explicit calls to the SDK are used to create, or using the dashboard
|
||||
IMPERATIVE
|
||||
}
|
||||
|
||||
enum ScheduleGeneratorType {
|
||||
CRON
|
||||
}
|
||||
|
||||
@@ -16,13 +16,57 @@ import { Task, TaskOptions, apiClientMissingError, createTask } from "../shared"
|
||||
import * as SchedulesAPI from "./api";
|
||||
import { tracer } from "../tracer";
|
||||
|
||||
export type ScheduleOptions<
|
||||
TIdentifier extends string,
|
||||
TOutput,
|
||||
TInitOutput extends InitOutput,
|
||||
> = TaskOptions<TIdentifier, SchedulesAPI.ScheduledTaskPayload, TOutput, TInitOutput> & {
|
||||
/** You can optionally specify a CRON schedule on your task. You can also dynamically add a schedule in the dashboard or using the SDK functions.
|
||||
*
|
||||
* 1. Pass a CRON pattern string
|
||||
* ```ts
|
||||
* "0 0 * * *"
|
||||
* ```
|
||||
*
|
||||
* 2. Or an object with a pattern and an optional timezone (default is "UTC")
|
||||
* ```ts
|
||||
* {
|
||||
* pattern: "0 0 * * *",
|
||||
* timezone: "America/Los_Angeles"
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @link https://trigger.dev/docs/v3/tasks-scheduled
|
||||
*/
|
||||
cron?:
|
||||
| string
|
||||
| {
|
||||
pattern: string;
|
||||
timezone?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function task<TIdentifier extends string, TOutput, TInitOutput extends InitOutput>(
|
||||
params: TaskOptions<TIdentifier, SchedulesAPI.ScheduledTaskPayload, TOutput, TInitOutput>
|
||||
params: ScheduleOptions<TIdentifier, TOutput, TInitOutput>
|
||||
): Task<TIdentifier, SchedulesAPI.ScheduledTaskPayload, TOutput> {
|
||||
const task = createTask(params);
|
||||
|
||||
const cron = params.cron
|
||||
? typeof params.cron === "string"
|
||||
? params.cron
|
||||
: params.cron.pattern
|
||||
: undefined;
|
||||
const timezone =
|
||||
(params.cron && typeof params.cron !== "string" ? params.cron.timezone : "UTC") ?? "UTC";
|
||||
|
||||
taskCatalog.updateTaskMetadata(task.id, {
|
||||
triggerSource: "schedule",
|
||||
schedule: cron
|
||||
? {
|
||||
cron: cron,
|
||||
timezone,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
return task;
|
||||
|
||||
@@ -2,7 +2,9 @@ import { logger, schedules, task } from "@trigger.dev/sdk/v3";
|
||||
|
||||
export const firstScheduledTask = schedules.task({
|
||||
id: "first-scheduled-task",
|
||||
run: async (payload) => {
|
||||
//every other minute
|
||||
cron: "0 */2 * * *",
|
||||
run: async (payload, { ctx }) => {
|
||||
const distanceInMs =
|
||||
payload.timestamp.getTime() - (payload.lastTimestamp ?? new Date()).getTime();
|
||||
|
||||
@@ -18,6 +20,15 @@ export const firstScheduledTask = schedules.task({
|
||||
},
|
||||
});
|
||||
|
||||
export const secondScheduledTask = schedules.task({
|
||||
id: "second-scheduled-task",
|
||||
cron: {
|
||||
pattern: "0 5 * * *",
|
||||
timezone: "Asia/Tokyo",
|
||||
},
|
||||
run: async (payload) => {},
|
||||
});
|
||||
|
||||
export const manageSchedules = task({
|
||||
id: "manage-schedules",
|
||||
run: async (payload) => {
|
||||
|
||||
Reference in New Issue
Block a user