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:
@@ -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'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+233
@@ -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>
|
||||
);
|
||||
}
|
||||
+14
@@ -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,
|
||||
});
|
||||
|
||||
+2
@@ -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 };
|
||||
@@ -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);
|
||||
|
||||
+17
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
+18
@@ -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;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "BatchTaskRun_runtimeEnvironmentId_idempotencyKey_key" ON "BatchTaskRun"("runtimeEnvironmentId", "idempotencyKey");
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "BatchTaskRun" ADD COLUMN "idempotencyKeyExpiresAt" TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "TaskRun" ADD COLUMN "idempotencyKeyExpiresAt" TIMESTAMP(3);
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "BatchTaskRun" ADD COLUMN "payload" TEXT,
|
||||
ADD COLUMN "payloadType" TEXT NOT NULL DEFAULT 'application/json';
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "BatchTaskRun" ADD COLUMN "options" JSONB;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS "TaskRun_projectId_id_idx" ON "TaskRun"("projectId", "id" DESC);
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS "TaskRun_runtimeEnvironmentId_batchId_idx" ON "TaskRun"("runtimeEnvironmentId", "batchId");
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "TaskRun" ADD COLUMN "taskVersion" TEXT;
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "TaskRun" ADD COLUMN "cliVersion" TEXT,
|
||||
ADD COLUMN "sdkVersion" TEXT;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "BatchTaskRun" ADD COLUMN "batchVersion" TEXT NOT NULL DEFAULT 'v1';
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -109,4 +109,4 @@
|
||||
"main": "./dist/commonjs/index.js",
|
||||
"types": "./dist/commonjs/index.d.ts",
|
||||
"module": "./dist/esm/index.js"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user