Batch Trigger upgrades (#1502)

* WIP batch trigger v2

* Fix for the DateField being one month out… getUTCMonth() is zero indexed 🤦‍♂️

* Added a custom date range filter

* Deal with closing the custom date range

* Child runs filter

* Fix for the clear button untoggling the child runs

* WIP batchTriggerV2

* Finished removing rate limit from the webapp

* Added an index TaskRun to make useRealtimeBatch performant

* Renamed the period filter labels to be “Last X mins”

* Denormalize background worker columns into TaskRun

* Use the runTags column on TaskRun

* Add TaskRun ("projectId", "id" DESC) index

* Improved the v2 batch trigger endpoint to process items in parallel and also added a threshold, below which the processing of items is async

* Added a runId filter, and WIP for batchId filter

* WIP triggerAll

* Add new batch methods for triggering multiple different tasks in a single batch

* Disabled switch styling

* Batch filtering, force child runs to show if filtering by batch/run

* Added schedule ID filtering

* Force child runs to show when filtering by scheduleId, for consistency

* realtime: allow setting enabled: false on useApiClient

* Batches page

* Always complete batches, not only batchTriggerAndWait in deployed tasks

* Add batch.retrieve and allow filtering by batch in runs.list

* Renamed pending to “In progress”

* Tidied up the table a bit

* Deal with old batches: “Legacy batch”

* Added the Batch to the run inspector

* Fixed the migration that created the new idempotency key index on BatchTaskRun

* Fixed the name of the idempotencyKeyExpiresAt option and now default idempotency key TTL is 30 days, not 24 hours

* Timezone fix: wrong month in Usage page dropdown

* The DateField now defaults to local time, but can be overriden to use utc with an option

* Don’t allow the task icon to get squished

* BatchFilters removed unused imports

* In the batch filtering, use `id` instead of `batchId` in the URL

* BatchFilters: we don’t need a child tasks hidden input field

* Creates some common filter components/functions

* Fix for batchVersion check when filtering by batch status

* Add additional logging around telemetry and more attributes for trigger spans

* Show clear button for specific id filters

* Batch list: only allow environments that are part of this project

* Unnecessary optional chain

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Add JSDocs

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
Eric Allam
2024-11-28 11:25:00 +00:00
committed by GitHub
parent b714d6fabf
commit 6df8069c0e
82 changed files with 5618 additions and 647 deletions
+80
View File
@@ -0,0 +1,80 @@
---
"@trigger.dev/sdk": patch
"trigger.dev": patch
"@trigger.dev/core": patch
---
Added new batch.trigger and batch.triggerByTask methods that allows triggering multiple different tasks in a single batch:
```ts
import { batch } from '@trigger.dev/sdk/v3';
import type { myTask1, myTask2 } from './trigger/tasks';
// Somewhere in your backend code
const response = await batch.trigger<typeof myTask1 | typeof myTask2>([
{ id: 'task1', payload: { foo: 'bar' } },
{ id: 'task2', payload: { baz: 'qux' } },
]);
for (const run of response.runs) {
if (run.ok) {
console.log(run.output);
} else {
console.error(run.error);
}
}
```
Or if you are inside of a task, you can use `triggerByTask`:
```ts
import { batch, task, runs } from '@trigger.dev/sdk/v3';
export const myParentTask = task({
id: 'myParentTask',
run: async () => {
const response = await batch.triggerByTask([
{ task: myTask1, payload: { foo: 'bar' } },
{ task: myTask2, payload: { baz: 'qux' } },
]);
const run1 = await runs.retrieve(response.runs[0]);
console.log(run1.output) // typed as { foo: string }
const run2 = await runs.retrieve(response.runs[1]);
console.log(run2.output) // typed as { baz: string }
const response2 = await batch.triggerByTaskAndWait([
{ task: myTask1, payload: { foo: 'bar' } },
{ task: myTask2, payload: { baz: 'qux' } },
]);
if (response2.runs[0].ok) {
console.log(response2.runs[0].output) // typed as { foo: string }
}
if (response2.runs[1].ok) {
console.log(response2.runs[1].output) // typed as { baz: string }
}
}
});
export const myTask1 = task({
id: 'myTask1',
run: async () => {
return {
foo: 'bar'
}
}
});
export const myTask2 = task({
id: 'myTask2',
run: async () => {
return {
baz: 'qux'
}
}
});
```
+25
View File
@@ -0,0 +1,25 @@
---
"@trigger.dev/react-hooks": minor
"@trigger.dev/sdk": minor
"@trigger.dev/core": minor
---
Improved Batch Triggering:
- The new Batch Trigger endpoint is now asynchronous and supports up to 500 runs per request.
- The new endpoint also supports triggering multiple different tasks in a single batch request (support in the SDK coming soon).
- The existing `batchTrigger` method now supports the new endpoint, and shouldn't require any changes to your code.
- Idempotency keys now expire after 24 hours, and you can customize the expiration time when creating a new key by using the `idempotencyKeyTTL` parameter:
```ts
await myTask.batchTrigger([{ payload: { foo: "bar" }}], { idempotencyKey: "my-key", idempotencyKeyTTL: "60s" })
// Works for individual items as well:
await myTask.batchTrigger([{ payload: { foo: "bar" }, options: { idempotencyKey: "my-key", idempotencyKeyTTL: "60s" }}])
// And `trigger`:
await myTask.trigger({ foo: "bar" }, { idempotencyKey: "my-key", idempotencyKeyTTL: "60s" });
```
### Breaking Changes
- We've removed the `idempotencyKey` option from `triggerAndWait` and `batchTriggerAndWait`, because it can lead to permanently frozen runs in deployed tasks. We're working on upgrading our entire system to support idempotency keys on these methods, and we'll re-add the option once that's complete.
+7
View File
@@ -0,0 +1,7 @@
---
"@trigger.dev/react-hooks": patch
"@trigger.dev/sdk": patch
"@trigger.dev/core": patch
---
Added ability to subscribe to a batch of runs using runs.subscribeToBatch
+1
View File
@@ -0,0 +1 @@
This is the repo for Trigger.dev, a background jobs platform written in TypeScript. Our webapp at apps/webapp is a Remix 2.1 app that uses Node.js v20. Our SDK is an isomorphic TypeScript SDK at packages/trigger-sdk. Always prefer using isomorphic code like fetch, ReadableStream, etc. instead of Node.js specific code. Our tests are all vitest. We use prisma in internal-packages/database for our database interactions using PostgreSQL. For TypeScript, we usually use types over interfaces. We use zod a lot in packages/core and in the webapp. Avoid enums. Use strict mode. No default exports, use function declarations.
@@ -12,6 +12,7 @@ import {
RectangleStackIcon,
ServerStackIcon,
ShieldCheckIcon,
Squares2X2Icon,
} from "@heroicons/react/20/solid";
import { UserGroupIcon, UserPlusIcon } from "@heroicons/react/24/solid";
import { useNavigation } from "@remix-run/react";
@@ -45,6 +46,7 @@ import {
projectSetupPath,
projectTriggersPath,
v3ApiKeysPath,
v3BatchesPath,
v3BillingPath,
v3ConcurrencyPath,
v3DeploymentsPath,
@@ -475,6 +477,13 @@ function V3ProjectSideMenu({
activeIconColor="text-teal-500"
to={v3RunsPath(organization, project)}
/>
<SideMenuItem
name="Batches"
icon={Squares2X2Icon}
activeIconColor="text-blue-500"
to={v3BatchesPath(organization, project)}
data-action="batches"
/>
<SideMenuItem
name="Test"
icon={BeakerIcon}
@@ -1,4 +1,4 @@
import { BellAlertIcon } from "@heroicons/react/20/solid";
import { BellAlertIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { CalendarDateTime, createCalendar } from "@internationalized/date";
import { useDateField, useDateSegment } from "@react-aria/datepicker";
import type { DateFieldState, DateSegment } from "@react-stately/datepicker";
@@ -12,7 +12,7 @@ const variants = {
small: {
fieldStyles: "h-5 text-sm rounded-sm px-0.5",
nowButtonVariant: "tertiary/small" as const,
clearButtonVariant: "minimal/small" as const,
clearButtonVariant: "tertiary/small" as const,
},
medium: {
fieldStyles: "h-7 text-base rounded px-1",
@@ -35,9 +35,12 @@ type DateFieldProps = {
showNowButton?: boolean;
showClearButton?: boolean;
onValueChange?: (value: Date | undefined) => void;
utc?: boolean;
variant?: Variant;
};
const deviceTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
export function DateField({
label,
defaultValue,
@@ -50,10 +53,11 @@ export function DateField({
showGuide = false,
showNowButton = false,
showClearButton = false,
utc = false,
variant = "small",
}: DateFieldProps) {
const [value, setValue] = useState<undefined | CalendarDateTime>(
utcDateToCalendarDate(defaultValue)
utc ? utcDateToCalendarDate(defaultValue) : dateToCalendarDate(defaultValue)
);
const state = useDateFieldState({
@@ -61,11 +65,11 @@ export function DateField({
onChange: (value) => {
if (value) {
setValue(value);
onValueChange?.(value.toDate("utc"));
onValueChange?.(value.toDate(utc ? "utc" : deviceTimezone));
}
},
minValue: utcDateToCalendarDate(minValue),
maxValue: utcDateToCalendarDate(maxValue),
minValue: utc ? utcDateToCalendarDate(minValue) : dateToCalendarDate(minValue),
maxValue: utc ? utcDateToCalendarDate(maxValue) : dateToCalendarDate(maxValue),
shouldForceLeadingZeros: true,
granularity,
locale: "en-US",
@@ -78,7 +82,9 @@ export function DateField({
useEffect(() => {
if (state.value === undefined && defaultValue === undefined) return;
const calendarDate = utcDateToCalendarDate(defaultValue);
const calendarDate = utc
? utcDateToCalendarDate(defaultValue)
: dateToCalendarDate(defaultValue);
//unchanged
if (state.value?.toDate("utc").getTime() === defaultValue?.getTime()) {
return;
@@ -134,23 +140,19 @@ export function DateField({
<Button
type="button"
variant={variants[variant].nowButtonVariant}
LeadingIcon={BellAlertIcon}
leadingIconClassName="text-text-dimmed group-hover:text-text-bright"
onClick={() => {
const now = new Date();
setValue(utcDateToCalendarDate(new Date()));
setValue(utc ? utcDateToCalendarDate(now) : dateToCalendarDate(now));
onValueChange?.(now);
}}
>
<span className="text-text-dimmed transition group-hover:text-text-bright">Now</span>
Now
</Button>
)}
{showClearButton && (
<Button
type="button"
variant={variants[variant].clearButtonVariant}
LeadingIcon={"close"}
leadingIconClassName="-mr-2"
onClick={() => {
setValue(undefined);
onValueChange?.(undefined);
@@ -181,7 +183,7 @@ function utcDateToCalendarDate(date?: Date) {
return date
? new CalendarDateTime(
date.getUTCFullYear(),
date.getUTCMonth(),
date.getUTCMonth() + 1,
date.getUTCDate(),
date.getUTCHours(),
date.getUTCMinutes(),
@@ -190,6 +192,19 @@ function utcDateToCalendarDate(date?: Date) {
: undefined;
}
function dateToCalendarDate(date?: Date) {
return date
? new CalendarDateTime(
date.getFullYear(),
date.getMonth() + 1,
date.getDate(),
date.getHours(),
date.getMinutes(),
date.getSeconds()
)
: undefined;
}
type DateSegmentProps = {
segment: DateSegment;
state: DateFieldState;
@@ -440,7 +440,7 @@ export interface SelectItemProps extends Ariakit.SelectItemProps {
}
const selectItemClasses =
"group cursor-pointer px-1 pt-1 text-sm text-text-dimmed focus-custom last:pb-1";
"group cursor-pointer px-1 pt-1 text-2sm text-text-dimmed focus-custom last:pb-1";
export function SelectItem({
icon,
@@ -613,7 +613,7 @@ export function SelectPopover({
"z-50 flex flex-col overflow-clip rounded border border-charcoal-700 bg-background-bright shadow-md outline-none animate-in fade-in-40",
"min-w-[max(180px,calc(var(--popover-anchor-width)+0.5rem))]",
"max-w-[min(480px,var(--popover-available-width))]",
"max-h-[min(520px,var(--popover-available-height))]",
"max-h-[min(600px,var(--popover-available-height))]",
"origin-[var(--popover-transform-origin)]",
className
)}
@@ -13,10 +13,10 @@ const variations = {
},
small: {
container:
"flex items-center h-[1.5rem] gap-x-1.5 rounded hover:bg-tertiary pr-1 py-[0.1rem] pl-1.5 transition focus-custom",
"flex items-center h-[1.5rem] gap-x-1.5 rounded hover:bg-tertiary disabled:hover:bg-transparent pr-1 py-[0.1rem] pl-1.5 transition focus-custom disabled:hover:text-charcoal-400 disabled:opacity-50 text-charcoal-400 hover:text-charcoal-200 disabled:hover:cursor-not-allowed hover:cursor-pointer",
root: "h-3 w-6",
thumb: "h-2.5 w-2.5 data-[state=checked]:translate-x-2.5 data-[state=unchecked]:translate-x-0",
text: "text-xs text-charcoal-400 group-hover:text-charcoal-200 hover:cursor-pointer transition",
text: "text-xs transition",
},
};
@@ -214,6 +214,7 @@ export function AbsoluteTimeFrame({
granularity="second"
showNowButton
showClearButton
utc
/>
</div>
<div className="space-y-1">
@@ -227,6 +228,7 @@ export function AbsoluteTimeFrame({
granularity="second"
showNowButton
showClearButton
utc
/>
</div>
</div>
@@ -0,0 +1,446 @@
import * as Ariakit from "@ariakit/react";
import { CalendarIcon, CpuChipIcon, Squares2X2Icon, TrashIcon } from "@heroicons/react/20/solid";
import { Form } from "@remix-run/react";
import type { BatchTaskRunStatus, RuntimeEnvironment } from "@trigger.dev/database";
import { ListFilterIcon } from "lucide-react";
import type { ReactNode } from "react";
import { useCallback, useMemo, useState } from "react";
import { z } from "zod";
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
import { FormError } from "~/components/primitives/FormError";
import { Input } from "~/components/primitives/Input";
import { Label } from "~/components/primitives/Label";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
ComboBox,
SelectButtonItem,
SelectItem,
SelectList,
SelectPopover,
SelectProvider,
SelectTrigger,
shortcutFromIndex,
} from "~/components/primitives/Select";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/components/primitives/Tooltip";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { useSearchParams } from "~/hooks/useSearchParam";
import { Button } from "../../primitives/Buttons";
import {
allBatchStatuses,
BatchStatusCombo,
batchStatusTitle,
descriptionForBatchStatus,
} from "./BatchStatus";
import {
AppliedCustomDateRangeFilter,
AppliedEnvironmentFilter,
AppliedPeriodFilter,
appliedSummary,
CreatedAtDropdown,
CustomDateRangeDropdown,
EnvironmentsDropdown,
FilterMenuProvider,
} from "./SharedFilters";
export const BatchStatus = z.enum(allBatchStatuses);
export const BatchListFilters = z.object({
cursor: z.string().optional(),
direction: z.enum(["forward", "backward"]).optional(),
environments: z.preprocess(
(value) => (typeof value === "string" ? [value] : value),
z.string().array().optional()
),
statuses: z.preprocess(
(value) => (typeof value === "string" ? [value] : value),
BatchStatus.array().optional()
),
period: z.preprocess((value) => (value === "all" ? undefined : value), z.string().optional()),
id: z.string().optional(),
from: z.coerce.number().optional(),
to: z.coerce.number().optional(),
});
export type BatchListFilters = z.infer<typeof BatchListFilters>;
type DisplayableEnvironment = Pick<RuntimeEnvironment, "type" | "id"> & {
userName?: string;
};
type BatchFiltersProps = {
possibleEnvironments: DisplayableEnvironment[];
hasFilters: boolean;
};
export function BatchFilters(props: BatchFiltersProps) {
const location = useOptimisticLocation();
const searchParams = new URLSearchParams(location.search);
const hasFilters =
searchParams.has("statuses") ||
searchParams.has("environments") ||
searchParams.has("id") ||
searchParams.has("period") ||
searchParams.has("from") ||
searchParams.has("to");
return (
<div className="flex flex-row flex-wrap items-center gap-1">
<FilterMenu {...props} />
<AppliedFilters {...props} />
{hasFilters && (
<Form className="h-6">
<Button variant="minimal/small" LeadingIcon={TrashIcon}>
Clear all
</Button>
</Form>
)}
</div>
);
}
const filterTypes = [
{
name: "statuses",
title: "Status",
icon: (
<div className="flex size-4 items-center justify-center">
<div className="size-3 rounded-full border-2 border-text-dimmed" />
</div>
),
},
{ name: "environments", title: "Environment", icon: <CpuChipIcon className="size-4" /> },
{ name: "created", title: "Created", icon: <CalendarIcon className="size-4" /> },
{ name: "daterange", title: "Custom date range", icon: <CalendarIcon className="size-4" /> },
{ name: "batch", title: "Batch ID", icon: <Squares2X2Icon className="size-4" /> },
] as const;
type FilterType = (typeof filterTypes)[number]["name"];
const shortcut = { key: "f" };
function FilterMenu(props: BatchFiltersProps) {
const [filterType, setFilterType] = useState<FilterType | undefined>();
const filterTrigger = (
<SelectTrigger
icon={
<div className="flex size-4 items-center justify-center">
<ListFilterIcon className="size-3.5" />
</div>
}
variant={"minimal/small"}
shortcut={shortcut}
tooltipTitle={"Filter runs"}
>
Filter
</SelectTrigger>
);
return (
<FilterMenuProvider onClose={() => setFilterType(undefined)}>
{(search, setSearch) => (
<Menu
searchValue={search}
clearSearchValue={() => setSearch("")}
trigger={filterTrigger}
filterType={filterType}
setFilterType={setFilterType}
{...props}
/>
)}
</FilterMenuProvider>
);
}
function AppliedFilters({ possibleEnvironments }: BatchFiltersProps) {
return (
<>
<AppliedStatusFilter />
<AppliedEnvironmentFilter possibleEnvironments={possibleEnvironments} />
<AppliedPeriodFilter />
<AppliedCustomDateRangeFilter />
<AppliedBatchIdFilter />
</>
);
}
type MenuProps = {
searchValue: string;
clearSearchValue: () => void;
trigger: React.ReactNode;
filterType: FilterType | undefined;
setFilterType: (filterType: FilterType | undefined) => void;
} & BatchFiltersProps;
function Menu(props: MenuProps) {
switch (props.filterType) {
case undefined:
return <MainMenu {...props} />;
case "statuses":
return <StatusDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "environments":
return <EnvironmentsDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "created":
return <CreatedAtDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "daterange":
return <CustomDateRangeDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "batch":
return <BatchIdDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
}
}
function MainMenu({ searchValue, trigger, clearSearchValue, setFilterType }: MenuProps) {
const filtered = useMemo(() => {
return filterTypes.filter((item) => {
if (item.name === "daterange") return false;
return item.title.toLowerCase().includes(searchValue.toLowerCase());
});
}, [searchValue]);
return (
<SelectProvider virtualFocus={true}>
{trigger}
<SelectPopover>
<ComboBox placeholder={"Filter by..."} shortcut={shortcut} value={searchValue} />
<SelectList>
{filtered.map((type, index) => (
<SelectButtonItem
key={type.name}
onClick={() => {
clearSearchValue();
setFilterType(type.name);
}}
icon={type.icon}
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
>
{type.title}
</SelectButtonItem>
))}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
const statuses = allBatchStatuses.map((status) => ({
title: batchStatusTitle(status),
value: status,
}));
function StatusDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
}) {
const { values, replace } = useSearchParams();
const handleChange = (values: string[]) => {
clearSearchValue();
replace({ statuses: values, cursor: undefined, direction: undefined });
};
const filtered = useMemo(() => {
return statuses.filter((item) => item.title.toLowerCase().includes(searchValue.toLowerCase()));
}, [searchValue]);
return (
<SelectProvider value={values("statuses")} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
>
<ComboBox placeholder={"Filter by status..."} value={searchValue} />
<SelectList>
{filtered.map((item, index) => (
<SelectItem
key={item.value}
value={item.value}
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
>
<TooltipProvider>
<Tooltip>
<TooltipTrigger className="group flex w-full flex-col py-0">
<BatchStatusCombo status={item.value} iconClassName="animate-none" />
</TooltipTrigger>
<TooltipContent side="right" sideOffset={9}>
<Paragraph variant="extra-small">
{descriptionForBatchStatus(item.value)}
</Paragraph>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</SelectItem>
))}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
function AppliedStatusFilter() {
const { values, del } = useSearchParams();
const statuses = values("statuses");
if (statuses.length === 0) {
return null;
}
return (
<FilterMenuProvider>
{(search, setSearch) => (
<StatusDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Status"
value={appliedSummary(
statuses.map((v) => batchStatusTitle(v as BatchTaskRunStatus))
)}
onRemove={() => del(["statuses", "cursor", "direction"])}
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
function BatchIdDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
}) {
const [open, setOpen] = useState<boolean | undefined>();
const { value, replace } = useSearchParams();
const batchIdValue = value("id");
const [batchId, setBatchId] = useState(batchIdValue);
const apply = useCallback(() => {
clearSearchValue();
replace({
cursor: undefined,
direction: undefined,
id: batchId === "" ? undefined : batchId?.toString(),
});
setOpen(false);
}, [batchId, replace]);
let error: string | undefined = undefined;
if (batchId) {
if (!batchId.startsWith("batch_")) {
error = "Batch IDs start with 'batch_'";
} else if (batchId.length !== 27) {
error = "Batch IDs are 27 characters long";
}
}
return (
<SelectProvider virtualFocus={true} open={open} setOpen={setOpen}>
{trigger}
<SelectPopover
hideOnEnter={false}
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
className="max-w-[min(32ch,var(--popover-available-width))]"
>
<div className="flex flex-col gap-4 p-3">
<div className="flex flex-col gap-1">
<Label>Batch ID</Label>
<Input
placeholder="batch_"
value={batchId ?? ""}
onChange={(e) => setBatchId(e.target.value)}
variant="small"
className="w-[29ch] font-mono"
spellCheck={false}
/>
{error ? <FormError>{error}</FormError> : null}
</div>
<div className="flex justify-between gap-1 border-t border-grid-dimmed pt-3">
<Button variant="tertiary/small" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
disabled={error !== undefined || !batchId}
variant="secondary/small"
shortcut={{
modifiers: ["meta"],
key: "Enter",
enabledOnInputElements: true,
}}
onClick={() => apply()}
>
Apply
</Button>
</div>
</div>
</SelectPopover>
</SelectProvider>
);
}
function AppliedBatchIdFilter() {
const { value, del } = useSearchParams();
if (value("id") === undefined) {
return null;
}
const batchId = value("id");
return (
<FilterMenuProvider>
{(search, setSearch) => (
<BatchIdDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Batch ID"
value={batchId}
onRemove={() => del(["id", "cursor", "direction"])}
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
@@ -0,0 +1,81 @@
import { CheckCircleIcon } from "@heroicons/react/20/solid";
import { BatchTaskRunStatus } from "@trigger.dev/database";
import assertNever from "assert-never";
import { Spinner } from "~/components/primitives/Spinner";
import { cn } from "~/utils/cn";
export const allBatchStatuses = ["PENDING", "COMPLETED"] as const satisfies Readonly<
Array<BatchTaskRunStatus>
>;
const descriptions: Record<BatchTaskRunStatus, string> = {
PENDING: "The batch has child runs that have not yet completed.",
COMPLETED: "All the batch child runs have finished.",
};
export function descriptionForBatchStatus(status: BatchTaskRunStatus): string {
return descriptions[status];
}
export function BatchStatusCombo({
status,
className,
iconClassName,
}: {
status: BatchTaskRunStatus;
className?: string;
iconClassName?: string;
}) {
return (
<span className={cn("flex items-center gap-1", className)}>
<BatchStatusIcon status={status} className={cn("h-4 w-4", iconClassName)} />
<BatchStatusLabel status={status} />
</span>
);
}
export function BatchStatusLabel({ status }: { status: BatchTaskRunStatus }) {
return <span className={batchStatusColor(status)}>{batchStatusTitle(status)}</span>;
}
export function BatchStatusIcon({
status,
className,
}: {
status: BatchTaskRunStatus;
className: string;
}) {
switch (status) {
case "PENDING":
return <Spinner className={cn(batchStatusColor(status), className)} />;
case "COMPLETED":
return <CheckCircleIcon className={cn(batchStatusColor(status), className)} />;
default: {
assertNever(status);
}
}
}
export function batchStatusColor(status: BatchTaskRunStatus): string {
switch (status) {
case "PENDING":
return "text-pending";
case "COMPLETED":
return "text-success";
default: {
assertNever(status);
}
}
}
export function batchStatusTitle(status: BatchTaskRunStatus): string {
switch (status) {
case "PENDING":
return "In progress";
case "COMPLETED":
return "Completed";
default: {
assertNever(status);
}
}
}
+394 -239
View File
@@ -1,30 +1,34 @@
import * as Ariakit from "@ariakit/react";
import {
ArrowPathIcon,
CalendarIcon,
ClockIcon,
CpuChipIcon,
InboxStackIcon,
FingerPrintIcon,
Squares2X2Icon,
TagIcon,
XMarkIcon,
TrashIcon,
} from "@heroicons/react/20/solid";
import { Form, useFetcher } from "@remix-run/react";
import type {
RuntimeEnvironment,
TaskTriggerSource,
TaskRunStatus,
BulkActionType,
RuntimeEnvironment,
TaskRunStatus,
TaskTriggerSource,
} from "@trigger.dev/database";
import { ListFilterIcon } from "lucide-react";
import { ListChecks, ListFilterIcon } from "lucide-react";
import { matchSorter } from "match-sorter";
import type { ReactNode } from "react";
import { startTransition, useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { z } from "zod";
import { TaskIcon } from "~/assets/icons/TaskIcon";
import { EnvironmentLabel, environmentTitle } from "~/components/environments/EnvironmentLabel";
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
import { DateTime } from "~/components/primitives/DateTime";
import { FormError } from "~/components/primitives/FormError";
import { Input } from "~/components/primitives/Input";
import { Label } from "~/components/primitives/Label";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
ComboBox,
ComboboxProvider,
SelectButtonItem,
SelectItem,
SelectList,
@@ -33,6 +37,8 @@ import {
SelectTrigger,
shortcutFromIndex,
} from "~/components/primitives/Select";
import { Spinner } from "~/components/primitives/Spinner";
import { Switch } from "~/components/primitives/Switch";
import {
Tooltip,
TooltipContent,
@@ -40,22 +46,29 @@ import {
TooltipTrigger,
} from "~/components/primitives/Tooltip";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { useProject } from "~/hooks/useProject";
import { useSearchParams } from "~/hooks/useSearchParam";
import { type loader as tagsLoader } from "~/routes/resources.projects.$projectParam.runs.tags";
import { Button } from "../../primitives/Buttons";
import { BulkActionStatusCombo } from "./BulkAction";
import {
AppliedCustomDateRangeFilter,
AppliedEnvironmentFilter,
AppliedPeriodFilter,
appliedSummary,
CreatedAtDropdown,
CustomDateRangeDropdown,
EnvironmentsDropdown,
FilterMenuProvider,
} from "./SharedFilters";
import {
TaskRunStatusCombo,
allTaskRunStatuses,
filterableTaskRunStatuses,
descriptionForTaskRunStatus,
filterableTaskRunStatuses,
runStatusTitle,
TaskRunStatusCombo,
} from "./TaskRunStatus";
import { TaskTriggerSourceIcon } from "./TaskTriggerSource";
import { DateTime } from "~/components/primitives/DateTime";
import { BulkActionStatusCombo } from "./BulkAction";
import { type loader } from "~/routes/resources.projects.$projectParam.runs.tags";
import { useProject } from "~/hooks/useProject";
import { Spinner } from "~/components/primitives/Spinner";
import { matchSorter } from "match-sorter";
export const TaskAttemptStatus = z.enum(allTaskRunStatuses);
@@ -86,6 +99,10 @@ export const TaskRunListSearchFilters = z.object({
bulkId: z.string().optional(),
from: z.coerce.number().optional(),
to: z.coerce.number().optional(),
showChildTasks: z.coerce.boolean().optional(),
batchId: z.string().optional(),
runId: z.string().optional(),
scheduleId: z.string().optional(),
});
export type TaskRunListSearchFilters = z.infer<typeof TaskRunListSearchFilters>;
@@ -114,15 +131,28 @@ export function RunsFilters(props: RunFiltersProps) {
searchParams.has("tasks") ||
searchParams.has("period") ||
searchParams.has("bulkId") ||
searchParams.has("tags");
searchParams.has("tags") ||
searchParams.has("from") ||
searchParams.has("to") ||
searchParams.has("batchId") ||
searchParams.has("runId") ||
searchParams.has("scheduleId");
return (
<div className="flex flex-row flex-wrap items-center gap-1">
<FilterMenu {...props} />
<ShowChildTasksToggle />
<AppliedFilters {...props} />
{hasFilters && (
<Form>
<Button variant="minimal/small" LeadingIcon={XMarkIcon}>
<Form className="h-6">
{searchParams.has("showChildTasks") && (
<input
type="hidden"
name="showChildTasks"
value={searchParams.get("showChildTasks") as string}
/>
)}
<Button variant="minimal/small" LeadingIcon={TrashIcon}>
Clear all
</Button>
</Form>
@@ -145,7 +175,11 @@ const filterTypes = [
{ name: "tasks", title: "Tasks", icon: <TaskIcon className="size-4" /> },
{ name: "tags", title: "Tags", icon: <TagIcon className="size-4" /> },
{ name: "created", title: "Created", icon: <CalendarIcon className="size-4" /> },
{ name: "bulk", title: "Bulk action", icon: <InboxStackIcon className="size-4" /> },
{ name: "daterange", title: "Custom date range", icon: <CalendarIcon className="size-4" /> },
{ name: "run", title: "Run ID", icon: <FingerPrintIcon className="size-4" /> },
{ name: "batch", title: "Batch ID", icon: <Squares2X2Icon className="size-4" /> },
{ name: "schedule", title: "Schedule ID", icon: <ClockIcon className="size-4" /> },
{ name: "bulk", title: "Bulk action", icon: <ListChecks className="size-4" /> },
] as const;
type FilterType = (typeof filterTypes)[number]["name"];
@@ -186,34 +220,6 @@ function FilterMenu(props: RunFiltersProps) {
);
}
function FilterMenuProvider({
children,
onClose,
}: {
children: (search: string, setSearch: (value: string) => void) => React.ReactNode;
onClose?: () => void;
}) {
const [searchValue, setSearchValue] = useState("");
return (
<ComboboxProvider
resetValueOnHide
setValue={(value) => {
startTransition(() => {
setSearchValue(value);
});
}}
setOpen={(open) => {
if (!open && onClose) {
onClose();
}
}}
>
{children(searchValue, setSearchValue)}
</ComboboxProvider>
);
}
function AppliedFilters({ possibleEnvironments, possibleTasks, bulkActions }: RunFiltersProps) {
return (
<>
@@ -222,6 +228,10 @@ function AppliedFilters({ possibleEnvironments, possibleTasks, bulkActions }: Ru
<AppliedTaskFilter possibleTasks={possibleTasks} />
<AppliedTagsFilter />
<AppliedPeriodFilter />
<AppliedCustomDateRangeFilter />
<AppliedRunIdFilter />
<AppliedBatchIdFilter />
<AppliedScheduleIdFilter />
<AppliedBulkActionsFilter bulkActions={bulkActions} />
</>
);
@@ -246,19 +256,28 @@ function Menu(props: MenuProps) {
case "tasks":
return <TasksDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "created":
return <CreatedDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
return <CreatedAtDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "daterange":
return <CustomDateRangeDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "bulk":
return <BulkActionsDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "tags":
return <TagsDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "run":
return <RunIdDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "batch":
return <BatchIdDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "schedule":
return <ScheduleIdDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
}
}
function MainMenu({ searchValue, trigger, clearSearchValue, setFilterType }: MenuProps) {
const filtered = useMemo(() => {
return filterTypes.filter((item) =>
item.title.toLowerCase().includes(searchValue.toLowerCase())
);
return filterTypes.filter((item) => {
if (item.name === "daterange") return false;
return item.title.toLowerCase().includes(searchValue.toLowerCase());
});
}, [searchValue]);
return (
@@ -384,100 +403,6 @@ function AppliedStatusFilter() {
);
}
function EnvironmentsDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
possibleEnvironments,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
possibleEnvironments: DisplayableEnvironment[];
}) {
const { values, replace } = useSearchParams();
const handleChange = (values: string[]) => {
clearSearchValue();
replace({ environments: values, cursor: undefined, direction: undefined });
};
const filtered = useMemo(() => {
return possibleEnvironments.filter((item) => {
const title = environmentTitle(item, item.userName);
return title.toLowerCase().includes(searchValue.toLowerCase());
});
}, [searchValue, possibleEnvironments]);
return (
<SelectProvider value={values("environments")} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
>
<ComboBox placeholder={"Filter by environment..."} value={searchValue} />
<SelectList>
{filtered.map((item, index) => (
<SelectItem
key={item.id}
value={item.id}
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
>
<EnvironmentLabel environment={item} userName={item.userName} />
</SelectItem>
))}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
function AppliedEnvironmentFilter({
possibleEnvironments,
}: Pick<RunFiltersProps, "possibleEnvironments">) {
const { values, del } = useSearchParams();
if (values("environments").length === 0) {
return null;
}
return (
<FilterMenuProvider>
{(search, setSearch) => (
<EnvironmentsDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Environment"
value={appliedSummary(
values("environments").map((v) => {
const environment = possibleEnvironments.find((env) => env.id === v);
return environment ? environmentTitle(environment, environment.userName) : v;
})
)}
onRemove={() => del(["environments", "cursor", "direction"])}
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
possibleEnvironments={possibleEnvironments}
/>
)}
</FilterMenuProvider>
);
}
function TasksDropdown({
trigger,
clearSearchValue,
@@ -524,7 +449,9 @@ function TasksDropdown({
<SelectItem
key={item.slug}
value={item.slug}
icon={<TaskTriggerSourceIcon source={item.triggerSource} className="size-4" />}
icon={
<TaskTriggerSourceIcon source={item.triggerSource} className="size-4 flex-none" />
}
>
{item.slug}
</SelectItem>
@@ -685,7 +612,7 @@ function TagsDropdown({
});
};
const fetcher = useFetcher<typeof loader>();
const fetcher = useFetcher<typeof tagsLoader>();
useEffect(() => {
const searchParams = new URLSearchParams();
@@ -780,62 +707,33 @@ function AppliedTagsFilter() {
);
}
const timePeriods = [
{
label: "All periods",
value: "all",
},
{
label: "5 mins ago",
value: "5m",
},
{
label: "15 mins ago",
value: "15m",
},
{
label: "30 mins ago",
value: "30m",
},
{
label: "1 hour ago",
value: "1h",
},
{
label: "3 hours ago",
value: "3h",
},
{
label: "6 hours ago",
value: "6h",
},
{
label: "1 day ago",
value: "1d",
},
{
label: "3 days ago",
value: "3d",
},
{
label: "7 days ago",
value: "7d",
},
{
label: "10 days ago",
value: "10d",
},
{
label: "14 days ago",
value: "14d",
},
{
label: "30 days ago",
value: "30d",
},
];
function ShowChildTasksToggle() {
const { value, replace } = useSearchParams();
function CreatedDropdown({
const showChildTasks = value("showChildTasks") === "true";
const batchId = value("batchId");
const runId = value("runId");
const scheduleId = value("scheduleId");
const disabled = !!batchId || !!runId || !!scheduleId;
return (
<Switch
disabled={disabled}
variant="small"
label="Show child runs"
checked={disabled ? true : showChildTasks}
onCheckedChange={(checked) => {
replace({
showChildTasks: checked ? "true" : undefined,
});
}}
/>
);
}
function RunIdDropdown({
trigger,
clearSearchValue,
searchValue,
@@ -846,25 +744,34 @@ function CreatedDropdown({
searchValue: string;
onClose?: () => void;
}) {
const [open, setOpen] = useState<boolean | undefined>();
const { value, replace } = useSearchParams();
const runIdValue = value("runId");
const handleChange = (newValue: string) => {
const [runId, setRunId] = useState(runIdValue);
const apply = useCallback(() => {
clearSearchValue();
if (newValue === "all") {
if (!value) return;
replace({
cursor: undefined,
direction: undefined,
runId: runId === "" ? undefined : runId?.toString(),
});
setOpen(false);
}, [runId, replace]);
let error: string | undefined = undefined;
if (runId) {
if (!runId.startsWith("run_")) {
error = "Run IDs start with 'run_'";
} else if (runId.length !== 25) {
error = "Run IDs are 25 characters long";
}
replace({ period: newValue, cursor: undefined, direction: undefined });
};
const filtered = useMemo(() => {
return timePeriods.filter((item) =>
item.label.toLowerCase().includes(searchValue.toLowerCase())
);
}, [searchValue]);
}
return (
<SelectProvider value={value("period")} setValue={handleChange} virtualFocus={true}>
<SelectProvider virtualFocus={true} open={open} setOpen={setOpen}>
{trigger}
<SelectPopover
hideOnEnter={false}
@@ -876,39 +783,63 @@ function CreatedDropdown({
return true;
}}
className="max-w-[min(32ch,var(--popover-available-width))]"
>
<ComboBox placeholder={"Filter by period..."} value={searchValue} />
<SelectList>
{filtered.map((item) => (
<SelectItem key={item.value} value={item.value} hideOnClick={false}>
{item.label}
</SelectItem>
))}
</SelectList>
<div className="flex flex-col gap-4 p-3">
<div className="flex flex-col gap-1">
<Label>Run ID</Label>
<Input
placeholder="run_"
value={runId ?? ""}
onChange={(e) => setRunId(e.target.value)}
variant="small"
className="w-[27ch] font-mono"
spellCheck={false}
/>
{error ? <FormError>{error}</FormError> : null}
</div>
<div className="flex justify-between gap-1 border-t border-grid-dimmed pt-3">
<Button variant="tertiary/small" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
disabled={error !== undefined || !runId}
variant="secondary/small"
shortcut={{
modifiers: ["meta"],
key: "Enter",
enabledOnInputElements: true,
}}
onClick={() => apply()}
>
Apply
</Button>
</div>
</div>
</SelectPopover>
</SelectProvider>
);
}
function AppliedPeriodFilter() {
function AppliedRunIdFilter() {
const { value, del } = useSearchParams();
if (value("period") === undefined || value("period") === "all") {
if (value("runId") === undefined) {
return null;
}
const runId = value("runId");
return (
<FilterMenuProvider>
{(search, setSearch) => (
<CreatedDropdown
<RunIdDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Created"
value={
timePeriods.find((t) => t.value === value("period"))?.label ?? value("period")
}
onRemove={() => del(["period", "cursor", "direction"])}
label="Run ID"
value={runId}
onRemove={() => del(["runId", "cursor", "direction"])}
/>
</Ariakit.Select>
}
@@ -920,14 +851,238 @@ function AppliedPeriodFilter() {
);
}
function appliedSummary(values: string[], maxValues = 3) {
if (values.length === 0) {
function BatchIdDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
}) {
const [open, setOpen] = useState<boolean | undefined>();
const { value, replace } = useSearchParams();
const batchIdValue = value("batchId");
const [batchId, setBatchId] = useState(batchIdValue);
const apply = useCallback(() => {
clearSearchValue();
replace({
cursor: undefined,
direction: undefined,
batchId: batchId === "" ? undefined : batchId?.toString(),
});
setOpen(false);
}, [batchId, replace]);
let error: string | undefined = undefined;
if (batchId) {
if (!batchId.startsWith("batch_")) {
error = "Batch IDs start with 'batch_'";
} else if (batchId.length !== 27) {
error = "Batch IDs are 27 characters long";
}
}
return (
<SelectProvider virtualFocus={true} open={open} setOpen={setOpen}>
{trigger}
<SelectPopover
hideOnEnter={false}
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
className="max-w-[min(32ch,var(--popover-available-width))]"
>
<div className="flex flex-col gap-4 p-3">
<div className="flex flex-col gap-1">
<Label>Batch ID</Label>
<Input
placeholder="batch_"
value={batchId ?? ""}
onChange={(e) => setBatchId(e.target.value)}
variant="small"
className="w-[29ch] font-mono"
spellCheck={false}
/>
{error ? <FormError>{error}</FormError> : null}
</div>
<div className="flex justify-between gap-1 border-t border-grid-dimmed pt-3">
<Button variant="tertiary/small" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
disabled={error !== undefined || !batchId}
variant="secondary/small"
shortcut={{
modifiers: ["meta"],
key: "Enter",
enabledOnInputElements: true,
}}
onClick={() => apply()}
>
Apply
</Button>
</div>
</div>
</SelectPopover>
</SelectProvider>
);
}
function AppliedBatchIdFilter() {
const { value, del } = useSearchParams();
if (value("batchId") === undefined) {
return null;
}
if (values.length > maxValues) {
return `${values.slice(0, maxValues).join(", ")} + ${values.length - maxValues} more`;
const batchId = value("batchId");
return (
<FilterMenuProvider>
{(search, setSearch) => (
<BatchIdDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Batch ID"
value={batchId}
onRemove={() => del(["batchId", "cursor", "direction"])}
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
function ScheduleIdDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
}) {
const [open, setOpen] = useState<boolean | undefined>();
const { value, replace } = useSearchParams();
const scheduleIdValue = value("scheduleId");
const [scheduleId, setScheduleId] = useState(scheduleIdValue);
const apply = useCallback(() => {
clearSearchValue();
replace({
cursor: undefined,
direction: undefined,
scheduleId: scheduleId === "" ? undefined : scheduleId?.toString(),
});
setOpen(false);
}, [scheduleId, replace]);
let error: string | undefined = undefined;
if (scheduleId) {
if (!scheduleId.startsWith("sched")) {
error = "Schedule IDs start with 'sched_'";
} else if (scheduleId.length !== 27) {
error = "Schedule IDs are 27 characters long";
}
}
return values.join(", ");
return (
<SelectProvider virtualFocus={true} open={open} setOpen={setOpen}>
{trigger}
<SelectPopover
hideOnEnter={false}
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
className="max-w-[min(32ch,var(--popover-available-width))]"
>
<div className="flex flex-col gap-4 p-3">
<div className="flex flex-col gap-1">
<Label>Batch ID</Label>
<Input
placeholder="sched_"
value={scheduleId ?? ""}
onChange={(e) => setScheduleId(e.target.value)}
variant="small"
className="w-[29ch] font-mono"
spellCheck={false}
/>
{error ? <FormError>{error}</FormError> : null}
</div>
<div className="flex justify-between gap-1 border-t border-grid-dimmed pt-3">
<Button variant="tertiary/small" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
disabled={error !== undefined || !scheduleId}
variant="secondary/small"
shortcut={{
modifiers: ["meta"],
key: "Enter",
enabledOnInputElements: true,
}}
onClick={() => apply()}
>
Apply
</Button>
</div>
</div>
</SelectPopover>
</SelectProvider>
);
}
function AppliedScheduleIdFilter() {
const { value, del } = useSearchParams();
if (value("scheduleId") === undefined) {
return null;
}
const scheduleId = value("scheduleId");
return (
<FilterMenuProvider>
{(search, setSearch) => (
<ScheduleIdDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Schedule ID"
value={scheduleId}
onRemove={() => del(["scheduleId", "cursor", "direction"])}
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
@@ -0,0 +1,482 @@
import * as Ariakit from "@ariakit/react";
import type { RuntimeEnvironment } from "@trigger.dev/database";
import type { ReactNode } from "react";
import { startTransition, useCallback, useMemo, useState } from "react";
import { EnvironmentLabel, environmentTitle } from "~/components/environments/EnvironmentLabel";
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
import { DateField } from "~/components/primitives/DateField";
import { DateTime } from "~/components/primitives/DateTime";
import { Label } from "~/components/primitives/Label";
import {
ComboBox,
ComboboxProvider,
SelectItem,
SelectList,
SelectPopover,
SelectProvider,
shortcutFromIndex,
} from "~/components/primitives/Select";
import { useSearchParams } from "~/hooks/useSearchParam";
import { Button } from "../../primitives/Buttons";
export type DisplayableEnvironment = Pick<RuntimeEnvironment, "type" | "id"> & {
userName?: string;
};
export function FilterMenuProvider({
children,
onClose,
}: {
children: (search: string, setSearch: (value: string) => void) => React.ReactNode;
onClose?: () => void;
}) {
const [searchValue, setSearchValue] = useState("");
return (
<ComboboxProvider
resetValueOnHide
setValue={(value) => {
startTransition(() => {
setSearchValue(value);
});
}}
setOpen={(open) => {
if (!open && onClose) {
onClose();
}
}}
>
{children(searchValue, setSearchValue)}
</ComboboxProvider>
);
}
export function EnvironmentsDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
possibleEnvironments,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
possibleEnvironments: DisplayableEnvironment[];
}) {
const { values, replace } = useSearchParams();
const handleChange = (values: string[]) => {
clearSearchValue();
replace({ environments: values, cursor: undefined, direction: undefined });
};
const filtered = useMemo(() => {
return possibleEnvironments.filter((item) => {
const title = environmentTitle(item, item.userName);
return title.toLowerCase().includes(searchValue.toLowerCase());
});
}, [searchValue, possibleEnvironments]);
return (
<SelectProvider value={values("environments")} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
>
<ComboBox placeholder={"Filter by environment..."} value={searchValue} />
<SelectList>
{filtered.map((item, index) => (
<SelectItem
key={item.id}
value={item.id}
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
>
<EnvironmentLabel environment={item} userName={item.userName} />
</SelectItem>
))}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
export function AppliedEnvironmentFilter({
possibleEnvironments,
}: {
possibleEnvironments: DisplayableEnvironment[];
}) {
const { values, del } = useSearchParams();
if (values("environments").length === 0) {
return null;
}
return (
<FilterMenuProvider>
{(search, setSearch) => (
<EnvironmentsDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Environment"
value={appliedSummary(
values("environments").map((v) => {
const environment = possibleEnvironments.find((env) => env.id === v);
return environment ? environmentTitle(environment, environment.userName) : v;
})
)}
onRemove={() => del(["environments", "cursor", "direction"])}
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
possibleEnvironments={possibleEnvironments}
/>
)}
</FilterMenuProvider>
);
}
const timePeriods = [
{
label: "Last 5 mins",
value: "5m",
},
{
label: "Last 30 mins",
value: "30m",
},
{
label: "Last 1 hour",
value: "1h",
},
{
label: "Last 6 hours",
value: "6h",
},
{
label: "Last 1 day",
value: "1d",
},
{
label: "Last 3 days",
value: "3d",
},
{
label: "Last 7 days",
value: "7d",
},
{
label: "Last 14 days",
value: "14d",
},
{
label: "Last 30 days",
value: "30d",
},
{
label: "All periods",
value: "all",
},
];
export function CreatedAtDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
setFilterType,
hideCustomRange,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
setFilterType?: (type: "daterange" | undefined) => void;
hideCustomRange?: boolean;
}) {
const { value, replace } = useSearchParams();
const from = value("from");
const to = value("to");
const period = value("period");
const handleChange = (newValue: string) => {
clearSearchValue();
if (newValue === "all") {
if (!period && !from && !to) return;
replace({
period: undefined,
from: undefined,
to: undefined,
cursor: undefined,
direction: undefined,
});
return;
}
if (newValue === "custom") {
setFilterType?.("daterange");
return;
}
replace({
period: newValue,
from: undefined,
to: undefined,
cursor: undefined,
direction: undefined,
});
};
const filtered = useMemo(() => {
return timePeriods.filter((item) =>
item.label.toLowerCase().includes(searchValue.toLowerCase())
);
}, [searchValue]);
return (
<SelectProvider
value={from || to ? "custom" : period ?? "all"}
setValue={handleChange}
virtualFocus={true}
>
{trigger}
<SelectPopover
hideOnEnter={false}
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
>
<ComboBox placeholder={"Filter by period..."} value={searchValue} />
<SelectList>
{filtered.map((item) => (
<SelectItem key={item.value} value={item.value} hideOnClick={false}>
{item.label}
</SelectItem>
))}
{!hideCustomRange ? (
<SelectItem value="custom" hideOnClick={false}>
Custom date range
</SelectItem>
) : null}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
export function AppliedPeriodFilter() {
const { value, del } = useSearchParams();
if (value("period") === undefined || value("period") === "all") {
return null;
}
return (
<FilterMenuProvider>
{(search, setSearch) => (
<CreatedAtDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Created"
value={
timePeriods.find((t) => t.value === value("period"))?.label ?? value("period")
}
onRemove={() => del(["period", "cursor", "direction"])}
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
hideCustomRange
/>
)}
</FilterMenuProvider>
);
}
export function CustomDateRangeDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
}) {
const [open, setOpen] = useState<boolean | undefined>();
const { value, replace } = useSearchParams();
const fromSearch = dateFromString(value("from"));
const toSearch = dateFromString(value("to"));
const [from, setFrom] = useState(fromSearch);
const [to, setTo] = useState(toSearch);
const apply = useCallback(() => {
clearSearchValue();
replace({
period: undefined,
cursor: undefined,
direction: undefined,
from: from?.getTime().toString(),
to: to?.getTime().toString(),
});
setOpen(false);
}, [from, to, replace]);
return (
<SelectProvider virtualFocus={true} open={open} setOpen={setOpen}>
{trigger}
<SelectPopover
hideOnEnter={false}
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
>
<div className="flex flex-col gap-4 p-3">
<div className="flex flex-col gap-1">
<Label>From (local time)</Label>
<DateField
label="From time"
defaultValue={from}
onValueChange={setFrom}
granularity="second"
showNowButton
showClearButton
variant="small"
/>
</div>
<div className="flex flex-col gap-1">
<Label>To (local time)</Label>
<DateField
label="To time"
defaultValue={to}
onValueChange={setTo}
granularity="second"
showNowButton
showClearButton
variant="small"
/>
</div>
<div className="flex justify-between gap-1 border-t border-grid-dimmed pt-3">
<Button variant="tertiary/small" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
variant="secondary/small"
shortcut={{
modifiers: ["meta"],
key: "Enter",
enabledOnInputElements: true,
}}
onClick={() => apply()}
>
Apply
</Button>
</div>
</div>
</SelectPopover>
</SelectProvider>
);
}
export function AppliedCustomDateRangeFilter() {
const { value, del } = useSearchParams();
if (value("from") === undefined && value("to") === undefined) {
return null;
}
const fromDate = dateFromString(value("from"));
const toDate = dateFromString(value("to"));
const rangeType = fromDate && toDate ? "range" : fromDate ? "from" : "to";
return (
<FilterMenuProvider>
{(search, setSearch) => (
<CustomDateRangeDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label={
rangeType === "range"
? "Created"
: rangeType === "from"
? "Created after"
: "Created before"
}
value={
<>
{rangeType === "range" ? (
<span>
<DateTime date={fromDate!} includeTime includeSeconds /> {" "}
<DateTime date={toDate!} includeTime includeSeconds />
</span>
) : rangeType === "from" ? (
<DateTime date={fromDate!} includeTime includeSeconds />
) : (
<DateTime date={toDate!} includeTime includeSeconds />
)}
</>
}
onRemove={() => del(["period", "from", "to", "cursor", "direction"])}
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
export function appliedSummary(values: string[], maxValues = 3) {
if (values.length === 0) {
return null;
}
if (values.length > maxValues) {
return `${values.slice(0, maxValues).join(", ")} + ${values.length - maxValues} more`;
}
return values.join(", ");
}
function dateFromString(value: string | undefined | null): Date | undefined {
if (!value) return;
//is it an int?
const int = parseInt(value);
if (!isNaN(int)) {
return new Date(int);
}
return new Date(value);
}
+2
View File
@@ -233,10 +233,12 @@ const EnvironmentSchema = z.object({
MAXIMUM_TRACE_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(25_000),
TASK_PAYLOAD_OFFLOAD_THRESHOLD: z.coerce.number().int().default(524_288), // 512KB
TASK_PAYLOAD_MAXIMUM_SIZE: z.coerce.number().int().default(3_145_728), // 3MB
BATCH_TASK_PAYLOAD_MAXIMUM_SIZE: z.coerce.number().int().default(1_000_000), // 1MB
TASK_RUN_METADATA_MAXIMUM_SIZE: z.coerce.number().int().default(4_096), // 4KB
MAXIMUM_DEV_QUEUE_SIZE: z.coerce.number().int().optional(),
MAXIMUM_DEPLOYED_QUEUE_SIZE: z.coerce.number().int().optional(),
MAX_BATCH_V2_TRIGGER_ITEMS: z.coerce.number().int().default(500),
});
export type Environment = z.infer<typeof EnvironmentSchema>;
+5 -2
View File
@@ -6,11 +6,10 @@ import type {
import { TaskRunError, TaskRunErrorCodes } from "@trigger.dev/core/v3";
import type {
BatchTaskRunItemStatus as BatchTaskRunItemStatusType,
TaskRun,
TaskRunAttempt,
TaskRunAttemptStatus as TaskRunAttemptStatusType,
TaskRunStatus as TaskRunStatusType,
BatchTaskRunItemStatus as BatchTaskRunItemStatusType,
} from "@trigger.dev/database";
import { assertNever } from "assert-never";
@@ -50,6 +49,7 @@ export function executionResultForTaskRun(
return {
ok: true,
id: taskRun.friendlyId,
taskIdentifier: taskRun.taskIdentifier,
output: attempt.output ?? undefined,
outputType: attempt.outputType,
} satisfies TaskRunSuccessfulExecutionResult;
@@ -60,6 +60,7 @@ export function executionResultForTaskRun(
return {
ok: false,
id: taskRun.friendlyId,
taskIdentifier: taskRun.taskIdentifier,
error: {
type: "INTERNAL_ERROR",
code: TaskRunErrorCodes.TASK_RUN_CANCELLED,
@@ -92,6 +93,7 @@ export function executionResultForTaskRun(
return {
ok: false,
id: taskRun.friendlyId,
taskIdentifier: taskRun.taskIdentifier,
error: {
type: "INTERNAL_ERROR",
code: TaskRunErrorCodes.CONFIGURED_INCORRECTLY,
@@ -102,6 +104,7 @@ export function executionResultForTaskRun(
return {
ok: false,
id: taskRun.friendlyId,
taskIdentifier: taskRun.taskIdentifier,
error: error.data,
} satisfies TaskRunFailedExecutionResult;
}
@@ -0,0 +1,32 @@
import { RetrieveBatchResponse } from "@trigger.dev/core/v3";
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { BasePresenter } from "./basePresenter.server";
export class ApiRetrieveBatchPresenter extends BasePresenter {
public async call(
friendlyId: string,
env: AuthenticatedEnvironment
): Promise<RetrieveBatchResponse | undefined> {
return this.traceWithEnv<RetrieveBatchResponse | undefined>("call", env, async (span) => {
const batch = await this._replica.batchTaskRun.findFirst({
where: {
friendlyId,
runtimeEnvironmentId: env.id,
},
});
if (!batch) {
return;
}
return {
id: batch.friendlyId,
status: batch.status,
idempotencyKey: batch.idempotencyKey ?? undefined,
createdAt: batch.createdAt,
updatedAt: batch.updatedAt,
runCount: batch.runCount,
};
});
}
}
@@ -119,6 +119,7 @@ export const ApiRunListSearchParams = z.object({
"filter[createdAt][from]": CoercedDate,
"filter[createdAt][to]": CoercedDate,
"filter[createdAt][period]": z.string().optional(),
"filter[batch]": z.string().optional(),
});
type ApiRunListSearchParams = z.infer<typeof ApiRunListSearchParams>;
@@ -209,6 +210,10 @@ export class ApiRunListPresenter extends BasePresenter {
options.isTest = searchParams["filter[isTest]"];
}
if (searchParams["filter[batch]"]) {
options.batchId = searchParams["filter[batch]"];
}
const presenter = new RunListPresenter();
logger.debug("Calling RunListPresenter", { options });
@@ -0,0 +1,220 @@
import { BatchTaskRunStatus, Prisma } from "@trigger.dev/database";
import parse from "parse-duration";
import { type Direction } from "~/components/runs/RunStatuses";
import { sqlDatabaseSchema } from "~/db.server";
import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
import { BasePresenter } from "./basePresenter.server";
export type BatchListOptions = {
userId?: string;
projectId: string;
//filters
friendlyId?: string;
statuses?: BatchTaskRunStatus[];
environments?: string[];
period?: string;
from?: number;
to?: number;
//pagination
direction?: Direction;
cursor?: string;
pageSize?: number;
};
const DEFAULT_PAGE_SIZE = 25;
export type BatchList = Awaited<ReturnType<BatchListPresenter["call"]>>;
export type BatchListItem = BatchList["batches"][0];
export type BatchListAppliedFilters = BatchList["filters"];
export class BatchListPresenter extends BasePresenter {
public async call({
userId,
projectId,
friendlyId,
statuses,
environments,
period,
from,
to,
direction = "forward",
cursor,
pageSize = DEFAULT_PAGE_SIZE,
}: BatchListOptions) {
const hasStatusFilters = statuses && statuses.length > 0;
const hasFilters =
hasStatusFilters ||
(environments !== undefined && environments.length > 0) ||
(period !== undefined && period !== "all") ||
friendlyId !== undefined ||
from !== undefined ||
to !== undefined;
// Find the project scoped to the organization
const project = await this._replica.project.findFirstOrThrow({
select: {
id: true,
environments: {
select: {
id: true,
type: true,
slug: true,
orgMember: {
select: {
user: {
select: {
id: true,
name: true,
displayName: true,
},
},
},
},
},
},
},
where: {
id: projectId,
},
});
let environmentIds = project.environments.map((e) => e.id);
if (environments && environments.length > 0) {
//if environments are passed in, we only include them if they're in the project
environmentIds = environments.filter((e) => project.environments.some((pe) => pe.id === e));
}
if (environmentIds.length === 0) {
throw new Error("No matching environments found for the project");
}
const periodMs = period ? parse(period) : undefined;
//get the batches
const batches = await this._replica.$queryRaw<
{
id: string;
friendlyId: string;
runtimeEnvironmentId: string;
status: BatchTaskRunStatus;
createdAt: Date;
updatedAt: Date;
runCount: BigInt;
batchVersion: string;
}[]
>`
SELECT
b.id,
b."friendlyId",
b."runtimeEnvironmentId",
b.status,
b."createdAt",
b."updatedAt",
b."runCount",
b."batchVersion"
FROM
${sqlDatabaseSchema}."BatchTaskRun" b
WHERE
-- environments
b."runtimeEnvironmentId" IN (${Prisma.join(environmentIds)})
-- cursor
${
cursor
? direction === "forward"
? Prisma.sql`AND b.id < ${cursor}`
: Prisma.sql`AND b.id > ${cursor}`
: Prisma.empty
}
-- filters
${friendlyId ? Prisma.sql`AND b."friendlyId" = ${friendlyId}` : Prisma.empty}
${
statuses && statuses.length > 0
? Prisma.sql`AND b.status = ANY(ARRAY[${Prisma.join(
statuses
)}]::"BatchTaskRunStatus"[]) AND b."batchVersion" <> 'v1'`
: Prisma.empty
}
${
periodMs
? Prisma.sql`AND b."createdAt" >= NOW() - INTERVAL '1 millisecond' * ${periodMs}`
: Prisma.empty
}
${
from
? Prisma.sql`AND b."createdAt" >= ${new Date(from).toISOString()}::timestamp`
: Prisma.empty
}
${to ? Prisma.sql`AND b."createdAt" <= ${new Date(to).toISOString()}::timestamp` : Prisma.empty}
ORDER BY
${direction === "forward" ? Prisma.sql`b.id DESC` : Prisma.sql`b.id ASC`}
LIMIT ${pageSize + 1}`;
const hasMore = batches.length > pageSize;
//get cursors for next and previous pages
let next: string | undefined;
let previous: string | undefined;
switch (direction) {
case "forward":
previous = cursor ? batches.at(0)?.id : undefined;
if (hasMore) {
next = batches[pageSize - 1]?.id;
}
break;
case "backward":
batches.reverse();
if (hasMore) {
previous = batches[1]?.id;
next = batches[pageSize]?.id;
} else {
next = batches[pageSize - 1]?.id;
}
break;
}
const batchesToReturn =
direction === "backward" && hasMore
? batches.slice(1, pageSize + 1)
: batches.slice(0, pageSize);
return {
batches: batchesToReturn.map((batch) => {
const environment = project.environments.find(
(env) => env.id === batch.runtimeEnvironmentId
);
if (!environment) {
throw new Error(`Environment not found for Batch ${batch.id}`);
}
const hasFinished = batch.status === "COMPLETED";
return {
id: batch.id,
friendlyId: batch.friendlyId,
createdAt: batch.createdAt.toISOString(),
updatedAt: batch.updatedAt.toISOString(),
hasFinished,
finishedAt: hasFinished ? batch.updatedAt.toISOString() : undefined,
status: batch.status,
environment: displayableEnvironment(environment, userId),
runCount: Number(batch.runCount),
batchVersion: batch.batchVersion,
};
}),
pagination: {
next,
previous,
},
filters: {
friendlyId,
statuses: statuses || [],
environments: environments || [],
from,
to,
},
hasFilters,
};
}
}
@@ -22,6 +22,9 @@ export type RunListOptions = {
from?: number;
to?: number;
isTest?: boolean;
rootOnly?: boolean;
batchId?: string;
runId?: string;
//pagination
direction?: Direction;
cursor?: string;
@@ -47,6 +50,9 @@ export class RunListPresenter extends BasePresenter {
period,
bulkId,
isTest,
rootOnly,
batchId,
runId,
from,
to,
direction = "forward",
@@ -66,7 +72,10 @@ export class RunListPresenter extends BasePresenter {
to !== undefined ||
(scheduleId !== undefined && scheduleId !== "") ||
(tags !== undefined && tags.length > 0) ||
typeof isTest === "boolean";
batchId !== undefined ||
runId !== undefined ||
typeof isTest === "boolean" ||
rootOnly === true;
// Find the project scoped to the organization
const project = await this._replica.project.findFirstOrThrow({
@@ -141,6 +150,43 @@ export class RunListPresenter extends BasePresenter {
}
}
//batch id is a friendly id
if (batchId) {
const batch = await this._replica.batchTaskRun.findFirst({
select: {
id: true,
},
where: {
friendlyId: batchId,
},
});
if (batch) {
batchId = batch.id;
}
}
//scheduleId can be a friendlyId
if (scheduleId && scheduleId.startsWith("sched_")) {
const schedule = await this._replica.taskSchedule.findFirst({
select: {
id: true,
},
where: {
friendlyId: scheduleId,
},
});
if (schedule) {
scheduleId = schedule?.id;
}
}
//show all runs if we are filtering by batchId or runId
if (batchId || runId || scheduleId) {
rootOnly = false;
}
const periodMs = period ? parse(period) : undefined;
//get the runs
@@ -166,9 +212,10 @@ export class RunListPresenter extends BasePresenter {
costInCents: number;
baseCostInCents: number;
usageDurationMs: BigInt;
tags: string[];
tags: null | string[];
depth: number;
rootTaskRunId: string | null;
batchId: string | null;
}[]
>`
SELECT
@@ -194,15 +241,11 @@ export class RunListPresenter extends BasePresenter {
tr."usageDurationMs" AS "usageDurationMs",
tr."depth" AS "depth",
tr."rootTaskRunId" AS "rootTaskRunId",
array_remove(array_agg(tag.name), NULL) AS "tags"
tr."runTags" AS "tags"
FROM
${sqlDatabaseSchema}."TaskRun" tr
LEFT JOIN
${sqlDatabaseSchema}."BackgroundWorker" bw ON tr."lockedToVersionId" = bw.id
LEFT JOIN
${sqlDatabaseSchema}."_TaskRunToTaskRunTag" trtg ON tr.id = trtg."A"
LEFT JOIN
${sqlDatabaseSchema}."TaskRunTag" tag ON trtg."B" = tag.id
WHERE
-- project
tr."projectId" = ${project.id}
@@ -215,6 +258,8 @@ WHERE
: Prisma.empty
}
-- filters
${runId ? Prisma.sql`AND tr."friendlyId" = ${runId}` : Prisma.empty}
${batchId ? Prisma.sql`AND tr."batchId" = ${batchId}` : Prisma.empty}
${
restrictToRunIds
? restrictToRunIds.length === 0
@@ -248,26 +293,16 @@ WHERE
from
? Prisma.sql`AND tr."createdAt" >= ${new Date(from).toISOString()}::timestamp`
: Prisma.empty
}
}
${
to ? Prisma.sql`AND tr."createdAt" <= ${new Date(to).toISOString()}::timestamp` : Prisma.empty
}
${
tags && tags.length > 0
? Prisma.sql`AND (
tr.id IN (
SELECT
trtg."A"
FROM
${sqlDatabaseSchema}."_TaskRunToTaskRunTag" trtg
JOIN
${sqlDatabaseSchema}."TaskRunTag" tag ON trtg."B" = tag.id
WHERE
tag.name IN (${Prisma.join(tags)})
)
)`
: Prisma.empty
}
}
${
tags && tags.length > 0
? Prisma.sql`AND tr."runTags" && ARRAY[${Prisma.join(tags)}]::text[]`
: Prisma.empty
}
${rootOnly === true ? Prisma.sql`AND tr."rootTaskRunId" IS NULL` : Prisma.empty}
GROUP BY
tr.id, bw.version
ORDER BY
@@ -336,7 +371,7 @@ WHERE
costInCents: run.costInCents,
baseCostInCents: run.baseCostInCents,
usageDurationMs: Number(run.usageDurationMs),
tags: run.tags.sort((a, b) => a.localeCompare(b)),
tags: run.tags ? run.tags.sort((a, b) => a.localeCompare(b)) : [],
depth: run.depth,
rootTaskRunId: run.rootTaskRunId,
};
@@ -149,6 +149,11 @@ export class SpanPresenter extends BasePresenter {
spanId: true,
},
},
batch: {
select: {
friendlyId: true,
},
},
},
where: {
spanId,
@@ -312,6 +317,7 @@ export class SpanPresenter extends BasePresenter {
context: JSON.stringify(context, null, 2),
metadata,
maxDurationInSeconds: getMaxDuration(run.maxDurationInSeconds),
batch: run.batch ? { friendlyId: run.batch.friendlyId } : undefined,
};
}
@@ -0,0 +1,233 @@
import { ExclamationCircleIcon } from "@heroicons/react/20/solid";
import { BookOpenIcon } from "@heroicons/react/24/solid";
import { useNavigation } from "@remix-run/react";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { formatDuration } from "@trigger.dev/core/v3/utils/durations";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { ListPagination } from "~/components/ListPagination";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { LinkButton } from "~/components/primitives/Buttons";
import { DateTime } from "~/components/primitives/DateTime";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Spinner } from "~/components/primitives/Spinner";
import {
Table,
TableBlankRow,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { BatchFilters, BatchListFilters } from "~/components/runs/v3/BatchFilters";
import {
allBatchStatuses,
BatchStatusCombo,
descriptionForBatchStatus,
} from "~/components/runs/v3/BatchStatus";
import { LiveTimer } from "~/components/runs/v3/LiveTimer";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { redirectWithErrorMessage } from "~/models/message.server";
import { findProjectBySlug } from "~/models/project.server";
import { BatchList, BatchListPresenter } from "~/presenters/v3/BatchListPresenter.server";
import { requireUserId } from "~/services/session.server";
import { docsPath, ProjectParamSchema, v3BatchRunsPath } from "~/utils/pathBuilder";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam, organizationSlug } = ProjectParamSchema.parse(params);
const url = new URL(request.url);
const s = {
cursor: url.searchParams.get("cursor") ?? undefined,
direction: url.searchParams.get("direction") ?? undefined,
environments: url.searchParams.getAll("environments"),
statuses: url.searchParams.getAll("statuses"),
period: url.searchParams.get("period") ?? undefined,
from: url.searchParams.get("from") ?? undefined,
to: url.searchParams.get("to") ?? undefined,
id: url.searchParams.get("id") ?? undefined,
};
const filters = BatchListFilters.parse(s);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
return redirectWithErrorMessage("/", request, "Project not found");
}
const presenter = new BatchListPresenter();
const list = await presenter.call({
userId,
projectId: project.id,
...filters,
friendlyId: filters.id,
});
return typedjson(list);
};
export default function Page() {
const { batches, hasFilters, filters, pagination } = useTypedLoaderData<typeof loader>();
const project = useProject();
return (
<PageContainer>
<NavBar>
<PageTitle title="Batches" />
<PageAccessories>
<AdminDebugTooltip />
<LinkButton
variant={"docs/small"}
LeadingIcon={BookOpenIcon}
to={docsPath("/triggering")}
>
Batches docs
</LinkButton>
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
<div className="grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden">
<div className="flex items-start justify-between gap-x-2 p-2">
<BatchFilters possibleEnvironments={project.environments} hasFilters={hasFilters} />
<div className="flex items-center justify-end gap-x-2">
<ListPagination list={{ pagination }} />
</div>
</div>
<BatchesTable
batches={batches}
filters={filters}
hasFilters={hasFilters}
pagination={pagination}
/>
</div>
</PageBody>
</PageContainer>
);
}
function BatchesTable({ batches, hasFilters, filters }: BatchList) {
const navigation = useNavigation();
const isLoading = navigation.state !== "idle";
const organization = useOrganization();
const project = useProject();
return (
<Table className="max-h-full overflow-y-auto">
<TableHeader>
<TableRow>
<TableHeaderCell>ID</TableHeaderCell>
<TableHeaderCell>Env</TableHeaderCell>
<TableHeaderCell
tooltip={
<div className="flex flex-col divide-y divide-grid-dimmed">
{allBatchStatuses.map((status) => (
<div
key={status}
className="grid grid-cols-[8rem_1fr] gap-x-2 py-2 first:pt-1 last:pb-1"
>
<div className="mb-0.5 flex items-center gap-1.5 whitespace-nowrap">
<BatchStatusCombo status={status} />
</div>
<Paragraph variant="extra-small" className="!text-wrap text-text-dimmed">
{descriptionForBatchStatus(status)}
</Paragraph>
</div>
))}
</div>
}
>
Status
</TableHeaderCell>
<TableHeaderCell>Runs</TableHeaderCell>
<TableHeaderCell>Duration</TableHeaderCell>
<TableHeaderCell>Created</TableHeaderCell>
<TableHeaderCell>Finished</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{batches.length === 0 && !hasFilters ? (
<TableBlankRow colSpan={7}>
{!isLoading && (
<div className="flex items-center justify-center">
<Paragraph className="w-auto">No batches</Paragraph>
</div>
)}
</TableBlankRow>
) : batches.length === 0 ? (
<TableBlankRow colSpan={7}>
<div className="flex items-center justify-center">
<Paragraph className="w-auto">No batches match these filters</Paragraph>
</div>
</TableBlankRow>
) : (
batches.map((batch, index) => {
const path = v3BatchRunsPath(organization, project, batch);
return (
<TableRow key={batch.id}>
<TableCell to={path}>{batch.friendlyId}</TableCell>
<TableCell to={path}>
<EnvironmentLabel
environment={batch.environment}
userName={batch.environment.userName}
/>
</TableCell>
<TableCell to={path}>
{batch.batchVersion === "v1" ? (
<SimpleTooltip
content="Upgrade to the latest SDK for batch statuses to appear."
disableHoverableContent
button={
<span className="flex items-center gap-1">
<ExclamationCircleIcon className="size-4 text-slate-500" />
<span>Legacy batch</span>
</span>
}
/>
) : (
<SimpleTooltip
content={descriptionForBatchStatus(batch.status)}
disableHoverableContent
button={<BatchStatusCombo status={batch.status} />}
/>
)}
</TableCell>
<TableCell to={path}>{batch.runCount}</TableCell>
<TableCell to={path} className="w-[1%]" actionClassName="pr-0 tabular-nums">
{batch.finishedAt ? (
formatDuration(new Date(batch.createdAt), new Date(batch.finishedAt), {
style: "short",
})
) : (
<LiveTimer startTime={new Date(batch.createdAt)} />
)}
</TableCell>
<TableCell to={path}>
<DateTime date={batch.createdAt} />
</TableCell>
<TableCell to={path}>
{batch.finishedAt ? <DateTime date={batch.finishedAt} /> : ""}
</TableCell>
</TableRow>
);
})
)}
{isLoading && (
<TableBlankRow
colSpan={7}
className="absolute left-0 top-0 flex h-full w-full items-center justify-center gap-2 bg-charcoal-900/90"
>
<Spinner /> <span className="text-text-dimmed">Loading</span>
</TableBlankRow>
)}
</TableBody>
</Table>
);
}
@@ -63,6 +63,12 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
period: url.searchParams.get("period") ?? undefined,
bulkId: url.searchParams.get("bulkId") ?? undefined,
tags: url.searchParams.getAll("tags").map((t) => decodeURIComponent(t)),
from: url.searchParams.get("from") ?? undefined,
to: url.searchParams.get("to") ?? undefined,
showChildTasks: url.searchParams.get("showChildTasks") === "true",
runId: url.searchParams.get("runId") ?? undefined,
batchId: url.searchParams.get("batchId") ?? undefined,
scheduleId: url.searchParams.get("scheduleId") ?? undefined,
};
const {
tasks,
@@ -76,6 +82,10 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
to,
cursor,
direction,
showChildTasks,
runId,
batchId,
scheduleId,
} = TaskRunListSearchFilters.parse(s);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
@@ -97,6 +107,10 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
bulkId,
from,
to,
batchId,
runId,
scheduleId,
rootOnly: !showChildTasks,
direction: direction,
cursor: cursor,
});
@@ -412,6 +412,7 @@ function ScheduledTaskForm({
granularity="second"
showNowButton
variant="medium"
utc
/>
<Hint>
This is the timestamp of the CRON, it will come through to your run in the
@@ -436,6 +437,7 @@ function ScheduledTaskForm({
showNowButton
showClearButton
variant="medium"
utc
/>
<Hint>
This is the timestamp of the previous run. You can use this in your code to find
@@ -87,6 +87,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
const monthDateFormatter = new Intl.DateTimeFormat("en-US", {
month: "long",
year: "numeric",
timeZone: "utc",
});
export default function Page() {
@@ -0,0 +1,31 @@
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { ApiRetrieveBatchPresenter } from "~/presenters/v3/ApiRetrieveBatchPresenter.server";
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
const ParamsSchema = z.object({
batchId: z.string(),
});
export const loader = createLoaderApiRoute(
{
params: ParamsSchema,
allowJWT: true,
corsStrategy: "all",
authorization: {
action: "read",
resource: (params) => ({ batch: params.batchId }),
superScopes: ["read:runs", "read:all", "admin"],
},
},
async ({ params, authentication }) => {
const presenter = new ApiRetrieveBatchPresenter();
const result = await presenter.call(params.batchId, authentication.environment);
if (!result) {
return json({ error: "Batch not found" }, { status: 404 });
}
return json(result);
}
);
@@ -6,6 +6,7 @@ import { env } from "~/env.server";
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { resolveIdempotencyKeyTTL } from "~/utils/idempotencyKeys.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { OutOfEntitlementError, TriggerTaskService } from "~/v3/services/triggerTask.server";
@@ -15,6 +16,7 @@ const ParamsSchema = z.object({
export const HeadersSchema = z.object({
"idempotency-key": z.string().nullish(),
"idempotency-key-ttl": z.string().nullish(),
"trigger-version": z.string().nullish(),
"x-trigger-span-parent-as-link": z.coerce.number().nullish(),
"x-trigger-worker": z.string().nullish(),
@@ -40,6 +42,7 @@ const { action, loader } = createActionApiRoute(
async ({ body, headers, params, authentication }) => {
const {
"idempotency-key": idempotencyKey,
"idempotency-key-ttl": idempotencyKeyTTL,
"trigger-version": triggerVersion,
"x-trigger-span-parent-as-link": spanParentAsLink,
traceparent,
@@ -59,6 +62,7 @@ const { action, loader } = createActionApiRoute(
logger.debug("Triggering task", {
taskId: params.taskId,
idempotencyKey,
idempotencyKeyTTL,
triggerVersion,
headers,
options: body.options,
@@ -66,8 +70,11 @@ const { action, loader } = createActionApiRoute(
traceContext,
});
const idempotencyKeyExpiresAt = resolveIdempotencyKeyTTL(idempotencyKeyTTL);
const run = await service.call(params.taskId, authentication.environment, body, {
idempotencyKey: idempotencyKey ?? undefined,
idempotencyKeyExpiresAt: idempotencyKeyExpiresAt,
triggerVersion: triggerVersion ?? undefined,
traceContext,
spanParentAsLink: spanParentAsLink === 1,
@@ -0,0 +1,156 @@
import { json } from "@remix-run/server-runtime";
import {
BatchTriggerTaskResponse,
BatchTriggerTaskV2RequestBody,
BatchTriggerTaskV2Response,
generateJWT,
} from "@trigger.dev/core/v3";
import { env } from "~/env.server";
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { HeadersSchema } from "./api.v1.tasks.$taskId.trigger";
import { resolveIdempotencyKeyTTL } from "~/utils/idempotencyKeys.server";
import { BatchTriggerV2Service } from "~/v3/services/batchTriggerV2.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { OutOfEntitlementError } from "~/v3/services/triggerTask.server";
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
const { action, loader } = createActionApiRoute(
{
headers: HeadersSchema,
body: BatchTriggerTaskV2RequestBody,
allowJWT: true,
maxContentLength: env.BATCH_TASK_PAYLOAD_MAXIMUM_SIZE,
authorization: {
action: "write",
resource: (_, __, ___, body) => ({
tasks: Array.from(new Set(body.items.map((i) => i.task))),
}),
superScopes: ["write:tasks", "admin"],
},
corsStrategy: "all",
},
async ({ body, headers, params, authentication }) => {
if (!body.items.length) {
return json({ error: "Batch cannot be triggered with no items" }, { status: 400 });
}
// Check the there are fewer than MAX_BATCH_V2_TRIGGER_ITEMS items
if (body.items.length > env.MAX_BATCH_V2_TRIGGER_ITEMS) {
return json(
{
error: `Batch size of ${body.items.length} is too large. Maximum allowed batch size is ${env.MAX_BATCH_V2_TRIGGER_ITEMS}.`,
},
{ status: 400 }
);
}
const {
"idempotency-key": idempotencyKey,
"idempotency-key-ttl": idempotencyKeyTTL,
"trigger-version": triggerVersion,
"x-trigger-span-parent-as-link": spanParentAsLink,
"x-trigger-worker": isFromWorker,
"x-trigger-client": triggerClient,
traceparent,
tracestate,
} = headers;
logger.debug("Batch trigger request", {
idempotencyKey,
idempotencyKeyTTL,
triggerVersion,
spanParentAsLink,
isFromWorker,
triggerClient,
traceparent,
tracestate,
});
const traceContext =
traceparent && isFromWorker // If the request is from a worker, we should pass the trace context
? { traceparent, tracestate }
: undefined;
// By default, the idempotency key expires in 30 days
const idempotencyKeyExpiresAt =
resolveIdempotencyKeyTTL(idempotencyKeyTTL) ??
new Date(Date.now() + 24 * 60 * 60 * 1000 * 30);
const service = new BatchTriggerV2Service();
try {
const batch = await service.call(authentication.environment, body, {
idempotencyKey: idempotencyKey ?? undefined,
idempotencyKeyExpiresAt,
triggerVersion: triggerVersion ?? undefined,
traceContext,
spanParentAsLink: spanParentAsLink === 1,
});
const $responseHeaders = await responseHeaders(
batch,
authentication.environment,
triggerClient
);
return json(batch, { status: 202, headers: $responseHeaders });
} catch (error) {
logger.error("Batch trigger error", {
error: {
message: (error as Error).message,
stack: (error as Error).stack,
},
});
if (error instanceof ServiceValidationError) {
return json({ error: error.message }, { status: 422 });
} else if (error instanceof OutOfEntitlementError) {
return json({ error: error.message }, { status: 422 });
} else if (error instanceof Error) {
return json(
{ error: error.message },
{ status: 500, headers: { "x-should-retry": "false" } }
);
}
return json({ error: "Something went wrong" }, { status: 500 });
}
}
);
async function responseHeaders(
batch: BatchTriggerTaskV2Response,
environment: AuthenticatedEnvironment,
triggerClient?: string | null
): Promise<Record<string, string>> {
const claimsHeader = JSON.stringify({
sub: environment.id,
pub: true,
});
if (triggerClient === "browser") {
const claims = {
sub: environment.id,
pub: true,
scopes: [`read:batch:${batch.id}`].concat(batch.runs.map((r) => `read:runs:${r.id}`)),
};
const jwt = await generateJWT({
secretKey: environment.apiKey,
payload: claims,
expirationTime: "1h",
});
return {
"x-trigger-jwt-claims": claimsHeader,
"x-trigger-jwt": jwt,
};
}
return {
"x-trigger-jwt-claims": claimsHeader,
};
}
export { action, loader };
+4 -1
View File
@@ -23,7 +23,10 @@ export const loader = createLoaderApiRoute(
const result = await presenter.call(params.runId, authentication.environment);
if (!result) {
return json({ error: "Run not found" }, { status: 404 });
return json(
{ error: "Run not found" },
{ status: 404, headers: { "x-should-retry": "true" } }
);
}
return json(result);
@@ -56,6 +56,8 @@ import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { formatCurrencyAccurate } from "~/utils/numberFormatter";
import {
v3BatchPath,
v3BatchRunsPath,
v3RunDownloadLogsPath,
v3RunPath,
v3RunSpanPath,
@@ -583,6 +585,21 @@ function RunBody({
</>
)
) : null}
{run.batch && (
<Property.Item>
<Property.Label>Batch</Property.Label>
<Property.Value>
<SimpleTooltip
button={
<TextLink to={v3BatchPath(organization, project, run.batch)}>
{run.batch.friendlyId}
</TextLink>
}
content={`Jump to ${run.batch.friendlyId}`}
/>
</Property.Value>
</Property.Item>
)}
<Property.Item>
<Property.Label>Version</Property.Label>
<Property.Value>
@@ -52,7 +52,14 @@ export class RealtimeClient {
batchId: string,
clientVersion?: string
) {
return this.#streamRunsWhere(url, environment, `"batchId"='${batchId}'`, clientVersion);
const whereClauses: string[] = [
`"runtimeEnvironmentId"='${environment.id}'`,
`"batchId"='${batchId}'`,
];
const whereClause = whereClauses.join(" AND ");
return this.#streamRunsWhere(url, environment, whereClause, clientVersion);
}
async streamRuns(
@@ -346,7 +346,24 @@ type ApiKeyActionRouteBuilderOptions<
TSearchParamsSchema extends z.AnyZodObject | undefined = undefined,
THeadersSchema extends z.AnyZodObject | undefined = undefined,
TBodySchema extends z.AnyZodObject | undefined = undefined
> = ApiKeyRouteBuilderOptions<TParamsSchema, TSearchParamsSchema, THeadersSchema> & {
> = {
params?: TParamsSchema;
searchParams?: TSearchParamsSchema;
headers?: THeadersSchema;
allowJWT?: boolean;
corsStrategy?: "all" | "none";
authorization?: {
action: AuthorizationAction;
resource: (
params: TParamsSchema extends z.AnyZodObject ? z.infer<TParamsSchema> : undefined,
searchParams: TSearchParamsSchema extends z.AnyZodObject
? z.infer<TSearchParamsSchema>
: undefined,
headers: THeadersSchema extends z.AnyZodObject ? z.infer<THeadersSchema> : undefined,
body: TBodySchema extends z.AnyZodObject ? z.infer<TBodySchema> : undefined
) => AuthorizationResources;
superScopes?: string[];
};
maxContentLength?: number;
body?: TBodySchema;
};
@@ -517,7 +534,7 @@ export function createActionApiRoute<
if (authorization) {
const { action, resource, superScopes } = authorization;
const $resource = resource(parsedParams, parsedSearchParams, parsedHeaders);
const $resource = resource(parsedParams, parsedSearchParams, parsedHeaders, parsedBody);
logger.debug("Checking authorization", {
action,
+11
View File
@@ -55,6 +55,7 @@ import {
CancelDevSessionRunsServiceOptions,
} from "~/v3/services/cancelDevSessionRuns.server";
import { logger } from "./logger.server";
import { BatchProcessingOptions, BatchTriggerV2Service } from "~/v3/services/batchTriggerV2.server";
const workerCatalog = {
indexEndpoint: z.object({
@@ -197,6 +198,7 @@ const workerCatalog = {
attemptId: z.string(),
}),
"v3.cancelDevSessionRuns": CancelDevSessionRunsServiceOptions,
"v3.processBatchTaskRun": BatchProcessingOptions,
};
const executionWorkerCatalog = {
@@ -727,6 +729,15 @@ function getWorkerQueue() {
return await service.call(payload);
},
},
"v3.processBatchTaskRun": {
priority: 0,
maxAttempts: 5,
handler: async (payload, job) => {
const service = new BatchTriggerV2Service();
await service.processBatchTaskRun(payload);
},
},
},
});
}
@@ -0,0 +1,41 @@
/**
* Resolve the TTL for an idempotency key.
*
* The TTL format is a string like "5m", "1h", "7d"
*
* @param ttl The TTL string
* @returns The date when the key will expire
* @throws If the TTL string is invalid
*/
export function resolveIdempotencyKeyTTL(ttl: string | undefined | null): Date | undefined {
if (!ttl) {
return undefined;
}
const match = ttl.match(/^(\d+)([smhd])$/);
if (!match) {
return;
}
const [, value, unit] = match;
const now = new Date();
switch (unit) {
case "s":
now.setSeconds(now.getSeconds() + parseInt(value, 10));
break;
case "m":
now.setMinutes(now.getMinutes() + parseInt(value, 10));
break;
case "h":
now.setHours(now.getHours() + parseInt(value, 10));
break;
case "d":
now.setDate(now.getDate() + parseInt(value, 10));
break;
}
return now;
}
+20
View File
@@ -437,6 +437,26 @@ export function v3NewSchedulePath(organization: OrgForPath, project: ProjectForP
return `${v3ProjectPath(organization, project)}/schedules/new`;
}
export function v3BatchesPath(organization: OrgForPath, project: ProjectForPath) {
return `${v3ProjectPath(organization, project)}/batches`;
}
export function v3BatchPath(
organization: OrgForPath,
project: ProjectForPath,
batch: { friendlyId: string }
) {
return `${v3ProjectPath(organization, project)}/batches?id=${batch.friendlyId}`;
}
export function v3BatchRunsPath(
organization: OrgForPath,
project: ProjectForPath,
batch: { friendlyId: string }
) {
return `${v3ProjectPath(organization, project)}/runs?batchId=${batch.friendlyId}`;
}
export function v3ProjectSettingsPath(organization: OrgForPath, project: ProjectForPath) {
return `${v3ProjectPath(organization, project)}/settings`;
}
@@ -378,24 +378,15 @@ export class DevQueueConsumer {
lockedById: backgroundTask.id,
status: "EXECUTING",
lockedToVersionId: backgroundWorker.id,
taskVersion: backgroundWorker.version,
sdkVersion: backgroundWorker.sdkVersion,
cliVersion: backgroundWorker.cliVersion,
startedAt: existingTaskRun.startedAt ?? new Date(),
maxDurationInSeconds: getMaxDuration(
existingTaskRun.maxDurationInSeconds,
backgroundTask.maxDurationInSeconds
),
},
include: {
attempts: {
take: 1,
orderBy: { number: "desc" },
},
tags: true,
batchItems: {
include: {
batchTaskRun: true,
},
},
},
});
if (!lockedTaskRun) {
@@ -407,6 +407,9 @@ export class SharedQueueConsumer {
lockedAt: new Date(),
lockedById: backgroundTask.id,
lockedToVersionId: deployment.worker.id,
taskVersion: deployment.worker.version,
sdkVersion: deployment.worker.sdkVersion,
cliVersion: deployment.worker.cliVersion,
startedAt: existingTaskRun.startedAt ?? new Date(),
baseCostInCents: env.CENTS_PER_RUN,
machinePreset: machinePresetFromConfig(backgroundTask.machineConfig ?? {}).name,
@@ -1035,6 +1038,7 @@ class SharedQueueTasks {
id: attempt.taskRun.friendlyId,
output: attempt.output ?? undefined,
outputType: attempt.outputType,
taskIdentifier: attempt.taskRun.taskIdentifier,
};
return success;
} else {
@@ -1042,6 +1046,7 @@ class SharedQueueTasks {
ok,
id: attempt.taskRun.friendlyId,
error: attempt.error as TaskRunError,
taskIdentifier: attempt.taskRun.taskIdentifier,
};
return failure;
}
@@ -1076,7 +1081,11 @@ class SharedQueueTasks {
tags: true,
batchItems: {
include: {
batchTaskRun: true,
batchTaskRun: {
select: {
friendlyId: true,
},
},
},
},
},
+4 -2
View File
@@ -10,7 +10,8 @@ export type QueueSizeGuardResult = {
export async function guardQueueSizeLimitsForEnv(
environment: AuthenticatedEnvironment,
marqs?: MarQS
marqs?: MarQS,
itemsToAdd: number = 1
): Promise<QueueSizeGuardResult> {
const maximumSize = getMaximumSizeForEnvironment(environment);
@@ -23,9 +24,10 @@ export async function guardQueueSizeLimitsForEnv(
}
const queueSize = await marqs.lengthOfEnvQueue(environment);
const projectedSize = queueSize + itemsToAdd;
return {
isWithinLimits: queueSize < maximumSize,
isWithinLimits: projectedSize <= maximumSize,
maximumSize,
queueSize,
};
+90 -3
View File
@@ -4,6 +4,7 @@ import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { singleton } from "~/utils/singleton";
import { startActiveSpan } from "./tracer.server";
import { IOPacket } from "@trigger.dev/core/v3";
export const r2 = singleton("r2", initializeR2);
@@ -18,13 +19,13 @@ function initializeR2() {
});
}
export async function uploadToObjectStore(
export async function uploadPacketToObjectStore(
filename: string,
data: string,
data: ReadableStream | string,
contentType: string,
environment: AuthenticatedEnvironment
): Promise<string> {
return await startActiveSpan("uploadToObjectStore()", async (span) => {
return await startActiveSpan("uploadPacketToObjectStore()", async (span) => {
if (!r2) {
throw new Error("Object store credentials are not set");
}
@@ -60,6 +61,92 @@ export async function uploadToObjectStore(
});
}
export async function downloadPacketFromObjectStore(
packet: IOPacket,
environment: AuthenticatedEnvironment
): Promise<IOPacket> {
if (packet.dataType !== "application/store") {
return packet;
}
return await startActiveSpan("downloadPacketFromObjectStore()", async (span) => {
if (!r2) {
throw new Error("Object store credentials are not set");
}
if (!env.OBJECT_STORE_BASE_URL) {
throw new Error("Object store base URL is not set");
}
span.setAttributes({
projectRef: environment.project.externalRef,
environmentSlug: environment.slug,
filename: packet.data,
});
const url = new URL(env.OBJECT_STORE_BASE_URL);
url.pathname = `/packets/${environment.project.externalRef}/${environment.slug}/${packet.data}`;
logger.debug("Downloading from object store", { url: url.href });
const response = await r2.fetch(url.toString());
if (!response.ok) {
throw new Error(`Failed to download input from ${url}: ${response.statusText}`);
}
const data = await response.text();
const rawPacket = {
data,
dataType: "application/json",
};
return rawPacket;
});
}
export async function uploadDataToObjectStore(
filename: string,
data: string,
contentType: string,
prefix?: string
): Promise<string> {
return await startActiveSpan("uploadDataToObjectStore()", async (span) => {
if (!r2) {
throw new Error("Object store credentials are not set");
}
if (!env.OBJECT_STORE_BASE_URL) {
throw new Error("Object store base URL is not set");
}
span.setAttributes({
prefix,
filename,
});
const url = new URL(env.OBJECT_STORE_BASE_URL);
url.pathname = `${prefix}/${filename}`;
logger.debug("Uploading to object store", { url: url.href });
const response = await r2.fetch(url.toString(), {
method: "PUT",
headers: {
"Content-Type": contentType,
},
body: data,
});
if (!response.ok) {
throw new Error(`Failed to upload data to ${url}: ${response.statusText}`);
}
return url.href;
});
}
export async function generatePresignedRequest(
projectRef: string,
envSlug: string,
@@ -26,10 +26,9 @@ export class BatchTriggerTaskService extends BaseService {
const existingBatch = options.idempotencyKey
? await this._prisma.batchTaskRun.findUnique({
where: {
runtimeEnvironmentId_taskIdentifier_idempotencyKey: {
runtimeEnvironmentId_idempotencyKey: {
runtimeEnvironmentId: environment.id,
idempotencyKey: options.idempotencyKey,
taskIdentifier: taskId,
},
},
include: {
@@ -0,0 +1,789 @@
import {
BatchTriggerTaskV2RequestBody,
BatchTriggerTaskV2Response,
IOPacket,
packetRequiresOffloading,
parsePacket,
} from "@trigger.dev/core/v3";
import { BatchTaskRun, TaskRunAttempt } from "@trigger.dev/database";
import { $transaction, PrismaClientOrTransaction } from "~/db.server";
import { env } from "~/env.server";
import { batchTaskRunItemStatusForRunStatus } from "~/models/taskRun.server";
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { getEntitlement } from "~/services/platform.v3.server";
import { workerQueue } from "~/services/worker.server";
import { generateFriendlyId } from "../friendlyIdentifiers";
import { marqs } from "../marqs/index.server";
import { guardQueueSizeLimitsForEnv } from "../queueSizeLimits.server";
import { downloadPacketFromObjectStore, uploadPacketToObjectStore } from "../r2.server";
import { isFinalAttemptStatus, isFinalRunStatus } from "../taskStatus";
import { startActiveSpan } from "../tracer.server";
import { BaseService, ServiceValidationError } from "./baseService.server";
import { OutOfEntitlementError, TriggerTaskService } from "./triggerTask.server";
import { z } from "zod";
const PROCESSING_BATCH_SIZE = 50;
const ASYNC_BATCH_PROCESS_SIZE_THRESHOLD = 20;
const BatchProcessingStrategy = z.enum(["sequential", "parallel"]);
type BatchProcessingStrategy = z.infer<typeof BatchProcessingStrategy>;
const CURRENT_STRATEGY: BatchProcessingStrategy = "parallel";
export const BatchProcessingOptions = z.object({
batchId: z.string(),
processingId: z.string(),
range: z.object({ start: z.number().int(), count: z.number().int() }),
attemptCount: z.number().int(),
strategy: BatchProcessingStrategy,
});
export type BatchProcessingOptions = z.infer<typeof BatchProcessingOptions>;
export type BatchTriggerTaskServiceOptions = {
idempotencyKey?: string;
idempotencyKeyExpiresAt?: Date;
triggerVersion?: string;
traceContext?: Record<string, string | undefined>;
spanParentAsLink?: boolean;
};
export class BatchTriggerV2Service extends BaseService {
public async call(
environment: AuthenticatedEnvironment,
body: BatchTriggerTaskV2RequestBody,
options: BatchTriggerTaskServiceOptions = {}
): Promise<BatchTriggerTaskV2Response> {
return await this.traceWithEnv<BatchTriggerTaskV2Response>(
"call()",
environment,
async (span) => {
const existingBatch = options.idempotencyKey
? await this._prisma.batchTaskRun.findUnique({
where: {
runtimeEnvironmentId_idempotencyKey: {
runtimeEnvironmentId: environment.id,
idempotencyKey: options.idempotencyKey,
},
},
})
: undefined;
if (existingBatch) {
if (
existingBatch.idempotencyKeyExpiresAt &&
existingBatch.idempotencyKeyExpiresAt < new Date()
) {
logger.debug("[BatchTriggerV2][call] Idempotency key has expired", {
idempotencyKey: options.idempotencyKey,
batch: {
id: existingBatch.id,
friendlyId: existingBatch.friendlyId,
runCount: existingBatch.runCount,
idempotencyKeyExpiresAt: existingBatch.idempotencyKeyExpiresAt,
idempotencyKey: existingBatch.idempotencyKey,
},
});
// Update the existing batch to remove the idempotency key
await this._prisma.batchTaskRun.update({
where: { id: existingBatch.id },
data: { idempotencyKey: null },
});
// Don't return, just continue with the batch trigger
} else {
span.setAttribute("batchId", existingBatch.friendlyId);
return this.#respondWithExistingBatch(existingBatch, environment);
}
}
const batchId = generateFriendlyId("batch");
span.setAttribute("batchId", batchId);
const dependentAttempt = body?.dependentAttempt
? await this._prisma.taskRunAttempt.findUnique({
where: { friendlyId: body.dependentAttempt },
include: {
taskRun: {
select: {
id: true,
status: true,
},
},
},
})
: undefined;
if (
dependentAttempt &&
(isFinalAttemptStatus(dependentAttempt.status) ||
isFinalRunStatus(dependentAttempt.taskRun.status))
) {
logger.debug("[BatchTriggerV2][call] Dependent attempt or run is in a terminal state", {
dependentAttempt: dependentAttempt,
batchId,
});
throw new ServiceValidationError(
"Cannot process batch as the parent run is already in a terminal state"
);
}
if (environment.type !== "DEVELOPMENT") {
const result = await getEntitlement(environment.organizationId);
if (result && result.hasAccess === false) {
throw new OutOfEntitlementError();
}
}
const idempotencyKeys = body.items.map((i) => i.options?.idempotencyKey).filter(Boolean);
const cachedRuns =
idempotencyKeys.length > 0
? await this._prisma.taskRun.findMany({
where: {
runtimeEnvironmentId: environment.id,
idempotencyKey: {
in: body.items.map((i) => i.options?.idempotencyKey).filter(Boolean),
},
},
select: {
friendlyId: true,
idempotencyKey: true,
idempotencyKeyExpiresAt: true,
},
})
: [];
if (cachedRuns.length) {
logger.debug("[BatchTriggerV2][call] Found cached runs", {
cachedRuns,
batchId,
});
}
// Now we need to create an array of all the run IDs, in order
// If we have a cached run, that isn't expired, we should use that run ID
// If we have a cached run, that is expired, we should generate a new run ID and save that cached run ID to a set of expired run IDs
// If we don't have a cached run, we should generate a new run ID
const expiredRunIds = new Set<string>();
let cachedRunCount = 0;
const runs = body.items.map((item) => {
const cachedRun = cachedRuns.find(
(r) => r.idempotencyKey === item.options?.idempotencyKey
);
if (cachedRun) {
if (
cachedRun.idempotencyKeyExpiresAt &&
cachedRun.idempotencyKeyExpiresAt < new Date()
) {
expiredRunIds.add(cachedRun.friendlyId);
return {
id: generateFriendlyId("run"),
isCached: false,
idempotencyKey: item.options?.idempotencyKey ?? undefined,
taskIdentifier: item.task,
};
}
cachedRunCount++;
return {
id: cachedRun.friendlyId,
isCached: true,
idempotencyKey: item.options?.idempotencyKey ?? undefined,
taskIdentifier: item.task,
};
}
return {
id: generateFriendlyId("run"),
isCached: false,
idempotencyKey: item.options?.idempotencyKey ?? undefined,
taskIdentifier: item.task,
};
});
// Calculate how many new runs we need to create
const newRunCount = body.items.length - cachedRunCount;
if (newRunCount === 0) {
logger.debug("[BatchTriggerV2][call] All runs are cached", {
batchId,
});
await this._prisma.batchTaskRun.create({
data: {
friendlyId: batchId,
runtimeEnvironmentId: environment.id,
idempotencyKey: options.idempotencyKey,
idempotencyKeyExpiresAt: options.idempotencyKeyExpiresAt,
dependentTaskAttemptId: dependentAttempt?.id,
runCount: body.items.length,
runIds: runs.map((r) => r.id),
status: "COMPLETED",
batchVersion: "v2",
},
});
return {
id: batchId,
isCached: false,
idempotencyKey: options.idempotencyKey ?? undefined,
runs,
};
}
const queueSizeGuard = await guardQueueSizeLimitsForEnv(environment, marqs, newRunCount);
logger.debug("Queue size guard result", {
newRunCount,
queueSizeGuard,
environment: {
id: environment.id,
type: environment.type,
organization: environment.organization,
project: environment.project,
},
});
if (!queueSizeGuard.isWithinLimits) {
throw new ServiceValidationError(
`Cannot trigger ${newRunCount} tasks as the queue size limit for this environment has been reached. The maximum size is ${queueSizeGuard.maximumSize}`
);
}
// Expire the cached runs that are no longer valid
if (expiredRunIds.size) {
logger.debug("Expiring cached runs", {
expiredRunIds: Array.from(expiredRunIds),
batchId,
});
// TODO: is there a limit to the number of items we can update in a single query?
await this._prisma.taskRun.updateMany({
where: { friendlyId: { in: Array.from(expiredRunIds) } },
data: { idempotencyKey: null },
});
}
// Upload to object store
const payloadPacket = await this.#handlePayloadPacket(
body.items,
`batch/${batchId}`,
environment
);
const batch = await this.#createAndProcessBatchTaskRun(
batchId,
runs,
payloadPacket,
newRunCount,
environment,
body,
options,
dependentAttempt ?? undefined
);
if (!batch) {
throw new Error("Failed to create batch");
}
return {
id: batch.friendlyId,
isCached: false,
idempotencyKey: batch.idempotencyKey ?? undefined,
runs,
};
}
);
}
async #createAndProcessBatchTaskRun(
batchId: string,
runs: Array<{
id: string;
isCached: boolean;
idempotencyKey: string | undefined;
taskIdentifier: string;
}>,
payloadPacket: IOPacket,
newRunCount: number,
environment: AuthenticatedEnvironment,
body: BatchTriggerTaskV2RequestBody,
options: BatchTriggerTaskServiceOptions = {},
dependentAttempt?: TaskRunAttempt
) {
if (newRunCount <= ASYNC_BATCH_PROCESS_SIZE_THRESHOLD) {
const batch = await this._prisma.batchTaskRun.create({
data: {
friendlyId: batchId,
runtimeEnvironmentId: environment.id,
idempotencyKey: options.idempotencyKey,
idempotencyKeyExpiresAt: options.idempotencyKeyExpiresAt,
dependentTaskAttemptId: dependentAttempt?.id,
runCount: newRunCount,
runIds: runs.map((r) => r.id),
payload: payloadPacket.data,
payloadType: payloadPacket.dataType,
options,
batchVersion: "v2",
},
});
const result = await this.#processBatchTaskRunItems(
batch,
environment,
0,
PROCESSING_BATCH_SIZE,
body.items,
options
);
switch (result.status) {
case "COMPLETE": {
logger.debug("[BatchTriggerV2][call] Batch inline processing complete", {
batchId: batch.friendlyId,
currentIndex: 0,
});
return batch;
}
case "INCOMPLETE": {
logger.debug("[BatchTriggerV2][call] Batch inline processing incomplete", {
batchId: batch.friendlyId,
currentIndex: result.workingIndex,
});
// If processing inline does not finish for some reason, enqueue processing the rest of the batch
await this.#enqueueBatchTaskRun({
batchId: batch.id,
processingId: "0",
range: {
start: result.workingIndex,
count: PROCESSING_BATCH_SIZE,
},
attemptCount: 0,
strategy: "sequential",
});
return batch;
}
case "ERROR": {
logger.error("[BatchTriggerV2][call] Batch inline processing error", {
batchId: batch.friendlyId,
currentIndex: result.workingIndex,
error: result.error,
});
await this.#enqueueBatchTaskRun({
batchId: batch.id,
processingId: "0",
range: {
start: result.workingIndex,
count: PROCESSING_BATCH_SIZE,
},
attemptCount: 0,
strategy: "sequential",
});
return batch;
}
}
} else {
return await $transaction(this._prisma, async (tx) => {
const batch = await tx.batchTaskRun.create({
data: {
friendlyId: batchId,
runtimeEnvironmentId: environment.id,
idempotencyKey: options.idempotencyKey,
idempotencyKeyExpiresAt: options.idempotencyKeyExpiresAt,
dependentTaskAttemptId: dependentAttempt?.id,
runCount: body.items.length,
runIds: runs.map((r) => r.id),
payload: payloadPacket.data,
payloadType: payloadPacket.dataType,
options,
batchVersion: "v2",
},
});
switch (CURRENT_STRATEGY) {
case "sequential": {
await this.#enqueueBatchTaskRun({
batchId: batch.id,
processingId: batchId,
range: { start: 0, count: PROCESSING_BATCH_SIZE },
attemptCount: 0,
strategy: CURRENT_STRATEGY,
});
break;
}
case "parallel": {
const ranges = Array.from({
length: Math.ceil(newRunCount / PROCESSING_BATCH_SIZE),
}).map((_, index) => ({
start: index * PROCESSING_BATCH_SIZE,
count: PROCESSING_BATCH_SIZE,
}));
await Promise.all(
ranges.map((range, index) =>
this.#enqueueBatchTaskRun(
{
batchId: batch.id,
processingId: `${index}`,
range,
attemptCount: 0,
strategy: CURRENT_STRATEGY,
},
tx
)
)
);
break;
}
}
return batch;
});
}
}
async #respondWithExistingBatch(
batch: BatchTaskRun,
environment: AuthenticatedEnvironment
): Promise<BatchTriggerTaskV2Response> {
// Resolve the payload
const payloadPacket = await downloadPacketFromObjectStore(
{
data: batch.payload ?? undefined,
dataType: batch.payloadType,
},
environment
);
const payload = await parsePacket(payloadPacket).then(
(p) => p as BatchTriggerTaskV2RequestBody["items"]
);
const runs = batch.runIds.map((id, index) => {
const item = payload[index];
return {
id,
taskIdentifier: item.task,
isCached: true,
idempotencyKey: item.options?.idempotencyKey ?? undefined,
};
});
return {
id: batch.friendlyId,
idempotencyKey: batch.idempotencyKey ?? undefined,
isCached: true,
runs,
};
}
async processBatchTaskRun(options: BatchProcessingOptions) {
logger.debug("[BatchTriggerV2][processBatchTaskRun] Processing batch", {
options,
});
const $attemptCount = options.attemptCount + 1;
const batch = await this._prisma.batchTaskRun.findFirst({
where: { id: options.batchId },
include: {
runtimeEnvironment: {
include: {
project: true,
organization: true,
},
},
},
});
if (!batch) {
return;
}
// Check to make sure the currentIndex is not greater than the runCount
if (options.range.start >= batch.runCount) {
logger.debug("[BatchTriggerV2][processBatchTaskRun] currentIndex is greater than runCount", {
options,
batchId: batch.friendlyId,
runCount: batch.runCount,
attemptCount: $attemptCount,
});
return;
}
// Resolve the payload
const payloadPacket = await downloadPacketFromObjectStore(
{
data: batch.payload ?? undefined,
dataType: batch.payloadType,
},
batch.runtimeEnvironment
);
const payload = await parsePacket(payloadPacket);
if (!payload) {
logger.debug("[BatchTriggerV2][processBatchTaskRun] Failed to parse payload", {
options,
batchId: batch.friendlyId,
attemptCount: $attemptCount,
});
throw new Error("Failed to parse payload");
}
// Skip zod parsing
const $payload = payload as BatchTriggerTaskV2RequestBody["items"];
const $options = batch.options as BatchTriggerTaskServiceOptions;
const result = await this.#processBatchTaskRunItems(
batch,
batch.runtimeEnvironment,
options.range.start,
options.range.count,
$payload,
$options
);
switch (result.status) {
case "COMPLETE": {
logger.debug("[BatchTriggerV2][processBatchTaskRun] Batch processing complete", {
options,
batchId: batch.friendlyId,
attemptCount: $attemptCount,
});
return;
}
case "INCOMPLETE": {
logger.debug("[BatchTriggerV2][processBatchTaskRun] Batch processing incomplete", {
batchId: batch.friendlyId,
currentIndex: result.workingIndex,
attemptCount: $attemptCount,
});
// Only enqueue the next batch task run if the strategy is sequential
// if the strategy is parallel, we will already have enqueued the next batch task run
if (options.strategy === "sequential") {
await this.#enqueueBatchTaskRun({
batchId: batch.id,
processingId: options.processingId,
range: {
start: result.workingIndex,
count: options.range.count,
},
attemptCount: 0,
strategy: options.strategy,
});
}
return;
}
case "ERROR": {
logger.error("[BatchTriggerV2][processBatchTaskRun] Batch processing error", {
batchId: batch.friendlyId,
currentIndex: result.workingIndex,
error: result.error,
attemptCount: $attemptCount,
});
// if the strategy is sequential, we will requeue processing with a count of the PROCESSING_BATCH_SIZE
// if the strategy is parallel, we will requeue processing with a range starting at the workingIndex and a count that is the remainder of this "slice" of the batch
if (options.strategy === "sequential") {
await this.#enqueueBatchTaskRun({
batchId: batch.id,
processingId: options.processingId,
range: {
start: result.workingIndex,
count: options.range.count, // This will be the same as the original count
},
attemptCount: $attemptCount,
strategy: options.strategy,
});
} else {
await this.#enqueueBatchTaskRun({
batchId: batch.id,
processingId: options.processingId,
range: {
start: result.workingIndex,
// This will be the remainder of the slice
// for example if the original range was 0-50 and the workingIndex is 25, the new range will be 25-25
// if the original range was 51-100 and the workingIndex is 75, the new range will be 75-25
count: options.range.count - result.workingIndex - options.range.start,
},
attemptCount: $attemptCount,
strategy: options.strategy,
});
}
return;
}
}
}
async #processBatchTaskRunItems(
batch: BatchTaskRun,
environment: AuthenticatedEnvironment,
currentIndex: number,
batchSize: number,
items: BatchTriggerTaskV2RequestBody["items"],
options?: BatchTriggerTaskServiceOptions
): Promise<
| { status: "COMPLETE" }
| { status: "INCOMPLETE"; workingIndex: number }
| { status: "ERROR"; error: string; workingIndex: number }
> {
// Grab the next PROCESSING_BATCH_SIZE runIds
const runIds = batch.runIds.slice(currentIndex, currentIndex + batchSize);
logger.debug("[BatchTriggerV2][processBatchTaskRun] Processing batch items", {
batchId: batch.friendlyId,
currentIndex,
runIds,
runCount: batch.runCount,
});
// Combine the "window" between currentIndex and currentIndex + PROCESSING_BATCH_SIZE with the runId and the item in the payload which is an array
const itemsToProcess = runIds.map((runId, index) => ({
runId,
item: items[index + currentIndex],
}));
let workingIndex = currentIndex;
for (const item of itemsToProcess) {
try {
await this.#processBatchTaskRunItem(batch, environment, item, workingIndex, options);
workingIndex++;
} catch (error) {
logger.error("[BatchTriggerV2][processBatchTaskRun] Failed to process item", {
batchId: batch.friendlyId,
currentIndex: workingIndex,
error,
});
return {
status: "ERROR",
error: error instanceof Error ? error.message : String(error),
workingIndex,
};
}
}
// if there are more items to process, requeue the batch
if (workingIndex < batch.runCount) {
return { status: "INCOMPLETE", workingIndex };
}
return { status: "COMPLETE" };
}
async #processBatchTaskRunItem(
batch: BatchTaskRun,
environment: AuthenticatedEnvironment,
task: { runId: string; item: BatchTriggerTaskV2RequestBody["items"][number] },
currentIndex: number,
options?: BatchTriggerTaskServiceOptions
) {
logger.debug("[BatchTriggerV2][processBatchTaskRunItem] Processing item", {
batchId: batch.friendlyId,
runId: task.runId,
currentIndex,
});
const triggerTaskService = new TriggerTaskService();
const run = await triggerTaskService.call(
task.item.task,
environment,
{
...task.item,
options: {
...task.item.options,
dependentBatch: batch.dependentTaskAttemptId ? batch.friendlyId : undefined, // Only set dependentBatch if dependentAttempt is set which means batchTriggerAndWait was called
parentBatch: batch.dependentTaskAttemptId ? undefined : batch.friendlyId, // Only set parentBatch if dependentAttempt is NOT set which means batchTrigger was called
},
},
{
triggerVersion: options?.triggerVersion,
traceContext: options?.traceContext,
spanParentAsLink: options?.spanParentAsLink,
batchId: batch.friendlyId,
skipChecks: true,
runId: task.runId,
}
);
if (!run) {
throw new Error(`Failed to trigger run ${task.runId} for batch ${batch.friendlyId}`);
}
await this._prisma.batchTaskRunItem.create({
data: {
batchTaskRunId: batch.id,
taskRunId: run.id,
status: batchTaskRunItemStatusForRunStatus(run.status),
},
});
}
async #enqueueBatchTaskRun(options: BatchProcessingOptions, tx?: PrismaClientOrTransaction) {
await workerQueue.enqueue("v3.processBatchTaskRun", options, {
tx,
jobKey: `BatchTriggerV2Service.process:${options.batchId}:${options.processingId}`,
});
}
async #handlePayloadPacket(
payload: any,
pathPrefix: string,
environment: AuthenticatedEnvironment
) {
return await startActiveSpan("handlePayloadPacket()", async (span) => {
const packet = { data: JSON.stringify(payload), dataType: "application/json" };
if (!packet.data) {
return packet;
}
const { needsOffloading } = packetRequiresOffloading(
packet,
env.TASK_PAYLOAD_OFFLOAD_THRESHOLD
);
if (!needsOffloading) {
return packet;
}
const filename = `${pathPrefix}/payload.json`;
await uploadPacketToObjectStore(filename, packet.data, packet.dataType, environment);
return {
data: filename,
dataType: "application/store",
};
});
}
}
@@ -190,7 +190,6 @@ export async function createBackgroundTasks(
},
update: {
concurrencyLimit,
rateLimit: task.queue?.rateLimit,
},
create: {
friendlyId: generateFriendlyId("queue"),
@@ -198,7 +197,6 @@ export async function createBackgroundTasks(
concurrencyLimit,
runtimeEnvironmentId: worker.runtimeEnvironmentId,
projectId: worker.projectId,
rateLimit: task.queue?.rateLimit,
type: task.queue?.name ? "NAMED" : "VIRTUAL",
},
});
@@ -72,7 +72,11 @@ export class CreateTaskRunAttemptService extends BaseService {
},
batchItems: {
include: {
batchTaskRun: true,
batchTaskRun: {
select: {
friendlyId: true,
},
},
},
},
},
@@ -14,6 +14,7 @@ import { BaseService } from "./baseService.server";
import { ResumeDependentParentsService } from "./resumeDependentParents.server";
import { ExpireEnqueuedRunService } from "./expireEnqueuedRun.server";
import { socketIo } from "../handleSocketIo.server";
import { ResumeBatchRunService } from "./resumeBatchRun.server";
type BaseInput = {
id: string;
@@ -81,6 +82,15 @@ export class FinalizeTaskRunService extends BaseService {
await this.finalizeRunError(run, error);
}
try {
await this.#finalizeBatch(run);
} catch (finalizeBatchError) {
logger.error("FinalizeTaskRunService: Failed to finalize batch", {
runId: run.id,
error: finalizeBatchError,
});
}
//resume any dependencies
const resumeService = new ResumeDependentParentsService(this._prisma);
const result = await resumeService.call({ id: run.id });
@@ -135,6 +145,72 @@ export class FinalizeTaskRunService extends BaseService {
return run as Output<T>;
}
async #finalizeBatch(run: TaskRun) {
if (!run.batchId) {
return;
}
logger.debug("FinalizeTaskRunService: Finalizing batch", { runId: run.id });
const environment = await this._prisma.runtimeEnvironment.findFirst({
where: {
id: run.runtimeEnvironmentId,
},
});
if (!environment) {
return;
}
const batchItems = await this._prisma.batchTaskRunItem.findMany({
where: {
taskRunId: run.id,
},
include: {
batchTaskRun: {
select: {
id: true,
dependentTaskAttemptId: true,
},
},
},
});
if (batchItems.length === 0) {
return;
}
if (batchItems.length > 10) {
logger.error("FinalizeTaskRunService: More than 10 batch items", {
runId: run.id,
batchItems: batchItems.length,
});
return;
}
for (const item of batchItems) {
// Don't do anything if this is a batchTriggerAndWait in a deployed task
if (environment.type !== "DEVELOPMENT" && item.batchTaskRun.dependentTaskAttemptId) {
continue;
}
// Update the item to complete
await this._prisma.batchTaskRunItem.update({
where: {
id: item.id,
},
data: {
status: "COMPLETED",
},
});
// This won't resume because this batch does not have a dependent task attempt ID
// or is in development, but this service will mark the batch as completed
await ResumeBatchRunService.enqueue(item.batchTaskRunId, this._prisma);
}
}
async finalizeRunError(run: TaskRun, error: TaskRunError) {
await this._prisma.taskRun.update({
where: { id: run.id },
@@ -13,26 +13,26 @@ export class ResumeBatchRunService extends BaseService {
id: batchRunId,
},
include: {
dependentTaskAttempt: {
runtimeEnvironment: {
include: {
runtimeEnvironment: {
include: {
project: true,
organization: true,
},
},
taskRun: true,
project: true,
organization: true,
},
},
items: {
select: {
status: true,
taskRunAttemptId: true,
},
},
items: true,
},
});
if (!batchRun || !batchRun.dependentTaskAttempt) {
if (!batchRun) {
logger.error(
"ResumeBatchRunService: Batch run doesn't exist or doesn't have a dependent attempt",
{
batchRun,
batchRunId,
}
);
return;
@@ -40,23 +40,28 @@ export class ResumeBatchRunService extends BaseService {
if (batchRun.status === "COMPLETED") {
logger.debug("ResumeBatchRunService: Batch run is already completed", {
batchRun: batchRun,
batchRunId: batchRun.id,
batchRun: {
id: batchRun.id,
status: batchRun.status,
},
});
return;
}
if (batchRun.items.some((item) => !finishedBatchRunStatuses.includes(item.status))) {
logger.debug("ResumeBatchRunService: All items aren't yet completed", {
batchRun: batchRun,
batchRunId: batchRun.id,
batchRun: {
id: batchRun.id,
status: batchRun.status,
},
});
return;
}
// This batch has a dependent attempt and just finalized, we should resume that attempt
const environment = batchRun.dependentTaskAttempt.runtimeEnvironment;
// If we are in development, we don't need to resume the dependent task (that will happen automatically)
if (environment.type === "DEVELOPMENT") {
// If we are in development, or there is no dependent attempt, we can just mark the batch as completed and return
if (batchRun.runtimeEnvironment.type === "DEVELOPMENT" || !batchRun.dependentTaskAttemptId) {
// We need to update the batchRun status so we don't resume it again
await this._prisma.batchTaskRun.update({
where: {
@@ -69,12 +74,42 @@ export class ResumeBatchRunService extends BaseService {
return;
}
const dependentRun = batchRun.dependentTaskAttempt.taskRun;
const dependentTaskAttempt = await this._prisma.taskRunAttempt.findFirst({
where: {
id: batchRun.dependentTaskAttemptId,
},
select: {
status: true,
id: true,
taskRun: {
select: {
id: true,
queue: true,
taskIdentifier: true,
concurrencyKey: true,
},
},
},
});
if (batchRun.dependentTaskAttempt.status === "PAUSED" && batchRun.checkpointEventId) {
if (!dependentTaskAttempt) {
logger.error("ResumeBatchRunService: Dependent attempt not found", {
batchRunId: batchRun.id,
dependentTaskAttemptId: batchRun.dependentTaskAttemptId,
});
return;
}
// This batch has a dependent attempt and just finalized, we should resume that attempt
const environment = batchRun.runtimeEnvironment;
const dependentRun = dependentTaskAttempt.taskRun;
if (dependentTaskAttempt.status === "PAUSED" && batchRun.checkpointEventId) {
logger.debug("ResumeBatchRunService: Attempt is paused and has a checkpoint event", {
batchRunId: batchRun.id,
dependentTaskAttempt: batchRun.dependentTaskAttempt,
dependentTaskAttempt: dependentTaskAttempt,
checkpointEventId: batchRun.checkpointEventId,
});
@@ -83,7 +118,7 @@ export class ResumeBatchRunService extends BaseService {
if (wasUpdated) {
logger.debug("ResumeBatchRunService: Resuming dependent run with checkpoint", {
batchRunId: batchRun.id,
dependentTaskAttemptId: batchRun.dependentTaskAttempt.id,
dependentTaskAttemptId: dependentTaskAttempt.id,
});
await marqs?.enqueueMessage(
environment,
@@ -92,19 +127,19 @@ export class ResumeBatchRunService extends BaseService {
{
type: "RESUME",
completedAttemptIds: [],
resumableAttemptId: batchRun.dependentTaskAttempt.id,
resumableAttemptId: dependentTaskAttempt.id,
checkpointEventId: batchRun.checkpointEventId,
taskIdentifier: batchRun.dependentTaskAttempt.taskRun.taskIdentifier,
projectId: batchRun.dependentTaskAttempt.runtimeEnvironment.projectId,
environmentId: batchRun.dependentTaskAttempt.runtimeEnvironment.id,
environmentType: batchRun.dependentTaskAttempt.runtimeEnvironment.type,
taskIdentifier: dependentTaskAttempt.taskRun.taskIdentifier,
projectId: environment.projectId,
environmentId: environment.id,
environmentType: environment.type,
},
dependentRun.concurrencyKey ?? undefined
);
} else {
logger.debug("ResumeBatchRunService: with checkpoint was already completed", {
batchRunId: batchRun.id,
dependentTaskAttempt: batchRun.dependentTaskAttempt,
dependentTaskAttempt: dependentTaskAttempt,
checkpointEventId: batchRun.checkpointEventId,
hasCheckpointEvent: !!batchRun.checkpointEventId,
});
@@ -112,17 +147,17 @@ export class ResumeBatchRunService extends BaseService {
} else {
logger.debug("ResumeBatchRunService: attempt is not paused or there's no checkpoint event", {
batchRunId: batchRun.id,
dependentTaskAttempt: batchRun.dependentTaskAttempt,
dependentTaskAttempt: dependentTaskAttempt,
checkpointEventId: batchRun.checkpointEventId,
hasCheckpointEvent: !!batchRun.checkpointEventId,
});
if (batchRun.dependentTaskAttempt.status === "PAUSED" && !batchRun.checkpointEventId) {
if (dependentTaskAttempt.status === "PAUSED" && !batchRun.checkpointEventId) {
// In case of race conditions the status can be PAUSED without a checkpoint event
// When the checkpoint is created, it will continue the run
logger.error("ResumeBatchRunService: attempt is paused but there's no checkpoint event", {
batchRunId: batchRun.id,
dependentTaskAttempt: batchRun.dependentTaskAttempt,
dependentTaskAttempt: dependentTaskAttempt,
checkpointEventId: batchRun.checkpointEventId,
hasCheckpointEvent: !!batchRun.checkpointEventId,
});
@@ -134,24 +169,24 @@ export class ResumeBatchRunService extends BaseService {
if (wasUpdated) {
logger.debug("ResumeBatchRunService: Resuming dependent run without checkpoint", {
batchRunId: batchRun.id,
dependentTaskAttempt: batchRun.dependentTaskAttempt,
dependentTaskAttempt: dependentTaskAttempt,
checkpointEventId: batchRun.checkpointEventId,
hasCheckpointEvent: !!batchRun.checkpointEventId,
});
await marqs?.replaceMessage(dependentRun.id, {
type: "RESUME",
completedAttemptIds: batchRun.items.map((item) => item.taskRunAttemptId).filter(Boolean),
resumableAttemptId: batchRun.dependentTaskAttempt.id,
resumableAttemptId: dependentTaskAttempt.id,
checkpointEventId: batchRun.checkpointEventId ?? undefined,
taskIdentifier: batchRun.dependentTaskAttempt.taskRun.taskIdentifier,
projectId: batchRun.dependentTaskAttempt.runtimeEnvironment.projectId,
environmentId: batchRun.dependentTaskAttempt.runtimeEnvironment.id,
environmentType: batchRun.dependentTaskAttempt.runtimeEnvironment.type,
taskIdentifier: dependentTaskAttempt.taskRun.taskIdentifier,
projectId: environment.projectId,
environmentId: environment.id,
environmentType: environment.type,
});
} else {
logger.debug("ResumeBatchRunService: without checkpoint was already completed", {
batchRunId: batchRun.id,
dependentTaskAttempt: batchRun.dependentTaskAttempt,
dependentTaskAttempt: dependentTaskAttempt,
checkpointEventId: batchRun.checkpointEventId,
hasCheckpointEvent: !!batchRun.checkpointEventId,
});
@@ -12,7 +12,7 @@ import { workerQueue } from "~/services/worker.server";
import { marqs, sanitizeQueueName } from "~/v3/marqs/index.server";
import { eventRepository } from "../eventRepository.server";
import { generateFriendlyId } from "../friendlyIdentifiers";
import { uploadToObjectStore } from "../r2.server";
import { uploadPacketToObjectStore } from "../r2.server";
import { startActiveSpan } from "../tracer.server";
import { getEntitlement } from "~/services/platform.v3.server";
import { BaseService, ServiceValidationError } from "./baseService.server";
@@ -25,15 +25,19 @@ import { parseNaturalLanguageDuration } from "@trigger.dev/core/v3/apps";
import { ExpireEnqueuedRunService } from "./expireEnqueuedRun.server";
import { guardQueueSizeLimitsForEnv } from "../queueSizeLimits.server";
import { clampMaxDuration } from "../utils/maxDuration";
import { resolveIdempotencyKeyTTL } from "~/utils/idempotencyKeys.server";
export type TriggerTaskServiceOptions = {
idempotencyKey?: string;
idempotencyKeyExpiresAt?: Date;
triggerVersion?: string;
traceContext?: Record<string, string | undefined>;
spanParentAsLink?: boolean;
parentAsLinkType?: "replay" | "trigger";
batchId?: string;
customIcon?: string;
runId?: string;
skipChecks?: boolean;
};
export class OutOfEntitlementError extends Error {
@@ -52,7 +56,13 @@ export class TriggerTaskService extends BaseService {
return await this.traceWithEnv("call()", environment, async (span) => {
span.setAttribute("taskId", taskId);
// TODO: Add idempotency key expiring here
const idempotencyKey = options.idempotencyKey ?? body.options?.idempotencyKey;
const idempotencyKeyExpiresAt =
options.idempotencyKeyExpiresAt ??
resolveIdempotencyKeyTTL(body.options?.idempotencyKeyTTL) ??
new Date(Date.now() + 24 * 60 * 60 * 1000 * 30); // 30 days
const delayUntil = await parseDelay(body.options?.delay);
const ttl =
@@ -73,34 +83,52 @@ export class TriggerTaskService extends BaseService {
: undefined;
if (existingRun) {
span.setAttribute("runId", existingRun.friendlyId);
if (
existingRun.idempotencyKeyExpiresAt &&
existingRun.idempotencyKeyExpiresAt < new Date()
) {
logger.debug("[TriggerTaskService][call] Idempotency key has expired", {
idempotencyKey: options.idempotencyKey,
run: existingRun,
});
return existingRun;
// Update the existing batch to remove the idempotency key
await this._prisma.taskRun.update({
where: { id: existingRun.id },
data: { idempotencyKey: null },
});
} else {
span.setAttribute("runId", existingRun.friendlyId);
return existingRun;
}
}
if (environment.type !== "DEVELOPMENT") {
if (environment.type !== "DEVELOPMENT" && !options.skipChecks) {
const result = await getEntitlement(environment.organizationId);
if (result && result.hasAccess === false) {
throw new OutOfEntitlementError();
}
}
const queueSizeGuard = await guardQueueSizeLimitsForEnv(environment, marqs);
if (!options.skipChecks) {
const queueSizeGuard = await guardQueueSizeLimitsForEnv(environment, marqs);
logger.debug("Queue size guard result", {
queueSizeGuard,
environment: {
id: environment.id,
type: environment.type,
organization: environment.organization,
project: environment.project,
},
});
logger.debug("Queue size guard result", {
queueSizeGuard,
environment: {
id: environment.id,
type: environment.type,
organization: environment.organization,
project: environment.project,
},
});
if (!queueSizeGuard.isWithinLimits) {
throw new ServiceValidationError(
`Cannot trigger ${taskId} as the queue size limit for this environment has been reached. The maximum size is ${queueSizeGuard.maximumSize}`
);
if (!queueSizeGuard.isWithinLimits) {
throw new ServiceValidationError(
`Cannot trigger ${taskId} as the queue size limit for this environment has been reached. The maximum size is ${queueSizeGuard.maximumSize}`
);
}
}
if (
@@ -113,7 +141,7 @@ export class TriggerTaskService extends BaseService {
);
}
const runFriendlyId = generateFriendlyId("run");
const runFriendlyId = options?.runId ?? generateFriendlyId("run");
const payloadPacket = await this.#handlePayloadPacket(
body.payload,
@@ -330,6 +358,7 @@ export class TriggerTaskService extends BaseService {
runtimeEnvironmentId: environment.id,
projectId: environment.projectId,
idempotencyKey,
idempotencyKeyExpiresAt: idempotencyKey ? idempotencyKeyExpiresAt : undefined,
taskIdentifier: taskId,
payload: payloadPacket.data ?? "",
payloadType: payloadPacket.dataType,
@@ -340,6 +369,9 @@ export class TriggerTaskService extends BaseService {
parentSpanId:
options.parentAsLinkType === "replay" ? undefined : traceparent?.spanId,
lockedToVersionId: lockedToBackgroundWorker?.id,
taskVersion: lockedToBackgroundWorker?.version,
sdkVersion: lockedToBackgroundWorker?.sdkVersion,
cliVersion: lockedToBackgroundWorker?.cliVersion,
concurrencyKey: body.options?.concurrencyKey,
queue: queueName,
isTest: body.options?.test ?? false,
@@ -428,7 +460,6 @@ export class TriggerTaskService extends BaseService {
data: {
concurrencyLimit:
typeof concurrencyLimit === "number" ? concurrencyLimit : null,
rateLimit: body.options.queue.rateLimit,
},
});
@@ -452,7 +483,6 @@ export class TriggerTaskService extends BaseService {
concurrencyLimit,
runtimeEnvironmentId: environment.id,
projectId: environment.projectId,
rateLimit: body.options.queue.rateLimit,
type: "NAMED",
},
});
@@ -617,7 +647,7 @@ export class TriggerTaskService extends BaseService {
const filename = `${pathPrefix}/payload.json`;
await uploadToObjectStore(filename, packet.data, packet.dataType, environment);
await uploadPacketToObjectStore(filename, packet.data, packet.dataType, environment);
return {
data: filename,
@@ -0,0 +1,18 @@
/*
Warnings:
- A unique constraint covering the columns `[runtimeEnvironmentId,idempotencyKey]` on the table `BatchTaskRun` will be added. If there are existing duplicate values, this will fail.
*/
-- DropIndex
DROP INDEX "BatchTaskRun_runtimeEnvironmentId_taskIdentifier_idempotenc_key";
-- AlterTable
ALTER TABLE
"BatchTaskRun"
ADD
COLUMN "runCount" INTEGER NOT NULL DEFAULT 0,
ADD
COLUMN "runIds" TEXT [] DEFAULT ARRAY [] :: TEXT [],
ALTER COLUMN
"taskIdentifier" DROP NOT NULL;
@@ -0,0 +1,2 @@
-- CreateIndex
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "BatchTaskRun_runtimeEnvironmentId_idempotencyKey_key" ON "BatchTaskRun"("runtimeEnvironmentId", "idempotencyKey");
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "BatchTaskRun" ADD COLUMN "idempotencyKeyExpiresAt" TIMESTAMP(3);
-- AlterTable
ALTER TABLE "TaskRun" ADD COLUMN "idempotencyKeyExpiresAt" TIMESTAMP(3);
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "BatchTaskRun" ADD COLUMN "payload" TEXT,
ADD COLUMN "payloadType" TEXT NOT NULL DEFAULT 'application/json';
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "BatchTaskRun" ADD COLUMN "options" JSONB;
@@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX CONCURRENTLY IF NOT EXISTS "TaskRun_projectId_id_idx" ON "TaskRun"("projectId", "id" DESC);
@@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX CONCURRENTLY IF NOT EXISTS "TaskRun_runtimeEnvironmentId_batchId_idx" ON "TaskRun"("runtimeEnvironmentId", "batchId");
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "TaskRun" ADD COLUMN "taskVersion" TEXT;
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "TaskRun" ADD COLUMN "cliVersion" TEXT,
ADD COLUMN "sdkVersion" TEXT;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "BatchTaskRun" ADD COLUMN "batchVersion" TEXT NOT NULL DEFAULT 'v1';
+37 -24
View File
@@ -1661,8 +1661,9 @@ model TaskRun {
status TaskRunStatus @default(PENDING)
idempotencyKey String?
taskIdentifier String
idempotencyKey String?
idempotencyKeyExpiresAt DateTime?
taskIdentifier String
isTest Boolean @default(false)
@@ -1691,6 +1692,11 @@ model TaskRun {
/// Denormized column that holds the raw tags
runTags String[]
/// Denormalized version of the background worker task
taskVersion String?
sdkVersion String?
cliVersion String?
checkpoints Checkpoint[]
startedAt DateTime?
@@ -1790,6 +1796,7 @@ model TaskRun {
@@index([projectId, createdAt, taskIdentifier])
//Runs list
@@index([projectId])
@@index([projectId, id(sort: Desc)])
@@index([projectId, taskIdentifier])
@@index([projectId, status])
@@index([projectId, taskIdentifier, status])
@@ -1803,6 +1810,8 @@ model TaskRun {
@@index([completedAt])
// Schedule list page
@@index([scheduleId, createdAt(sort: Desc)])
// Finding runs in a batch
@@index([runtimeEnvironmentId, batchId])
}
enum TaskRunStatus {
@@ -2133,32 +2142,36 @@ enum TaskQueueType {
}
model BatchTaskRun {
id String @id @default(cuid())
id String @id @default(cuid())
friendlyId String @unique
idempotencyKey String?
idempotencyKeyExpiresAt DateTime?
runtimeEnvironment RuntimeEnvironment @relation(fields: [runtimeEnvironmentId], references: [id], onDelete: Cascade, onUpdate: Cascade)
status BatchTaskRunStatus @default(PENDING)
runtimeEnvironmentId String
runs TaskRun[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
friendlyId String @unique
// new columns
runIds String[] @default([])
runCount Int @default(0)
payload String?
payloadType String @default("application/json")
options Json?
batchVersion String @default("v1")
status BatchTaskRunStatus @default(PENDING)
idempotencyKey String?
taskIdentifier String
checkpointEvent CheckpointRestoreEvent? @relation(fields: [checkpointEventId], references: [id], onDelete: Cascade, onUpdate: Cascade)
checkpointEventId String? @unique
runtimeEnvironment RuntimeEnvironment @relation(fields: [runtimeEnvironmentId], references: [id], onDelete: Cascade, onUpdate: Cascade)
runtimeEnvironmentId String
dependentTaskAttempt TaskRunAttempt? @relation(fields: [dependentTaskAttemptId], references: [id], onDelete: Cascade, onUpdate: Cascade)
///all the below properties are engine v1 only
items BatchTaskRunItem[]
taskIdentifier String?
checkpointEvent CheckpointRestoreEvent? @relation(fields: [checkpointEventId], references: [id], onDelete: Cascade, onUpdate: Cascade)
checkpointEventId String? @unique
dependentTaskAttempt TaskRunAttempt? @relation(fields: [dependentTaskAttemptId], references: [id], onDelete: Cascade, onUpdate: Cascade)
dependentTaskAttemptId String?
runDependencies TaskRunDependency[] @relation("dependentBatchRun")
items BatchTaskRunItem[]
runDependencies TaskRunDependency[] @relation("dependentBatchRun")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
TaskRun TaskRun[]
@@unique([runtimeEnvironmentId, taskIdentifier, idempotencyKey])
///this is used for all engine versions
@@unique([runtimeEnvironmentId, idempotencyKey])
}
enum BatchTaskRunStatus {
@@ -498,6 +498,7 @@ export class BackgroundWorker {
ok: false,
retry: undefined,
error: TaskRunProcess.parseExecuteError(e),
taskIdentifier: payload.execution.task.id,
};
}
}
@@ -216,6 +216,7 @@ const zodIpc = new ZodIpcConnection({
usage: {
durationMs: 0,
},
taskIdentifier: execution.task.id,
},
});
@@ -246,6 +247,7 @@ const zodIpc = new ZodIpcConnection({
usage: {
durationMs: 0,
},
taskIdentifier: execution.task.id,
},
});
@@ -277,6 +279,7 @@ const zodIpc = new ZodIpcConnection({
usage: {
durationMs: 0,
},
taskIdentifier: execution.task.id,
},
});
@@ -303,6 +306,7 @@ const zodIpc = new ZodIpcConnection({
usage: {
durationMs: 0,
},
taskIdentifier: execution.task.id,
},
});
@@ -357,6 +361,7 @@ const zodIpc = new ZodIpcConnection({
usage: {
durationMs: usageSample.cpuTime,
},
taskIdentifier: execution.task.id,
},
});
}
@@ -380,6 +385,7 @@ const zodIpc = new ZodIpcConnection({
usage: {
durationMs: usageSample.cpuTime,
},
taskIdentifier: execution.task.id,
},
});
}
@@ -402,6 +408,7 @@ const zodIpc = new ZodIpcConnection({
usage: {
durationMs: 0,
},
taskIdentifier: execution.task.id,
},
});
}
@@ -194,6 +194,7 @@ const zodIpc = new ZodIpcConnection({
usage: {
durationMs: 0,
},
taskIdentifier: execution.task.id,
},
});
@@ -222,6 +223,7 @@ const zodIpc = new ZodIpcConnection({
usage: {
durationMs: 0,
},
taskIdentifier: execution.task.id,
},
});
@@ -247,6 +249,7 @@ const zodIpc = new ZodIpcConnection({
usage: {
durationMs: 0,
},
taskIdentifier: execution.task.id,
},
});
@@ -273,6 +276,7 @@ const zodIpc = new ZodIpcConnection({
usage: {
durationMs: 0,
},
taskIdentifier: execution.task.id,
},
});
@@ -324,6 +328,7 @@ const zodIpc = new ZodIpcConnection({
usage: {
durationMs: usageSample.cpuTime,
},
taskIdentifier: execution.task.id,
},
});
}
@@ -347,6 +352,7 @@ const zodIpc = new ZodIpcConnection({
usage: {
durationMs: usageSample.cpuTime,
},
taskIdentifier: execution.task.id,
},
});
}
+61 -27
View File
@@ -1,10 +1,11 @@
import { z } from "zod";
import { VERSION } from "../../version.js";
import { generateJWT } from "../jwt.js";
import {
AddTagsRequestBody,
BatchTaskRunExecutionResult,
BatchTriggerTaskRequestBody,
BatchTriggerTaskResponse,
BatchTriggerTaskV2RequestBody,
BatchTriggerTaskV2Response,
CanceledRunResponse,
CreateEnvironmentVariableRequestBody,
CreateScheduleOptions,
@@ -17,6 +18,7 @@ import {
ListScheduleOptions,
ReplayRunResponse,
RescheduleRunRequestBody,
RetrieveBatchResponse,
RetrieveRunResponse,
ScheduleObject,
TaskRunExecutionResult,
@@ -28,6 +30,7 @@ import {
UpdateScheduleOptions,
} from "../schemas/index.js";
import { taskContext } from "../task-context-api.js";
import { AnyRunTypes, TriggerJwtOptions } from "../types/tasks.js";
import {
ApiRequestOptions,
CursorPagePromise,
@@ -39,13 +42,14 @@ import {
} from "./core.js";
import { ApiError } from "./errors.js";
import {
RunShape,
AnyRunShape,
runShapeStream,
RealtimeRun,
AnyRealtimeRun,
RunShape,
RunStreamCallback,
RunSubscription,
TaskRunShape,
RealtimeRun,
runShapeStream,
} from "./runStream.js";
import {
CreateEnvironmentVariableParams,
@@ -55,20 +59,23 @@ import {
SubscribeToRunsQueryParams,
UpdateEnvironmentVariableParams,
} from "./types.js";
import { generateJWT } from "../jwt.js";
import { AnyRunTypes, TriggerJwtOptions } from "../types/tasks.js";
export type {
CreateEnvironmentVariableParams,
ImportEnvironmentVariablesParams,
UpdateEnvironmentVariableParams,
SubscribeToRunsQueryParams,
UpdateEnvironmentVariableParams,
};
export type TriggerOptions = {
export type ClientTriggerOptions = {
spanParentAsLink?: boolean;
};
export type ClientBatchTriggerOptions = ClientTriggerOptions & {
idempotencyKey?: string;
idempotencyKeyTTL?: string;
};
export type TriggerRequestOptions = ZodFetchOptions & {
publicAccessToken?: TriggerJwtOptions;
};
@@ -79,23 +86,24 @@ export type TriggerApiRequestOptions = ApiRequestOptions & {
const DEFAULT_ZOD_FETCH_OPTIONS: ZodFetchOptions = {
retry: {
maxAttempts: 3,
maxAttempts: 5,
minTimeoutInMs: 1000,
maxTimeoutInMs: 30_000,
factor: 2,
factor: 1.6,
randomize: false,
},
};
export { isRequestOptions };
export type { ApiRequestOptions };
export type {
RunShape,
AnyRunShape,
TaskRunShape,
ApiRequestOptions,
RealtimeRun,
AnyRealtimeRun,
RunShape,
RunStreamCallback,
RunSubscription,
TaskRunShape,
};
/**
@@ -173,7 +181,7 @@ export class ApiClient {
triggerTask(
taskId: string,
body: TriggerTaskRequestBody,
options?: TriggerOptions,
clientOptions?: ClientTriggerOptions,
requestOptions?: TriggerRequestOptions
) {
const encodedTaskId = encodeURIComponent(taskId);
@@ -183,7 +191,7 @@ export class ApiClient {
`${this.baseUrl}/api/v1/tasks/${encodedTaskId}/trigger`,
{
method: "POST",
headers: this.#getHeaders(options?.spanParentAsLink ?? false),
headers: this.#getHeaders(clientOptions?.spanParentAsLink ?? false),
body: JSON.stringify(body),
},
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
@@ -220,20 +228,20 @@ export class ApiClient {
});
}
batchTriggerTask(
taskId: string,
body: BatchTriggerTaskRequestBody,
options?: TriggerOptions,
batchTriggerV2(
body: BatchTriggerTaskV2RequestBody,
clientOptions?: ClientBatchTriggerOptions,
requestOptions?: TriggerRequestOptions
) {
const encodedTaskId = encodeURIComponent(taskId);
return zodfetch(
BatchTriggerTaskResponse,
`${this.baseUrl}/api/v1/tasks/${encodedTaskId}/batch`,
BatchTriggerTaskV2Response,
`${this.baseUrl}/api/v1/tasks/batch`,
{
method: "POST",
headers: this.#getHeaders(options?.spanParentAsLink ?? false),
headers: this.#getHeaders(clientOptions?.spanParentAsLink ?? false, {
"idempotency-key": clientOptions?.idempotencyKey,
"idempotency-key-ttl": clientOptions?.idempotencyKeyTTL,
}),
body: JSON.stringify(body),
},
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
@@ -247,7 +255,7 @@ export class ApiClient {
secretKey: this.accessToken,
payload: {
...claims,
scopes: [`read:batch:${data.batchId}`].concat(data.runs.map((r) => `read:runs:${r}`)),
scopes: [`read:batch:${data.id}`].concat(data.runs.map((r) => `read:runs:${r.id}`)),
},
expirationTime: requestOptions?.publicAccessToken?.expirationTime ?? "1h",
});
@@ -663,11 +671,33 @@ export class ApiClient {
);
}
#getHeaders(spanParentAsLink: boolean) {
retrieveBatch(batchId: string, requestOptions?: ZodFetchOptions) {
return zodfetch(
RetrieveBatchResponse,
`${this.baseUrl}/api/v1/batches/${batchId}`,
{
method: "GET",
headers: this.#getHeaders(false),
},
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
);
}
#getHeaders(spanParentAsLink: boolean, additionalHeaders?: Record<string, string | undefined>) {
const headers: Record<string, string> = {
"Content-Type": "application/json",
Authorization: `Bearer ${this.accessToken}`,
"trigger-version": VERSION,
...Object.entries(additionalHeaders ?? {}).reduce(
(acc, [key, value]) => {
if (value !== undefined) {
acc[key] = value;
}
return acc;
},
{} as Record<string, string>
),
};
// Only inject the context if we are inside a task
@@ -776,6 +806,10 @@ function createSearchQueryForListRuns(query?: ListRunsQueryParams): URLSearchPar
if (query.period) {
searchParams.append("filter[createdAt][period]", query.period);
}
if (query.batch) {
searchParams.append("filter[batch]", query.batch);
}
}
return searchParams;
@@ -43,6 +43,7 @@ export type AnyRunShape = RunShape<AnyRunTypes>;
export type TaskRunShape<TTask extends AnyTask> = RunShape<InferRunTypes<TTask>>;
export type RealtimeRun<TTask extends AnyTask> = TaskRunShape<TTask>;
export type AnyRealtimeRun = RealtimeRun<AnyTask>;
export type RunStreamCallback<TRunTypes extends AnyRunTypes> = (
run: RunShape<TRunTypes>
+1
View File
@@ -31,6 +31,7 @@ export interface ListRunsQueryParams extends CursorPageParams {
tag?: Array<string> | string;
schedule?: string;
isTest?: boolean;
batch?: string;
}
export interface ListProjectRunsQueryParams extends CursorPageParams, ListRunsQueryParams {
+66
View File
@@ -76,6 +76,7 @@ export const TriggerTaskRequestBody = z.object({
queue: QueueOptions.optional(),
concurrencyKey: z.string().optional(),
idempotencyKey: z.string().optional(),
idempotencyKeyTTL: z.string().optional(),
test: z.boolean().optional(),
payloadType: z.string().optional(),
delay: z.string().or(z.coerce.date()).optional(),
@@ -104,6 +105,56 @@ export const BatchTriggerTaskRequestBody = z.object({
export type BatchTriggerTaskRequestBody = z.infer<typeof BatchTriggerTaskRequestBody>;
export const BatchTriggerTaskItem = z.object({
task: z.string(),
payload: z.any(),
context: z.any(),
options: z
.object({
lockToVersion: z.string().optional(),
queue: QueueOptions.optional(),
concurrencyKey: z.string().optional(),
idempotencyKey: z.string().optional(),
idempotencyKeyTTL: z.string().optional(),
test: z.boolean().optional(),
payloadType: z.string().optional(),
delay: z.string().or(z.coerce.date()).optional(),
ttl: z.string().or(z.number().nonnegative().int()).optional(),
tags: RunTags.optional(),
maxAttempts: z.number().int().optional(),
metadata: z.any(),
metadataType: z.string().optional(),
maxDuration: z.number().optional(),
parentAttempt: z.string().optional(),
})
.optional(),
});
export type BatchTriggerTaskItem = z.infer<typeof BatchTriggerTaskItem>;
export const BatchTriggerTaskV2RequestBody = z.object({
items: BatchTriggerTaskItem.array(),
dependentAttempt: z.string().optional(),
});
export type BatchTriggerTaskV2RequestBody = z.infer<typeof BatchTriggerTaskV2RequestBody>;
export const BatchTriggerTaskV2Response = z.object({
id: z.string(),
isCached: z.boolean(),
idempotencyKey: z.string().optional(),
runs: z.array(
z.object({
id: z.string(),
taskIdentifier: z.string(),
isCached: z.boolean(),
idempotencyKey: z.string().optional(),
})
),
});
export type BatchTriggerTaskV2Response = z.infer<typeof BatchTriggerTaskV2Response>;
export const BatchTriggerTaskResponse = z.object({
batchId: z.string(),
runs: z.string().array(),
@@ -661,3 +712,18 @@ export const SubscribeRunRawShape = z.object({
});
export type SubscribeRunRawShape = z.infer<typeof SubscribeRunRawShape>;
export const BatchStatus = z.enum(["PENDING", "COMPLETED"]);
export type BatchStatus = z.infer<typeof BatchStatus>;
export const RetrieveBatchResponse = z.object({
id: z.string(),
status: BatchStatus,
idempotencyKey: z.string().optional(),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
runCount: z.number(),
});
export type RetrieveBatchResponse = z.infer<typeof RetrieveBatchResponse>;
+4
View File
@@ -251,6 +251,8 @@ export const TaskRunFailedExecutionResult = z.object({
retry: TaskRunExecutionRetry.optional(),
skippedRetrying: z.boolean().optional(),
usage: TaskRunExecutionUsage.optional(),
// Optional for now for backwards compatibility
taskIdentifier: z.string().optional(),
});
export type TaskRunFailedExecutionResult = z.infer<typeof TaskRunFailedExecutionResult>;
@@ -261,6 +263,8 @@ export const TaskRunSuccessfulExecutionResult = z.object({
output: z.string().optional(),
outputType: z.string(),
usage: TaskRunExecutionUsage.optional(),
// Optional for now for backwards compatibility
taskIdentifier: z.string().optional(),
});
export type TaskRunSuccessfulExecutionResult = z.infer<typeof TaskRunSuccessfulExecutionResult>;
-2
View File
@@ -136,8 +136,6 @@ export const QueueOptions = z.object({
*
* If this property is omitted, the task can potentially use up the full concurrency of an environment. */
concurrencyLimit: z.number().int().min(0).max(1000).optional(),
/** @deprecated This feature is coming soon */
rateLimit: RateLimitOptions.optional(),
});
export type QueueOptions = z.infer<typeof QueueOptions>;
+163 -18
View File
@@ -49,18 +49,24 @@ export class SubtaskUnwrapError extends Error {
}
}
export class TaskRunPromise<T> extends Promise<TaskRunResult<T>> {
export class TaskRunPromise<TIdentifier extends string, TOutput> extends Promise<
TaskRunResult<TIdentifier, TOutput>
> {
constructor(
executor: (
resolve: (value: TaskRunResult<T> | PromiseLike<TaskRunResult<T>>) => void,
resolve: (
value:
| TaskRunResult<TIdentifier, TOutput>
| PromiseLike<TaskRunResult<TIdentifier, TOutput>>
) => void,
reject: (reason?: any) => void
) => void,
private readonly taskId: string
private readonly taskId: TIdentifier
) {
super(executor);
}
unwrap(): Promise<T> {
unwrap(): Promise<TOutput> {
return this.then((result) => {
if (result.ok) {
return result.output;
@@ -371,15 +377,29 @@ export type RunHandle<TTaskIdentifier extends string, TPayload, TOutput> = Brand
export type AnyRunHandle = RunHandle<string, any, any>;
export type BatchedRunHandle<TTaskIdentifier extends string, TPayload, TOutput> = BrandedRun<
{
id: string;
taskIdentifier: TTaskIdentifier;
isCached: boolean;
idempotencyKey?: string;
},
TPayload,
TOutput
>;
export type AnyBatchedRunHandle = BatchedRunHandle<string, any, any>;
/**
* A BatchRunHandle can be used to retrieve the runs of a batch trigger in a typesafe manner.
*/
export type BatchRunHandle<TTaskIdentifier extends string, TPayload, TOutput> = BrandedRun<
{
batchId: string;
runs: Array<RunHandle<TTaskIdentifier, TPayload, TOutput>>;
isCached: boolean;
idempotencyKey?: string;
runs: Array<BatchedRunHandle<TTaskIdentifier, TPayload, TOutput>>;
publicAccessToken: string;
taskIdentifier: TTaskIdentifier;
},
TOutput,
TPayload
@@ -401,24 +421,100 @@ export type RunHandleTaskIdentifier<TRunHandle> = TRunHandle extends RunHandle<
? TTaskIdentifier
: never;
export type TaskRunResult<TOutput = any> =
export type TaskRunResult<TIdentifier extends string, TOutput = any> =
| {
ok: true;
id: string;
taskIdentifier: TIdentifier;
output: TOutput;
}
| {
ok: false;
id: string;
taskIdentifier: TIdentifier;
error: unknown;
};
export type BatchResult<TOutput = any> = {
export type AnyTaskRunResult = TaskRunResult<string, any>;
export type TaskRunResultFromTask<TTask extends AnyTask> = TTask extends Task<
infer TIdentifier,
any,
infer TOutput
>
? TaskRunResult<TIdentifier, TOutput>
: never;
export type BatchResult<TIdentifier extends string, TOutput = any> = {
id: string;
runs: TaskRunResult<TOutput>[];
runs: TaskRunResult<TIdentifier, TOutput>[];
};
export type BatchItem<TInput> = { payload: TInput; options?: TaskRunOptions };
export type BatchByIdResult<TTask extends AnyTask> = {
id: string;
runs: Array<TaskRunResultFromTask<TTask>>;
};
export type BatchByTaskResult<TTasks extends readonly AnyTask[]> = {
id: string;
runs: {
[K in keyof TTasks]: TaskRunResultFromTask<TTasks[K]>;
};
};
/**
* A BatchRunHandle can be used to retrieve the runs of a batch trigger in a typesafe manner.
*/
// export type BatchTasksRunHandle<TTasks extends readonly AnyTask[]> = BrandedRun<
// {
// batchId: string;
// isCached: boolean;
// idempotencyKey?: string;
// runs: {
// [K in keyof TTasks]: BatchedRunHandle<
// TaskIdentifier<TTasks[K]>,
// TaskPayload<TTasks[K]>,
// TaskOutput<TTasks[K]>
// >;
// };
// publicAccessToken: string;
// },
// any,
// any
// >;
export type BatchTasksResult<TTasks extends readonly AnyTask[]> = BatchTasksRunHandle<TTasks>;
export type BatchItem<TInput> = { payload: TInput; options?: TriggerOptions };
export type BatchTriggerAndWaitItem<TInput> = {
payload: TInput;
options?: TriggerAndWaitOptions;
};
export type BatchByIdItem<TRunTypes extends AnyRunTypes> = {
id: TRunTypes["taskIdentifier"];
payload: TRunTypes["payload"];
options?: TriggerOptions;
};
export type BatchByIdAndWaitItem<TRunTypes extends AnyRunTypes> = {
id: TRunTypes["taskIdentifier"];
payload: TRunTypes["payload"];
options?: TriggerAndWaitOptions;
};
export type BatchByTaskItem<TTask extends AnyTask> = {
task: TTask;
payload: TaskPayload<TTask>;
options?: TriggerOptions;
};
export type BatchByTaskAndWaitItem<TTask extends AnyTask> = {
task: TTask;
payload: TaskPayload<TTask>;
options?: TriggerAndWaitOptions;
};
export interface Task<TIdentifier extends string, TInput = void, TOutput = any> {
/**
@@ -437,7 +533,7 @@ export interface Task<TIdentifier extends string, TInput = void, TOutput = any>
*/
trigger: (
payload: TInput,
options?: TaskRunOptions,
options?: TriggerOptions,
requestOptions?: TriggerApiRequestOptions
) => Promise<RunHandle<TIdentifier, TInput, TOutput>>;
@@ -450,6 +546,7 @@ export interface Task<TIdentifier extends string, TInput = void, TOutput = any>
*/
batchTrigger: (
items: Array<BatchItem<TInput>>,
options?: BatchTriggerOptions,
requestOptions?: TriggerApiRequestOptions
) => Promise<BatchRunHandle<TIdentifier, TInput, TOutput>>;
@@ -469,7 +566,10 @@ export interface Task<TIdentifier extends string, TInput = void, TOutput = any>
* }
* ```
*/
triggerAndWait: (payload: TInput, options?: TaskRunOptions) => TaskRunPromise<TOutput>;
triggerAndWait: (
payload: TInput,
options?: TriggerAndWaitOptions
) => TaskRunPromise<TIdentifier, TOutput>;
/**
* Batch trigger multiple task runs with the given payloads, and wait for the results. Returns the results of the task runs.
@@ -491,7 +591,9 @@ export interface Task<TIdentifier extends string, TInput = void, TOutput = any>
* }
* ```
*/
batchTriggerAndWait: (items: Array<BatchItem<TInput>>) => Promise<BatchResult<TOutput>>;
batchTriggerAndWait: (
items: Array<BatchTriggerAndWaitItem<TInput>>
) => Promise<BatchResult<TIdentifier, TOutput>>;
}
export interface TaskWithSchema<
@@ -544,6 +646,11 @@ export type TaskIdentifier<TTask extends AnyTask> = TTask extends Task<infer TId
? TIdentifier
: never;
export type TaskFromIdentifier<
TTask extends AnyTask,
TIdentifier extends TTask["id"],
> = TTask extends { id: TIdentifier } ? TTask : never;
export type TriggerJwtOptions = {
/**
* The expiration time of the JWT. This can be a string like "1h" or a Date object.
@@ -553,7 +660,7 @@ export type TriggerJwtOptions = {
expirationTime?: number | Date | string;
};
export type TaskRunOptions = {
export type TriggerOptions = {
/**
* A unique key that can be used to ensure that a task is only triggered once per key.
*
@@ -600,6 +707,13 @@ export type TaskRunOptions = {
*
*/
idempotencyKey?: IdempotencyKey | string | string[];
/**
* The time-to-live for the idempotency key. Once the TTL has passed, the key can be used again.
*
* Specify a duration string like "1h", "10s", "30m", etc.
*/
idempotencyKeyTTL?: string;
maxAttempts?: number;
queue?: TaskRunConcurrencyOptions;
concurrencyKey?: string;
@@ -662,6 +776,13 @@ export type TaskRunOptions = {
maxDuration?: number;
};
export type TriggerAndWaitOptions = Omit<TriggerOptions, "idempotencyKey" | "idempotencyKeyTTL">;
export type BatchTriggerOptions = {
idempotencyKey?: IdempotencyKey | string | string[];
idempotencyKeyTTL?: string;
};
export type TaskMetadataWithFunctions = TaskMetadata & {
fns: {
run: (payload: any, params: RunFnParams<any>) => Promise<any>;
@@ -693,6 +814,8 @@ export type InferRunTypes<T> = T extends RunHandle<
infer TPayload,
infer TOutput
>
? RunTypes<TTaskIdentifier, TPayload, TOutput>
: T extends BatchedRunHandle<infer TTaskIdentifier, infer TPayload, infer TOutput>
? RunTypes<TTaskIdentifier, TPayload, TOutput>
: T extends Task<infer TTaskIdentifier, infer TPayload, infer TOutput>
? RunTypes<TTaskIdentifier, TPayload, TOutput>
@@ -704,8 +827,30 @@ export type RunHandleFromTypes<TRunTypes extends AnyRunTypes> = RunHandle<
TRunTypes["output"]
>;
export type BatchRunHandleFromTypes<TRunTypes extends AnyRunTypes> = BatchRunHandle<
TRunTypes["taskIdentifier"],
TRunTypes["payload"],
TRunTypes["output"]
export type BatchRunHandleFromTypes<TRunTypes extends AnyRunTypes> = TRunTypes extends AnyRunTypes
? BatchRunHandle<TRunTypes["taskIdentifier"], TRunTypes["payload"], TRunTypes["output"]>
: never;
/**
* A BatchRunHandle can be used to retrieve the runs of a batch trigger in a typesafe manner.
*/
export type BatchTasksRunHandle<TTasks extends readonly AnyTask[]> = BrandedRun<
{
batchId: string;
isCached: boolean;
idempotencyKey?: string;
runs: {
[K in keyof TTasks]: BatchedRunHandle<
TaskIdentifier<TTasks[K]>,
TaskPayload<TTasks[K]>,
TaskOutput<TTasks[K]>
>;
};
publicAccessToken: string;
},
any,
any
>;
export type BatchTasksRunHandleFromTypes<TTasks extends readonly AnyTask[]> =
BatchTasksRunHandle<TTasks>;
+12 -1
View File
@@ -13,6 +13,13 @@ export type UseApiClientOptions = {
baseURL?: string;
/** Optional additional request configuration */
requestOptions?: ApiRequestOptions;
/**
* Enable or disable the API client instance.
*
* Set enabled to false if you don't have an accessToken and don't want to throw an error.
*/
enabled?: boolean;
};
/**
@@ -35,13 +42,17 @@ export type UseApiClientOptions = {
* });
* ```
*/
export function useApiClient(options?: UseApiClientOptions): ApiClient {
export function useApiClient(options?: UseApiClientOptions): ApiClient | undefined {
const auth = useTriggerAuthContextOptional();
const baseUrl = options?.baseURL ?? auth?.baseURL ?? "https://api.trigger.dev";
const accessToken = options?.accessToken ?? auth?.accessToken;
if (!accessToken) {
if (options?.enabled === false) {
return undefined;
}
throw new Error("Missing accessToken in TriggerAuthContext or useApiClient options");
}
+10 -2
View File
@@ -73,7 +73,7 @@ export function useRealtimeRun<TTask extends AnyTask>(
const triggerRequest = useCallback(async () => {
try {
if (!runId) {
if (!runId || !apiClient) {
return;
}
@@ -207,7 +207,7 @@ export function useRealtimeRunWithStreams<
const triggerRequest = useCallback(async () => {
try {
if (!runId) {
if (!runId || !apiClient) {
return;
}
@@ -321,6 +321,10 @@ export function useRealtimeRunsWithTag<TTask extends AnyTask>(
const triggerRequest = useCallback(async () => {
try {
if (!apiClient) {
return;
}
const abortController = new AbortController();
abortControllerRef.current = abortController;
@@ -407,6 +411,10 @@ export function useRealtimeBatch<TTask extends AnyTask>(
const triggerRequest = useCallback(async () => {
try {
if (!apiClient) {
return;
}
const abortController = new AbortController();
abortControllerRef.current = abortController;
+19 -9
View File
@@ -33,17 +33,27 @@ export function useRun<TTask extends AnyTask>(
error,
isLoading,
isValidating,
} = useSWR<RetrieveRunResult<TTask>>(runId, () => apiClient.retrieveRun(runId), {
revalidateOnReconnect: options?.revalidateOnReconnect,
refreshInterval: (run) => {
if (!run) return options?.refreshInterval ?? 0;
} = useSWR<RetrieveRunResult<TTask>>(
runId,
() => {
if (!apiClient) {
throw new Error("Could not call useRun: Missing access token");
}
if (run.isCompleted) return 0;
return options?.refreshInterval ?? 0;
return apiClient.retrieveRun(runId);
},
revalidateOnFocus: options?.revalidateOnFocus,
});
{
revalidateOnReconnect: options?.revalidateOnReconnect,
refreshInterval: (run) => {
if (!run) return options?.refreshInterval ?? 0;
if (run.isCompleted) return 0;
return options?.refreshInterval ?? 0;
},
revalidateOnFocus: options?.revalidateOnFocus,
}
);
return { run, error, isLoading, isValidating, isError: !!error };
}
@@ -8,7 +8,7 @@ import {
makeIdempotencyKey,
RunHandleFromTypes,
stringifyIO,
TaskRunOptions,
TriggerOptions,
} from "@trigger.dev/core/v3";
import useSWRMutation from "swr/mutation";
import { useApiClient, UseApiClientOptions } from "./useApiClient.js";
@@ -64,8 +64,12 @@ export function useTaskTrigger<TTask extends AnyTask>(
id: string,
{
arg: { payload, options },
}: { arg: { payload: TaskPayload<TTask>; options?: TaskRunOptions } }
}: { arg: { payload: TaskPayload<TTask>; options?: TriggerOptions } }
) {
if (!apiClient) {
throw new Error("Could not trigger task in useTaskTrigger: Missing access token");
}
const payloadPacket = await stringifyIO(payload);
const handle = await apiClient.triggerTask(id, {
+1 -1
View File
@@ -109,4 +109,4 @@
"main": "./dist/commonjs/index.js",
"types": "./dist/commonjs/index.d.ts",
"module": "./dist/esm/index.js"
}
}
+70
View File
@@ -0,0 +1,70 @@
import {
accessoryAttributes,
apiClientManager,
ApiPromise,
ApiRequestOptions,
mergeRequestOptions,
RetrieveBatchResponse,
} from "@trigger.dev/core/v3";
import {
batchTriggerById,
batchTriggerByIdAndWait,
batchTriggerTasks,
batchTriggerAndWaitTasks,
} from "./shared.js";
import { tracer } from "./tracer.js";
export const batch = {
trigger: batchTriggerById,
triggerAndWait: batchTriggerByIdAndWait,
triggerByTask: batchTriggerTasks,
triggerByTaskAndWait: batchTriggerAndWaitTasks,
retrieve: retrieveBatch,
};
/**
* Retrieves details about a specific batch by its ID.
*
* @param {string} batchId - The unique identifier of the batch to retrieve
* @param {ApiRequestOptions} [requestOptions] - Optional API request configuration options
* @returns {ApiPromise<RetrieveBatchResponse>} A promise that resolves with the batch details
*
* @example
* // First trigger a batch
* const response = await batch.trigger([
* { id: "simple-task", payload: { message: "Hello, World!" } }
* ]);
*
* // Then retrieve the batch details
* const batchDetails = await batch.retrieve(response.batchId);
* console.log("batch", batchDetails);
*/
function retrieveBatch(
batchId: string,
requestOptions?: ApiRequestOptions
): ApiPromise<RetrieveBatchResponse> {
const apiClient = apiClientManager.clientOrThrow();
const $requestOptions = mergeRequestOptions(
{
tracer,
name: "batch.retrieve()",
icon: "batch",
attributes: {
batchId: batchId,
...accessoryAttributes({
items: [
{
text: batchId,
variant: "normal",
},
],
style: "codepath",
}),
},
},
requestOptions
);
return apiClient.retrieveBatch(batchId, $requestOptions);
}
+2
View File
@@ -3,6 +3,7 @@ export * from "./config.js";
export { retry, type RetryOptions } from "./retry.js";
export { queue } from "./shared.js";
export * from "./tasks.js";
export * from "./batch.js";
export * from "./wait.js";
export * from "./waitUntil.js";
export * from "./usage.js";
@@ -40,6 +41,7 @@ export {
type AnyRunShape,
type TaskRunShape,
type RealtimeRun,
type AnyRealtimeRun,
type RetrieveRunResult,
type AnyRetrieveRunResult,
} from "./runs.js";
+102 -2
View File
@@ -9,8 +9,10 @@ import type {
RetrieveRunResult,
RunShape,
RealtimeRun,
AnyRealtimeRun,
RunSubscription,
TaskRunShape,
AnyBatchedRunHandle,
} from "@trigger.dev/core/v3";
import {
ApiPromise,
@@ -36,6 +38,7 @@ export type {
RunShape,
TaskRunShape,
RealtimeRun,
AnyRealtimeRun,
};
export const runs = {
@@ -47,6 +50,7 @@ export const runs = {
poll,
subscribeToRun,
subscribeToRunsWithTag,
subscribeToBatch: subscribeToRunsInBatch,
};
export type ListRunsItem = ListRunResponseItem;
@@ -150,7 +154,7 @@ function listRunsRequestOptions(
}
// Extract out the expected type of the id, can be either a string or a RunHandle
type RunId<TRunId> = TRunId extends AnyRunHandle
type RunId<TRunId> = TRunId extends AnyRunHandle | AnyBatchedRunHandle
? TRunId
: TRunId extends AnyTask
? string
@@ -158,7 +162,7 @@ type RunId<TRunId> = TRunId extends AnyRunHandle
? TRunId
: never;
function retrieveRun<TRunId extends AnyRunHandle | AnyTask | string>(
function retrieveRun<TRunId extends AnyRunHandle | AnyBatchedRunHandle | AnyTask | string>(
runId: RunId<TRunId>,
requestOptions?: ApiRequestOptions
): ApiPromise<RetrieveRunResult<TRunId>> {
@@ -331,6 +335,35 @@ async function poll<TRunId extends AnyRunHandle | AnyTask | string>(
);
}
/**
* Subscribes to real-time updates for a specific run.
*
* This function allows you to receive real-time updates whenever a run changes, including:
* - Status changes in the run lifecycle
* - Tag additions or removals
* - Metadata updates
*
* @template TRunId - The type parameter extending AnyRunHandle, AnyTask, or string
* @param {RunId<TRunId>} runId - The ID of the run to subscribe to. Can be a string ID, RunHandle, or Task
* @returns {RunSubscription<InferRunTypes<TRunId>>} An async iterator that yields updated run objects
*
* @example
* ```ts
* // Subscribe using a run handle
* const handle = await tasks.trigger("my-task", { some: "data" });
* for await (const run of runs.subscribeToRun(handle.id)) {
* console.log("Run updated:", run);
* }
*
* // Subscribe with type safety
* for await (const run of runs.subscribeToRun<typeof myTask>(runId)) {
* console.log("Payload:", run.payload.some);
* if (run.output) {
* console.log("Output:", run.output);
* }
* }
* ```
*/
function subscribeToRun<TRunId extends AnyRunHandle | AnyTask | string>(
runId: RunId<TRunId>
): RunSubscription<InferRunTypes<TRunId>> {
@@ -341,6 +374,36 @@ function subscribeToRun<TRunId extends AnyRunHandle | AnyTask | string>(
return apiClient.subscribeToRun($runId);
}
/**
* Subscribes to real-time updates for all runs that have specific tags.
*
* This function allows you to monitor multiple runs simultaneously by filtering on tags.
* You'll receive updates whenever any run with the specified tag(s) changes.
*
* @template TTasks - The type parameter extending AnyTask for type-safe payload and output
* @param {string | string[]} tag - A single tag or array of tags to filter runs
* @returns {RunSubscription<InferRunTypes<TTasks>>} An async iterator that yields updated run objects
*
* @example
* ```ts
* // Subscribe to runs with a single tag
* for await (const run of runs.subscribeToRunsWithTag("user:1234")) {
* console.log("Run updated:", run);
* }
*
* // Subscribe with multiple tags and type safety
* for await (const run of runs.subscribeToRunsWithTag<typeof myTask | typeof otherTask>(["tag1", "tag2"])) {
* switch (run.taskIdentifier) {
* case "my-task":
* console.log("MyTask output:", run.output.foo);
* break;
* case "other-task":
* console.log("OtherTask output:", run.output.bar);
* break;
* }
* }
* ```
*/
function subscribeToRunsWithTag<TTasks extends AnyTask>(
tag: string | string[]
): RunSubscription<InferRunTypes<TTasks>> {
@@ -348,3 +411,40 @@ function subscribeToRunsWithTag<TTasks extends AnyTask>(
return apiClient.subscribeToRunsWithTag<InferRunTypes<TTasks>>(tag);
}
/**
* Subscribes to real-time updates for all runs within a specific batch.
*
* Use this function when you've triggered multiple runs using `batchTrigger` and want
* to monitor all runs in that batch. You'll receive updates whenever any run in the batch changes.
*
* @template TTasks - The type parameter extending AnyTask for type-safe payload and output
* @param {string} batchId - The ID of the batch to subscribe to
* @returns {RunSubscription<InferRunTypes<TTasks>>} An async iterator that yields updated run objects
*
* @example
* ```ts
* // Subscribe to all runs in a batch
* for await (const run of runs.subscribeToRunsInBatch("batch-123")) {
* console.log("Batch run updated:", run);
* }
*
* // Subscribe with type safety
* for await (const run of runs.subscribeToRunsInBatch<typeof myTask>("batch-123")) {
* console.log("Run payload:", run.payload);
* if (run.output) {
* console.log("Run output:", run.output);
* }
* }
* ```
*
* @note The run objects received will include standard fields like id, status, payload, output,
* createdAt, updatedAt, tags, and more. See the Run object documentation for full details.
*/
function subscribeToRunsInBatch<TTasks extends AnyTask>(
batchId: string
): RunSubscription<InferRunTypes<TTasks>> {
const apiClient = apiClientManager.clientOrThrow();
return apiClient.subscribeToBatch<InferRunTypes<TTasks>>(batchId);
}
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -24,8 +24,9 @@ import type {
TaskOptions,
TaskOutput,
TaskPayload,
TaskRunOptions,
TriggerOptions,
TaskRunResult,
TaskFromIdentifier,
} from "./shared.js";
export type {
@@ -40,8 +41,9 @@ export type {
TaskOptions,
TaskOutput,
TaskPayload,
TaskRunOptions,
TriggerOptions,
TaskRunResult,
TaskFromIdentifier,
};
/** Creates a task that can be triggered
@@ -4,6 +4,7 @@ import RunDetails from "@/components/RunDetails";
import { Card, CardContent } from "@/components/ui/card";
import { TriggerAuthContext, useRealtimeRun } from "@trigger.dev/react-hooks";
import type { exampleTask } from "@/trigger/example";
import { useEffect, useState } from "react";
function RunDetailsWrapper({
runId,
@@ -12,8 +13,20 @@ function RunDetailsWrapper({
runId: string;
publicAccessToken: string;
}) {
const [accessToken, setAccessToken] = useState<string | undefined>(undefined);
// call setAccessToken with publicAccessToken after 2 seconds
useEffect(() => {
const timeout = setTimeout(() => {
setAccessToken(publicAccessToken);
}, 2000);
return () => clearTimeout(timeout);
}, [publicAccessToken]);
const { run, error } = useRealtimeRun<typeof exampleTask>(runId, {
accessToken: publicAccessToken,
accessToken,
enabled: accessToken !== undefined,
});
if (error) {
+18 -2
View File
@@ -1,4 +1,4 @@
import { configure, envvars, runs, schedules } from "@trigger.dev/sdk/v3";
import { configure, envvars, runs, schedules, batch } from "@trigger.dev/sdk/v3";
import dotenv from "dotenv";
import { unfriendlyIdTask } from "./trigger/other.js";
import { spamRateLimiter, taskThatErrors } from "./trigger/retries.js";
@@ -255,9 +255,25 @@ async function doTriggerUnfriendlyTaskId() {
console.log("completed run", completedRun);
}
async function doBatchTrigger() {
const response = await batch.triggerByTask([
{ task: simpleChildTask, payload: { message: "Hello, World!" } },
]);
console.log("batch trigger response", response);
const $batch = await batch.retrieve(response.batchId);
console.log("batch", $batch);
const $runs = await runs.list({ batch: response.batchId });
console.log("batch runs", $runs.data);
}
// doRuns().catch(console.error);
// doListRuns().catch(console.error);
// doScheduleLists().catch(console.error);
doSchedules().catch(console.error);
doBatchTrigger().catch(console.error);
// doEnvVars().catch(console.error);
// doTriggerUnfriendlyTaskId().catch(console.error);
+621 -19
View File
@@ -1,28 +1,65 @@
import { logger, task, wait } from "@trigger.dev/sdk/v3";
import { Equal, Expect } from "@/utils/types.js";
import {
AnyRealtimeRun,
auth,
batch,
idempotencyKeys,
logger,
runs,
task,
tasks,
wait,
} from "@trigger.dev/sdk/v3";
import assert from "node:assert";
import { randomUUID } from "node:crypto";
import { setTimeout } from "node:timers/promises";
export const batchParentTask = task({
id: "batch-parent-task",
run: async () => {
const response = await batchChildTask.batchTrigger([
{ payload: "item1" },
{ payload: "item2" },
{ payload: "item3" },
]);
const items = Array.from({ length: 10 }, (_, i) => ({
payload: {
id: `item${i}`,
name: `Item Name ${i}`,
description: `This is a description for item ${i}`,
value: i,
timestamp: new Date().toISOString(),
foo: {
id: `item${i}`,
name: `Item Name ${i}`,
description: `This is a description for item ${i}`,
value: i,
timestamp: new Date().toISOString(),
},
bar: {
id: `item${i}`,
name: `Item Name ${i}`,
description: `This is a description for item ${i}`,
value: i,
timestamp: new Date().toISOString(),
},
},
options: {
idempotencyKey: `item${i}`,
},
}));
logger.info("Batch task response", { response });
return await batchChildTask.batchTrigger(items);
},
});
await wait.for({ seconds: 5 });
await wait.until({ date: new Date(Date.now() + 1000 * 5) }); // 5 seconds
const waitResponse = await batchChildTask.batchTriggerAndWait([
{ payload: "item4" },
{ payload: "item5" },
{ payload: "item6" },
]);
logger.info("Batch task wait response", { waitResponse });
return response.batchId;
export const triggerWithQueue = task({
id: "trigger-with-queue",
run: async () => {
await batchChildTask.trigger(
{},
{
queue: {
name: "batch-queue-foo",
concurrencyLimit: 10,
},
}
);
},
});
@@ -81,3 +118,568 @@ export const taskThatFails = task({
};
},
});
export const allV2TestTask = task({
id: "all-v2-test",
retry: {
maxAttempts: 1,
},
run: async () => {
const response1 = await batch.trigger<typeof allV2ChildTask1 | typeof allV2ChildTask2>([
{ id: "all-v2-test-child-1", payload: { child1: "foo" } },
{ id: "all-v2-test-child-2", payload: { child2: "bar" } },
{ id: "all-v2-test-child-1", payload: { child1: "baz" } },
]);
logger.debug("Response 1", { response1 });
for (const run of response1.runs) {
switch (run.taskIdentifier) {
case "all-v2-test-child-1": {
const run1 = await runs.retrieve(run);
type Run1Payload = Expect<Equal<typeof run1.payload, { child1: string } | undefined>>;
type Run1Output = Expect<Equal<typeof run1.output, { foo: string } | undefined>>;
break;
}
case "all-v2-test-child-2": {
const run2 = await runs.retrieve(run);
type Run2Payload = Expect<Equal<typeof run2.payload, { child2: string } | undefined>>;
type Run2Output = Expect<Equal<typeof run2.output, { bar: string } | undefined>>;
break;
}
}
}
const {
runs: [batchRun1, batchRun2, batchRun3],
} = await batch.triggerByTask([
{ task: allV2ChildTask1, payload: { child1: "foo" } },
{ task: allV2ChildTask2, payload: { child2: "bar" } },
{ task: allV2ChildTask1, payload: { child1: "baz" } },
]);
logger.debug("Batch runs", { batchRun1, batchRun2, batchRun3 });
const taskRun1 = await runs.retrieve(batchRun1);
type TaskRun1Payload = Expect<Equal<typeof taskRun1.payload, { child1: string } | undefined>>;
type TaskRun1Output = Expect<Equal<typeof taskRun1.output, { foo: string } | undefined>>;
const taskRun2 = await runs.retrieve(batchRun2);
type TaskRun2Payload = Expect<Equal<typeof taskRun2.payload, { child2: string } | undefined>>;
type TaskRun2Output = Expect<Equal<typeof taskRun2.output, { bar: string } | undefined>>;
const taskRun3 = await runs.retrieve(batchRun3);
type TaskRun3Payload = Expect<Equal<typeof taskRun3.payload, { child1: string } | undefined>>;
type TaskRun3Output = Expect<Equal<typeof taskRun3.output, { foo: string } | undefined>>;
const response3 = await batch.triggerAndWait<typeof allV2ChildTask1 | typeof allV2ChildTask2>([
{ id: "all-v2-test-child-1", payload: { child1: "foo" } },
{ id: "all-v2-test-child-2", payload: { child2: "bar" } },
{ id: "all-v2-test-child-1", payload: { child1: "baz" } },
]);
logger.debug("Response 3", { response3 });
for (const run of response3.runs) {
if (run.ok) {
switch (run.taskIdentifier) {
case "all-v2-test-child-1": {
type Run1Output = Expect<Equal<typeof run.output, { foo: string }>>;
break;
}
case "all-v2-test-child-2": {
type Run2Output = Expect<Equal<typeof run.output, { bar: string }>>;
break;
}
}
}
}
for (const run of response3.runs) {
switch (run.taskIdentifier) {
case "all-v2-test-child-1": {
if (run.ok) {
type Run1Output = Expect<Equal<typeof run.output, { foo: string }>>;
}
break;
}
case "all-v2-test-child-2": {
if (run.ok) {
type Run2Output = Expect<Equal<typeof run.output, { bar: string }>>;
}
break;
}
}
}
const {
runs: [batch2Run1, batch2Run2, batch2Run3],
} = await batch.triggerByTaskAndWait([
{ task: allV2ChildTask1, payload: { child1: "foo" } },
{ task: allV2ChildTask2, payload: { child2: "bar" } },
{ task: allV2ChildTask1, payload: { child1: "baz" } },
]);
logger.debug("Batch 2 runs", { batch2Run1, batch2Run2, batch2Run3 });
if (batch2Run1.ok) {
type Batch2Run1Output = Expect<Equal<typeof batch2Run1.output, { foo: string }>>;
}
if (batch2Run2.ok) {
type Batch2Run2Output = Expect<Equal<typeof batch2Run2.output, { bar: string }>>;
}
if (batch2Run3.ok) {
type Batch2Run3Output = Expect<Equal<typeof batch2Run3.output, { foo: string }>>;
}
},
});
export const allV2ChildTask1 = task({
id: "all-v2-test-child-1",
retry: {
maxAttempts: 1,
},
run: async (payload: { child1: string }) => {
return {
foo: "bar",
};
},
});
export const allV2ChildTask2 = task({
id: "all-v2-test-child-2",
retry: {
maxAttempts: 1,
},
run: async (payload: { child2: string }) => {
return {
bar: "baz",
};
},
});
export const batchV2TestTask = task({
id: "batch-v2-test",
retry: {
maxAttempts: 1,
},
run: async () => {
// First lets try triggering with too many items
try {
await tasks.batchTrigger<typeof batchV2TestChild>(
"batch-v2-test-child",
Array.from({ length: 501 }, (_, i) => ({
payload: { foo: `bar${i}` },
}))
);
assert.fail("Batch trigger should have failed");
} catch (error: any) {
assert.equal(
error.message,
'400 "Batch size of 501 is too large. Maximum allowed batch size is 500."',
"Batch trigger failed with wrong error"
);
}
// TODO tests:
// tasks.batchTrigger
// tasks.batchTriggerAndWait
// myTask.batchTriggerAndWait
const response1 = await batchV2TestChild.batchTrigger([
{ payload: { foo: "bar" } },
{ payload: { foo: "baz" } },
]);
logger.info("Response 1", { response1 });
// Check that the batch ID matches this kind of ID: batch_g5obektq4xv699mq7eb9q
assert.match(response1.batchId, /^batch_[a-z0-9]{21}$/, "response1: Batch ID is invalid");
assert.equal(response1.runs.length, 2, "response1: Items length is invalid");
assert.match(response1.runs[0].id, /^run_[a-z0-9]{21}$/, "response1: Run ID is invalid");
assert.equal(
response1.runs[0].taskIdentifier,
"batch-v2-test-child",
"response1: runs[0] Task identifier is invalid"
);
assert.equal(response1.runs[0].isCached, false, "response1: runs[0] Run is cached");
assert.equal(
response1.runs[0].idempotencyKey,
undefined,
"response1: runs[0] Idempotent key is invalid"
);
assert.match(
response1.runs[1].id,
/^run_[a-z0-9]{21}$/,
"response1: runs[1] Run ID is invalid"
);
assert.equal(
response1.runs[1].taskIdentifier,
"batch-v2-test-child",
"response1: runs[1] Task identifier is invalid"
);
assert.equal(response1.runs[1].isCached, false, "response1: runs[1] Run is cached");
assert.equal(
response1.runs[1].idempotencyKey,
undefined,
"response1: runs[1] Idempotent key is invalid"
);
await auth.withAuth({ accessToken: response1.publicAccessToken }, async () => {
const [run0, run1] = await Promise.all([
runs.retrieve(response1.runs[0].id),
runs.retrieve(response1.runs[1].id),
]);
logger.debug("retrieved response 1 runs", { run0, run1 });
for await (const liveRun0 of runs.subscribeToRun(response1.runs[0].id)) {
logger.debug("subscribed to run0", { liveRun0 });
}
for await (const liveRun1 of runs.subscribeToRun(response1.runs[1].id)) {
logger.debug("subscribed to run1", { liveRun1 });
}
});
// Now let's do another batch trigger, this time with 100 items, and immediately try and retrieve the last run
const response2 = await batchV2TestChild.batchTrigger(
Array.from({ length: 30 }, (_, i) => ({
payload: { foo: `bar${i}` },
}))
);
logger.info("Response 2", { response2 });
assert.equal(response2.runs.length, 30, "response2: Items length is invalid");
const lastRunId = response2.runs[response2.runs.length - 1].id;
const lastRun = await runs.retrieve(lastRunId);
logger.info("Last run", { lastRun });
assert.equal(lastRun.id, lastRunId, "response2: Last run ID is invalid");
// okay, now we are going to test using the batch-level idempotency key
// we need to test that when reusing the idempotency key, we retrieve the same batch and runs and the response is correct
// we will also need to test idempotencyKeyTTL and make sure that the key is not reused after the TTL has expired
const idempotencyKey1 = randomUUID();
const response3 = await batchV2TestChild.batchTrigger(
[{ payload: { foo: "bar" } }, { payload: { foo: "baz" } }],
{
idempotencyKey: idempotencyKey1,
idempotencyKeyTTL: "5s",
}
);
logger.info("Response 3", { response3 });
assert.equal(response3.isCached, false, "response3: Batch is cached");
assert.ok(response3.idempotencyKey, "response3: Batch idempotency key is invalid");
assert.equal(response3.runs.length, 2, "response3: Items length is invalid");
assert.equal(response3.runs[0].isCached, false, "response3: runs[0] Run is cached");
assert.equal(response3.runs[1].isCached, false, "response3: runs[1] Run is cached");
const response4 = await batchV2TestChild.batchTrigger(
[{ payload: { foo: "bar" } }, { payload: { foo: "baz" } }],
{
idempotencyKey: idempotencyKey1,
idempotencyKeyTTL: "5s",
}
);
logger.info("Response 4", { response4 });
assert.equal(response4.batchId, response3.batchId, "response4: Batch ID is invalid");
assert.equal(response4.isCached, true, "response4: Batch is not cached");
assert.equal(response4.runs.length, 2, "response4: Items length is invalid");
assert.equal(response4.runs[0].isCached, true, "response4: runs[0] Run is not cached");
assert.equal(response4.runs[1].isCached, true, "response4: runs[1] Run is not cached");
assert.equal(
response4.runs[0].id,
response3.runs[0].id,
"response4: runs[0] Run ID is invalid"
);
assert.equal(
response4.runs[1].id,
response3.runs[1].id,
"response4: runs[1] Run ID is invalid"
);
await wait.for({ seconds: 6 });
const response5 = await batchV2TestChild.batchTrigger(
[{ payload: { foo: "bar" } }, { payload: { foo: "baz" } }],
{
idempotencyKey: idempotencyKey1,
idempotencyKeyTTL: "5s",
}
);
logger.info("Response 5", { response5 });
assert.equal(response5.isCached, false, "response5: Batch is cached");
assert.notEqual(response5.batchId, response3.batchId, "response5: Batch ID is invalid");
assert.equal(response5.runs.length, 2, "response5: Items length is invalid");
assert.equal(response5.runs[0].isCached, false, "response5: runs[0] Run is cached");
assert.equal(response5.runs[1].isCached, false, "response5: runs[1] Run is cached");
// Now we need to test with idempotency keys on the individual runs
// The first test will make sure that the idempotency key is passed to the child task
const idempotencyKeyChild1 = randomUUID();
const idempotencyKeyChild2 = randomUUID();
const response6 = await batchV2TestChild.batchTrigger([
{
payload: { foo: "bar" },
options: { idempotencyKey: idempotencyKeyChild1, idempotencyKeyTTL: "5s" },
},
{
payload: { foo: "baz" },
options: { idempotencyKey: idempotencyKeyChild2, idempotencyKeyTTL: "15s" },
},
]);
logger.info("Response 6", { response6 });
assert.equal(response6.runs.length, 2, "response6: Items length is invalid");
assert.equal(response6.runs[0].isCached, false, "response6: runs[0] Run is cached");
assert.equal(response6.runs[1].isCached, false, "response6: runs[1] Run is cached");
assert.ok(response6.runs[0].idempotencyKey, "response6: runs[0] Idempotent key is invalid");
assert.ok(response6.runs[1].idempotencyKey, "response6: runs[1] Idempotent key is invalid");
await setTimeout(1000);
const response7 = await batchV2TestChild.batchTrigger([
{ payload: { foo: "bar" }, options: { idempotencyKey: idempotencyKeyChild1 } },
{ payload: { foo: "baz" }, options: { idempotencyKey: idempotencyKeyChild2 } },
]);
logger.info("Response 7", { response7 });
assert.equal(response7.runs.length, 2, "response7: Items length is invalid");
assert.equal(response7.runs[0].isCached, true, "response7: runs[0] Run is not cached");
assert.equal(response7.runs[1].isCached, true, "response7: runs[1] Run is not cached");
assert.equal(
response7.runs[0].id,
response6.runs[0].id,
"response7: runs[0] Run ID is invalid"
);
assert.equal(
response7.runs[1].id,
response6.runs[1].id,
"response7: runs[1] Run ID is invalid"
);
await wait.for({ seconds: 6 });
// Now we need to test that the first run is not cached and is a new run, and the second run is cached
const response8 = await batchV2TestChild.batchTrigger([
{ payload: { foo: "bar" }, options: { idempotencyKey: idempotencyKeyChild1 } },
{ payload: { foo: "baz" }, options: { idempotencyKey: idempotencyKeyChild2 } },
]);
logger.info("Response 8", { response8 });
assert.equal(response8.runs.length, 2, "response8: Items length is invalid");
assert.equal(response8.runs[0].isCached, false, "response8: runs[0] Run is cached");
assert.equal(response8.runs[1].isCached, true, "response8: runs[1] Run is not cached");
assert.notEqual(
response8.runs[0].id,
response6.runs[0].id,
"response8: runs[0] Run ID is invalid"
);
assert.equal(
response8.runs[1].id,
response6.runs[1].id,
"response8: runs[1] Run ID is invalid"
);
// Now we need to test with batchTriggerAndWait
const response9 = await batchV2TestChild.batchTriggerAndWait([
{ payload: { foo: "bar" } },
{ payload: { foo: "baz" } },
]);
logger.debug("Response 9", { response9 });
assert.match(response9.id, /^batch_[a-z0-9]{21}$/, "response9: Batch ID is invalid");
assert.equal(response9.runs.length, 2, "response9: Items length is invalid");
assert.ok(response9.runs[0].ok, "response9: runs[0] is not ok");
assert.ok(response9.runs[1].ok, "response9: runs[1] is not ok");
assert.equal(
response9.runs[0].taskIdentifier,
"batch-v2-test-child",
"response9: runs[0] Task identifier is invalid"
);
assert.equal(
response9.runs[1].taskIdentifier,
"batch-v2-test-child",
"response9: runs[1] Task identifier is invalid"
);
assert.deepEqual(
response9.runs[0].output,
{ foo: "bar" },
"response9: runs[0] result is invalid"
);
assert.deepEqual(
response9.runs[1].output,
{ foo: "baz" },
"response9: runs[1] result is invalid"
);
// Now batchTriggerAndWait with 21 items
const response10 = await batchV2TestChild.batchTriggerAndWait(
Array.from({ length: 21 }, (_, i) => ({
payload: { foo: `bar${i}` },
}))
);
logger.debug("Response 10", { response10 });
assert.match(response10.id, /^batch_[a-z0-9]{21}$/, "response10: Batch ID is invalid");
assert.equal(response10.runs.length, 21, "response10: Items length is invalid");
// Now repeat the first few tests using `tasks.batchTrigger`:
const response11 = await tasks.batchTrigger<typeof batchV2TestChild>("batch-v2-test-child", [
{ payload: { foo: "bar" } },
{ payload: { foo: "baz" } },
]);
logger.debug("Response 11", { response11 });
assert.match(response11.batchId, /^batch_[a-z0-9]{21}$/, "response11: Batch ID is invalid");
assert.equal(response11.runs.length, 2, "response11: Items length is invalid");
assert.match(response11.runs[0].id, /^run_[a-z0-9]{21}$/, "response11: Run ID is invalid");
assert.equal(
response11.runs[0].taskIdentifier,
"batch-v2-test-child",
"response11: runs[0] Task identifier is invalid"
);
assert.equal(response11.runs[0].isCached, false, "response11: runs[0] Run is cached");
assert.equal(
response11.runs[0].idempotencyKey,
undefined,
"response11: runs[0] Idempotent key is invalid"
);
// Now use tasks.batchTrigger with 100 items
const response12 = await tasks.batchTrigger<typeof batchV2TestChild>(
"batch-v2-test-child",
Array.from({ length: 100 }, (_, i) => ({
payload: { foo: `bar${i}` },
}))
);
const response12Start = performance.now();
logger.debug("Response 12", { response12 });
assert.match(response12.batchId, /^batch_[a-z0-9]{21}$/, "response12: Batch ID is invalid");
assert.equal(response12.runs.length, 100, "response12: Items length is invalid");
const runsById: Map<string, AnyRealtimeRun> = new Map();
for await (const run of runs.subscribeToBatch(response12.batchId)) {
runsById.set(run.id, run);
// Break if we have received all runs
if (runsById.size === response12.runs.length) {
break;
}
}
const response12End = performance.now();
logger.debug("Response 12 time", { time: response12End - response12Start });
logger.debug("All runs", { runsById: Object.fromEntries(runsById) });
assert.equal(runsById.size, 100, "All runs were not received");
},
});
export const batchV2TestChild = task({
id: "batch-v2-test-child",
queue: {
concurrencyLimit: 10,
},
run: async (payload: any) => {
return payload;
},
});
export const batchAutoIdempotencyKeyTask = task({
id: "batch-auto-idempotency-key",
retry: {
maxAttempts: 3,
},
run: async () => {
const idempotencyKey = await idempotencyKeys.create("first-batch-1");
logger.debug("Idempotency key", { idempotencyKey });
const response1 = await batchAutoIdempotencyKeyChild.batchTrigger(
[{ payload: { foo: "bar" } }, { payload: { foo: "baz" } }],
{
idempotencyKey: idempotencyKey,
}
);
logger.debug("Response 1", { response1 });
const idempotencyKey2 = await idempotencyKeys.create("first-batch-2", { scope: "global" });
logger.debug("Idempotency key 2", { idempotencyKey2 });
const response2 = await batchAutoIdempotencyKeyChild.batchTrigger(
[{ payload: { foo: "bar" } }, { payload: { foo: "baz" } }],
{
idempotencyKey: idempotencyKey2,
}
);
logger.debug("Response 2", { response2 });
const idempotencyKey3 = await idempotencyKeys.create(randomUUID());
logger.debug("Idempotency key 3", { idempotencyKey3 });
const response3 = await batchAutoIdempotencyKeyChild.batchTrigger(
[{ payload: { foo: "bar" } }, { payload: { foo: "baz" } }],
{
idempotencyKey: idempotencyKey3,
}
);
logger.debug("Response 3", { response3 });
throw new Error("Forcing a retry to see if another batch is created");
},
});
export const batchAutoIdempotencyKeyChild = task({
id: "batch-auto-idempotency-key-child",
retry: {
maxAttempts: 3,
},
run: async (payload: any) => {
return payload;
},
});
@@ -131,8 +131,6 @@ export const triggerAndWaitLoops = task({
const handle = await taskWithNoPayload.trigger();
await taskWithNoPayload.triggerAndWait();
await taskWithNoPayload.batchTrigger([{}]);
await taskWithNoPayload.batchTriggerAndWait([{}]);
// Don't do this!
// await Promise.all(
+8
View File
@@ -0,0 +1,8 @@
export type Expect<T extends true> = T;
export type ExpectTrue<T extends true> = T;
export type ExpectFalse<T extends false> = T;
export type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2
? true
: false;
export type NotEqual<X, Y> = true extends Equal<X, Y> ? false : true;
+1 -1
View File
@@ -20,7 +20,7 @@ export default defineConfig({
// Set the maxDuration to 300s for all tasks. See https://trigger.dev/docs/runs/max-duration
// maxDuration: 300,
retries: {
enabledInDev: false,
enabledInDev: true,
default: {
maxAttempts: 10,
minTimeoutInMs: 5_000,