feat: replicate task runs to clickhouse to power dashboard improvements (#2035)
* WIP clickhouse package with test containers setup * More clickhouse client setup now with otel and real tests, and the v1 of raw run events * Add some additional columns to raw_run_events_v1 * WIP runs dashboard service * Create a new run engine event bus event for the runs dashboard to hook into * Track run events in the run engine * make sure engine v1 runs get synced to CH * Update the attemptNumber of v3 task runs * Restructure the run events to be more sparse * emit more stuff * Setup replication package * scaffold the replication package * replication wip * resolve conflicts * more replication stuff * Add ability to drop the replication slot completely on teardown * Use the new single replacingmergetree task events table for replication * get it working * insert payloads into their own table only on insert and then join * prepare for using clickhouse cloud and now running ch migrations during boot in the entrypoint.sh * Handover WIP and tests * Testing the replication service * Remove the runs dashboard stuff that we aren't using anymore * Added a test for large payloads * hacky typecheck fix * Fix new internal package typecheck issues and start adding telemetry to the replication service * tracing over spans, some other improvements * Improvements to the runs replication service, now ready for testing * Some fixes and cleanups * Don't need this code anymore * move transaction types into the runs replication service * only send spans where there are transaction events * A couple of suggested tweaks
This commit is contained in:
@@ -725,6 +725,47 @@ const EnvironmentSchema = z.object({
|
||||
// BetterStack
|
||||
BETTERSTACK_API_KEY: z.string().optional(),
|
||||
BETTERSTACK_STATUS_PAGE_ID: z.string().optional(),
|
||||
|
||||
RUN_REPLICATION_REDIS_HOST: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v ?? process.env.REDIS_HOST),
|
||||
RUN_REPLICATION_REDIS_READER_HOST: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v ?? process.env.REDIS_READER_HOST),
|
||||
RUN_REPLICATION_REDIS_READER_PORT: z.coerce
|
||||
.number()
|
||||
.optional()
|
||||
.transform(
|
||||
(v) =>
|
||||
v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined)
|
||||
),
|
||||
RUN_REPLICATION_REDIS_PORT: z.coerce
|
||||
.number()
|
||||
.optional()
|
||||
.transform((v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)),
|
||||
RUN_REPLICATION_REDIS_USERNAME: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v ?? process.env.REDIS_USERNAME),
|
||||
RUN_REPLICATION_REDIS_PASSWORD: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v ?? process.env.REDIS_PASSWORD),
|
||||
RUN_REPLICATION_REDIS_TLS_DISABLED: z.string().default(process.env.REDIS_TLS_DISABLED ?? "false"),
|
||||
|
||||
RUN_REPLICATION_CLICKHOUSE_URL: z.string().optional(),
|
||||
RUN_REPLICATION_ENABLED: z.string().default("0"),
|
||||
RUN_REPLICATION_SLOT_NAME: z.string().default("task_runs_to_clickhouse_v1"),
|
||||
RUN_REPLICATION_PUBLICATION_NAME: z.string().default("task_runs_to_clickhouse_v1_publication"),
|
||||
RUN_REPLICATION_MAX_FLUSH_CONCURRENCY: z.coerce.number().int().default(100),
|
||||
RUN_REPLICATION_FLUSH_INTERVAL_MS: z.coerce.number().int().default(1000),
|
||||
RUN_REPLICATION_FLUSH_BATCH_SIZE: z.coerce.number().int().default(100),
|
||||
RUN_REPLICATION_LEADER_LOCK_TIMEOUT_MS: z.coerce.number().int().default(30_000),
|
||||
RUN_REPLICATION_LEADER_LOCK_EXTEND_INTERVAL_MS: z.coerce.number().int().default(10_000),
|
||||
RUN_REPLICATION_ACK_INTERVAL_SECONDS: z.coerce.number().int().default(10),
|
||||
RUN_REPLICATION_LOG_LEVEL: z.enum(["log", "error", "warn", "info", "debug"]).default("info"),
|
||||
});
|
||||
|
||||
export type Environment = z.infer<typeof EnvironmentSchema>;
|
||||
|
||||
@@ -4,7 +4,9 @@ import { env } from "./env.server";
|
||||
|
||||
export const metricsRegister = singleton("metricsRegister", initializeMetricsRegister);
|
||||
|
||||
function initializeMetricsRegister() {
|
||||
export type MetricsRegister = Registry<OpenMetricsContentType>;
|
||||
|
||||
function initializeMetricsRegister(): MetricsRegister {
|
||||
const registry = new Registry<OpenMetricsContentType>();
|
||||
|
||||
register.setDefaultLabels({
|
||||
|
||||
+506
@@ -0,0 +1,506 @@
|
||||
import { ArrowPathIcon, StopCircleIcon } from "@heroicons/react/20/solid";
|
||||
import { BeakerIcon, BookOpenIcon } from "@heroicons/react/24/solid";
|
||||
import { Form, type MetaFunction, useNavigation } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { IconCircleX } from "@tabler/icons-react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { ListChecks, ListX } from "lucide-react";
|
||||
import { Suspense, useState } from "react";
|
||||
import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson";
|
||||
import { TaskIcon } from "~/assets/icons/TaskIcon";
|
||||
import { DevDisconnectedBanner, useDevPresence } from "~/components/DevPresence";
|
||||
import { StepContentContainer } from "~/components/StepContentContainer";
|
||||
import { MainCenteredContainer, PageBody } from "~/components/layout/AppLayout";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTrigger,
|
||||
} from "~/components/primitives/Dialog";
|
||||
import { Header1, Header2 } from "~/components/primitives/Headers";
|
||||
import { InfoPanel } from "~/components/primitives/InfoPanel";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import {
|
||||
SelectedItemsProvider,
|
||||
useSelectedItems,
|
||||
} from "~/components/primitives/SelectedItemsProvider";
|
||||
import { Spinner, SpinnerWhite } from "~/components/primitives/Spinner";
|
||||
import { StepNumber } from "~/components/primitives/StepNumber";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { RunsFilters, TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters";
|
||||
import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable";
|
||||
import { BULK_ACTION_RUN_LIMIT } from "~/consts";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { RunListPresenter } from "~/presenters/v3/RunListPresenter.server";
|
||||
import {
|
||||
getRootOnlyFilterPreference,
|
||||
setRootOnlyFilterPreference,
|
||||
uiPreferencesStorage,
|
||||
} from "~/services/preferences/uiPreferences.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import {
|
||||
docsPath,
|
||||
EnvironmentParamSchema,
|
||||
v3ProjectPath,
|
||||
v3RunsNextPath,
|
||||
v3TestPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { ListPagination } from "../../components/ListPagination";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{
|
||||
title: `Runs | Trigger.dev`,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const url = new URL(request.url);
|
||||
|
||||
let rootOnlyValue = false;
|
||||
if (url.searchParams.has("rootOnly")) {
|
||||
rootOnlyValue = url.searchParams.get("rootOnly") === "true";
|
||||
} else {
|
||||
rootOnlyValue = await getRootOnlyFilterPreference(request);
|
||||
}
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
throw new Error("Project not found");
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
throw new Error("Environment not found");
|
||||
}
|
||||
|
||||
const s = {
|
||||
cursor: url.searchParams.get("cursor") ?? undefined,
|
||||
direction: url.searchParams.get("direction") ?? undefined,
|
||||
statuses: url.searchParams.getAll("statuses"),
|
||||
environments: [environment.id],
|
||||
tasks: url.searchParams.getAll("tasks"),
|
||||
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,
|
||||
rootOnly: rootOnlyValue,
|
||||
runId: url.searchParams.get("runId") ?? undefined,
|
||||
batchId: url.searchParams.get("batchId") ?? undefined,
|
||||
scheduleId: url.searchParams.get("scheduleId") ?? undefined,
|
||||
};
|
||||
const {
|
||||
tasks,
|
||||
versions,
|
||||
statuses,
|
||||
environments,
|
||||
tags,
|
||||
period,
|
||||
bulkId,
|
||||
from,
|
||||
to,
|
||||
cursor,
|
||||
direction,
|
||||
rootOnly,
|
||||
runId,
|
||||
batchId,
|
||||
scheduleId,
|
||||
} = TaskRunListSearchFilters.parse(s);
|
||||
|
||||
const presenter = new RunListPresenter();
|
||||
const list = presenter.call({
|
||||
userId,
|
||||
projectId: project.id,
|
||||
tasks,
|
||||
versions,
|
||||
statuses,
|
||||
environments,
|
||||
tags,
|
||||
period,
|
||||
bulkId,
|
||||
from,
|
||||
to,
|
||||
batchId,
|
||||
runIds: runId ? [runId] : undefined,
|
||||
scheduleId,
|
||||
rootOnly,
|
||||
direction: direction,
|
||||
cursor: cursor,
|
||||
});
|
||||
|
||||
const session = await setRootOnlyFilterPreference(rootOnlyValue, request);
|
||||
const cookieValue = await uiPreferencesStorage.commitSession(session);
|
||||
|
||||
return typeddefer(
|
||||
{
|
||||
data: list,
|
||||
rootOnlyDefault: rootOnlyValue,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Set-Cookie": cookieValue,
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { data, rootOnlyDefault } = useTypedLoaderData<typeof loader>();
|
||||
const navigation = useNavigation();
|
||||
const isLoading = navigation.state !== "idle";
|
||||
const { isConnected } = useDevPresence();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
return (
|
||||
<>
|
||||
<NavBar>
|
||||
<PageTitle title="Runs" />
|
||||
{environment.type === "DEVELOPMENT" && project.engine === "V2" && (
|
||||
<DevDisconnectedBanner isConnected={isConnected} />
|
||||
)}
|
||||
<PageAccessories>
|
||||
<LinkButton
|
||||
variant={"docs/small"}
|
||||
LeadingIcon={BookOpenIcon}
|
||||
to={docsPath("/runs-and-attempts")}
|
||||
>
|
||||
Runs docs
|
||||
</LinkButton>
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody scrollable={false}>
|
||||
<SelectedItemsProvider
|
||||
initialSelectedItems={[]}
|
||||
maxSelectedItemCount={BULK_ACTION_RUN_LIMIT}
|
||||
>
|
||||
{({ selectedItems }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"grid h-full max-h-full overflow-hidden",
|
||||
selectedItems.size === 0 ? "grid-rows-1" : "grid-rows-[1fr_auto]"
|
||||
)}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<div className="mx-auto flex items-center gap-2">
|
||||
<Spinner />
|
||||
<Paragraph variant="small">Loading runs</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TypedAwait resolve={data}>
|
||||
{(list) => (
|
||||
<>
|
||||
{list.runs.length === 0 && !list.hasAnyRuns ? (
|
||||
list.possibleTasks.length === 0 ? (
|
||||
<CreateFirstTaskInstructions />
|
||||
) : (
|
||||
<RunTaskInstructions />
|
||||
)
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-x-2 p-2">
|
||||
<RunsFilters
|
||||
possibleTasks={list.possibleTasks}
|
||||
bulkActions={list.bulkActions}
|
||||
hasFilters={list.hasFilters}
|
||||
rootOnlyDefault={rootOnlyDefault}
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<ListPagination list={list} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TaskRunsTable
|
||||
total={list.runs.length}
|
||||
hasFilters={list.hasFilters}
|
||||
filters={list.filters}
|
||||
runs={list.runs}
|
||||
isLoading={isLoading}
|
||||
allowSelection
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
<BulkActionBar />
|
||||
</div>
|
||||
)}
|
||||
</SelectedItemsProvider>
|
||||
</PageBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BulkActionBar() {
|
||||
const { selectedItems, deselectAll } = useSelectedItems();
|
||||
const [barState, setBarState] = useState<"none" | "replay" | "cancel">("none");
|
||||
|
||||
const hasSelectedMaximum = selectedItems.size >= BULK_ACTION_RUN_LIMIT;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{selectedItems.size > 0 && (
|
||||
<motion.div
|
||||
initial={{ translateY: "100%" }}
|
||||
animate={{ translateY: 0 }}
|
||||
exit={{ translateY: "100%" }}
|
||||
className="flex items-center justify-between gap-3 border-t border-grid-bright bg-background-bright py-3 pl-4 pr-3"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-sm text-text-bright">
|
||||
<ListChecks className="mr-1 size-7 text-indigo-400" />
|
||||
<Header2>Bulk actions:</Header2>
|
||||
{hasSelectedMaximum ? (
|
||||
<Paragraph className="text-warning">
|
||||
Maximum of {selectedItems.size} runs selected
|
||||
</Paragraph>
|
||||
) : (
|
||||
<Paragraph className="">{selectedItems.size} runs selected</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<CancelRuns
|
||||
onOpen={(o) => {
|
||||
if (o) {
|
||||
setBarState("cancel");
|
||||
} else {
|
||||
setBarState("none");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ReplayRuns
|
||||
onOpen={(o) => {
|
||||
if (o) {
|
||||
setBarState("replay");
|
||||
} else {
|
||||
setBarState("none");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="tertiary/medium"
|
||||
shortcut={{ key: "esc", enabledOnInputElements: true }}
|
||||
onClick={() => {
|
||||
if (barState !== "none") return;
|
||||
deselectAll();
|
||||
}}
|
||||
LeadingIcon={ListX}
|
||||
leadingIconClassName="text-indigo-400 w-6 h-6"
|
||||
>
|
||||
Clear selection
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
function CancelRuns({ onOpen }: { onOpen: (open: boolean) => void }) {
|
||||
const { selectedItems } = useSelectedItems();
|
||||
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const failedRedirect = v3RunsNextPath(organization, project, environment);
|
||||
|
||||
const formAction = `/resources/taskruns/bulk/cancel`;
|
||||
|
||||
const navigation = useNavigation();
|
||||
const isLoading = navigation.formAction === formAction;
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={(o) => onOpen(o)}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="tertiary/medium"
|
||||
shortcut={{ key: "c", enabledOnInputElements: true }}
|
||||
LeadingIcon={IconCircleX}
|
||||
leadingIconClassName="text-error w-[1.3rem] h-[1.3rem]"
|
||||
>
|
||||
Cancel runs
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent key="replay">
|
||||
<DialogHeader>Cancel {selectedItems.size} runs?</DialogHeader>
|
||||
<DialogDescription className="pt-2">
|
||||
Canceling these runs will stop them from running. Only runs that are not already finished
|
||||
will be canceled, the others will remain in their existing state.
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<Form action={formAction} method="post" reloadDocument>
|
||||
<input type="hidden" name="failedRedirect" value={failedRedirect} />
|
||||
<input type="hidden" name="organizationSlug" value={organization.slug} />
|
||||
<input type="hidden" name="projectSlug" value={project.slug} />
|
||||
<input type="hidden" name="environmentSlug" value={environment.slug} />
|
||||
{[...selectedItems].map((runId) => (
|
||||
<input key={runId} type="hidden" name="runIds" value={runId} />
|
||||
))}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="danger/medium"
|
||||
LeadingIcon={isLoading ? SpinnerWhite : StopCircleIcon}
|
||||
disabled={isLoading}
|
||||
shortcut={{ modifiers: ["mod"], key: "enter" }}
|
||||
>
|
||||
{isLoading ? "Canceling..." : `Cancel ${selectedItems.size} runs`}
|
||||
</Button>
|
||||
</Form>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ReplayRuns({ onOpen }: { onOpen: (open: boolean) => void }) {
|
||||
const { selectedItems } = useSelectedItems();
|
||||
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const failedRedirect = v3RunsNextPath(organization, project, environment);
|
||||
|
||||
const formAction = `/resources/taskruns/bulk/replay`;
|
||||
|
||||
const navigation = useNavigation();
|
||||
const isLoading = navigation.formAction === formAction;
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={(o) => onOpen(o)}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="tertiary/medium"
|
||||
shortcut={{ key: "r", enabledOnInputElements: true }}
|
||||
LeadingIcon={ArrowPathIcon}
|
||||
leadingIconClassName="text-blue-400 w-[1.3rem] h-[1.3rem]"
|
||||
>
|
||||
<span className="text-text-bright">Replay {selectedItems.size} runs</span>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent key="replay">
|
||||
<DialogHeader>Replay runs?</DialogHeader>
|
||||
<DialogDescription className="pt-2">
|
||||
Replaying these runs will create a new run for each with the same payload and environment
|
||||
as the original. It will use the latest version of the code for each task.
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<Form action={formAction} method="post" reloadDocument>
|
||||
<input type="hidden" name="failedRedirect" value={failedRedirect} />
|
||||
<input type="hidden" name="organizationSlug" value={organization.slug} />
|
||||
<input type="hidden" name="projectSlug" value={project.slug} />
|
||||
<input type="hidden" name="environmentSlug" value={environment.slug} />
|
||||
{[...selectedItems].map((runId) => (
|
||||
<input key={runId} type="hidden" name="runIds" value={runId} />
|
||||
))}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary/medium"
|
||||
LeadingIcon={isLoading ? SpinnerWhite : ArrowPathIcon}
|
||||
disabled={isLoading}
|
||||
shortcut={{ modifiers: ["mod"], key: "enter" }}
|
||||
>
|
||||
{isLoading ? "Replaying..." : `Replay ${selectedItems.size} runs`}
|
||||
</Button>
|
||||
</Form>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateFirstTaskInstructions() {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
return (
|
||||
<MainCenteredContainer className="max-w-md">
|
||||
<InfoPanel
|
||||
icon={TaskIcon}
|
||||
iconClassName="text-blue-500"
|
||||
panelClassName="max-full"
|
||||
title="Create your first task"
|
||||
accessory={
|
||||
<LinkButton to={v3ProjectPath(organization, project)} variant="primary/small">
|
||||
Create a task
|
||||
</LinkButton>
|
||||
}
|
||||
>
|
||||
<Paragraph variant="small">
|
||||
Before running a task, you must first create one. Follow the instructions on the{" "}
|
||||
<TextLink to={v3ProjectPath(organization, project)}>Tasks</TextLink> page to create a
|
||||
task, then return here to run it.
|
||||
</Paragraph>
|
||||
</InfoPanel>
|
||||
</MainCenteredContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function RunTaskInstructions() {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
return (
|
||||
<MainCenteredContainer className="max-w-prose">
|
||||
<Header1 className="mb-6 border-b py-2">How to run your tasks</Header1>
|
||||
<StepNumber stepNumber="A" title="Trigger a test run" />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
Perform a test run with a payload directly from the dashboard.
|
||||
</Paragraph>
|
||||
<LinkButton
|
||||
to={v3TestPath(organization, project, environment)}
|
||||
variant="secondary/medium"
|
||||
LeadingIcon={BeakerIcon}
|
||||
leadingIconClassName="text-lime-500"
|
||||
className="inline-flex"
|
||||
>
|
||||
Test
|
||||
</LinkButton>
|
||||
<div className="mt-6 flex items-center gap-2">
|
||||
<hr className="w-full" />
|
||||
<Paragraph variant="extra-extra-small/dimmed/caps">OR</Paragraph>
|
||||
<hr className="w-full" />
|
||||
</div>
|
||||
</StepContentContainer>
|
||||
|
||||
<StepNumber stepNumber="B" title="Trigger your task for real" />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
Performing a real run depends on the type of trigger your task is using.
|
||||
</Paragraph>
|
||||
<LinkButton
|
||||
to={docsPath("/triggering")}
|
||||
variant="docs/medium"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
How to trigger a task
|
||||
</LinkButton>
|
||||
</StepContentContainer>
|
||||
</MainCenteredContainer>
|
||||
);
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { Outlet } from "@remix-run/react";
|
||||
import { PageContainer } from "~/components/layout/AppLayout";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Outlet />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { prisma } from "~/db.server";
|
||||
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
|
||||
import { runsReplicationInstance } from "~/services/runsReplicationInstance.server";
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
// Next authenticate the request
|
||||
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
return json({ error: "Invalid or Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: authenticationResult.userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return json({ error: "Invalid or Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!user.admin) {
|
||||
return json({ error: "You must be an admin to perform this action" }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
await runsReplicationInstance?.start();
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
});
|
||||
} catch (error) {
|
||||
return json({ error: error instanceof Error ? error.message : error }, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { prisma } from "~/db.server";
|
||||
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
|
||||
import { runsReplicationInstance } from "~/services/runsReplicationInstance.server";
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
// Next authenticate the request
|
||||
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
return json({ error: "Invalid or Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: authenticationResult.userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return json({ error: "Invalid or Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!user.admin) {
|
||||
return json({ error: "You must be an admin to perform this action" }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
await runsReplicationInstance?.stop();
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
});
|
||||
} catch (error) {
|
||||
return json({ error: error instanceof Error ? error.message : error }, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { prisma } from "~/db.server";
|
||||
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
|
||||
import { runsReplicationInstance } from "~/services/runsReplicationInstance.server";
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
// Next authenticate the request
|
||||
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
return json({ error: "Invalid or Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: authenticationResult.userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return json({ error: "Invalid or Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!user.admin) {
|
||||
return json({ error: "You must be an admin to perform this action" }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
await runsReplicationInstance?.teardown();
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
});
|
||||
} catch (error) {
|
||||
return json({ error: error instanceof Error ? error.message : error }, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { createTag, getTagsForRunId, MAX_TAGS_PER_RUN } from "~/models/taskRunTag.server";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
import { generateFriendlyId } from "~/v3/friendlyIdentifiers";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
runId: z.string(),
|
||||
@@ -80,7 +79,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
}
|
||||
}
|
||||
|
||||
const taskRun = await prisma.taskRun.update({
|
||||
await prisma.taskRun.update({
|
||||
where: {
|
||||
friendlyId: parsedParams.data.runId,
|
||||
runtimeEnvironmentId: authenticationResult.environment.id,
|
||||
|
||||
@@ -305,6 +305,8 @@ export class RunEngineTriggerTaskService {
|
||||
? parentRun.queueTimestamp ?? undefined
|
||||
: undefined,
|
||||
runChainState,
|
||||
scheduleId: options.scheduleId,
|
||||
scheduleInstanceId: options.scheduleInstanceId,
|
||||
},
|
||||
this.prisma
|
||||
);
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
TriggerTaskRequestBody,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { z } from "zod";
|
||||
|
||||
export type TriggerTaskServiceOptions = {
|
||||
idempotencyKey?: string;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { ClickHouse } from "@internal/clickhouse";
|
||||
import { RunsReplicationService } from "./runsReplicationService.server";
|
||||
import { singleton } from "~/utils/singleton";
|
||||
import invariant from "tiny-invariant";
|
||||
import { env } from "~/env.server";
|
||||
import { metricsRegister } from "~/metrics.server";
|
||||
import { logger } from "./logger.server";
|
||||
|
||||
export const runsReplicationInstance = singleton(
|
||||
"runsReplicationInstance",
|
||||
initializeRunsReplicationInstance
|
||||
);
|
||||
|
||||
function initializeRunsReplicationInstance() {
|
||||
const { DATABASE_URL } = process.env;
|
||||
invariant(typeof DATABASE_URL === "string", "DATABASE_URL env var not set");
|
||||
|
||||
if (!env.RUN_REPLICATION_CLICKHOUSE_URL) {
|
||||
logger.info("🗃️ Runs replication service not enabled");
|
||||
return;
|
||||
}
|
||||
|
||||
const clickhouse = new ClickHouse({
|
||||
url: env.RUN_REPLICATION_CLICKHOUSE_URL,
|
||||
name: "runs-replication",
|
||||
});
|
||||
|
||||
const service = new RunsReplicationService({
|
||||
clickhouse: clickhouse,
|
||||
pgConnectionUrl: DATABASE_URL,
|
||||
serviceName: "runs-replication",
|
||||
slotName: env.RUN_REPLICATION_SLOT_NAME,
|
||||
publicationName: env.RUN_REPLICATION_PUBLICATION_NAME,
|
||||
redisOptions: {
|
||||
keyPrefix: "runs-replication:",
|
||||
port: env.RUN_REPLICATION_REDIS_PORT ?? undefined,
|
||||
host: env.RUN_REPLICATION_REDIS_HOST ?? undefined,
|
||||
username: env.RUN_REPLICATION_REDIS_USERNAME ?? undefined,
|
||||
password: env.RUN_REPLICATION_REDIS_PASSWORD ?? undefined,
|
||||
enableAutoPipelining: true,
|
||||
...(env.RUN_REPLICATION_REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
|
||||
},
|
||||
maxFlushConcurrency: env.RUN_REPLICATION_MAX_FLUSH_CONCURRENCY,
|
||||
flushIntervalMs: env.RUN_REPLICATION_FLUSH_INTERVAL_MS,
|
||||
flushBatchSize: env.RUN_REPLICATION_FLUSH_BATCH_SIZE,
|
||||
leaderLockTimeoutMs: env.RUN_REPLICATION_LEADER_LOCK_TIMEOUT_MS,
|
||||
leaderLockExtendIntervalMs: env.RUN_REPLICATION_LEADER_LOCK_EXTEND_INTERVAL_MS,
|
||||
ackIntervalSeconds: env.RUN_REPLICATION_ACK_INTERVAL_SECONDS,
|
||||
logLevel: env.RUN_REPLICATION_LOG_LEVEL,
|
||||
});
|
||||
|
||||
if (env.RUN_REPLICATION_ENABLED === "1") {
|
||||
service
|
||||
.start()
|
||||
.then(() => {
|
||||
logger.info("🗃️ Runs replication service started");
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error("🗃️ Runs replication service failed to start", {
|
||||
error,
|
||||
});
|
||||
});
|
||||
|
||||
process.on("SIGTERM", service.shutdown.bind(service));
|
||||
process.on("SIGINT", service.shutdown.bind(service));
|
||||
}
|
||||
|
||||
return service;
|
||||
}
|
||||
@@ -0,0 +1,775 @@
|
||||
import type { ClickHouse, RawTaskRunPayloadV1, TaskRunV1 } from "@internal/clickhouse";
|
||||
import { RedisOptions } from "@internal/redis";
|
||||
import {
|
||||
LogicalReplicationClient,
|
||||
type MessageDelete,
|
||||
type MessageInsert,
|
||||
type MessageUpdate,
|
||||
type PgoutputMessage,
|
||||
} from "@internal/replication";
|
||||
import { startSpan, trace, type Tracer } from "@internal/tracing";
|
||||
import { Logger, LogLevel } from "@trigger.dev/core/logger";
|
||||
import { tryCatch } from "@trigger.dev/core/utils";
|
||||
import { parsePacket } from "@trigger.dev/core/v3/utils/ioSerialization";
|
||||
import { TaskRun } from "@trigger.dev/database";
|
||||
import { nanoid } from "nanoid";
|
||||
import EventEmitter from "node:events";
|
||||
import pLimit from "p-limit";
|
||||
|
||||
interface TransactionEvent<T = any> {
|
||||
tag: "insert" | "update" | "delete";
|
||||
data: T;
|
||||
raw: MessageInsert | MessageUpdate | MessageDelete;
|
||||
}
|
||||
|
||||
interface Transaction<T = any> {
|
||||
beginStartTimestamp: number;
|
||||
commitLsn: string | null;
|
||||
commitEndLsn: string | null;
|
||||
xid: number;
|
||||
events: TransactionEvent<T>[];
|
||||
replicationLagMs: number;
|
||||
}
|
||||
|
||||
export type RunsReplicationServiceOptions = {
|
||||
clickhouse: ClickHouse;
|
||||
pgConnectionUrl: string;
|
||||
serviceName: string;
|
||||
slotName: string;
|
||||
publicationName: string;
|
||||
redisOptions: RedisOptions;
|
||||
maxFlushConcurrency?: number;
|
||||
flushIntervalMs?: number;
|
||||
flushBatchSize?: number;
|
||||
leaderLockTimeoutMs?: number;
|
||||
leaderLockExtendIntervalMs?: number;
|
||||
ackIntervalSeconds?: number;
|
||||
acknowledgeTimeoutMs?: number;
|
||||
logger?: Logger;
|
||||
logLevel?: LogLevel;
|
||||
tracer?: Tracer;
|
||||
};
|
||||
|
||||
type TaskRunInsert = { _version: bigint; run: TaskRun; event: "insert" | "update" | "delete" };
|
||||
|
||||
export type RunsReplicationServiceEvents = {
|
||||
message: [{ lsn: string; message: PgoutputMessage; service: RunsReplicationService }];
|
||||
};
|
||||
|
||||
export class RunsReplicationService {
|
||||
private _isSubscribed = false;
|
||||
private _currentTransaction:
|
||||
| (Omit<Transaction<TaskRun>, "commitEndLsn" | "replicationLagMs"> & {
|
||||
commitEndLsn?: string | null;
|
||||
replicationLagMs?: number;
|
||||
})
|
||||
| null = null;
|
||||
|
||||
private _replicationClient: LogicalReplicationClient;
|
||||
private _concurrentFlushScheduler: ConcurrentFlushScheduler<TaskRunInsert>;
|
||||
private logger: Logger;
|
||||
private _isShuttingDown = false;
|
||||
private _isShutDownComplete = false;
|
||||
private _tracer: Tracer;
|
||||
private _currentParseDurationMs: number | null = null;
|
||||
private _lastAcknowledgedAt: number | null = null;
|
||||
private _acknowledgeTimeoutMs: number;
|
||||
private _latestCommitEndLsn: string | null = null;
|
||||
private _lastAcknowledgedLsn: string | null = null;
|
||||
private _acknowledgeInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
public readonly events: EventEmitter<RunsReplicationServiceEvents>;
|
||||
|
||||
constructor(private readonly options: RunsReplicationServiceOptions) {
|
||||
this.logger =
|
||||
options.logger ?? new Logger("RunsReplicationService", options.logLevel ?? "info");
|
||||
this.events = new EventEmitter();
|
||||
this._tracer = options.tracer ?? trace.getTracer("runs-replication-service");
|
||||
|
||||
this._acknowledgeTimeoutMs = options.acknowledgeTimeoutMs ?? 1_000;
|
||||
|
||||
this._replicationClient = new LogicalReplicationClient({
|
||||
pgConfig: {
|
||||
connectionString: options.pgConnectionUrl,
|
||||
},
|
||||
name: options.serviceName,
|
||||
slotName: options.slotName,
|
||||
publicationName: options.publicationName,
|
||||
table: "TaskRun",
|
||||
redisOptions: options.redisOptions,
|
||||
autoAcknowledge: false,
|
||||
publicationActions: ["insert", "update", "delete"],
|
||||
logger: new Logger("LogicalReplicationClient", options.logLevel ?? "info"),
|
||||
leaderLockTimeoutMs: options.leaderLockTimeoutMs ?? 30_000,
|
||||
leaderLockExtendIntervalMs: options.leaderLockExtendIntervalMs ?? 10_000,
|
||||
ackIntervalSeconds: options.ackIntervalSeconds ?? 10,
|
||||
});
|
||||
|
||||
this._concurrentFlushScheduler = new ConcurrentFlushScheduler<TaskRunInsert>({
|
||||
batchSize: options.flushBatchSize ?? 50,
|
||||
flushInterval: options.flushIntervalMs ?? 100,
|
||||
maxConcurrency: options.maxFlushConcurrency ?? 100,
|
||||
callback: this.#flushBatch.bind(this),
|
||||
logger: new Logger("ConcurrentFlushScheduler", options.logLevel ?? "info"),
|
||||
});
|
||||
|
||||
this._replicationClient.events.on("data", async ({ lsn, log, parseDuration }) => {
|
||||
this.#handleData(lsn, log, parseDuration);
|
||||
});
|
||||
|
||||
this._replicationClient.events.on("heartbeat", async ({ lsn, shouldRespond }) => {
|
||||
if (this._isShuttingDown) return;
|
||||
if (this._isShutDownComplete) return;
|
||||
|
||||
if (shouldRespond) {
|
||||
this._lastAcknowledgedLsn = lsn;
|
||||
await this._replicationClient.acknowledge(lsn);
|
||||
}
|
||||
});
|
||||
|
||||
this._replicationClient.events.on("error", (error) => {
|
||||
this.logger.error("Replication client error", {
|
||||
error,
|
||||
});
|
||||
});
|
||||
|
||||
this._replicationClient.events.on("start", () => {
|
||||
this.logger.debug("Replication client started");
|
||||
});
|
||||
|
||||
this._replicationClient.events.on("acknowledge", ({ lsn }) => {
|
||||
this.logger.debug("Acknowledged", { lsn });
|
||||
});
|
||||
|
||||
this._replicationClient.events.on("leaderElection", (isLeader) => {
|
||||
this.logger.debug("Leader election", { isLeader });
|
||||
});
|
||||
}
|
||||
|
||||
public async shutdown() {
|
||||
this._isShuttingDown = true;
|
||||
|
||||
this.logger.info("Initiating shutdown of runs replication service");
|
||||
|
||||
if (!this._currentTransaction) {
|
||||
this.logger.info("No transaction to commit, shutting down immediately");
|
||||
await this._replicationClient.stop();
|
||||
this._isShutDownComplete = true;
|
||||
return;
|
||||
}
|
||||
|
||||
this._concurrentFlushScheduler.shutdown();
|
||||
}
|
||||
|
||||
async start() {
|
||||
this.logger.info("Starting replication client", {
|
||||
lastLsn: this._latestCommitEndLsn,
|
||||
});
|
||||
|
||||
await this._replicationClient.subscribe(this._latestCommitEndLsn ?? undefined);
|
||||
|
||||
this._acknowledgeInterval = setInterval(this.#acknowledgeLatestTransaction.bind(this), 1000);
|
||||
this._concurrentFlushScheduler.start();
|
||||
}
|
||||
|
||||
async stop() {
|
||||
this.logger.info("Stopping replication client");
|
||||
|
||||
await this._replicationClient.stop();
|
||||
|
||||
if (this._acknowledgeInterval) {
|
||||
clearInterval(this._acknowledgeInterval);
|
||||
}
|
||||
}
|
||||
|
||||
async teardown() {
|
||||
this.logger.info("Teardown replication client");
|
||||
|
||||
await this._replicationClient.teardown();
|
||||
|
||||
if (this._acknowledgeInterval) {
|
||||
clearInterval(this._acknowledgeInterval);
|
||||
}
|
||||
}
|
||||
|
||||
#handleData(lsn: string, message: PgoutputMessage, parseDuration: bigint) {
|
||||
this.logger.debug("Handling data", {
|
||||
lsn,
|
||||
tag: message.tag,
|
||||
parseDuration,
|
||||
});
|
||||
|
||||
this.events.emit("message", { lsn, message, service: this });
|
||||
|
||||
switch (message.tag) {
|
||||
case "begin": {
|
||||
if (this._isShuttingDown || this._isShutDownComplete) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._currentTransaction = {
|
||||
beginStartTimestamp: Date.now(),
|
||||
commitLsn: message.commitLsn,
|
||||
xid: message.xid,
|
||||
events: [],
|
||||
};
|
||||
|
||||
this._currentParseDurationMs = Number(parseDuration) / 1_000_000;
|
||||
|
||||
break;
|
||||
}
|
||||
case "insert": {
|
||||
if (!this._currentTransaction) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._currentParseDurationMs) {
|
||||
this._currentParseDurationMs =
|
||||
this._currentParseDurationMs + Number(parseDuration) / 1_000_000;
|
||||
}
|
||||
|
||||
this._currentTransaction.events.push({
|
||||
tag: message.tag,
|
||||
data: message.new as TaskRun,
|
||||
raw: message,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "update": {
|
||||
if (!this._currentTransaction) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._currentParseDurationMs) {
|
||||
this._currentParseDurationMs =
|
||||
this._currentParseDurationMs + Number(parseDuration) / 1_000_000;
|
||||
}
|
||||
|
||||
this._currentTransaction.events.push({
|
||||
tag: message.tag,
|
||||
data: message.new as TaskRun,
|
||||
raw: message,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "delete": {
|
||||
if (!this._currentTransaction) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._currentParseDurationMs) {
|
||||
this._currentParseDurationMs =
|
||||
this._currentParseDurationMs + Number(parseDuration) / 1_000_000;
|
||||
}
|
||||
|
||||
this._currentTransaction.events.push({
|
||||
tag: message.tag,
|
||||
data: message.old as TaskRun,
|
||||
raw: message,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case "commit": {
|
||||
if (!this._currentTransaction) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._currentParseDurationMs) {
|
||||
this._currentParseDurationMs =
|
||||
this._currentParseDurationMs + Number(parseDuration) / 1_000_000;
|
||||
}
|
||||
|
||||
const replicationLagMs = Date.now() - Number(message.commitTime / 1000n);
|
||||
this._currentTransaction.commitEndLsn = message.commitEndLsn;
|
||||
this._currentTransaction.replicationLagMs = replicationLagMs;
|
||||
const transaction = this._currentTransaction as Transaction<TaskRun>;
|
||||
this._currentTransaction = null;
|
||||
|
||||
if (transaction.commitEndLsn) {
|
||||
this._latestCommitEndLsn = transaction.commitEndLsn;
|
||||
}
|
||||
|
||||
this.#handleTransaction(transaction);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
this.logger.debug("Unknown message tag", {
|
||||
pgMessage: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#handleTransaction(transaction: Transaction<TaskRun>) {
|
||||
if (this._isShutDownComplete) return;
|
||||
|
||||
if (this._isShuttingDown) {
|
||||
this._replicationClient.stop().finally(() => {
|
||||
this._isShutDownComplete = true;
|
||||
});
|
||||
}
|
||||
|
||||
// If there are no events, do nothing
|
||||
if (transaction.events.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!transaction.commitEndLsn) {
|
||||
this.logger.error("Transaction has no commit end lsn", {
|
||||
transaction,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.debug("Handling transaction", {
|
||||
transaction,
|
||||
});
|
||||
|
||||
const lsnToUInt64Start = process.hrtime.bigint();
|
||||
|
||||
// If there are events, we need to handle them
|
||||
const _version = lsnToUInt64(transaction.commitEndLsn);
|
||||
|
||||
const lsnToUInt64DurationMs = Number(process.hrtime.bigint() - lsnToUInt64Start) / 1_000_000;
|
||||
|
||||
this._concurrentFlushScheduler.addToBatch(
|
||||
transaction.events.map((event) => ({
|
||||
_version,
|
||||
run: event.data,
|
||||
event: event.tag,
|
||||
}))
|
||||
);
|
||||
|
||||
const currentSpan = this._tracer.startSpan("handle_transaction", {
|
||||
attributes: {
|
||||
"transaction.xid": transaction.xid,
|
||||
"transaction.replication_lag_ms": transaction.replicationLagMs,
|
||||
"transaction.events": transaction.events.length,
|
||||
"transaction.commit_end_lsn": transaction.commitEndLsn,
|
||||
"transaction.parse_duration_ms": this._currentParseDurationMs ?? undefined,
|
||||
"transaction.lsn_to_uint64_ms": lsnToUInt64DurationMs,
|
||||
"transaction.version": _version.toString(),
|
||||
},
|
||||
startTime: transaction.beginStartTimestamp,
|
||||
});
|
||||
|
||||
currentSpan.end();
|
||||
}
|
||||
|
||||
async #acknowledgeLatestTransaction() {
|
||||
if (!this._latestCommitEndLsn) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._lastAcknowledgedLsn === this._latestCommitEndLsn) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
if (this._lastAcknowledgedAt) {
|
||||
const timeSinceLastAcknowledged = now - this._lastAcknowledgedAt;
|
||||
// If we've already acknowledged within the last second, don't acknowledge again
|
||||
if (timeSinceLastAcknowledged < this._acknowledgeTimeoutMs) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this._lastAcknowledgedAt = now;
|
||||
this._lastAcknowledgedLsn = this._latestCommitEndLsn;
|
||||
|
||||
this.logger.debug("Acknowledging transaction", {
|
||||
commitEndLsn: this._latestCommitEndLsn,
|
||||
lastAcknowledgedAt: this._lastAcknowledgedAt,
|
||||
});
|
||||
|
||||
const [ackError] = await tryCatch(
|
||||
this._replicationClient.acknowledge(this._latestCommitEndLsn)
|
||||
);
|
||||
|
||||
if (ackError) {
|
||||
this.logger.error("Error acknowledging transaction", { ackError });
|
||||
}
|
||||
|
||||
if (this._isShutDownComplete && this._acknowledgeInterval) {
|
||||
clearInterval(this._acknowledgeInterval);
|
||||
}
|
||||
}
|
||||
|
||||
async #flushBatch(flushId: string, batch: Array<TaskRunInsert>) {
|
||||
if (batch.length === 0) {
|
||||
this.logger.debug("No runs to flush", {
|
||||
flushId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.debug("Flushing batch", {
|
||||
flushId,
|
||||
batchSize: batch.length,
|
||||
});
|
||||
|
||||
await startSpan(this._tracer, "flushBatch", async (span) => {
|
||||
const preparedInserts = await startSpan(this._tracer, "prepare_inserts", async (span) => {
|
||||
return await Promise.all(batch.map(this.#prepareRunInserts.bind(this)));
|
||||
});
|
||||
|
||||
const taskRunInserts = preparedInserts
|
||||
.map(({ taskRunInsert }) => taskRunInsert)
|
||||
.filter(Boolean);
|
||||
|
||||
const payloadInserts = preparedInserts
|
||||
.map(({ payloadInsert }) => payloadInsert)
|
||||
.filter(Boolean);
|
||||
|
||||
span.setAttribute("task_run_inserts", taskRunInserts.length);
|
||||
span.setAttribute("payload_inserts", payloadInserts.length);
|
||||
|
||||
this.logger.debug("Flushing inserts", {
|
||||
flushId,
|
||||
taskRunInserts: taskRunInserts.length,
|
||||
payloadInserts: payloadInserts.length,
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
this.#insertTaskRunInserts(taskRunInserts),
|
||||
this.#insertPayloadInserts(payloadInserts),
|
||||
]);
|
||||
|
||||
this.logger.debug("Flushed inserts", {
|
||||
flushId,
|
||||
taskRunInserts: taskRunInserts.length,
|
||||
payloadInserts: payloadInserts.length,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async #insertTaskRunInserts(taskRunInserts: TaskRunV1[]) {
|
||||
const [insertError, insertResult] = await this.options.clickhouse.taskRuns.insert(
|
||||
taskRunInserts,
|
||||
{
|
||||
params: {
|
||||
clickhouse_settings: {
|
||||
wait_for_async_insert: 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (insertError) {
|
||||
this.logger.error("Error inserting task run inserts", {
|
||||
error: insertError,
|
||||
});
|
||||
}
|
||||
|
||||
return insertResult;
|
||||
}
|
||||
|
||||
async #insertPayloadInserts(payloadInserts: RawTaskRunPayloadV1[]) {
|
||||
const [insertError, insertResult] = await this.options.clickhouse.taskRuns.insertPayloads(
|
||||
payloadInserts,
|
||||
{
|
||||
params: {
|
||||
clickhouse_settings: {
|
||||
wait_for_async_insert: 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (insertError) {
|
||||
this.logger.error("Error inserting payload inserts", {
|
||||
error: insertError,
|
||||
});
|
||||
}
|
||||
|
||||
return insertResult;
|
||||
}
|
||||
|
||||
async #prepareRunInserts(
|
||||
batchedRun: TaskRunInsert
|
||||
): Promise<{ taskRunInsert?: TaskRunV1; payloadInsert?: RawTaskRunPayloadV1 }> {
|
||||
this.logger.debug("Preparing run", {
|
||||
batchedRun,
|
||||
});
|
||||
|
||||
const { run, _version, event } = batchedRun;
|
||||
|
||||
if (!run.environmentType) {
|
||||
return {
|
||||
taskRunInsert: undefined,
|
||||
payloadInsert: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (!run.organizationId) {
|
||||
return {
|
||||
taskRunInsert: undefined,
|
||||
payloadInsert: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (event === "update" || event === "delete") {
|
||||
const taskRunInsert = await this.#prepareTaskRunInsert(
|
||||
run,
|
||||
run.organizationId,
|
||||
run.environmentType,
|
||||
event,
|
||||
_version
|
||||
);
|
||||
|
||||
return {
|
||||
taskRunInsert,
|
||||
payloadInsert: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const [taskRunInsert, payloadInsert] = await Promise.all([
|
||||
this.#prepareTaskRunInsert(run, run.organizationId, run.environmentType, event, _version),
|
||||
this.#preparePayloadInsert(run, _version),
|
||||
]);
|
||||
|
||||
return {
|
||||
taskRunInsert,
|
||||
payloadInsert,
|
||||
};
|
||||
}
|
||||
|
||||
async #prepareTaskRunInsert(
|
||||
run: TaskRun,
|
||||
organizationId: string,
|
||||
environmentType: string,
|
||||
event: "insert" | "update" | "delete",
|
||||
_version: bigint
|
||||
): Promise<TaskRunV1> {
|
||||
const output = await this.#prepareJson(run.output, run.outputType);
|
||||
|
||||
return {
|
||||
environment_id: run.runtimeEnvironmentId,
|
||||
organization_id: organizationId,
|
||||
project_id: run.projectId,
|
||||
run_id: run.id,
|
||||
updated_at: run.updatedAt.getTime(),
|
||||
created_at: run.createdAt.getTime(),
|
||||
status: run.status,
|
||||
environment_type: environmentType,
|
||||
friendly_id: run.friendlyId,
|
||||
engine: run.engine,
|
||||
task_identifier: run.taskIdentifier,
|
||||
queue: run.queue,
|
||||
span_id: run.spanId,
|
||||
trace_id: run.traceId,
|
||||
error: { data: run.error },
|
||||
attempt: run.attemptNumber ?? 1,
|
||||
schedule_id: run.scheduleId ?? "",
|
||||
batch_id: run.batchId ?? "",
|
||||
completed_at: run.completedAt?.getTime(),
|
||||
started_at: run.startedAt?.getTime(),
|
||||
executed_at: run.executedAt?.getTime(),
|
||||
delay_until: run.delayUntil?.getTime(),
|
||||
queued_at: run.queuedAt?.getTime(),
|
||||
expired_at: run.expiredAt?.getTime(),
|
||||
usage_duration_ms: run.usageDurationMs,
|
||||
cost_in_cents: run.costInCents,
|
||||
base_cost_in_cents: run.baseCostInCents,
|
||||
tags: run.runTags ?? [],
|
||||
task_version: run.taskVersion ?? "",
|
||||
sdk_version: run.sdkVersion ?? "",
|
||||
cli_version: run.cliVersion ?? "",
|
||||
machine_preset: run.machinePreset ?? "",
|
||||
root_run_id: run.rootTaskRunId ?? "",
|
||||
parent_run_id: run.parentTaskRunId ?? "",
|
||||
depth: run.depth,
|
||||
is_test: run.isTest,
|
||||
idempotency_key: run.idempotencyKey ?? "",
|
||||
expiration_ttl: run.ttl ?? "",
|
||||
output,
|
||||
_version: _version.toString(),
|
||||
_is_deleted: event === "delete" ? 1 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
async #preparePayloadInsert(run: TaskRun, _version: bigint): Promise<RawTaskRunPayloadV1> {
|
||||
const payload = await this.#prepareJson(run.payload, run.payloadType);
|
||||
|
||||
return {
|
||||
run_id: run.id,
|
||||
created_at: run.createdAt.getTime(),
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
async #prepareJson(
|
||||
data: string | undefined | null,
|
||||
dataType: string
|
||||
): Promise<{ data: unknown }> {
|
||||
if (!data) {
|
||||
return { data: undefined };
|
||||
}
|
||||
|
||||
if (dataType !== "application/json" && dataType !== "application/super+json") {
|
||||
return { data: undefined };
|
||||
}
|
||||
|
||||
const packet = {
|
||||
data,
|
||||
dataType,
|
||||
};
|
||||
|
||||
const [parseError, parsedData] = await tryCatch(parsePacket(packet));
|
||||
|
||||
if (parseError) {
|
||||
this.logger.error("Error parsing packet", {
|
||||
error: parseError,
|
||||
packet,
|
||||
});
|
||||
|
||||
return { data: undefined };
|
||||
}
|
||||
|
||||
return { data: parsedData };
|
||||
}
|
||||
}
|
||||
|
||||
export type ConcurrentFlushSchedulerConfig<T> = {
|
||||
batchSize: number;
|
||||
flushInterval: number;
|
||||
maxConcurrency?: number;
|
||||
callback: (flushId: string, batch: T[]) => Promise<void>;
|
||||
tracer?: Tracer;
|
||||
logger?: Logger;
|
||||
};
|
||||
|
||||
export class ConcurrentFlushScheduler<T> {
|
||||
private currentBatch: T[]; // Adjust the type according to your data structure
|
||||
private readonly BATCH_SIZE: number;
|
||||
private readonly flushInterval: number;
|
||||
private readonly MAX_CONCURRENCY: number;
|
||||
private readonly concurrencyLimiter: ReturnType<typeof pLimit>;
|
||||
private flushTimer: NodeJS.Timeout | null;
|
||||
private failedBatchCount;
|
||||
private logger: Logger;
|
||||
private _tracer: Tracer;
|
||||
private _isShutDown = false;
|
||||
|
||||
constructor(private readonly config: ConcurrentFlushSchedulerConfig<T>) {
|
||||
this.logger = config.logger ?? new Logger("ConcurrentFlushScheduler", "info");
|
||||
this._tracer = config.tracer ?? trace.getTracer("concurrent-flush-scheduler");
|
||||
|
||||
this.currentBatch = [];
|
||||
this.BATCH_SIZE = config.batchSize;
|
||||
this.flushInterval = config.flushInterval;
|
||||
this.MAX_CONCURRENCY = config.maxConcurrency || 1;
|
||||
this.concurrencyLimiter = pLimit(this.MAX_CONCURRENCY);
|
||||
this.flushTimer = null;
|
||||
this.failedBatchCount = 0;
|
||||
}
|
||||
|
||||
addToBatch(items: T[]): void {
|
||||
this.currentBatch = this.currentBatch.concat(items);
|
||||
this.#flushNextBatchIfNeeded();
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.logger.info("Starting ConcurrentFlushScheduler", {
|
||||
batchSize: this.BATCH_SIZE,
|
||||
flushInterval: this.flushInterval,
|
||||
maxConcurrency: this.MAX_CONCURRENCY,
|
||||
});
|
||||
|
||||
this.#startFlushTimer();
|
||||
}
|
||||
|
||||
shutdown(): void {
|
||||
this.logger.info("Shutting down ConcurrentFlushScheduler");
|
||||
|
||||
this._isShutDown = true;
|
||||
|
||||
this.#clearTimer();
|
||||
this.#flushNextBatchIfNeeded();
|
||||
}
|
||||
|
||||
#flushNextBatchIfNeeded(): void {
|
||||
if (this.currentBatch.length >= this.BATCH_SIZE || this._isShutDown) {
|
||||
this.logger.debug("Batch size threshold reached, initiating flush", {
|
||||
batchSize: this.BATCH_SIZE,
|
||||
currentSize: this.currentBatch.length,
|
||||
isShutDown: this._isShutDown,
|
||||
});
|
||||
|
||||
this.#flushNextBatch().catch((error) => {
|
||||
this.logger.error("Error flushing next batch", {
|
||||
error,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#startFlushTimer(): void {
|
||||
this.flushTimer = setInterval(() => this.#checkAndFlush().catch(() => {}), this.flushInterval);
|
||||
this.logger.debug("Started flush timer", { interval: this.flushInterval });
|
||||
}
|
||||
|
||||
#clearTimer(): void {
|
||||
if (this.flushTimer) {
|
||||
clearInterval(this.flushTimer);
|
||||
this.logger.debug("Flush timer cleared");
|
||||
}
|
||||
}
|
||||
|
||||
async #checkAndFlush(): Promise<void> {
|
||||
if (this.currentBatch.length > 0) {
|
||||
this.logger.debug("Periodic flush check triggered", {
|
||||
currentBatchSize: this.currentBatch.length,
|
||||
});
|
||||
await this.#flushNextBatch();
|
||||
}
|
||||
}
|
||||
|
||||
async #flushNextBatch(): Promise<void> {
|
||||
if (this.currentBatch.length === 0) return;
|
||||
|
||||
const batch = this.currentBatch;
|
||||
this.currentBatch = [];
|
||||
|
||||
const callback = this.config.callback;
|
||||
|
||||
const promise = this.concurrencyLimiter(async () => {
|
||||
await startSpan(this._tracer, "flushNextBatch", async (span) => {
|
||||
const batchId = nanoid();
|
||||
|
||||
span.setAttribute("batch_id", batchId);
|
||||
span.setAttribute("batch_size", batch.length);
|
||||
span.setAttribute("concurrency_active_count", this.concurrencyLimiter.activeCount);
|
||||
span.setAttribute("concurrency_pending_count", this.concurrencyLimiter.pendingCount);
|
||||
span.setAttribute("concurrency_concurrency", this.concurrencyLimiter.concurrency);
|
||||
|
||||
await callback(batchId, batch);
|
||||
});
|
||||
});
|
||||
|
||||
const [error] = await tryCatch(promise);
|
||||
|
||||
if (error) {
|
||||
this.logger.error("Error flushing batch", {
|
||||
error,
|
||||
});
|
||||
|
||||
this.failedBatchCount++;
|
||||
}
|
||||
|
||||
this.logger.debug("Batch flush complete", {
|
||||
totalBatches: 1,
|
||||
successfulBatches: 1,
|
||||
failedBatches: 0,
|
||||
totalFailedBatches: this.failedBatchCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function lsnToUInt64(lsn: string): bigint {
|
||||
const [seg, off] = lsn.split("/");
|
||||
return (BigInt("0x" + seg) << 32n) | BigInt("0x" + off);
|
||||
}
|
||||
@@ -233,6 +233,17 @@ export function v3RunsPath(
|
||||
return `${v3EnvironmentPath(organization, project, environment)}/runs${query}`;
|
||||
}
|
||||
|
||||
export function v3RunsNextPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath,
|
||||
filters?: TaskRunListSearchFilters
|
||||
) {
|
||||
const searchParams = objectToSearchParams(filters);
|
||||
const query = searchParams ? `?${searchParams.toString()}` : "";
|
||||
return `${v3EnvironmentPath(organization, project, environment)}/runs/next${query}`;
|
||||
}
|
||||
|
||||
export function v3RunPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
|
||||
@@ -6,11 +6,14 @@ import {
|
||||
TaskRunFailedExecutionResult,
|
||||
serverWebsocketMessages,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { getMaxDuration } from "@trigger.dev/core/v3/isomorphic";
|
||||
import { ZodMessageSender } from "@trigger.dev/core/v3/zodMessageHandler";
|
||||
import { BackgroundWorker, BackgroundWorkerTask } from "@trigger.dev/database";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { createNewSession, disconnectSession } from "~/models/runtimeEnvironment.server";
|
||||
import { findQueueInEnvironment, sanitizeQueueName } from "~/models/taskQueue.server";
|
||||
import { RedisClient, createRedisClient } from "~/redis.server";
|
||||
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { marqs } from "~/v3/marqs/index.server";
|
||||
@@ -19,10 +22,7 @@ import { FailedTaskRunService } from "../failedTaskRun.server";
|
||||
import { CancelDevSessionRunsService } from "../services/cancelDevSessionRuns.server";
|
||||
import { CompleteAttemptService } from "../services/completeAttempt.server";
|
||||
import { attributesFromAuthenticatedEnv, tracer } from "../tracer.server";
|
||||
import { getMaxDuration } from "@trigger.dev/core/v3/isomorphic";
|
||||
import { DevSubscriber, devPubSub } from "./devPubSub.server";
|
||||
import { findQueueInEnvironment, sanitizeQueueName } from "~/models/taskQueue.server";
|
||||
import { createRedisClient, RedisClient } from "~/redis.server";
|
||||
|
||||
const MessageBody = z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
@@ -440,19 +440,22 @@ export class DevQueueConsumer {
|
||||
return;
|
||||
}
|
||||
|
||||
const lockedAt = new Date();
|
||||
const startedAt = existingTaskRun.startedAt ?? new Date();
|
||||
|
||||
const lockedTaskRun = await prisma.taskRun.update({
|
||||
where: {
|
||||
id: message.messageId,
|
||||
},
|
||||
data: {
|
||||
lockedAt: new Date(),
|
||||
lockedAt,
|
||||
lockedById: backgroundTask.id,
|
||||
status: "EXECUTING",
|
||||
lockedToVersionId: backgroundWorker.id,
|
||||
taskVersion: backgroundWorker.version,
|
||||
sdkVersion: backgroundWorker.sdkVersion,
|
||||
cliVersion: backgroundWorker.cliVersion,
|
||||
startedAt: existingTaskRun.startedAt ?? new Date(),
|
||||
startedAt,
|
||||
maxDurationInSeconds: getMaxDuration(
|
||||
existingTaskRun.maxDurationInSeconds,
|
||||
backgroundTask.maxDurationInSeconds
|
||||
|
||||
@@ -707,26 +707,32 @@ export class SharedQueueConsumer {
|
||||
};
|
||||
}
|
||||
|
||||
const lockedAt = new Date();
|
||||
const machinePreset =
|
||||
existingTaskRun.machinePreset ??
|
||||
machinePresetFromConfig(backgroundTask.machineConfig ?? {}).name;
|
||||
const maxDurationInSeconds = getMaxDuration(
|
||||
existingTaskRun.maxDurationInSeconds,
|
||||
backgroundTask.maxDurationInSeconds
|
||||
);
|
||||
const startedAt = existingTaskRun.startedAt ?? dequeuedAt;
|
||||
const baseCostInCents = env.CENTS_PER_RUN;
|
||||
|
||||
const lockedTaskRun = await prisma.taskRun.update({
|
||||
where: {
|
||||
id: message.messageId,
|
||||
},
|
||||
data: {
|
||||
lockedAt: new Date(),
|
||||
lockedAt,
|
||||
lockedById: backgroundTask.id,
|
||||
lockedToVersionId: worker.id,
|
||||
taskVersion: worker.version,
|
||||
sdkVersion: worker.sdkVersion,
|
||||
cliVersion: worker.cliVersion,
|
||||
startedAt: existingTaskRun.startedAt ?? dequeuedAt,
|
||||
baseCostInCents: env.CENTS_PER_RUN,
|
||||
machinePreset:
|
||||
existingTaskRun.machinePreset ??
|
||||
machinePresetFromConfig(backgroundTask.machineConfig ?? {}).name,
|
||||
maxDurationInSeconds: getMaxDuration(
|
||||
existingTaskRun.maxDurationInSeconds,
|
||||
backgroundTask.maxDurationInSeconds
|
||||
),
|
||||
startedAt: startedAt,
|
||||
baseCostInCents: baseCostInCents,
|
||||
machinePreset: machinePreset,
|
||||
maxDurationInSeconds,
|
||||
},
|
||||
include: {
|
||||
runtimeEnvironment: true,
|
||||
@@ -1430,7 +1436,7 @@ export class SharedQueueConsumer {
|
||||
async #markRunAsWaitingForDeploy(runId: string) {
|
||||
logger.debug("Marking run as waiting for deploy", { runId });
|
||||
|
||||
return await prisma.taskRun.update({
|
||||
const run = await prisma.taskRun.update({
|
||||
where: {
|
||||
id: runId,
|
||||
},
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
TaskRunFailedExecutionResult,
|
||||
TaskRunSuccessfulExecutionResult,
|
||||
flattenAttributes,
|
||||
isManualOutOfMemoryError,
|
||||
isOOMRunError,
|
||||
sanitizeError,
|
||||
shouldRetryError,
|
||||
@@ -26,14 +25,14 @@ import { safeJsonParse } from "~/utils/json";
|
||||
import { marqs } from "~/v3/marqs/index.server";
|
||||
import { createExceptionPropertiesFromError, eventRepository } from "../eventRepository.server";
|
||||
import { FailedTaskRunRetryHelper } from "../failedTaskRun.server";
|
||||
import { socketIo } from "../handleSocketIo.server";
|
||||
import { getTaskEventStoreTableForRun } from "../taskEventStore.server";
|
||||
import { FAILED_RUN_STATUSES, isFinalAttemptStatus, isFinalRunStatus } from "../taskStatus";
|
||||
import { BaseService } from "./baseService.server";
|
||||
import { CancelAttemptService } from "./cancelAttempt.server";
|
||||
import { CreateCheckpointService } from "./createCheckpoint.server";
|
||||
import { FinalizeTaskRunService } from "./finalizeTaskRun.server";
|
||||
import { RetryAttemptService } from "./retryAttempt.server";
|
||||
import { getTaskEventStoreTableForRun } from "../taskEventStore.server";
|
||||
import { socketIo } from "../handleSocketIo.server";
|
||||
|
||||
type FoundAttempt = Awaited<ReturnType<typeof findAttempt>>;
|
||||
|
||||
|
||||
@@ -2,16 +2,16 @@ import { parsePacket, TaskRunExecution } from "@trigger.dev/core/v3";
|
||||
import { TaskRun, TaskRunAttempt } from "@trigger.dev/database";
|
||||
import { MAX_TASK_RUN_ATTEMPTS } from "~/consts";
|
||||
import { $transaction, prisma, PrismaClientOrTransaction } from "~/db.server";
|
||||
import { findQueueInEnvironment } from "~/models/taskQueue.server";
|
||||
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { reportInvocationUsage } from "~/services/platform.v3.server";
|
||||
import { generateFriendlyId } from "../friendlyIdentifiers";
|
||||
import { machinePresetFromConfig, machinePresetFromRun } from "../machinePresets.server";
|
||||
import { FINAL_RUN_STATUSES } from "../taskStatus";
|
||||
import { BaseService, ServiceValidationError } from "./baseService.server";
|
||||
import { CrashTaskRunService } from "./crashTaskRun.server";
|
||||
import { ExpireEnqueuedRunService } from "./expireEnqueuedRun.server";
|
||||
import { findQueueInEnvironment } from "~/models/taskQueue.server";
|
||||
import { FINAL_RUN_STATUSES } from "../taskStatus";
|
||||
|
||||
export class CreateTaskRunAttemptService extends BaseService {
|
||||
public async call({
|
||||
@@ -159,6 +159,7 @@ export class CreateTaskRunAttemptService extends BaseService {
|
||||
data: {
|
||||
status: setToExecuting ? "EXECUTING" : undefined,
|
||||
executedAt: taskRun.executedAt ?? new Date(),
|
||||
attemptNumber: nextAttemptNumber,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { parseNaturalLanguageDuration } from "@trigger.dev/core/v3/isomorphic";
|
||||
import { $transaction } from "~/db.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { marqs } from "~/v3/marqs/index.server";
|
||||
import { BaseService } from "./baseService.server";
|
||||
import { ExpireEnqueuedRunService } from "./expireEnqueuedRun.server";
|
||||
import { commonWorker } from "../commonWorker.server";
|
||||
import { workerQueue } from "~/services/worker.server";
|
||||
import { commonWorker } from "../commonWorker.server";
|
||||
import { BaseService } from "./baseService.server";
|
||||
import { enqueueRun } from "./enqueueRun.server";
|
||||
import { ExpireEnqueuedRunService } from "./expireEnqueuedRun.server";
|
||||
|
||||
export class EnqueueDelayedRunService extends BaseService {
|
||||
public static async enqueue(runId: string, runAt?: Date) {
|
||||
@@ -82,26 +80,24 @@ export class EnqueueDelayedRunService extends BaseService {
|
||||
return;
|
||||
}
|
||||
|
||||
await $transaction(this._prisma, "delayed run enqueue", async (tx) => {
|
||||
await tx.taskRun.update({
|
||||
where: {
|
||||
id: run.id,
|
||||
},
|
||||
data: {
|
||||
status: "PENDING",
|
||||
queuedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
if (run.ttl) {
|
||||
const expireAt = parseNaturalLanguageDuration(run.ttl);
|
||||
|
||||
if (expireAt) {
|
||||
await ExpireEnqueuedRunService.enqueue(run.id, expireAt);
|
||||
}
|
||||
}
|
||||
await this._prisma.taskRun.update({
|
||||
where: {
|
||||
id: run.id,
|
||||
},
|
||||
data: {
|
||||
status: "PENDING",
|
||||
queuedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
if (run.ttl) {
|
||||
const expireAt = parseNaturalLanguageDuration(run.ttl);
|
||||
|
||||
if (expireAt) {
|
||||
await ExpireEnqueuedRunService.enqueue(run.id, expireAt);
|
||||
}
|
||||
}
|
||||
|
||||
await enqueueRun({
|
||||
env: run.runtimeEnvironment,
|
||||
run: run,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { PrismaClientOrTransaction } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { workerQueue } from "~/services/worker.server";
|
||||
import { marqs } from "~/v3/marqs/index.server";
|
||||
import { BaseService } from "./baseService.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { env } from "~/env.server";
|
||||
|
||||
export class ExecuteTasksWaitingForDeployService extends BaseService {
|
||||
public async call(backgroundWorkerId: string) {
|
||||
@@ -51,6 +51,8 @@ export class ExecuteTasksWaitingForDeployService extends BaseService {
|
||||
taskIdentifier: true,
|
||||
concurrencyKey: true,
|
||||
queue: true,
|
||||
updatedAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
take: maxCount + 1,
|
||||
});
|
||||
|
||||
@@ -94,9 +94,11 @@ export class FinalizeTaskRunService extends BaseService {
|
||||
// - A single update is more efficient than two
|
||||
// - If the status updates to a final status, realtime will receive that status and then shut down the stream
|
||||
// before the error is updated, which would cause the error to be lost
|
||||
const taskRunError = error ? sanitizeError(error) : undefined;
|
||||
|
||||
const run = await this._prisma.taskRun.update({
|
||||
where: { id },
|
||||
data: { status, expiredAt, completedAt, error: error ? sanitizeError(error) : undefined },
|
||||
data: { status, expiredAt, completedAt, error: taskRunError },
|
||||
...(include ? { include } : {}),
|
||||
});
|
||||
|
||||
|
||||
@@ -147,7 +147,11 @@ export class TriggerScheduledTaskService extends BaseService {
|
||||
instance.taskSchedule.taskIdentifier,
|
||||
instance.environment,
|
||||
{ payload: payloadPacket.data, options: { payloadType: payloadPacket.dataType } },
|
||||
{ customIcon: "scheduled" }
|
||||
{
|
||||
customIcon: "scheduled",
|
||||
scheduleId: instance.taskSchedule.id,
|
||||
scheduleInstanceId: instance.id,
|
||||
}
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
@@ -157,16 +161,6 @@ export class TriggerScheduledTaskService extends BaseService {
|
||||
payloadPacket,
|
||||
});
|
||||
} else {
|
||||
await this._prisma.taskRun.update({
|
||||
where: {
|
||||
id: result.run.id,
|
||||
},
|
||||
data: {
|
||||
scheduleId: instance.taskSchedule.id,
|
||||
scheduleInstanceId: instance.id,
|
||||
},
|
||||
});
|
||||
|
||||
await this._prisma.taskSchedule.update({
|
||||
where: {
|
||||
id: instance.taskSchedule.id,
|
||||
|
||||
@@ -29,6 +29,8 @@ export type TriggerTaskServiceOptions = {
|
||||
runFriendlyId?: string;
|
||||
skipChecks?: boolean;
|
||||
oneTimeUseToken?: string;
|
||||
scheduleId?: string;
|
||||
scheduleInstanceId?: string;
|
||||
};
|
||||
|
||||
export class OutOfEntitlementError extends Error {
|
||||
|
||||
@@ -375,6 +375,8 @@ export class TriggerTaskServiceV1 extends BaseService {
|
||||
number: num,
|
||||
friendlyId: runFriendlyId,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
environmentType: environment.type,
|
||||
organizationId: environment.organizationId,
|
||||
projectId: environment.projectId,
|
||||
idempotencyKey,
|
||||
idempotencyKeyExpiresAt: idempotencyKey ? idempotencyKeyExpiresAt : undefined,
|
||||
@@ -434,6 +436,8 @@ export class TriggerTaskServiceV1 extends BaseService {
|
||||
runTags: bodyTags,
|
||||
oneTimeUseToken: options.oneTimeUseToken,
|
||||
machinePreset: body.options?.machine,
|
||||
scheduleId: options.scheduleId,
|
||||
scheduleInstanceId: options.scheduleInstanceId,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -105,6 +105,7 @@
|
||||
"@trigger.dev/otlp-importer": "workspace:*",
|
||||
"@trigger.dev/platform": "1.0.14",
|
||||
"@trigger.dev/sdk": "workspace:*",
|
||||
"@internal/tracing": "workspace:*",
|
||||
"@types/pg": "8.6.6",
|
||||
"@uiw/react-codemirror": "^4.19.5",
|
||||
"@unkey/cache": "^1.5.0",
|
||||
@@ -146,6 +147,7 @@
|
||||
"ohash": "^1.1.3",
|
||||
"openai": "^4.33.1",
|
||||
"parse-duration": "^1.1.0",
|
||||
"p-limit": "^6.2.0",
|
||||
"posthog-js": "^1.93.3",
|
||||
"posthog-node": "^3.1.3",
|
||||
"prism-react-renderer": "^2.3.1",
|
||||
@@ -193,6 +195,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internal/testcontainers": "workspace:*",
|
||||
"@internal/replication": "workspace:*",
|
||||
"@internal/clickhouse": "workspace:*",
|
||||
"@remix-run/dev": "2.1.0",
|
||||
"@remix-run/eslint-config": "2.1.0",
|
||||
"@remix-run/testing": "^2.1.0",
|
||||
@@ -258,4 +262,4 @@
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ module.exports = {
|
||||
"superjson",
|
||||
"prismjs/components/prism-json",
|
||||
"prismjs/components/prism-typescript",
|
||||
"redlock",
|
||||
],
|
||||
browserNodeBuiltinsPolyfill: { modules: { path: true, os: true, crypto: true } },
|
||||
};
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
ValidationResult,
|
||||
} from "~/runEngine/types";
|
||||
import { RunEngineTriggerTaskService } from "../../app/runEngine/services/triggerTask.server";
|
||||
import { getEntitlement } from "~/services/platform.v3.server";
|
||||
|
||||
vi.setConfig({ testTimeout: 30_000 }); // 30 seconds timeout
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
|
||||
import { InMemorySpanExporter, SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base";
|
||||
import { context, trace } from "@opentelemetry/api";
|
||||
|
||||
export function createInMemoryTracing() {
|
||||
// Initialize the tracer provider and exporter
|
||||
const provider = new NodeTracerProvider();
|
||||
const exporter = new InMemorySpanExporter();
|
||||
provider.addSpanProcessor(new SimpleSpanProcessor(exporter));
|
||||
provider.register();
|
||||
|
||||
// Retrieve the tracer
|
||||
const tracer = trace.getTracer("test-tracer");
|
||||
|
||||
return {
|
||||
exporter,
|
||||
tracer,
|
||||
};
|
||||
}
|
||||
@@ -3,14 +3,14 @@
|
||||
"include": ["remix.env.d.ts", "global.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"compilerOptions": {
|
||||
"types": ["vitest/globals"],
|
||||
"lib": ["DOM", "DOM.Iterable", "DOM.AsyncIterable", "ES2019"],
|
||||
"lib": ["DOM", "DOM.Iterable", "DOM.AsyncIterable", "ES2020"],
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"jsx": "react-jsx",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"target": "ES2019",
|
||||
"target": "ES2020",
|
||||
"strict": true,
|
||||
"allowJs": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
ARG NODE_IMAGE=node:20.11.1-bullseye-slim@sha256:5a5a92b3a8d392691c983719dbdc65d9f30085d6dcd65376e7a32e6fe9bf4cbe
|
||||
|
||||
FROM golang:1.23-alpine AS goose_builder
|
||||
RUN go install github.com/pressly/goose/v3/cmd/goose@latest
|
||||
|
||||
FROM ${NODE_IMAGE} AS pruner
|
||||
|
||||
WORKDIR /triggerdotdev
|
||||
@@ -43,6 +46,11 @@ WORKDIR /triggerdotdev
|
||||
# Corepack is used to install pnpm
|
||||
RUN corepack enable
|
||||
|
||||
# Goose and schemas
|
||||
COPY --from=goose_builder /go/bin/goose /usr/local/bin/goose
|
||||
RUN chmod +x /usr/local/bin/goose
|
||||
COPY --chown=node:node internal-packages/clickhouse/schema /triggerdotdev/internal-packages/clickhouse/schema
|
||||
|
||||
COPY --from=pruner --chown=node:node /triggerdotdev/out/full/ .
|
||||
COPY --from=dev-deps --chown=node:node /triggerdotdev/ .
|
||||
COPY --chown=node:node turbo.json turbo.json
|
||||
@@ -70,6 +78,10 @@ COPY --from=builder --chown=node:node /triggerdotdev/apps/webapp/public ./apps/w
|
||||
COPY --from=builder --chown=node:node /triggerdotdev/apps/webapp/prisma/seed.js ./apps/webapp/prisma/seed.js
|
||||
COPY --from=builder --chown=node:node /triggerdotdev/scripts ./scripts
|
||||
|
||||
# Goose and schemas
|
||||
COPY --from=builder /usr/local/bin/goose /usr/local/bin/goose
|
||||
COPY --from=builder --chown=node:node /triggerdotdev/internal-packages/clickhouse/schema /triggerdotdev/internal-packages/clickhouse/schema
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
USER node
|
||||
|
||||
+95
-25
@@ -3,6 +3,7 @@ version: "3"
|
||||
volumes:
|
||||
database-data:
|
||||
redis-data:
|
||||
clickhouse:
|
||||
|
||||
networks:
|
||||
app_network:
|
||||
@@ -10,8 +11,10 @@ networks:
|
||||
|
||||
services:
|
||||
db:
|
||||
container_name: devdb
|
||||
image: postgres:14
|
||||
container_name: db-dev
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.postgres
|
||||
restart: always
|
||||
volumes:
|
||||
- database-data:/var/lib/postgresql/data/
|
||||
@@ -23,29 +26,63 @@ services:
|
||||
- app_network
|
||||
ports:
|
||||
- 5432:5432
|
||||
app:
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: ./docker/Dockerfile
|
||||
ports:
|
||||
- 3030:3030
|
||||
depends_on:
|
||||
- db
|
||||
env_file:
|
||||
- ../.env
|
||||
command:
|
||||
- -c
|
||||
- listen_addresses=*
|
||||
- -c
|
||||
- wal_level=logical
|
||||
- -c
|
||||
- shared_preload_libraries=pg_partman_bgw
|
||||
|
||||
electric:
|
||||
container_name: electric-dev
|
||||
image: electricsql/electric:1.0.0-beta.15@sha256:4ae0f895753b82684aa31ea1c708e9e86d0a9bca355acb7270dcb24062520810
|
||||
restart: always
|
||||
environment:
|
||||
DATABASE_URL: postgres://postgres:postgres@db:5432/postgres?schema=public
|
||||
DIRECT_URL: postgres://postgres:postgres@db:5432/postgres?schema=public
|
||||
SESSION_SECRET: secret123
|
||||
MAGIC_LINK_SECRET: secret123
|
||||
ENCRYPTION_KEY: secret123
|
||||
REMIX_APP_PORT: 3030
|
||||
PORT: 3030
|
||||
DATABASE_URL: postgresql://postgres:postgres@db:5432/postgres?sslmode=disable
|
||||
networks:
|
||||
- app_network
|
||||
ports:
|
||||
- "3060:3000"
|
||||
depends_on:
|
||||
- db
|
||||
|
||||
clickhouse:
|
||||
image: bitnami/clickhouse:latest
|
||||
container_name: clickhouse-dev
|
||||
environment:
|
||||
CLICKHOUSE_ADMIN_USER: default
|
||||
CLICKHOUSE_ADMIN_PASSWORD: password
|
||||
ports:
|
||||
- "8123:8123"
|
||||
- "9000:9000"
|
||||
volumes:
|
||||
- clickhouse:/bitnami/clickhouse
|
||||
networks:
|
||||
- app_network
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"clickhouse-client",
|
||||
"--host",
|
||||
"localhost",
|
||||
"--port",
|
||||
"9000",
|
||||
"--user",
|
||||
"default",
|
||||
"--password",
|
||||
"password",
|
||||
"--query",
|
||||
"SELECT 1",
|
||||
]
|
||||
interval: 3s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
redis:
|
||||
container_name: redis
|
||||
container_name: redis-dev
|
||||
image: redis:7
|
||||
restart: always
|
||||
volumes:
|
||||
@@ -55,9 +92,42 @@ services:
|
||||
ports:
|
||||
- 6379:6379
|
||||
|
||||
redisinsight:
|
||||
image: redislabs/redisinsight:latest
|
||||
app:
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: ./docker/Dockerfile
|
||||
ports:
|
||||
- "8001:8001"
|
||||
volumes:
|
||||
- redis-data:/redisinsight
|
||||
- 3030:3030
|
||||
depends_on:
|
||||
- db
|
||||
- electric
|
||||
- clickhouse
|
||||
- redis
|
||||
env_file:
|
||||
- ../.env
|
||||
environment:
|
||||
DATABASE_URL: postgres://postgres:postgres@db:5432/postgres?schema=public
|
||||
DIRECT_URL: postgres://postgres:postgres@db:5432/postgres?schema=public
|
||||
CLICKHOUSE_URL: http://default:password@clickhouse:8123
|
||||
SESSION_SECRET: secret123
|
||||
MAGIC_LINK_SECRET: secret123
|
||||
ENCRYPTION_KEY: secret123
|
||||
REMIX_APP_PORT: 3030
|
||||
PORT: 3030
|
||||
networks:
|
||||
- app_network
|
||||
|
||||
ch-ui:
|
||||
image: ghcr.io/caioricciuti/ch-ui:latest
|
||||
container_name: ch-ui-dev
|
||||
restart: always
|
||||
ports:
|
||||
- "5521:5521"
|
||||
environment:
|
||||
VITE_CLICKHOUSE_URL: "http://clickhouse:8123"
|
||||
VITE_CLICKHOUSE_USER: "default"
|
||||
VITE_CLICKHOUSE_PASS: "password"
|
||||
depends_on:
|
||||
- clickhouse
|
||||
networks:
|
||||
- app_network
|
||||
|
||||
+58
-93
@@ -5,12 +5,7 @@ volumes:
|
||||
database-data-alt:
|
||||
pgadmin-data:
|
||||
redis-data:
|
||||
redis-cluster_data-0:
|
||||
redis-cluster_data-1:
|
||||
redis-cluster_data-2:
|
||||
redis-cluster_data-3:
|
||||
redis-cluster_data-4:
|
||||
redis-cluster_data-5:
|
||||
clickhouse:
|
||||
|
||||
networks:
|
||||
app_network:
|
||||
@@ -52,93 +47,6 @@ services:
|
||||
ports:
|
||||
- 6379:6379
|
||||
|
||||
# redis-node-0:
|
||||
# image: docker.io/bitnami/redis-cluster:7.0
|
||||
# container_name: redis-node-0
|
||||
# networks:
|
||||
# - app_network
|
||||
# ports:
|
||||
# - "6378:6379"
|
||||
# volumes:
|
||||
# - redis-cluster_data-0:/bitnami/redis/data
|
||||
# environment:
|
||||
# - "REDIS_PASSWORD=bitnami"
|
||||
# - "REDIS_NODES=redis-node-0 redis-node-1 redis-node-2 redis-node-3 redis-node-4 redis-node-5"
|
||||
|
||||
# redis-node-1:
|
||||
# image: docker.io/bitnami/redis-cluster:7.0
|
||||
# container_name: redis-node-1
|
||||
# networks:
|
||||
# - app_network
|
||||
# ports:
|
||||
# - "6380:6379"
|
||||
# volumes:
|
||||
# - redis-cluster_data-1:/bitnami/redis/data
|
||||
# environment:
|
||||
# - "REDIS_PASSWORD=bitnami"
|
||||
# - "REDIS_NODES=redis-node-0 redis-node-1 redis-node-2 redis-node-3 redis-node-4 redis-node-5"
|
||||
|
||||
# redis-node-2:
|
||||
# image: docker.io/bitnami/redis-cluster:7.0
|
||||
# container_name: redis-node-2
|
||||
# networks:
|
||||
# - app_network
|
||||
# ports:
|
||||
# - "6381:6379"
|
||||
# volumes:
|
||||
# - redis-cluster_data-2:/bitnami/redis/data
|
||||
# environment:
|
||||
# - "REDIS_PASSWORD=bitnami"
|
||||
# - "REDIS_NODES=redis-node-0 redis-node-1 redis-node-2 redis-node-3 redis-node-4 redis-node-5"
|
||||
|
||||
# redis-node-3:
|
||||
# image: docker.io/bitnami/redis-cluster:7.0
|
||||
# container_name: redis-node-3
|
||||
# networks:
|
||||
# - app_network
|
||||
# ports:
|
||||
# - "6382:6379"
|
||||
# volumes:
|
||||
# - redis-cluster_data-3:/bitnami/redis/data
|
||||
# environment:
|
||||
# - "REDIS_PASSWORD=bitnami"
|
||||
# - "REDIS_NODES=redis-node-0 redis-node-1 redis-node-2 redis-node-3 redis-node-4 redis-node-5"
|
||||
|
||||
# redis-node-4:
|
||||
# image: docker.io/bitnami/redis-cluster:7.0
|
||||
# container_name: redis-node-4
|
||||
# networks:
|
||||
# - app_network
|
||||
# ports:
|
||||
# - "6383:6379"
|
||||
# volumes:
|
||||
# - redis-cluster_data-4:/bitnami/redis/data
|
||||
# environment:
|
||||
# - "REDIS_PASSWORD=bitnami"
|
||||
# - "REDIS_NODES=redis-node-0 redis-node-1 redis-node-2 redis-node-3 redis-node-4 redis-node-5"
|
||||
|
||||
# redis-node-5:
|
||||
# image: docker.io/bitnami/redis-cluster:7.0
|
||||
# container_name: redis-node-5
|
||||
# networks:
|
||||
# - app_network
|
||||
# ports:
|
||||
# - "6384:6379"
|
||||
# volumes:
|
||||
# - redis-cluster_data-5:/bitnami/redis/data
|
||||
# depends_on:
|
||||
# - redis-node-0
|
||||
# - redis-node-1
|
||||
# - redis-node-2
|
||||
# - redis-node-3
|
||||
# - redis-node-4
|
||||
# environment:
|
||||
# - "REDIS_PASSWORD=bitnami"
|
||||
# - "REDISCLI_AUTH=bitnami"
|
||||
# - "REDIS_CLUSTER_REPLICAS=1"
|
||||
# - "REDIS_NODES=redis-node-0 redis-node-1 redis-node-2 redis-node-3 redis-node-4 redis-node-5"
|
||||
# - "REDIS_CLUSTER_CREATOR=yes"
|
||||
|
||||
electric:
|
||||
container_name: electric
|
||||
image: electricsql/electric:1.0.0-beta.15@sha256:4ae0f895753b82684aa31ea1c708e9e86d0a9bca355acb7270dcb24062520810
|
||||
@@ -152,6 +60,63 @@ services:
|
||||
depends_on:
|
||||
- database
|
||||
|
||||
clickhouse:
|
||||
image: bitnami/clickhouse:latest
|
||||
container_name: clickhouse
|
||||
environment:
|
||||
CLICKHOUSE_ADMIN_USER: default
|
||||
CLICKHOUSE_ADMIN_PASSWORD: password
|
||||
ports:
|
||||
- "8123:8123"
|
||||
- "9000:9000"
|
||||
volumes:
|
||||
- clickhouse:/bitnami/clickhouse
|
||||
networks:
|
||||
- app_network
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"clickhouse-client",
|
||||
"--host",
|
||||
"localhost",
|
||||
"--port",
|
||||
"9000",
|
||||
"--user",
|
||||
"default",
|
||||
"--password",
|
||||
"password",
|
||||
"--query",
|
||||
"SELECT 1",
|
||||
]
|
||||
interval: 3s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
clickhouse_migrator:
|
||||
build:
|
||||
context: ../internal-packages/clickhouse
|
||||
dockerfile: ./Dockerfile
|
||||
depends_on:
|
||||
clickhouse:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- app_network
|
||||
command: ["goose", "${GOOSE_COMMAND:-up}"]
|
||||
|
||||
ch-ui:
|
||||
image: ghcr.io/caioricciuti/ch-ui:latest
|
||||
restart: always
|
||||
ports:
|
||||
- "5521:5521"
|
||||
environment:
|
||||
VITE_CLICKHOUSE_URL: "http://clickhouse:8123"
|
||||
VITE_CLICKHOUSE_USER: "default"
|
||||
VITE_CLICKHOUSE_PASS: "password"
|
||||
networks:
|
||||
- app_network
|
||||
|
||||
# otel-collector:
|
||||
# container_name: otel-collector
|
||||
# image: otel/opentelemetry-collector-contrib:latest
|
||||
|
||||
@@ -6,7 +6,21 @@ if [ -n "$DATABASE_HOST" ]; then
|
||||
fi
|
||||
|
||||
# Run migrations
|
||||
echo "Running prisma migrations"
|
||||
pnpm --filter @trigger.dev/database db:migrate:deploy
|
||||
echo "Prisma migrations done"
|
||||
|
||||
if [ -n "$CLICKHOUSE_URL" ]; then
|
||||
# Run ClickHouse migrations
|
||||
echo "Running ClickHouse migrations..."
|
||||
export GOOSE_DRIVER=clickhouse
|
||||
export GOOSE_DBSTRING="$CLICKHOUSE_URL" # Use the full URL provided by the env var
|
||||
export GOOSE_MIGRATION_DIR=/triggerdotdev/internal-packages/clickhouse/schema
|
||||
/usr/local/bin/goose up
|
||||
echo "ClickHouse migrations complete."
|
||||
else
|
||||
echo "CLICKHOUSE_URL not set, skipping ClickHouse migrations."
|
||||
fi
|
||||
|
||||
# Copy over required prisma files
|
||||
cp internal-packages/database/prisma/schema.prisma apps/webapp/prisma/
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
FROM golang
|
||||
|
||||
|
||||
RUN go install github.com/pressly/goose/v3/cmd/goose@latest
|
||||
|
||||
|
||||
COPY ./schema ./schema
|
||||
|
||||
ENV GOOSE_DRIVER=clickhouse
|
||||
ENV GOOSE_DBSTRING="tcp://default:password@clickhouse:9000"
|
||||
ENV GOOSE_MIGRATION_DIR=./schema
|
||||
CMD ["goose", "up"]
|
||||
@@ -0,0 +1,64 @@
|
||||
# ClickHouse Table Naming Conventions
|
||||
|
||||
The following document is heavily inspired by the [Unkey](https://unkey.dev) ClickHouse naming conventions.
|
||||
|
||||
This document outlines the naming conventions for tables and materialized views in our ClickHouse setup. Adhering to these conventions ensures consistency, clarity, and ease of management across our data infrastructure.
|
||||
|
||||
## General Rules
|
||||
|
||||
1. Use lowercase letters and separate words with underscores.
|
||||
2. Avoid ClickHouse reserved words and special characters in names.
|
||||
3. Be descriptive but concise.
|
||||
|
||||
## Table Naming Convention
|
||||
|
||||
Format: `[prefix]_[domain]_[description]_[version]`
|
||||
|
||||
### Prefixes
|
||||
|
||||
- `raw_`: Input data tables
|
||||
- `tmp_{yourname}_`: Temporary tables for experiments, add your name, so it's easy to identify ownership.
|
||||
|
||||
### Versioning
|
||||
|
||||
- Version numbers: `_v1`, `_v2`, etc.
|
||||
|
||||
### Aggregation Suffixes
|
||||
|
||||
For aggregated or summary tables, use suffixes like:
|
||||
|
||||
- `_per_day`
|
||||
- `_per_month`
|
||||
- `_summary`
|
||||
|
||||
## Materialized View Naming Convention
|
||||
|
||||
Format: `[description]_[aggregation]_mv_[version]`
|
||||
|
||||
- Always suffix with `mv_[version]`
|
||||
- Include a description of the view's purpose
|
||||
- Add aggregation level if applicable
|
||||
|
||||
## Examples
|
||||
|
||||
1. Raw Data Table:
|
||||
`raw_sales_transactions_v1`
|
||||
|
||||
2. Materialized View:
|
||||
`active_users_per_day_mv_v2`
|
||||
|
||||
3. Temporary Table:
|
||||
`tmp_eric_user_analysis_v1`
|
||||
|
||||
4. Aggregated Table:
|
||||
`sales_summary_per_hour_mv_v1`
|
||||
|
||||
## Consistency Across Related Objects
|
||||
|
||||
Maintain consistent naming across related tables, views, and other objects:
|
||||
|
||||
- `raw_user_activity_v1`
|
||||
- `user_activity_per_day_v1`
|
||||
- `user_activity_per_day_mv_v1`
|
||||
|
||||
By following these conventions, we ensure a clear, consistent, and scalable naming structure for our ClickHouse setup.
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@internal/clickhouse",
|
||||
"private": true,
|
||||
"version": "0.0.2",
|
||||
"main": "./dist/src/index.js",
|
||||
"types": "./dist/src/index.d.ts",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@clickhouse/client": "^1.11.1",
|
||||
"@internal/tracing": "workspace:*",
|
||||
"@trigger.dev/core": "workspace:*",
|
||||
"zod": "3.23.8",
|
||||
"zod-error": "1.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internal/testcontainers": "workspace:*",
|
||||
"@vitest/coverage-v8": "^3.0.8",
|
||||
"rimraf": "6.0.1",
|
||||
"vitest": "^3.0.8"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rimraf dist",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.build.json",
|
||||
"build": "pnpm run clean && tsc -p tsconfig.build.json",
|
||||
"dev": "tsc --watch -p tsconfig.build.json",
|
||||
"db:migrate": "docker compose -p triggerdotdev-docker -f ../../docker/docker-compose.yml up clickhouse_migrator --build",
|
||||
"db:migrate:down": "GOOSE_COMMAND=down pnpm run db:migrate",
|
||||
"test": "vitest --sequence.concurrent=false --no-file-parallelism",
|
||||
"test:coverage": "vitest --sequence.concurrent=false --no-file-parallelism --coverage.enabled"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
-- +goose up
|
||||
|
||||
CREATE DATABASE trigger_dev;
|
||||
|
||||
-- +goose down
|
||||
DROP DATABASE trigger_dev;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE IF NOT EXISTS trigger_dev.smoke_test (
|
||||
id UUID DEFAULT generateUUIDv4(),
|
||||
timestamp DateTime64(3) DEFAULT now64(3),
|
||||
message String,
|
||||
number UInt32
|
||||
) ENGINE = MergeTree()
|
||||
ORDER BY (timestamp, id);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS trigger_dev.smoke_test;
|
||||
@@ -0,0 +1,100 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE trigger_dev.task_runs_v1
|
||||
(
|
||||
/* ─── ids & hierarchy ─────────────────────────────────────── */
|
||||
environment_id String,
|
||||
organization_id String,
|
||||
project_id String,
|
||||
run_id String,
|
||||
|
||||
environment_type LowCardinality(String),
|
||||
friendly_id String,
|
||||
attempt UInt8 DEFAULT 1,
|
||||
|
||||
/* ─── enums / status ──────────────────────────────────────── */
|
||||
engine LowCardinality(String),
|
||||
status LowCardinality(String),
|
||||
|
||||
/* ─── queue / concurrency / schedule ─────────────────────── */
|
||||
task_identifier String,
|
||||
queue String,
|
||||
|
||||
schedule_id String,
|
||||
batch_id String,
|
||||
|
||||
/* ─── related runs ─────────────────────────────────────────────── */
|
||||
root_run_id String,
|
||||
parent_run_id String,
|
||||
depth UInt8 DEFAULT 0,
|
||||
|
||||
/* ─── telemetry ─────────────────────────────────────────────── */
|
||||
span_id String,
|
||||
trace_id String,
|
||||
idempotency_key String,
|
||||
|
||||
/* ─── timing ─────────────────────────────────────────────── */
|
||||
created_at DateTime64(3),
|
||||
updated_at DateTime64(3),
|
||||
started_at Nullable(DateTime64(3)),
|
||||
executed_at Nullable(DateTime64(3)),
|
||||
completed_at Nullable(DateTime64(3)),
|
||||
delay_until Nullable(DateTime64(3)),
|
||||
queued_at Nullable(DateTime64(3)),
|
||||
expired_at Nullable(DateTime64(3)),
|
||||
expiration_ttl String,
|
||||
|
||||
/* ─── cost / usage ───────────────────────────────────────── */
|
||||
usage_duration_ms UInt32 DEFAULT 0,
|
||||
cost_in_cents Float64 DEFAULT 0,
|
||||
base_cost_in_cents Float64 DEFAULT 0,
|
||||
|
||||
/* ─── payload & context ──────────────────────────────────── */
|
||||
output JSON(max_dynamic_paths = 1024),
|
||||
error JSON(max_dynamic_paths = 64),
|
||||
|
||||
/* ─── tagging / versions ─────────────────────────────────── */
|
||||
tags Array(String) CODEC(ZSTD(1)),
|
||||
task_version String CODEC(LZ4),
|
||||
sdk_version String CODEC(LZ4),
|
||||
cli_version String CODEC(LZ4),
|
||||
machine_preset LowCardinality(String) CODEC(LZ4),
|
||||
|
||||
is_test UInt8 DEFAULT 0,
|
||||
|
||||
/* ─── commit lsn ─────────────────────────────────────────────── */
|
||||
_version UInt64,
|
||||
_is_deleted UInt8 DEFAULT 0
|
||||
)
|
||||
ENGINE = ReplacingMergeTree(_version, _is_deleted)
|
||||
PARTITION BY toYYYYMM(created_at)
|
||||
ORDER BY (toDate(created_at), environment_id, task_identifier, created_at, run_id)
|
||||
SETTINGS enable_json_type = 1;
|
||||
|
||||
/* Fast tag filtering */
|
||||
ALTER TABLE trigger_dev.task_runs_v1
|
||||
ADD INDEX idx_tags tags TYPE tokenbf_v1(32768, 3, 0) GRANULARITY 4;
|
||||
|
||||
CREATE TABLE trigger_dev.raw_task_runs_payload_v1
|
||||
(
|
||||
run_id String,
|
||||
created_at DateTime64(3),
|
||||
payload JSON(max_dynamic_paths = 1024)
|
||||
)
|
||||
ENGINE = MergeTree
|
||||
PARTITION BY toYYYYMM(created_at)
|
||||
ORDER BY (run_id)
|
||||
SETTINGS enable_json_type = 1;
|
||||
|
||||
CREATE VIEW trigger_dev.tmp_eric_task_runs_full_v1 AS
|
||||
SELECT
|
||||
s.*,
|
||||
p.payload as payload
|
||||
FROM trigger_dev.task_runs_v1 AS s FINAL
|
||||
LEFT JOIN trigger_dev.raw_task_runs_payload_v1 AS p ON s.run_id = p.run_id
|
||||
SETTINGS enable_json_type = 1;
|
||||
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS trigger_dev.task_runs_v1;
|
||||
DROP TABLE IF EXISTS trigger_dev.raw_task_runs_payload_v1;
|
||||
DROP VIEW IF EXISTS trigger_dev.tmp_eric_task_runs_full_v1;
|
||||
@@ -0,0 +1,149 @@
|
||||
import { clickhouseTest } from "@internal/testcontainers";
|
||||
import { ClickhouseClient } from "./client.js";
|
||||
import { z } from "zod";
|
||||
import { setTimeout } from "timers/promises";
|
||||
|
||||
describe("ClickHouse Client", () => {
|
||||
clickhouseTest("should be able to insert and query data", async ({ clickhouseContainer }) => {
|
||||
const client = new ClickhouseClient({
|
||||
name: "test",
|
||||
url: clickhouseContainer.getConnectionUrl(),
|
||||
});
|
||||
|
||||
const insertSmokeTest = client.insert({
|
||||
name: "insert-smoke-test",
|
||||
table: "trigger_dev.smoke_test",
|
||||
schema: z.object({
|
||||
message: z.string(),
|
||||
number: z.number(),
|
||||
}),
|
||||
});
|
||||
|
||||
const querySmokeTest = client.query({
|
||||
name: "query-smoke-test",
|
||||
query: "SELECT * FROM trigger_dev.smoke_test",
|
||||
schema: z.object({
|
||||
message: z.string(),
|
||||
number: z.number(),
|
||||
timestamp: z.string(),
|
||||
id: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
const [insertError, insertResult] = await insertSmokeTest([
|
||||
{ message: "hello", number: 42 },
|
||||
{ message: "world", number: 100 },
|
||||
]);
|
||||
|
||||
expect(insertError).toBeNull();
|
||||
expect(insertResult).toEqual(
|
||||
expect.objectContaining({
|
||||
executed: true,
|
||||
query_id: expect.any(String),
|
||||
summary: expect.objectContaining({ read_rows: "2", elapsed_ns: expect.any(String) }),
|
||||
})
|
||||
);
|
||||
|
||||
const [queryError, result] = await querySmokeTest({});
|
||||
|
||||
expect(queryError).toBeNull();
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
message: "hello",
|
||||
number: 42,
|
||||
timestamp: expect.any(String),
|
||||
id: expect.any(String),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
message: "world",
|
||||
number: 100,
|
||||
timestamp: expect.any(String),
|
||||
id: expect.any(String),
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
const insertSmokeTestAsyncWaiting = client.insert({
|
||||
name: "insert-smoke-test-async-waiting",
|
||||
table: "trigger_dev.smoke_test",
|
||||
schema: z.object({
|
||||
message: z.string(),
|
||||
number: z.number(),
|
||||
}),
|
||||
settings: {
|
||||
async_insert: 1,
|
||||
wait_for_async_insert: 1,
|
||||
async_insert_busy_timeout_ms: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
const [insertErrorAsyncWaiting, insertResultAsyncWaiting] = await insertSmokeTestAsyncWaiting([
|
||||
{ message: "async-waiting-hello", number: 42 },
|
||||
{ message: "async-waiting-world", number: 100 },
|
||||
]);
|
||||
|
||||
expect(insertErrorAsyncWaiting).toBeNull();
|
||||
expect(insertResultAsyncWaiting).toEqual(expect.objectContaining({ executed: true }));
|
||||
|
||||
// Should be able to query for the data right away
|
||||
const [queryErrorAsyncWaiting, resultAsyncWaiting] = await querySmokeTest({});
|
||||
|
||||
expect(queryErrorAsyncWaiting).toBeNull();
|
||||
expect(resultAsyncWaiting).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ message: "async-waiting-hello", number: 42 }),
|
||||
expect.objectContaining({ message: "async-waiting-world", number: 100 }),
|
||||
])
|
||||
);
|
||||
|
||||
const insertSmokeTestAsyncDontWait = client.insert({
|
||||
name: "insert-smoke-test-async-dont-wait",
|
||||
table: "trigger_dev.smoke_test",
|
||||
schema: z.object({
|
||||
message: z.string(),
|
||||
number: z.number(),
|
||||
}),
|
||||
settings: {
|
||||
async_insert: 1,
|
||||
wait_for_async_insert: 0,
|
||||
async_insert_busy_timeout_ms: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
const [insertErrorAsyncDontWait, insertResultAsyncDontWait] =
|
||||
await insertSmokeTestAsyncDontWait([
|
||||
{ message: "async-dont-wait-hello", number: 42 },
|
||||
{ message: "async-dont-wait-world", number: 100 },
|
||||
]);
|
||||
|
||||
expect(insertErrorAsyncDontWait).toBeNull();
|
||||
expect(insertResultAsyncDontWait).toEqual(expect.objectContaining({ executed: true }));
|
||||
|
||||
// Querying now should return an array without the data
|
||||
const [queryErrorAsyncDontWait, resultAsyncDontWait] = await querySmokeTest({});
|
||||
|
||||
expect(queryErrorAsyncDontWait).toBeNull();
|
||||
expect(resultAsyncDontWait).toEqual(
|
||||
expect.not.arrayContaining([
|
||||
expect.objectContaining({ message: "async-dont-wait-hello", number: 42 }),
|
||||
expect.objectContaining({ message: "async-dont-wait-world", number: 100 }),
|
||||
])
|
||||
);
|
||||
|
||||
// Now we wait for the data to be flushed
|
||||
await setTimeout(2000);
|
||||
|
||||
// Querying now should return the data
|
||||
const [queryErrorAsyncDontWait2, resultAsyncDontWait2] = await querySmokeTest({});
|
||||
|
||||
expect(queryErrorAsyncDontWait2).toBeNull();
|
||||
expect(resultAsyncDontWait2).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ message: "async-dont-wait-hello", number: 42 }),
|
||||
expect.objectContaining({ message: "async-dont-wait-world", number: 100 }),
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
import {
|
||||
type ClickHouseClient,
|
||||
ClickHouseError,
|
||||
type ClickHouseSettings,
|
||||
createClient,
|
||||
} from "@clickhouse/client";
|
||||
import { recordSpanError, Span, startSpan, trace, Tracer } from "@internal/tracing";
|
||||
import { flattenAttributes, tryCatch } from "@trigger.dev/core/v3";
|
||||
import { z } from "zod";
|
||||
import { InsertError, QueryError } from "./errors.js";
|
||||
import type {
|
||||
ClickhouseInsertFunction,
|
||||
ClickhouseQueryFunction,
|
||||
ClickhouseReader,
|
||||
ClickhouseWriter,
|
||||
} from "./types.js";
|
||||
import { generateErrorMessage } from "zod-error";
|
||||
import { Logger } from "@trigger.dev/core/logger";
|
||||
|
||||
export type ClickhouseConfig = {
|
||||
name: string;
|
||||
url: string;
|
||||
tracer?: Tracer;
|
||||
clickhouseSettings?: ClickHouseSettings;
|
||||
logger?: Logger;
|
||||
};
|
||||
|
||||
export class ClickhouseClient implements ClickhouseReader, ClickhouseWriter {
|
||||
public readonly client: ClickHouseClient;
|
||||
private readonly tracer: Tracer;
|
||||
private readonly name: string;
|
||||
private readonly logger: Logger;
|
||||
|
||||
constructor(config: ClickhouseConfig) {
|
||||
this.name = config.name;
|
||||
this.logger = config.logger ?? new Logger("ClickhouseClient", "debug");
|
||||
|
||||
this.client = createClient({
|
||||
url: config.url,
|
||||
|
||||
clickhouse_settings: {
|
||||
...config.clickhouseSettings,
|
||||
output_format_json_quote_64bit_integers: 0,
|
||||
output_format_json_quote_64bit_floats: 0,
|
||||
},
|
||||
});
|
||||
|
||||
this.tracer = config.tracer ?? trace.getTracer("@internal/clickhouse");
|
||||
}
|
||||
|
||||
public async close() {
|
||||
await this.client.close();
|
||||
}
|
||||
|
||||
public query<TIn extends z.ZodSchema<any>, TOut extends z.ZodSchema<any>>(req: {
|
||||
/**
|
||||
* The name of the operation.
|
||||
* This will be used to identify the operation in the span.
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* The SQL query to run.
|
||||
* Use {paramName: Type} to define parameters
|
||||
* Example: `SELECT * FROM table WHERE id = {id: String}`
|
||||
*/
|
||||
query: string;
|
||||
/**
|
||||
* The schema of the parameters
|
||||
* Example: z.object({ id: z.string() })
|
||||
*/
|
||||
params?: TIn;
|
||||
/**
|
||||
* The schema of the output of each row
|
||||
* Example: z.object({ id: z.string() })
|
||||
*/
|
||||
schema: TOut;
|
||||
/**
|
||||
* The settings to use for the query.
|
||||
* These will be merged with the default settings.
|
||||
*/
|
||||
settings?: ClickHouseSettings;
|
||||
}): ClickhouseQueryFunction<z.input<TIn>, z.output<TOut>> {
|
||||
return async (params, options) => {
|
||||
return await startSpan(this.tracer, "query", async (span) => {
|
||||
span.setAttributes({
|
||||
"clickhouse.clientName": this.name,
|
||||
"clickhouse.operationName": req.name,
|
||||
...flattenAttributes(req.settings, "clickhouse.settings"),
|
||||
...flattenAttributes(options?.attributes),
|
||||
});
|
||||
|
||||
const validParams = req.params?.safeParse(params);
|
||||
|
||||
if (validParams?.error) {
|
||||
recordSpanError(span, validParams.error);
|
||||
|
||||
this.logger.error("Error parsing query params", {
|
||||
name: req.name,
|
||||
error: validParams.error,
|
||||
query: req.query,
|
||||
params,
|
||||
});
|
||||
|
||||
return [
|
||||
new QueryError(`Bad params: ${generateErrorMessage(validParams.error.issues)}`, {
|
||||
query: req.query,
|
||||
}),
|
||||
null,
|
||||
];
|
||||
}
|
||||
|
||||
let unparsedRows: Array<TOut> = [];
|
||||
|
||||
const [clickhouseError, res] = await tryCatch(
|
||||
this.client.query({
|
||||
query: req.query,
|
||||
query_params: validParams?.data,
|
||||
format: "JSONEachRow",
|
||||
...options?.params,
|
||||
clickhouse_settings: {
|
||||
...req.settings,
|
||||
...options?.params?.clickhouse_settings,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
if (clickhouseError) {
|
||||
this.logger.error("Error querying clickhouse", {
|
||||
name: req.name,
|
||||
error: clickhouseError,
|
||||
query: req.query,
|
||||
params,
|
||||
});
|
||||
|
||||
recordClickhouseError(span, clickhouseError);
|
||||
|
||||
return [
|
||||
new QueryError(`Unable to query clickhouse: ${clickhouseError.message}`, {
|
||||
query: req.query,
|
||||
}),
|
||||
null,
|
||||
];
|
||||
}
|
||||
|
||||
unparsedRows = await res.json();
|
||||
|
||||
span.setAttributes({
|
||||
"clickhouse.query_id": res.query_id,
|
||||
...flattenAttributes(res.response_headers, "clickhouse.response_headers"),
|
||||
});
|
||||
|
||||
const summaryHeader = res.response_headers["x-clickhouse-summary"];
|
||||
|
||||
if (typeof summaryHeader === "string") {
|
||||
span.setAttributes({
|
||||
...flattenAttributes(JSON.parse(summaryHeader), "clickhouse.summary"),
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = z.array(req.schema).safeParse(unparsedRows);
|
||||
|
||||
if (parsed.error) {
|
||||
this.logger.error("Error parsing clickhouse query result", {
|
||||
name: req.name,
|
||||
error: parsed.error,
|
||||
query: req.query,
|
||||
params,
|
||||
});
|
||||
|
||||
const queryError = new QueryError(generateErrorMessage(parsed.error.issues), {
|
||||
query: req.query,
|
||||
});
|
||||
|
||||
recordSpanError(span, queryError);
|
||||
|
||||
return [queryError, null];
|
||||
}
|
||||
|
||||
span.setAttributes({
|
||||
"clickhouse.rows": unparsedRows.length,
|
||||
});
|
||||
|
||||
return [null, parsed.data];
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
public insert<TSchema extends z.ZodSchema<any>>(req: {
|
||||
name: string;
|
||||
table: string;
|
||||
schema: TSchema;
|
||||
settings?: ClickHouseSettings;
|
||||
}): ClickhouseInsertFunction<z.input<TSchema>> {
|
||||
return async (events, options) => {
|
||||
return await startSpan(this.tracer, "insert", async (span) => {
|
||||
span.setAttributes({
|
||||
"clickhouse.clientName": this.name,
|
||||
"clickhouse.tableName": req.table,
|
||||
"clickhouse.operationName": req.name,
|
||||
...flattenAttributes(req.settings, "clickhouse.settings"),
|
||||
...flattenAttributes(options?.attributes),
|
||||
});
|
||||
|
||||
let validatedEvents: z.output<TSchema> | z.output<TSchema>[] | undefined = undefined;
|
||||
|
||||
const v = Array.isArray(events)
|
||||
? req.schema.array().safeParse(events)
|
||||
: req.schema.safeParse(events);
|
||||
|
||||
if (!v.success) {
|
||||
this.logger.error("Error validating insert events", {
|
||||
name: req.name,
|
||||
table: req.table,
|
||||
error: v.error,
|
||||
});
|
||||
|
||||
const error = new InsertError(generateErrorMessage(v.error.issues));
|
||||
|
||||
recordSpanError(span, error);
|
||||
|
||||
return [error, null];
|
||||
}
|
||||
|
||||
validatedEvents = v.data;
|
||||
|
||||
const [clickhouseError, result] = await tryCatch(
|
||||
this.client.insert({
|
||||
table: req.table,
|
||||
format: "JSONEachRow",
|
||||
values: Array.isArray(validatedEvents) ? validatedEvents : [validatedEvents],
|
||||
...options?.params,
|
||||
clickhouse_settings: {
|
||||
...req.settings,
|
||||
...options?.params?.clickhouse_settings,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
if (clickhouseError) {
|
||||
this.logger.error("Error inserting into clickhouse", {
|
||||
name: req.name,
|
||||
error: clickhouseError,
|
||||
table: req.table,
|
||||
});
|
||||
|
||||
recordClickhouseError(span, clickhouseError);
|
||||
|
||||
return [new InsertError(clickhouseError.message), null];
|
||||
}
|
||||
|
||||
span.setAttributes({
|
||||
"clickhouse.query_id": result.query_id,
|
||||
"clickhouse.executed": result.executed,
|
||||
"clickhouse.summary.read_rows": result.summary?.read_rows,
|
||||
"clickhouse.summary.read_bytes": result.summary?.read_bytes,
|
||||
"clickhouse.summary.written_rows": result.summary?.written_rows,
|
||||
"clickhouse.summary.written_bytes": result.summary?.written_bytes,
|
||||
"clickhouse.summary.total_rows_to_read": result.summary?.total_rows_to_read,
|
||||
"clickhouse.summary.result_rows": result.summary?.result_rows,
|
||||
"clickhouse.summary.result_bytes": result.summary?.result_bytes,
|
||||
"clickhouse.summary.elapsed_ns": result.summary?.elapsed_ns,
|
||||
});
|
||||
|
||||
return [null, result];
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function recordClickhouseError(span: Span, error: Error) {
|
||||
if (error instanceof ClickHouseError) {
|
||||
span.setAttributes({
|
||||
"clickhouse.error.code": error.code,
|
||||
"clickhouse.error.message": error.message,
|
||||
"clickhouse.error.type": error.type,
|
||||
});
|
||||
recordSpanError(span, error);
|
||||
} else {
|
||||
recordSpanError(span, error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
export type ErrorContext = Record<string, unknown>;
|
||||
|
||||
export abstract class BaseError<TContext extends ErrorContext = ErrorContext> extends Error {
|
||||
public abstract readonly retry: boolean;
|
||||
public readonly cause: BaseError | undefined;
|
||||
public readonly context: TContext | undefined;
|
||||
public readonly message: string;
|
||||
public abstract readonly name: string;
|
||||
|
||||
constructor(opts: { message: string; cause?: BaseError; context?: TContext }) {
|
||||
super(opts.message);
|
||||
this.message = opts.message;
|
||||
this.cause = opts.cause;
|
||||
this.context = opts.context;
|
||||
}
|
||||
|
||||
public toString(): string {
|
||||
return `${this.name}: ${this.message} - ${JSON.stringify(
|
||||
this.context
|
||||
)} - caused by ${this.cause?.toString()}`;
|
||||
}
|
||||
}
|
||||
|
||||
export class InsertError extends BaseError {
|
||||
public readonly retry = true;
|
||||
public readonly name = InsertError.name;
|
||||
constructor(message: string) {
|
||||
super({
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
export class QueryError extends BaseError<{ query: string }> {
|
||||
public readonly retry = true;
|
||||
public readonly name = QueryError.name;
|
||||
constructor(message: string, context: { query: string }) {
|
||||
super({
|
||||
message,
|
||||
context,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Result } from "@trigger.dev/core/v3";
|
||||
import { InsertError, QueryError } from "./errors.js";
|
||||
import { ClickhouseWriter } from "./types.js";
|
||||
import { ClickhouseReader } from "./types.js";
|
||||
import { z } from "zod";
|
||||
import { ClickHouseSettings, InsertResult } from "@clickhouse/client";
|
||||
|
||||
export class NoopClient implements ClickhouseReader, ClickhouseWriter {
|
||||
public async close() {
|
||||
return;
|
||||
}
|
||||
|
||||
public query<TIn extends z.ZodSchema<any>, TOut extends z.ZodSchema<any>>(req: {
|
||||
query: string;
|
||||
params?: TIn;
|
||||
schema: TOut;
|
||||
}): (params: z.input<TIn>) => Promise<Result<z.output<TOut>[], QueryError>> {
|
||||
return async (params: z.input<TIn>) => {
|
||||
const validParams = req.params?.safeParse(params);
|
||||
|
||||
if (validParams?.error) {
|
||||
return [new QueryError(`Bad params: ${validParams.error.message}`, { query: "" }), null];
|
||||
}
|
||||
|
||||
return [null, []];
|
||||
};
|
||||
}
|
||||
|
||||
public insert<TSchema extends z.ZodSchema<any>>(req: {
|
||||
name: string;
|
||||
table: string;
|
||||
schema: TSchema;
|
||||
settings?: ClickHouseSettings;
|
||||
}): (
|
||||
events: z.input<TSchema> | z.input<TSchema>[]
|
||||
) => Promise<Result<InsertResult, InsertError>> {
|
||||
return async (events: z.input<TSchema> | z.input<TSchema>[]) => {
|
||||
const v = Array.isArray(events)
|
||||
? req.schema.array().safeParse(events)
|
||||
: req.schema.safeParse(events);
|
||||
|
||||
if (!v.success) {
|
||||
return [new InsertError(v.error.message), null];
|
||||
}
|
||||
|
||||
return [
|
||||
null,
|
||||
{
|
||||
executed: true,
|
||||
query_id: "noop",
|
||||
summary: {
|
||||
read_rows: "0",
|
||||
read_bytes: "0",
|
||||
written_rows: "0",
|
||||
written_bytes: "0",
|
||||
total_rows_to_read: "0",
|
||||
result_rows: "0",
|
||||
result_bytes: "0",
|
||||
elapsed_ns: "0",
|
||||
},
|
||||
response_headers: {},
|
||||
},
|
||||
];
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Result } from "@trigger.dev/core/v3";
|
||||
import type { z } from "zod";
|
||||
import type { InsertError, QueryError } from "./errors.js";
|
||||
import { ClickHouseSettings } from "@clickhouse/client";
|
||||
import type { BaseQueryParams, InsertResult } from "@clickhouse/client";
|
||||
|
||||
export type ClickhouseQueryFunction<TInput, TOutput> = (
|
||||
params: TInput,
|
||||
options?: {
|
||||
attributes?: Record<string, string | number | boolean>;
|
||||
params?: BaseQueryParams;
|
||||
}
|
||||
) => Promise<Result<TOutput[], QueryError>>;
|
||||
|
||||
export interface ClickhouseReader {
|
||||
query<TIn extends z.ZodSchema<any>, TOut extends z.ZodSchema<any>>(req: {
|
||||
/**
|
||||
* The name of the operation.
|
||||
* This will be used to identify the operation in the span.
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* The SQL query to run.
|
||||
* Use {paramName: Type} to define parameters
|
||||
* Example: `SELECT * FROM table WHERE id = {id: String}`
|
||||
*/
|
||||
query: string;
|
||||
/**
|
||||
* The schema of the parameters
|
||||
* Example: z.object({ id: z.string() })
|
||||
*/
|
||||
params?: TIn;
|
||||
/**
|
||||
* The schema of the output of each row
|
||||
* Example: z.object({ id: z.string() })
|
||||
*/
|
||||
schema: TOut;
|
||||
/**
|
||||
* The settings to use for the query.
|
||||
* These will be merged with the default settings.
|
||||
*/
|
||||
settings?: ClickHouseSettings;
|
||||
}): ClickhouseQueryFunction<z.input<TIn>, z.output<TOut>>;
|
||||
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export type ClickhouseInsertFunction<TInput> = (
|
||||
events: TInput | TInput[],
|
||||
options?: {
|
||||
attributes?: Record<string, string | number | boolean>;
|
||||
params?: BaseQueryParams;
|
||||
}
|
||||
) => Promise<Result<InsertResult, InsertError>>;
|
||||
|
||||
export interface ClickhouseWriter {
|
||||
insert<TSchema extends z.ZodSchema<any>>(req: {
|
||||
name: string;
|
||||
table: string;
|
||||
schema: TSchema;
|
||||
settings?: ClickHouseSettings;
|
||||
}): ClickhouseInsertFunction<z.input<TSchema>>;
|
||||
|
||||
close(): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { ClickHouseSettings } from "@clickhouse/client";
|
||||
import { ClickhouseClient } from "./client/client.js";
|
||||
import { ClickhouseReader, ClickhouseWriter } from "./client/types.js";
|
||||
import { NoopClient } from "./client/noop.js";
|
||||
import { insertTaskRuns, insertRawTaskRunPayloads } from "./taskRuns.js";
|
||||
import { Logger } from "@trigger.dev/core/logger";
|
||||
|
||||
export type * from "./taskRuns.js";
|
||||
|
||||
export type ClickHouseConfig =
|
||||
| {
|
||||
name?: string;
|
||||
url?: string;
|
||||
writerUrl?: never;
|
||||
readerUrl?: never;
|
||||
clickhouseSettings?: ClickHouseSettings;
|
||||
logger?: Logger;
|
||||
}
|
||||
| {
|
||||
name?: never;
|
||||
url?: never;
|
||||
writerName?: string;
|
||||
writerUrl: string;
|
||||
readerName?: string;
|
||||
readerUrl: string;
|
||||
clickhouseSettings?: ClickHouseSettings;
|
||||
logger?: Logger;
|
||||
};
|
||||
|
||||
export class ClickHouse {
|
||||
public readonly reader: ClickhouseReader;
|
||||
public readonly writer: ClickhouseWriter;
|
||||
private readonly logger: Logger;
|
||||
private _splitClients: boolean;
|
||||
|
||||
constructor(config: ClickHouseConfig) {
|
||||
this.logger = config.logger ?? new Logger("ClickHouse", "debug");
|
||||
|
||||
if (config.url) {
|
||||
const url = new URL(config.url);
|
||||
url.password = "redacted";
|
||||
|
||||
this.logger.info("🏠 Initializing ClickHouse client with url", { url: url.toString() });
|
||||
|
||||
const client = new ClickhouseClient({
|
||||
name: config.name ?? "clickhouse",
|
||||
url: config.url,
|
||||
clickhouseSettings: config.clickhouseSettings,
|
||||
logger: this.logger,
|
||||
});
|
||||
this.reader = client;
|
||||
this.writer = client;
|
||||
|
||||
this._splitClients = false;
|
||||
} else if (config.writerUrl && config.readerUrl) {
|
||||
this.reader = new ClickhouseClient({
|
||||
name: config.readerName ?? "clickhouse-reader",
|
||||
url: config.readerUrl,
|
||||
clickhouseSettings: config.clickhouseSettings,
|
||||
logger: this.logger,
|
||||
});
|
||||
this.writer = new ClickhouseClient({
|
||||
name: config.writerName ?? "clickhouse-writer",
|
||||
url: config.writerUrl,
|
||||
clickhouseSettings: config.clickhouseSettings,
|
||||
logger: this.logger,
|
||||
});
|
||||
|
||||
this._splitClients = true;
|
||||
} else {
|
||||
this.reader = new NoopClient();
|
||||
this.writer = new NoopClient();
|
||||
|
||||
this._splitClients = true;
|
||||
}
|
||||
}
|
||||
|
||||
static fromEnv(): ClickHouse {
|
||||
if (
|
||||
typeof process.env.CLICKHOUSE_WRITER_URL === "string" &&
|
||||
typeof process.env.CLICKHOUSE_READER_URL === "string"
|
||||
) {
|
||||
return new ClickHouse({
|
||||
writerUrl: process.env.CLICKHOUSE_WRITER_URL,
|
||||
readerUrl: process.env.CLICKHOUSE_READER_URL,
|
||||
writerName: process.env.CLICKHOUSE_WRITER_NAME,
|
||||
readerName: process.env.CLICKHOUSE_READER_NAME,
|
||||
});
|
||||
}
|
||||
|
||||
return new ClickHouse({
|
||||
url: process.env.CLICKHOUSE_URL,
|
||||
name: process.env.CLICKHOUSE_NAME,
|
||||
});
|
||||
}
|
||||
|
||||
async close() {
|
||||
if (this._splitClients) {
|
||||
await Promise.all([this.reader.close(), this.writer.close()]);
|
||||
} else {
|
||||
await this.reader.close();
|
||||
}
|
||||
}
|
||||
|
||||
get taskRuns() {
|
||||
return {
|
||||
insert: insertTaskRuns(this.writer),
|
||||
insertPayloads: insertRawTaskRunPayloads(this.writer),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import { clickhouseTest } from "@internal/testcontainers";
|
||||
import { z } from "zod";
|
||||
import { ClickhouseClient } from "./client/client.js";
|
||||
import { insertRawTaskRunPayloads, insertTaskRuns } from "./taskRuns.js";
|
||||
|
||||
describe("Task Runs V1", () => {
|
||||
clickhouseTest("should be able to insert task runs", async ({ clickhouseContainer }) => {
|
||||
const client = new ClickhouseClient({
|
||||
name: "test",
|
||||
url: clickhouseContainer.getConnectionUrl(),
|
||||
});
|
||||
|
||||
const insert = insertTaskRuns(client, {
|
||||
async_insert: 0, // turn off async insert for this test
|
||||
});
|
||||
|
||||
const insertPayloads = insertRawTaskRunPayloads(client, {
|
||||
async_insert: 0, // turn off async insert for this test
|
||||
});
|
||||
|
||||
const [insertError, insertResult] = await insert([
|
||||
{
|
||||
environment_id: "env_1234",
|
||||
environment_type: "DEVELOPMENT",
|
||||
organization_id: "org_1234",
|
||||
project_id: "project_1234",
|
||||
run_id: "run_1234",
|
||||
friendly_id: "friendly_1234",
|
||||
attempt: 1,
|
||||
engine: "V2",
|
||||
status: "PENDING",
|
||||
task_identifier: "my-task",
|
||||
queue: "my-queue",
|
||||
schedule_id: "schedule_1234",
|
||||
batch_id: "batch_1234",
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
completed_at: undefined,
|
||||
tags: ["tag1", "tag2"],
|
||||
output: {
|
||||
key: "value",
|
||||
},
|
||||
error: {
|
||||
type: "BUILT_IN_ERROR",
|
||||
name: "Error",
|
||||
message: "error",
|
||||
stackTrace: "stack trace",
|
||||
},
|
||||
usage_duration_ms: 1000,
|
||||
cost_in_cents: 100,
|
||||
task_version: "1.0.0",
|
||||
sdk_version: "1.0.0",
|
||||
cli_version: "1.0.0",
|
||||
machine_preset: "small-1x",
|
||||
is_test: true,
|
||||
span_id: "span_1234",
|
||||
trace_id: "trace_1234",
|
||||
idempotency_key: "idempotency_key_1234",
|
||||
expiration_ttl: "1h",
|
||||
root_run_id: "root_run_1234",
|
||||
parent_run_id: "parent_run_1234",
|
||||
depth: 1,
|
||||
_version: "1",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(insertError).toBeNull();
|
||||
expect(insertResult).toEqual(expect.objectContaining({ executed: true }));
|
||||
expect(insertResult?.summary?.written_rows).toEqual("1");
|
||||
|
||||
const query = client.query({
|
||||
name: "query-task-runs",
|
||||
query: "SELECT * FROM trigger_dev.task_runs_v1",
|
||||
schema: z.object({
|
||||
environment_id: z.string(),
|
||||
run_id: z.string(),
|
||||
}),
|
||||
params: z.object({
|
||||
run_id: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
const [queryError, result] = await query({ run_id: "run_1234" });
|
||||
|
||||
expect(queryError).toBeNull();
|
||||
expect(result).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
environment_id: "env_1234",
|
||||
run_id: "run_1234",
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
const [insertPayloadsError, insertPayloadsResult] = await insertPayloads([
|
||||
{
|
||||
run_id: "run_1234",
|
||||
created_at: Date.now(),
|
||||
payload: {
|
||||
key: "value",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(insertPayloadsError).toBeNull();
|
||||
expect(insertPayloadsResult).toEqual(expect.objectContaining({ executed: true }));
|
||||
expect(insertPayloadsResult?.summary?.written_rows).toEqual("1");
|
||||
|
||||
const queryPayloads = client.query({
|
||||
name: "query-raw-task-run-payloads",
|
||||
query: "SELECT * FROM trigger_dev.raw_task_runs_payload_v1",
|
||||
schema: z.object({
|
||||
run_id: z.string(),
|
||||
created_at: z.coerce.date(),
|
||||
payload: z.unknown(),
|
||||
}),
|
||||
});
|
||||
|
||||
const [queryPayloadsError, resultPayloads] = await queryPayloads({ run_id: "run_1234" });
|
||||
|
||||
expect(queryPayloadsError).toBeNull();
|
||||
expect(resultPayloads).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ run_id: "run_1234" })])
|
||||
);
|
||||
});
|
||||
|
||||
clickhouseTest("should deduplicate on the _version column", async ({ clickhouseContainer }) => {
|
||||
const client = new ClickhouseClient({
|
||||
name: "test",
|
||||
url: clickhouseContainer.getConnectionUrl(),
|
||||
});
|
||||
|
||||
const insert = insertTaskRuns(client, {
|
||||
async_insert: 0, // turn off async insert for this test
|
||||
});
|
||||
|
||||
const [insertError, insertResult] = await insert([
|
||||
{
|
||||
environment_id: "cm9kddfcs01zqdy88ld9mmrli",
|
||||
organization_id: "cm8zs78wb0002dy616dg75tv3",
|
||||
project_id: "cm9kddfbz01zpdy88t9dstecu",
|
||||
run_id: "cma45oli70002qrdy47w0j4n7",
|
||||
environment_type: "PRODUCTION",
|
||||
friendly_id: "run_cma45oli70002qrdy47w0j4n7",
|
||||
attempt: 1,
|
||||
engine: "V2",
|
||||
status: "PENDING",
|
||||
task_identifier: "retry-task",
|
||||
queue: "task/retry-task",
|
||||
schedule_id: "",
|
||||
batch_id: "",
|
||||
root_run_id: "",
|
||||
parent_run_id: "",
|
||||
depth: 0,
|
||||
span_id: "538677637f937f54",
|
||||
trace_id: "20a28486b0b9f50c647b35e8863e36a5",
|
||||
idempotency_key: "",
|
||||
created_at: new Date("2025-04-30 16:34:04.312").getTime(),
|
||||
updated_at: new Date("2025-04-30 16:34:04.312").getTime(),
|
||||
started_at: null,
|
||||
executed_at: null,
|
||||
completed_at: null,
|
||||
delay_until: null,
|
||||
queued_at: new Date("2025-04-30 16:34:04.311").getTime(),
|
||||
expired_at: null,
|
||||
expiration_ttl: "",
|
||||
usage_duration_ms: 0,
|
||||
cost_in_cents: 0,
|
||||
base_cost_in_cents: 0,
|
||||
output: null,
|
||||
error: null,
|
||||
tags: [],
|
||||
task_version: "",
|
||||
sdk_version: "",
|
||||
cli_version: "",
|
||||
machine_preset: "",
|
||||
is_test: true,
|
||||
_version: "1",
|
||||
},
|
||||
{
|
||||
environment_id: "cm9kddfcs01zqdy88ld9mmrli",
|
||||
organization_id: "cm8zs78wb0002dy616dg75tv3",
|
||||
project_id: "cm9kddfbz01zpdy88t9dstecu",
|
||||
run_id: "cma45oli70002qrdy47w0j4n7",
|
||||
environment_type: "PRODUCTION",
|
||||
friendly_id: "run_cma45oli70002qrdy47w0j4n7",
|
||||
attempt: 1,
|
||||
engine: "V2",
|
||||
status: "COMPLETED_SUCCESSFULLY",
|
||||
task_identifier: "retry-task",
|
||||
queue: "task/retry-task",
|
||||
schedule_id: "",
|
||||
batch_id: "",
|
||||
root_run_id: "",
|
||||
parent_run_id: "",
|
||||
depth: 0,
|
||||
span_id: "538677637f937f54",
|
||||
trace_id: "20a28486b0b9f50c647b35e8863e36a5",
|
||||
idempotency_key: "",
|
||||
created_at: new Date("2025-04-30 16:34:04.312").getTime(),
|
||||
updated_at: new Date("2025-04-30 16:34:04.312").getTime(),
|
||||
started_at: null,
|
||||
executed_at: null,
|
||||
completed_at: null,
|
||||
delay_until: null,
|
||||
queued_at: new Date("2025-04-30 16:34:04.311").getTime(),
|
||||
expired_at: null,
|
||||
expiration_ttl: "",
|
||||
usage_duration_ms: 0,
|
||||
cost_in_cents: 0,
|
||||
base_cost_in_cents: 0,
|
||||
output: null,
|
||||
error: null,
|
||||
tags: [],
|
||||
task_version: "",
|
||||
sdk_version: "",
|
||||
cli_version: "",
|
||||
machine_preset: "",
|
||||
is_test: true,
|
||||
_version: "2",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(insertError).toBeNull();
|
||||
expect(insertResult).toEqual(expect.objectContaining({ executed: true }));
|
||||
|
||||
const query = client.query({
|
||||
name: "query-task-runs",
|
||||
query: "SELECT * FROM trigger_dev.task_runs_v1 FINAL",
|
||||
schema: z.object({
|
||||
environment_id: z.string(),
|
||||
run_id: z.string(),
|
||||
status: z.string(),
|
||||
}),
|
||||
params: z.object({
|
||||
run_id: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
const [queryError, result] = await query({ run_id: "cma45oli70002qrdy47w0j4n7" });
|
||||
|
||||
expect(queryError).toBeNull();
|
||||
expect(result).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
environment_id: "cm9kddfcs01zqdy88ld9mmrli",
|
||||
run_id: "cma45oli70002qrdy47w0j4n7",
|
||||
status: "COMPLETED_SUCCESSFULLY",
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { ClickHouseSettings } from "@clickhouse/client";
|
||||
import { z } from "zod";
|
||||
import { ClickhouseWriter } from "./client/types.js";
|
||||
|
||||
export const TaskRunV1 = z.object({
|
||||
environment_id: z.string(),
|
||||
organization_id: z.string(),
|
||||
project_id: z.string(),
|
||||
run_id: z.string(),
|
||||
updated_at: z.number().int(),
|
||||
created_at: z.number().int(),
|
||||
status: z.string(),
|
||||
environment_type: z.string(),
|
||||
friendly_id: z.string(),
|
||||
attempt: z.number().int().default(1),
|
||||
engine: z.string(),
|
||||
task_identifier: z.string(),
|
||||
queue: z.string(),
|
||||
schedule_id: z.string(),
|
||||
batch_id: z.string(),
|
||||
completed_at: z.number().int().nullish(),
|
||||
started_at: z.number().int().nullish(),
|
||||
executed_at: z.number().int().nullish(),
|
||||
delay_until: z.number().int().nullish(),
|
||||
queued_at: z.number().int().nullish(),
|
||||
expired_at: z.number().int().nullish(),
|
||||
usage_duration_ms: z.number().int().default(0),
|
||||
cost_in_cents: z.number().default(0),
|
||||
base_cost_in_cents: z.number().default(0),
|
||||
output: z.unknown(),
|
||||
error: z.unknown(),
|
||||
tags: z.array(z.string()).default([]),
|
||||
task_version: z.string(),
|
||||
sdk_version: z.string(),
|
||||
cli_version: z.string(),
|
||||
machine_preset: z.string(),
|
||||
root_run_id: z.string(),
|
||||
parent_run_id: z.string(),
|
||||
depth: z.number().int().default(0),
|
||||
span_id: z.string(),
|
||||
trace_id: z.string(),
|
||||
idempotency_key: z.string(),
|
||||
expiration_ttl: z.string(),
|
||||
is_test: z.boolean().default(false),
|
||||
_version: z.string(),
|
||||
_is_deleted: z.number().int().default(0),
|
||||
});
|
||||
|
||||
export type TaskRunV1 = z.input<typeof TaskRunV1>;
|
||||
|
||||
export function insertTaskRuns(ch: ClickhouseWriter, settings?: ClickHouseSettings) {
|
||||
return ch.insert({
|
||||
name: "insertTaskRuns",
|
||||
table: "trigger_dev.task_runs_v1",
|
||||
schema: TaskRunV1,
|
||||
settings: {
|
||||
async_insert: 1,
|
||||
wait_for_async_insert: 0,
|
||||
async_insert_max_data_size: "1000000",
|
||||
async_insert_busy_timeout_ms: 1000,
|
||||
enable_json_type: 1,
|
||||
...settings,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const RawTaskRunPayloadV1 = z.object({
|
||||
run_id: z.string(),
|
||||
created_at: z.number().int(),
|
||||
payload: z.unknown(),
|
||||
});
|
||||
|
||||
export type RawTaskRunPayloadV1 = z.infer<typeof RawTaskRunPayloadV1>;
|
||||
|
||||
export function insertRawTaskRunPayloads(ch: ClickhouseWriter, settings?: ClickHouseSettings) {
|
||||
return ch.insert({
|
||||
name: "insertRawTaskRunPayloads",
|
||||
table: "trigger_dev.raw_task_runs_payload_v1",
|
||||
schema: RawTaskRunPayloadV1,
|
||||
settings: {
|
||||
async_insert: 1,
|
||||
wait_for_async_insert: 0,
|
||||
async_insert_max_data_size: "1000000",
|
||||
async_insert_busy_timeout_ms: 1000,
|
||||
enable_json_type: 1,
|
||||
...settings,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"target": "ES2019",
|
||||
"lib": ["ES2019", "DOM", "DOM.Iterable", "DOM.AsyncIterable"],
|
||||
"outDir": "dist",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"moduleDetection": "force",
|
||||
"verbatimModuleSyntax": false,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"preserveWatchOutput": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"declaration": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"references": [{ "path": "./tsconfig.src.json" }, { "path": "./tsconfig.test.json" }],
|
||||
"compilerOptions": {
|
||||
"moduleResolution": "Node16",
|
||||
"module": "Node16",
|
||||
"customConditions": ["@triggerdotdev/source"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "src/**/*.test.ts"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"target": "ES2019",
|
||||
"lib": ["ES2019", "DOM", "DOM.Iterable", "DOM.AsyncIterable"],
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"moduleDetection": "force",
|
||||
"verbatimModuleSyntax": false,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"preserveWatchOutput": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"customConditions": ["@triggerdotdev/source"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"include": ["src/**/*.test.ts", "vitest.config.ts"],
|
||||
"references": [{ "path": "./tsconfig.src.json" }],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"target": "ES2019",
|
||||
"lib": ["ES2019", "DOM", "DOM.Iterable", "DOM.AsyncIterable"],
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"moduleDetection": "force",
|
||||
"verbatimModuleSyntax": false,
|
||||
"types": ["vitest/globals"],
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"preserveWatchOutput": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"customConditions": ["@triggerdotdev/source"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["**/*.test.ts"],
|
||||
globals: true,
|
||||
isolate: true,
|
||||
fileParallelism: false,
|
||||
poolOptions: {
|
||||
threads: {
|
||||
singleThread: true,
|
||||
},
|
||||
},
|
||||
testTimeout: 60_000,
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
},
|
||||
},
|
||||
});
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "TaskRun" ADD COLUMN "environmentType" "RuntimeEnvironmentType",
|
||||
ADD COLUMN "organizationId" TEXT;
|
||||
|
||||
@@ -1731,9 +1731,13 @@ model TaskRun {
|
||||
runtimeEnvironment RuntimeEnvironment @relation(fields: [runtimeEnvironmentId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||
runtimeEnvironmentId String
|
||||
|
||||
environmentType RuntimeEnvironmentType?
|
||||
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||
projectId String
|
||||
|
||||
organizationId String?
|
||||
|
||||
// The specific queue this run is in
|
||||
queue String
|
||||
// The queueId is set when the run is locked to a specific queue
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Replication
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@internal/replication",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"main": "./dist/src/index.js",
|
||||
"types": "./dist/src/index.d.ts",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@internal/redis": "workspace:*",
|
||||
"@internal/tracing": "workspace:*",
|
||||
"@trigger.dev/core": "workspace:*",
|
||||
"pg": "8.15.6",
|
||||
"redlock": "5.0.0-beta.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internal/testcontainers": "workspace:*",
|
||||
"@vitest/coverage-v8": "^3.0.8",
|
||||
"rimraf": "6.0.1",
|
||||
"vitest": "^3.0.8",
|
||||
"@types/pg": "8.11.14"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rimraf dist",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.build.json",
|
||||
"build": "pnpm run clean && tsc -p tsconfig.build.json",
|
||||
"dev": "tsc --watch -p tsconfig.build.json",
|
||||
"test": "vitest --sequence.concurrent=false --no-file-parallelism",
|
||||
"test:coverage": "vitest --sequence.concurrent=false --no-file-parallelism --coverage.enabled"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { postgresAndRedisTest } from "@internal/testcontainers";
|
||||
import { LogicalReplicationClient } from "./client.js";
|
||||
import { setTimeout } from "timers/promises";
|
||||
|
||||
describe("Replication Client", () => {
|
||||
postgresAndRedisTest(
|
||||
"should be able to subscribe to changes on a table",
|
||||
async ({ postgresContainer, prisma, redisOptions }) => {
|
||||
await prisma.$executeRawUnsafe(`ALTER TABLE public."TaskRun" REPLICA IDENTITY FULL;`);
|
||||
|
||||
const client = new LogicalReplicationClient({
|
||||
name: "test",
|
||||
slotName: "test_slot",
|
||||
publicationName: "test_publication",
|
||||
redisOptions,
|
||||
table: "TaskRun",
|
||||
pgConfig: {
|
||||
connectionString: postgresContainer.getConnectionUri(),
|
||||
},
|
||||
});
|
||||
|
||||
const logs: Array<{
|
||||
lsn: string;
|
||||
log: unknown;
|
||||
}> = [];
|
||||
|
||||
client.events.on("data", (data) => {
|
||||
console.log(data);
|
||||
logs.push(data);
|
||||
});
|
||||
|
||||
client.events.on("error", (error) => {
|
||||
console.error(error);
|
||||
});
|
||||
|
||||
await client.subscribe();
|
||||
|
||||
const organization = await prisma.organization.create({
|
||||
data: {
|
||||
title: "test",
|
||||
slug: "test",
|
||||
},
|
||||
});
|
||||
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
name: "test",
|
||||
slug: "test",
|
||||
organizationId: organization.id,
|
||||
externalRef: "test",
|
||||
},
|
||||
});
|
||||
|
||||
const runtimeEnvironment = await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
slug: "test",
|
||||
type: "DEVELOPMENT",
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
apiKey: "test",
|
||||
pkApiKey: "test",
|
||||
shortcode: "test",
|
||||
},
|
||||
});
|
||||
|
||||
// Now we insert a row into the table
|
||||
await prisma.taskRun.create({
|
||||
data: {
|
||||
friendlyId: "run_1234",
|
||||
taskIdentifier: "my-task",
|
||||
payload: JSON.stringify({ foo: "bar" }),
|
||||
traceId: "1234",
|
||||
spanId: "1234",
|
||||
queue: "test",
|
||||
runtimeEnvironmentId: runtimeEnvironment.id,
|
||||
projectId: project.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Wait for a bit of time
|
||||
await setTimeout(50);
|
||||
|
||||
// Now we should see the row in the logs
|
||||
expect(logs.length).toBeGreaterThan(0);
|
||||
|
||||
await client.stop();
|
||||
}
|
||||
);
|
||||
|
||||
postgresAndRedisTest(
|
||||
"should be able to teardown",
|
||||
async ({ postgresContainer, prisma, redisOptions }) => {
|
||||
await prisma.$executeRawUnsafe(`ALTER TABLE public."TaskRun" REPLICA IDENTITY FULL;`);
|
||||
|
||||
const client = new LogicalReplicationClient({
|
||||
name: "test",
|
||||
slotName: "test_slot",
|
||||
publicationName: "test_publication",
|
||||
redisOptions,
|
||||
table: "TaskRun",
|
||||
pgConfig: {
|
||||
connectionString: postgresContainer.getConnectionUri(),
|
||||
},
|
||||
});
|
||||
|
||||
const logs: Array<{
|
||||
lsn: string;
|
||||
log: unknown;
|
||||
}> = [];
|
||||
|
||||
client.events.on("data", (data) => {
|
||||
console.log(data);
|
||||
logs.push(data);
|
||||
});
|
||||
|
||||
client.events.on("error", (error) => {
|
||||
console.error(error);
|
||||
});
|
||||
|
||||
await client.subscribe();
|
||||
|
||||
const organization = await prisma.organization.create({
|
||||
data: {
|
||||
title: "test",
|
||||
slug: "test",
|
||||
},
|
||||
});
|
||||
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
name: "test",
|
||||
slug: "test",
|
||||
organizationId: organization.id,
|
||||
externalRef: "test",
|
||||
},
|
||||
});
|
||||
|
||||
const runtimeEnvironment = await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
slug: "test",
|
||||
type: "DEVELOPMENT",
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
apiKey: "test",
|
||||
pkApiKey: "test",
|
||||
shortcode: "test",
|
||||
},
|
||||
});
|
||||
|
||||
// Now we insert a row into the table
|
||||
await prisma.taskRun.create({
|
||||
data: {
|
||||
friendlyId: "run_1234",
|
||||
taskIdentifier: "my-task",
|
||||
payload: JSON.stringify({ foo: "bar" }),
|
||||
traceId: "1234",
|
||||
spanId: "1234",
|
||||
queue: "test",
|
||||
runtimeEnvironmentId: runtimeEnvironment.id,
|
||||
projectId: project.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Wait for a bit of time
|
||||
await setTimeout(50);
|
||||
|
||||
// Now we should see the row in the logs
|
||||
expect(logs.length).toBeGreaterThan(0);
|
||||
|
||||
const slotDropped = await client.teardown();
|
||||
|
||||
expect(slotDropped).toBe(true);
|
||||
|
||||
// Now the replication slot should be gone
|
||||
const slotExists = await prisma.$queryRaw<
|
||||
{ exists: boolean }[]
|
||||
>`SELECT EXISTS (SELECT 1 FROM pg_replication_slots WHERE slot_name = 'test_slot');`;
|
||||
|
||||
console.log(slotExists);
|
||||
|
||||
expect(slotExists[0].exists).toBe(false);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,662 @@
|
||||
import { tryCatch } from "@trigger.dev/core/utils";
|
||||
import { Redis, type RedisOptions } from "@internal/redis";
|
||||
import EventEmitter from "node:events";
|
||||
import { Client, ClientConfig, Connection } from "pg";
|
||||
import Redlock, { Lock } from "redlock";
|
||||
import { createRedisClient } from "@internal/redis";
|
||||
import { Logger } from "@trigger.dev/core/logger";
|
||||
import { LogicalReplicationClientError } from "./errors.js";
|
||||
import { PgoutputMessage, PgoutputParser, getPgoutputStartReplicationSQL } from "./pgoutput.js";
|
||||
import { startSpan, trace, Tracer } from "@internal/tracing";
|
||||
|
||||
export interface LogicalReplicationClientOptions {
|
||||
/**
|
||||
* The pg client config.
|
||||
*/
|
||||
pgConfig: ClientConfig;
|
||||
|
||||
/**
|
||||
* The name of this LogicalReplicationClient instance, used for leader election.
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* The table to replicate (for publication creation).
|
||||
*/
|
||||
table: string;
|
||||
/**
|
||||
* The name of the replication slot to use.
|
||||
*/
|
||||
slotName: string;
|
||||
/**
|
||||
* The name of the publication to use.
|
||||
*/
|
||||
publicationName: string;
|
||||
/**
|
||||
* A connected Redis client instance for Redlock.
|
||||
*/
|
||||
redisOptions: RedisOptions;
|
||||
/**
|
||||
* Whether to automatically acknowledge messages.
|
||||
*/
|
||||
autoAcknowledge?: boolean;
|
||||
/**
|
||||
* A logger instance for logging.
|
||||
*/
|
||||
logger?: Logger;
|
||||
/**
|
||||
* The initial leader lock timeout in ms (default: 30000)
|
||||
*/
|
||||
leaderLockTimeoutMs?: number;
|
||||
/**
|
||||
* The interval in ms to extend the leader lock (default: 10000)
|
||||
*/
|
||||
leaderLockExtendIntervalMs?: number;
|
||||
|
||||
/**
|
||||
* The number of times to retry acquiring the leader lock (default: 120)
|
||||
*/
|
||||
leaderLockRetryCount?: number;
|
||||
|
||||
/**
|
||||
* The interval in ms to retry acquiring the leader lock (default: 500)
|
||||
*/
|
||||
leaderLockRetryIntervalMs?: number;
|
||||
|
||||
/**
|
||||
* The interval in seconds to automatically acknowledge the last LSN if no ack has been sent (default: 10)
|
||||
*/
|
||||
ackIntervalSeconds?: number;
|
||||
|
||||
/**
|
||||
* The actions to publish to the publication.
|
||||
*/
|
||||
publicationActions?: Array<"insert" | "update" | "delete" | "truncate">;
|
||||
|
||||
tracer?: Tracer;
|
||||
}
|
||||
|
||||
export type LogicalReplicationClientEvents = {
|
||||
leaderElection: [boolean];
|
||||
error: [Error];
|
||||
data: [{ lsn: string; log: PgoutputMessage; parseDuration: bigint }];
|
||||
start: [];
|
||||
acknowledge: [{ lsn: string }];
|
||||
heartbeat: [{ lsn: string; timestamp: number; shouldRespond: boolean }];
|
||||
};
|
||||
|
||||
export class LogicalReplicationClient {
|
||||
private readonly options: LogicalReplicationClientOptions;
|
||||
private client: Client | null = null;
|
||||
private connection: Connection | null = null;
|
||||
private redis: Redis;
|
||||
private redlock: Redlock;
|
||||
private leaderLock: Lock | null = null;
|
||||
public readonly events: EventEmitter<LogicalReplicationClientEvents>;
|
||||
private logger: Logger;
|
||||
private autoAcknowledge: boolean;
|
||||
private lastAcknowledgedLsn: string | null = null;
|
||||
private leaderLockTimeoutMs: number;
|
||||
private leaderLockExtendIntervalMs: number;
|
||||
private leaderLockRetryCount: number;
|
||||
private leaderLockRetryIntervalMs: number;
|
||||
private leaderLockHeartbeatTimer: NodeJS.Timeout | null = null;
|
||||
private ackIntervalSeconds: number;
|
||||
private lastAckTimestamp: number = 0;
|
||||
private ackIntervalTimer: NodeJS.Timeout | null = null;
|
||||
private _isStopped: boolean = false;
|
||||
private _tracer: Tracer;
|
||||
|
||||
public get lastLsn(): string {
|
||||
return this.lastAcknowledgedLsn ?? "0/00000000";
|
||||
}
|
||||
|
||||
public get isStopped(): boolean {
|
||||
return this._isStopped;
|
||||
}
|
||||
|
||||
constructor(options: LogicalReplicationClientOptions) {
|
||||
this.options = options;
|
||||
this.logger = options.logger ?? new Logger("LogicalReplicationClient", "info");
|
||||
this._tracer = options.tracer ?? trace.getTracer("logical-replication-client");
|
||||
|
||||
this.autoAcknowledge =
|
||||
typeof options.autoAcknowledge === "boolean" ? options.autoAcknowledge : true;
|
||||
|
||||
this.leaderLockTimeoutMs = options.leaderLockTimeoutMs ?? 30000;
|
||||
this.leaderLockExtendIntervalMs = options.leaderLockExtendIntervalMs ?? 10000;
|
||||
this.leaderLockRetryCount = options.leaderLockRetryCount ?? 120;
|
||||
this.leaderLockRetryIntervalMs = options.leaderLockRetryIntervalMs ?? 500;
|
||||
this.ackIntervalSeconds = options.ackIntervalSeconds ?? 10;
|
||||
|
||||
this.redis = createRedisClient(
|
||||
{
|
||||
...options.redisOptions,
|
||||
keyPrefix: `${options.redisOptions.keyPrefix}logical-replication-client:`,
|
||||
},
|
||||
{
|
||||
onError: (error) => {
|
||||
this.logger.error(`RunLock redis client error:`, {
|
||||
error,
|
||||
keyPrefix: options.redisOptions.keyPrefix,
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
this.redlock = new Redlock([this.redis], {
|
||||
retryCount: 0,
|
||||
});
|
||||
this.events = new EventEmitter<LogicalReplicationClientEvents>();
|
||||
}
|
||||
|
||||
public async stop(): Promise<this> {
|
||||
return await startSpan(this._tracer, "logical_replication_client.stop", async (span) => {
|
||||
if (this._isStopped) return this;
|
||||
|
||||
span.setAttribute("replication_client.name", this.options.name);
|
||||
span.setAttribute("replication_client.table", this.options.table);
|
||||
span.setAttribute("replication_client.slot_name", this.options.slotName);
|
||||
span.setAttribute("replication_client.publication_name", this.options.publicationName);
|
||||
|
||||
this._isStopped = true;
|
||||
// Clean up leader lock heartbeat
|
||||
if (this.leaderLockHeartbeatTimer) {
|
||||
clearInterval(this.leaderLockHeartbeatTimer);
|
||||
this.leaderLockHeartbeatTimer = null;
|
||||
}
|
||||
// Clean up ack interval
|
||||
if (this.ackIntervalTimer) {
|
||||
clearInterval(this.ackIntervalTimer);
|
||||
this.ackIntervalTimer = null;
|
||||
}
|
||||
// Release leader lock if held
|
||||
await this.#releaseLeaderLock();
|
||||
|
||||
this.connection?.removeAllListeners();
|
||||
this.connection = null;
|
||||
|
||||
if (this.client) {
|
||||
this.client.removeAllListeners();
|
||||
|
||||
const [endError] = await tryCatch(this.client.end());
|
||||
|
||||
if (endError) {
|
||||
this.logger.error("Failed to end client", {
|
||||
name: this.options.name,
|
||||
error: endError,
|
||||
});
|
||||
} else {
|
||||
this.logger.info("Ended client", {
|
||||
name: this.options.name,
|
||||
});
|
||||
}
|
||||
this.client = null;
|
||||
}
|
||||
|
||||
// clear any intervals
|
||||
if (this.leaderLockHeartbeatTimer) {
|
||||
clearInterval(this.leaderLockHeartbeatTimer);
|
||||
this.leaderLockHeartbeatTimer = null;
|
||||
}
|
||||
|
||||
if (this.ackIntervalTimer) {
|
||||
clearInterval(this.ackIntervalTimer);
|
||||
this.ackIntervalTimer = null;
|
||||
}
|
||||
|
||||
return this;
|
||||
});
|
||||
}
|
||||
|
||||
public async teardown(): Promise<boolean> {
|
||||
await this.stop();
|
||||
|
||||
// Acquire the leaderLock
|
||||
const leaderLockAcquired = await this.#acquireLeaderLock();
|
||||
|
||||
if (!leaderLockAcquired) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.client = new Client({
|
||||
...this.options.pgConfig,
|
||||
// @ts-expect-error
|
||||
replication: "database",
|
||||
application_name: this.options.name,
|
||||
});
|
||||
await this.client.connect();
|
||||
|
||||
// Drop the slot
|
||||
const slotDropped = await this.#dropSlot();
|
||||
|
||||
await this.client.end();
|
||||
this.client = null;
|
||||
|
||||
await this.#releaseLeaderLock();
|
||||
|
||||
return slotDropped;
|
||||
}
|
||||
|
||||
public async subscribe(startLsn?: string): Promise<this> {
|
||||
await this.stop();
|
||||
|
||||
this.lastAcknowledgedLsn = startLsn ?? this.lastAcknowledgedLsn;
|
||||
|
||||
this.logger.info("Subscribing to logical replication", {
|
||||
name: this.options.name,
|
||||
table: this.options.table,
|
||||
slotName: this.options.slotName,
|
||||
publicationName: this.options.publicationName,
|
||||
startLsn,
|
||||
});
|
||||
|
||||
// 1. Leader election
|
||||
const leaderLockAcquired = await this.#acquireLeaderLock();
|
||||
|
||||
if (!leaderLockAcquired) {
|
||||
this.events.emit("leaderElection", false);
|
||||
return this.stop();
|
||||
}
|
||||
|
||||
this.events.emit("leaderElection", true);
|
||||
|
||||
this.logger.info("Leader election successful", {
|
||||
name: this.options.name,
|
||||
table: this.options.table,
|
||||
slotName: this.options.slotName,
|
||||
publicationName: this.options.publicationName,
|
||||
startLsn,
|
||||
});
|
||||
|
||||
// Start leader lock heartbeat
|
||||
this.#startLeaderLockHeartbeat();
|
||||
|
||||
// Start auto-acknowledge interval
|
||||
this.#startAckInterval();
|
||||
|
||||
// 2. Connect pg client
|
||||
this.client = new Client({
|
||||
...this.options.pgConfig,
|
||||
// @ts-expect-error
|
||||
replication: "database",
|
||||
application_name: this.options.name,
|
||||
});
|
||||
await this.client.connect();
|
||||
// @ts-ignore
|
||||
this.connection = this.client.connection;
|
||||
|
||||
const publicationCreated = await this.#createPublication();
|
||||
|
||||
if (!publicationCreated) {
|
||||
return this.stop();
|
||||
}
|
||||
|
||||
this.logger.info("Publication created", {
|
||||
name: this.options.name,
|
||||
table: this.options.table,
|
||||
slotName: this.options.slotName,
|
||||
publicationName: this.options.publicationName,
|
||||
startLsn,
|
||||
});
|
||||
|
||||
const slotCreated = await this.#createSlot();
|
||||
|
||||
if (!slotCreated) {
|
||||
return this.stop();
|
||||
}
|
||||
|
||||
this.logger.info("Slot created", {
|
||||
name: this.options.name,
|
||||
table: this.options.table,
|
||||
slotName: this.options.slotName,
|
||||
publicationName: this.options.publicationName,
|
||||
startLsn,
|
||||
});
|
||||
|
||||
// 5. Start replication (pgoutput)
|
||||
const parser = new PgoutputParser();
|
||||
const sql = getPgoutputStartReplicationSQL(this.options.slotName, this.lastLsn, {
|
||||
protoVersion: 1,
|
||||
publicationNames: [this.options.publicationName],
|
||||
messages: false,
|
||||
});
|
||||
|
||||
// 6. Listen for replication events (copyData, etc.)
|
||||
if (!this.connection) {
|
||||
this.events.emit(
|
||||
"error",
|
||||
new LogicalReplicationClientError("No connection after starting replication")
|
||||
);
|
||||
return this.stop();
|
||||
}
|
||||
|
||||
this.connection.once("replicationStart", () => {
|
||||
this._isStopped = false;
|
||||
this.events.emit("start");
|
||||
});
|
||||
|
||||
this.connection.on(
|
||||
"copyData",
|
||||
async ({ chunk: buffer }: { length: number; chunk: Buffer; name: string }) => {
|
||||
// pgoutput protocol: 0x77 = XLogData, 0x6b = Primary keepalive
|
||||
if (buffer[0] !== 0x77 && buffer[0] !== 0x6b) {
|
||||
this.logger.warn("Unknown replication message type", { byte: buffer[0] });
|
||||
return;
|
||||
}
|
||||
const lsn =
|
||||
buffer.readUInt32BE(1).toString(16).toUpperCase() +
|
||||
"/" +
|
||||
buffer.readUInt32BE(5).toString(16).toUpperCase();
|
||||
|
||||
if (buffer[0] === 0x77) {
|
||||
// XLogData
|
||||
try {
|
||||
const start = process.hrtime.bigint();
|
||||
const log = parser.parse(buffer.subarray(25));
|
||||
const duration = process.hrtime.bigint() - start;
|
||||
this.events.emit("data", { lsn, log, parseDuration: duration });
|
||||
await this.#acknowledge(lsn);
|
||||
} catch (err) {
|
||||
this.logger.error("Failed to parse XLogData", { error: err });
|
||||
this.events.emit("error", err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
} else if (buffer[0] === 0x6b) {
|
||||
// Primary keepalive message
|
||||
const timestamp = Math.floor(
|
||||
buffer.readUInt32BE(9) * 4294967.296 + buffer.readUInt32BE(13) / 1000 + 946080000000
|
||||
);
|
||||
const shouldRespond = !!buffer.readInt8(17);
|
||||
this.events.emit("heartbeat", { lsn, timestamp, shouldRespond });
|
||||
if (shouldRespond) {
|
||||
await this.#acknowledge(lsn);
|
||||
}
|
||||
}
|
||||
|
||||
this.lastAcknowledgedLsn = lsn;
|
||||
}
|
||||
);
|
||||
|
||||
// 7. Handle errors and cleanup
|
||||
this.client.on("error", (err) => {
|
||||
this.events.emit("error", err);
|
||||
});
|
||||
|
||||
this.logger.info("Started replication", {
|
||||
name: this.options.name,
|
||||
table: this.options.table,
|
||||
slotName: this.options.slotName,
|
||||
publicationName: this.options.publicationName,
|
||||
startLsn,
|
||||
sql: sql.replace(/\s+/g, " "),
|
||||
});
|
||||
|
||||
// Start the replication stream
|
||||
this.client.query(sql).catch((err) => {
|
||||
this.logger.error("Failed to start replication", {
|
||||
name: this.options.name,
|
||||
table: this.options.table,
|
||||
slotName: this.options.slotName,
|
||||
publicationName: this.options.publicationName,
|
||||
error: err,
|
||||
});
|
||||
|
||||
this.events.emit("error", err);
|
||||
return this.stop();
|
||||
});
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
async #createPublication(): Promise<boolean> {
|
||||
if (!this.client) {
|
||||
this.events.emit("error", new LogicalReplicationClientError("Client not connected"));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (await this.#doesPublicationExist()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const [createError] = await tryCatch(
|
||||
this.client.query(
|
||||
`CREATE PUBLICATION "${this.options.publicationName}" FOR TABLE "${this.options.table}" ${
|
||||
this.options.publicationActions
|
||||
? `WITH (publish = '${this.options.publicationActions.join(", ")}')`
|
||||
: ""
|
||||
};`
|
||||
)
|
||||
);
|
||||
|
||||
if (createError) {
|
||||
this.logger.error("Failed to create publication", {
|
||||
name: this.options.name,
|
||||
table: this.options.table,
|
||||
slotName: this.options.slotName,
|
||||
publicationName: this.options.publicationName,
|
||||
error: createError,
|
||||
});
|
||||
|
||||
this.events.emit("error", createError);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async #doesPublicationExist(): Promise<boolean> {
|
||||
if (!this.client) {
|
||||
this.events.emit(
|
||||
"error",
|
||||
new LogicalReplicationClientError("Cannot check if publication exists")
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const res = await this.client.query(
|
||||
`SELECT EXISTS (SELECT 1 FROM pg_publication WHERE pubname = '${this.options.publicationName}');`
|
||||
);
|
||||
|
||||
return res.rows[0].exists;
|
||||
}
|
||||
|
||||
async #createSlot(): Promise<boolean> {
|
||||
if (!this.client) {
|
||||
this.events.emit("error", new LogicalReplicationClientError("Cannot create slot"));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (await this.#doesSlotExist()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const [createError] = await tryCatch(
|
||||
this.client.query(
|
||||
`SELECT * FROM pg_create_logical_replication_slot('${this.options.slotName}', 'pgoutput')`
|
||||
)
|
||||
);
|
||||
|
||||
if (createError) {
|
||||
this.logger.error("Failed to create slot", {
|
||||
name: this.options.name,
|
||||
table: this.options.table,
|
||||
slotName: this.options.slotName,
|
||||
publicationName: this.options.publicationName,
|
||||
error: createError,
|
||||
});
|
||||
|
||||
this.events.emit("error", createError);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async #doesSlotExist(): Promise<boolean> {
|
||||
if (!this.client) {
|
||||
this.events.emit("error", new LogicalReplicationClientError("Cannot check if slot exists"));
|
||||
return false;
|
||||
}
|
||||
|
||||
const res = await this.client.query(
|
||||
`SELECT EXISTS (SELECT 1 FROM pg_replication_slots WHERE slot_name = '${this.options.slotName}');`
|
||||
);
|
||||
|
||||
return res.rows[0].exists;
|
||||
}
|
||||
|
||||
async #dropSlot(): Promise<boolean> {
|
||||
if (!this.client) {
|
||||
this.events.emit("error", new LogicalReplicationClientError("Cannot drop slot"));
|
||||
return false;
|
||||
}
|
||||
|
||||
const [dropError] = await tryCatch(
|
||||
this.client.query(`SELECT pg_drop_replication_slot('${this.options.slotName}');`)
|
||||
);
|
||||
|
||||
if (dropError) {
|
||||
this.logger.error("Failed to drop slot", {
|
||||
name: this.options.name,
|
||||
table: this.options.table,
|
||||
slotName: this.options.slotName,
|
||||
publicationName: this.options.publicationName,
|
||||
error: dropError,
|
||||
});
|
||||
|
||||
this.events.emit("error", dropError);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async #acknowledge(lsn: string): Promise<void> {
|
||||
if (!this.autoAcknowledge) return;
|
||||
this.events.emit("acknowledge", { lsn });
|
||||
await this.acknowledge(lsn);
|
||||
}
|
||||
|
||||
public async acknowledge(lsn: string): Promise<boolean> {
|
||||
if (this._isStopped) return false;
|
||||
if (!this.connection) return false;
|
||||
|
||||
return await startSpan(this._tracer, "logical_replication_client.acknowledge", async (span) => {
|
||||
span.setAttribute("replication_client.lsn", lsn);
|
||||
span.setAttribute("replication_client.name", this.options.name);
|
||||
span.setAttribute("replication_client.table", this.options.table);
|
||||
span.setAttribute("replication_client.slot_name", this.options.slotName);
|
||||
span.setAttribute("replication_client.publication_name", this.options.publicationName);
|
||||
|
||||
// WAL LSN split
|
||||
const slice = lsn.split("/");
|
||||
let [upperWAL, lowerWAL]: [number, number] = [parseInt(slice[0], 16), parseInt(slice[1], 16)];
|
||||
// Timestamp as microseconds since midnight 2000-01-01
|
||||
const now = Date.now() - 946080000000;
|
||||
const upperTimestamp = Math.floor(now / 4294967.296);
|
||||
const lowerTimestamp = Math.floor(now - upperTimestamp * 4294967.296);
|
||||
if (lowerWAL === 4294967295) {
|
||||
upperWAL = upperWAL + 1;
|
||||
lowerWAL = 0;
|
||||
} else {
|
||||
lowerWAL = lowerWAL + 1;
|
||||
}
|
||||
const response = Buffer.alloc(34);
|
||||
response.fill(0x72); // 'r'
|
||||
response.writeUInt32BE(upperWAL, 1);
|
||||
response.writeUInt32BE(lowerWAL, 5);
|
||||
response.writeUInt32BE(upperWAL, 9);
|
||||
response.writeUInt32BE(lowerWAL, 13);
|
||||
response.writeUInt32BE(upperWAL, 17);
|
||||
response.writeUInt32BE(lowerWAL, 21);
|
||||
response.writeUInt32BE(upperTimestamp, 25);
|
||||
response.writeUInt32BE(lowerTimestamp, 29);
|
||||
response.writeInt8(0, 33);
|
||||
// @ts-ignore
|
||||
this.connection.sendCopyFromChunk(response);
|
||||
this.lastAckTimestamp = Date.now();
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async #acquireLeaderLock(): Promise<boolean> {
|
||||
try {
|
||||
this.leaderLock = await this.redlock.acquire(
|
||||
[`logical-replication-client:${this.options.name}`],
|
||||
this.leaderLockTimeoutMs,
|
||||
{
|
||||
retryCount: this.leaderLockRetryCount,
|
||||
retryDelay: this.leaderLockRetryIntervalMs,
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error("Leader election failed", {
|
||||
name: this.options.name,
|
||||
table: this.options.table,
|
||||
slotName: this.options.slotName,
|
||||
publicationName: this.options.publicationName,
|
||||
error: err,
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async #releaseLeaderLock() {
|
||||
if (!this.leaderLock) return;
|
||||
const [releaseError] = await tryCatch(this.leaderLock.release());
|
||||
this.leaderLock = null;
|
||||
|
||||
if (releaseError) {
|
||||
this.logger.error("Failed to release leader lock", {
|
||||
name: this.options.name,
|
||||
error: releaseError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async #startLeaderLockHeartbeat() {
|
||||
if (this.leaderLockHeartbeatTimer) {
|
||||
clearInterval(this.leaderLockHeartbeatTimer);
|
||||
}
|
||||
if (!this.leaderLock) return;
|
||||
this.leaderLockHeartbeatTimer = setInterval(async () => {
|
||||
if (!this.leaderLock) return;
|
||||
if (this._isStopped) return;
|
||||
try {
|
||||
this.leaderLock = await this.leaderLock.extend(this.leaderLockTimeoutMs);
|
||||
this.logger.debug("Extended leader lock", {
|
||||
name: this.options.name,
|
||||
slotName: this.options.slotName,
|
||||
publicationName: this.options.publicationName,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error("Failed to extend leader lock", {
|
||||
name: this.options.name,
|
||||
slotName: this.options.slotName,
|
||||
publicationName: this.options.publicationName,
|
||||
error: err,
|
||||
});
|
||||
// Optionally emit an error or handle loss of leadership
|
||||
this.events.emit("error", err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}, this.leaderLockExtendIntervalMs);
|
||||
}
|
||||
|
||||
#startAckInterval() {
|
||||
if (this.ackIntervalTimer) {
|
||||
clearInterval(this.ackIntervalTimer);
|
||||
}
|
||||
if (!this.autoAcknowledge || this.ackIntervalSeconds <= 0) return;
|
||||
this.ackIntervalTimer = setInterval(async () => {
|
||||
if (this._isStopped) return;
|
||||
const now = Date.now();
|
||||
if (
|
||||
this.lastAcknowledgedLsn &&
|
||||
now - this.lastAckTimestamp > this.ackIntervalSeconds * 1000
|
||||
) {
|
||||
await this.acknowledge(this.lastAcknowledgedLsn);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export class LogicalReplicationClientError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./client.js";
|
||||
export * from "./errors.js";
|
||||
export type * from "./pgoutput.js";
|
||||
@@ -0,0 +1,403 @@
|
||||
// NOTE: This file requires ES2020 or higher for BigInt literals (used in BinaryReader.readTime)
|
||||
import { Client } from "pg";
|
||||
import { types } from "pg";
|
||||
|
||||
export interface PgoutputOptions {
|
||||
protoVersion: 1 | 2;
|
||||
publicationNames: string[];
|
||||
messages?: boolean;
|
||||
}
|
||||
|
||||
export type PgoutputMessage =
|
||||
| MessageBegin
|
||||
| MessageCommit
|
||||
| MessageDelete
|
||||
| MessageInsert
|
||||
| MessageMessage
|
||||
| MessageOrigin
|
||||
| MessageRelation
|
||||
| MessageTruncate
|
||||
| MessageType
|
||||
| MessageUpdate;
|
||||
|
||||
export interface MessageBegin {
|
||||
tag: "begin";
|
||||
commitLsn: string | null;
|
||||
commitTime: bigint;
|
||||
xid: number;
|
||||
}
|
||||
export interface MessageCommit {
|
||||
tag: "commit";
|
||||
flags: number;
|
||||
commitLsn: string | null;
|
||||
commitEndLsn: string | null;
|
||||
commitTime: bigint;
|
||||
}
|
||||
export interface MessageDelete {
|
||||
tag: "delete";
|
||||
relation: MessageRelation;
|
||||
key: Record<string, any> | null;
|
||||
old: Record<string, any> | null;
|
||||
}
|
||||
export interface MessageInsert {
|
||||
tag: "insert";
|
||||
relation: MessageRelation;
|
||||
new: Record<string, any>;
|
||||
}
|
||||
export interface MessageMessage {
|
||||
tag: "message";
|
||||
flags: number;
|
||||
transactional: boolean;
|
||||
messageLsn: string | null;
|
||||
prefix: string;
|
||||
content: Uint8Array;
|
||||
}
|
||||
export interface MessageOrigin {
|
||||
tag: "origin";
|
||||
originLsn: string | null;
|
||||
originName: string;
|
||||
}
|
||||
export interface MessageRelation {
|
||||
tag: "relation";
|
||||
relationOid: number;
|
||||
schema: string;
|
||||
name: string;
|
||||
replicaIdentity: "default" | "nothing" | "full" | "index";
|
||||
columns: RelationColumn[];
|
||||
keyColumns: string[];
|
||||
}
|
||||
export interface RelationColumn {
|
||||
name: string;
|
||||
flags: number;
|
||||
typeOid: number;
|
||||
typeMod: number;
|
||||
typeSchema: string | null;
|
||||
typeName: string | null;
|
||||
parser: (raw: any) => any;
|
||||
}
|
||||
export interface MessageTruncate {
|
||||
tag: "truncate";
|
||||
cascade: boolean;
|
||||
restartIdentity: boolean;
|
||||
relations: MessageRelation[];
|
||||
}
|
||||
export interface MessageType {
|
||||
tag: "type";
|
||||
typeOid: number;
|
||||
typeSchema: string;
|
||||
typeName: string;
|
||||
}
|
||||
export interface MessageUpdate {
|
||||
tag: "update";
|
||||
relation: MessageRelation;
|
||||
key: Record<string, any> | null;
|
||||
old: Record<string, any> | null;
|
||||
new: Record<string, any>;
|
||||
}
|
||||
|
||||
class BinaryReader {
|
||||
private offset = 0;
|
||||
constructor(private buf: Buffer) {}
|
||||
readUint8(): number {
|
||||
return this.buf.readUInt8(this.offset++);
|
||||
}
|
||||
readInt16(): number {
|
||||
const v = this.buf.readInt16BE(this.offset);
|
||||
this.offset += 2;
|
||||
return v;
|
||||
}
|
||||
readInt32(): number {
|
||||
const v = this.buf.readInt32BE(this.offset);
|
||||
this.offset += 4;
|
||||
return v;
|
||||
}
|
||||
readString(): string {
|
||||
let end = this.buf.indexOf(0, this.offset);
|
||||
if (end === -1) throw new Error("Null-terminated string not found");
|
||||
const str = this.buf.toString("utf8", this.offset, end);
|
||||
this.offset = end + 1;
|
||||
return str;
|
||||
}
|
||||
read(len: number): Buffer {
|
||||
const b = this.buf.subarray(this.offset, this.offset + len);
|
||||
this.offset += len;
|
||||
return b;
|
||||
}
|
||||
decodeText(buf: Buffer): string {
|
||||
return buf.toString("utf8");
|
||||
}
|
||||
array<T>(n: number, fn: () => T): T[] {
|
||||
return Array.from({ length: n }, fn);
|
||||
}
|
||||
|
||||
readLsn(): string | null {
|
||||
const upper = this.readUint32();
|
||||
const lower = this.readUint32();
|
||||
if (upper === 0 && lower === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
upper.toString(16).padStart(8, "0").toUpperCase() +
|
||||
"/" +
|
||||
lower.toString(16).padStart(8, "0").toUpperCase()
|
||||
);
|
||||
}
|
||||
|
||||
readUint32(): number {
|
||||
// >>> 0 ensures unsigned
|
||||
return this.readInt32() >>> 0;
|
||||
}
|
||||
|
||||
readUint64(): bigint {
|
||||
// Combine two unsigned 32-bit ints into a 64-bit bigint
|
||||
return (BigInt(this.readUint32()) << 32n) | BigInt(this.readUint32());
|
||||
}
|
||||
|
||||
readTime(): bigint {
|
||||
// (POSTGRES_EPOCH_JDATE - UNIX_EPOCH_JDATE) * USECS_PER_DAY == 946684800000000
|
||||
const microsSinceUnixEpoch = this.readUint64() + 946684800000000n;
|
||||
return microsSinceUnixEpoch;
|
||||
}
|
||||
}
|
||||
|
||||
export class PgoutputParser {
|
||||
private _typeCache = new Map<number, { typeSchema: string; typeName: string }>();
|
||||
private _relationCache = new Map<number, MessageRelation>();
|
||||
|
||||
public parse(buf: Buffer): PgoutputMessage {
|
||||
const reader = new BinaryReader(buf);
|
||||
const tag = reader.readUint8();
|
||||
switch (tag) {
|
||||
case 0x42:
|
||||
return this.msgBegin(reader);
|
||||
case 0x4f:
|
||||
return this.msgOrigin(reader);
|
||||
case 0x59:
|
||||
return this.msgType(reader);
|
||||
case 0x52:
|
||||
return this.msgRelation(reader);
|
||||
case 0x49:
|
||||
return this.msgInsert(reader);
|
||||
case 0x55:
|
||||
return this.msgUpdate(reader);
|
||||
case 0x44:
|
||||
return this.msgDelete(reader);
|
||||
case 0x54:
|
||||
return this.msgTruncate(reader);
|
||||
case 0x4d:
|
||||
return this.msgMessage(reader);
|
||||
case 0x43:
|
||||
return this.msgCommit(reader);
|
||||
default:
|
||||
throw Error("unknown pgoutput message");
|
||||
}
|
||||
}
|
||||
|
||||
private msgBegin(reader: BinaryReader): MessageBegin {
|
||||
return {
|
||||
tag: "begin",
|
||||
commitLsn: reader.readLsn(),
|
||||
commitTime: reader.readTime(),
|
||||
xid: reader.readInt32(),
|
||||
};
|
||||
}
|
||||
private msgOrigin(reader: BinaryReader): MessageOrigin {
|
||||
return {
|
||||
tag: "origin",
|
||||
originLsn: reader.readLsn(),
|
||||
originName: reader.readString(),
|
||||
};
|
||||
}
|
||||
private msgType(reader: BinaryReader): MessageType {
|
||||
const typeOid = reader.readInt32();
|
||||
const typeSchema = reader.readString();
|
||||
const typeName = reader.readString();
|
||||
this._typeCache.set(typeOid, { typeSchema, typeName });
|
||||
return { tag: "type", typeOid, typeSchema, typeName };
|
||||
}
|
||||
private msgRelation(reader: BinaryReader): MessageRelation {
|
||||
const relationOid = reader.readInt32();
|
||||
const schema = reader.readString();
|
||||
const name = reader.readString();
|
||||
const replicaIdentity = this.readRelationReplicaIdentity(reader);
|
||||
const columns = reader.array(reader.readInt16(), () => this.readRelationColumn(reader));
|
||||
const keyColumns = columns.filter((it) => it.flags & 0b1).map((it) => it.name);
|
||||
const msg: MessageRelation = {
|
||||
tag: "relation",
|
||||
relationOid,
|
||||
schema,
|
||||
name,
|
||||
replicaIdentity,
|
||||
columns,
|
||||
keyColumns,
|
||||
};
|
||||
this._relationCache.set(relationOid, msg);
|
||||
return msg;
|
||||
}
|
||||
private readRelationReplicaIdentity(reader: BinaryReader) {
|
||||
const ident = reader.readUint8();
|
||||
switch (ident) {
|
||||
case 0x64:
|
||||
return "default";
|
||||
case 0x6e:
|
||||
return "nothing";
|
||||
case 0x66:
|
||||
return "full";
|
||||
case 0x69:
|
||||
return "index";
|
||||
default:
|
||||
throw Error(`unknown replica identity ${String.fromCharCode(ident)}`);
|
||||
}
|
||||
}
|
||||
private readRelationColumn(reader: BinaryReader): RelationColumn {
|
||||
const flags = reader.readUint8();
|
||||
const name = reader.readString();
|
||||
const typeOid = reader.readInt32();
|
||||
const typeMod = reader.readInt32();
|
||||
return {
|
||||
flags,
|
||||
name,
|
||||
typeOid,
|
||||
typeMod,
|
||||
typeSchema: null,
|
||||
typeName: null,
|
||||
...this._typeCache.get(typeOid),
|
||||
parser: types.getTypeParser(typeOid),
|
||||
};
|
||||
}
|
||||
private msgInsert(reader: BinaryReader): MessageInsert {
|
||||
const relation = this._relationCache.get(reader.readInt32());
|
||||
if (!relation) throw Error("missing relation");
|
||||
reader.readUint8(); // consume the 'N' key
|
||||
return {
|
||||
tag: "insert",
|
||||
relation,
|
||||
new: this.readTuple(reader, relation),
|
||||
};
|
||||
}
|
||||
private msgUpdate(reader: BinaryReader): MessageUpdate {
|
||||
const relation = this._relationCache.get(reader.readInt32());
|
||||
if (!relation) throw Error("missing relation");
|
||||
let key: Record<string, any> | null = null;
|
||||
let old: Record<string, any> | null = null;
|
||||
let new_: Record<string, any> | null = null;
|
||||
const subMsgKey = reader.readUint8();
|
||||
if (subMsgKey === 0x4b) {
|
||||
key = this.readKeyTuple(reader, relation);
|
||||
reader.readUint8();
|
||||
new_ = this.readTuple(reader, relation);
|
||||
} else if (subMsgKey === 0x4f) {
|
||||
old = this.readTuple(reader, relation);
|
||||
reader.readUint8();
|
||||
new_ = this.readTuple(reader, relation, old);
|
||||
} else if (subMsgKey === 0x4e) {
|
||||
new_ = this.readTuple(reader, relation);
|
||||
} else {
|
||||
throw Error(`unknown submessage key ${String.fromCharCode(subMsgKey)}`);
|
||||
}
|
||||
return { tag: "update", relation, key, old, new: new_ };
|
||||
}
|
||||
private msgDelete(reader: BinaryReader): MessageDelete {
|
||||
const relation = this._relationCache.get(reader.readInt32());
|
||||
if (!relation) throw Error("missing relation");
|
||||
let key: Record<string, any> | null = null;
|
||||
let old: Record<string, any> | null = null;
|
||||
const subMsgKey = reader.readUint8();
|
||||
if (subMsgKey === 0x4b) {
|
||||
key = this.readKeyTuple(reader, relation);
|
||||
} else if (subMsgKey === 0x4f) {
|
||||
old = this.readTuple(reader, relation);
|
||||
} else {
|
||||
throw Error(`unknown submessage key ${String.fromCharCode(subMsgKey)}`);
|
||||
}
|
||||
return { tag: "delete", relation, key, old };
|
||||
}
|
||||
private readKeyTuple(reader: BinaryReader, relation: MessageRelation): Record<string, any> {
|
||||
const tuple = this.readTuple(reader, relation);
|
||||
const key = Object.create(null);
|
||||
for (const k of relation.keyColumns) {
|
||||
key[k] = tuple[k] === null ? undefined : tuple[k];
|
||||
}
|
||||
return key;
|
||||
}
|
||||
private readTuple(
|
||||
reader: BinaryReader,
|
||||
{ columns }: MessageRelation,
|
||||
unchangedToastFallback?: Record<string, any> | null
|
||||
): Record<string, any> {
|
||||
const nfields = reader.readInt16();
|
||||
const tuple = Object.create(null);
|
||||
for (let i = 0; i < nfields; i++) {
|
||||
const { name, parser } = columns[i];
|
||||
const kind = reader.readUint8();
|
||||
switch (kind) {
|
||||
case 0x62: // 'b' binary
|
||||
const bsize = reader.readInt32();
|
||||
const bval = reader.read(bsize);
|
||||
tuple[name] = bval;
|
||||
break;
|
||||
case 0x74: // 't' text
|
||||
const valsize = reader.readInt32();
|
||||
const valbuf = reader.read(valsize);
|
||||
const valtext = reader.decodeText(valbuf);
|
||||
tuple[name] = parser(valtext);
|
||||
break;
|
||||
case 0x6e: // 'n' null
|
||||
tuple[name] = null;
|
||||
break;
|
||||
case 0x75: // 'u' unchanged toast datum
|
||||
tuple[name] = unchangedToastFallback?.[name];
|
||||
break;
|
||||
default:
|
||||
throw Error(`unknown attribute kind ${String.fromCharCode(kind)}`);
|
||||
}
|
||||
}
|
||||
return tuple;
|
||||
}
|
||||
private msgTruncate(reader: BinaryReader): MessageTruncate {
|
||||
const nrels = reader.readInt32();
|
||||
const flags = reader.readUint8();
|
||||
return {
|
||||
tag: "truncate",
|
||||
cascade: Boolean(flags & 0b1),
|
||||
restartIdentity: Boolean(flags & 0b10),
|
||||
relations: reader.array(
|
||||
nrels,
|
||||
() => this._relationCache.get(reader.readInt32()) as MessageRelation
|
||||
),
|
||||
};
|
||||
}
|
||||
private msgMessage(reader: BinaryReader): MessageMessage {
|
||||
const flags = reader.readUint8();
|
||||
return {
|
||||
tag: "message",
|
||||
flags,
|
||||
transactional: Boolean(flags & 0b1),
|
||||
messageLsn: reader.readLsn(),
|
||||
prefix: reader.readString(),
|
||||
content: reader.read(reader.readInt32()),
|
||||
};
|
||||
}
|
||||
private msgCommit(reader: BinaryReader): MessageCommit {
|
||||
return {
|
||||
tag: "commit",
|
||||
flags: reader.readUint8(),
|
||||
commitLsn: reader.readLsn(),
|
||||
commitEndLsn: reader.readLsn(),
|
||||
commitTime: reader.readTime(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function getPgoutputStartReplicationSQL(
|
||||
slotName: string,
|
||||
lastLsn: string,
|
||||
options: PgoutputOptions
|
||||
): string {
|
||||
const opts = [
|
||||
`proto_version '${options.protoVersion}'`,
|
||||
`publication_names '${options.publicationNames.join(",")}'`,
|
||||
`messages '${options.messages ?? false}'`,
|
||||
];
|
||||
return `START_REPLICATION SLOT "${slotName}" LOGICAL ${lastLsn} (${opts.join(", ")});`;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable", "DOM.AsyncIterable"],
|
||||
"outDir": "dist",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"moduleDetection": "force",
|
||||
"verbatimModuleSyntax": false,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"preserveWatchOutput": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"declaration": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"references": [{ "path": "./tsconfig.src.json" }, { "path": "./tsconfig.test.json" }],
|
||||
"compilerOptions": {
|
||||
"moduleResolution": "Node16",
|
||||
"module": "Node16",
|
||||
"customConditions": ["@triggerdotdev/source"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "src/**/*.test.ts"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable", "DOM.AsyncIterable"],
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"moduleDetection": "force",
|
||||
"verbatimModuleSyntax": false,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"preserveWatchOutput": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"customConditions": ["@triggerdotdev/source"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"include": ["src/**/*.test.ts", "vitest.config.ts"],
|
||||
"references": [{ "path": "./tsconfig.src.json" }],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable", "DOM.AsyncIterable"],
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"moduleDetection": "force",
|
||||
"verbatimModuleSyntax": false,
|
||||
"types": ["vitest/globals"],
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"preserveWatchOutput": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"customConditions": ["@triggerdotdev/source"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["**/*.test.ts"],
|
||||
globals: true,
|
||||
isolate: true,
|
||||
fileParallelism: false,
|
||||
poolOptions: {
|
||||
threads: {
|
||||
singleThread: true,
|
||||
},
|
||||
},
|
||||
testTimeout: 60_000,
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,20 +1,134 @@
|
||||
import { TaskRunExecutionStatus, TaskRunStatus } from "@trigger.dev/database";
|
||||
import { AuthenticatedEnvironment } from "../shared/index.js";
|
||||
import { FlushedRunMetadata, TaskRunError } from "@trigger.dev/core/v3";
|
||||
import {
|
||||
RuntimeEnvironmentType,
|
||||
TaskRunExecutionStatus,
|
||||
TaskRunStatus,
|
||||
} from "@trigger.dev/database";
|
||||
import { EventEmitter } from "events";
|
||||
import { AuthenticatedEnvironment } from "../shared/index.js";
|
||||
|
||||
export type EventBusEvents = {
|
||||
runCreated: [
|
||||
{
|
||||
time: Date;
|
||||
runId: string;
|
||||
},
|
||||
];
|
||||
runEnqueuedAfterDelay: [
|
||||
{
|
||||
time: Date;
|
||||
run: {
|
||||
id: string;
|
||||
status: TaskRunStatus;
|
||||
queuedAt: Date;
|
||||
updatedAt: Date;
|
||||
createdAt: Date;
|
||||
};
|
||||
organization: {
|
||||
id: string;
|
||||
};
|
||||
project: {
|
||||
id: string;
|
||||
};
|
||||
environment: {
|
||||
id: string;
|
||||
};
|
||||
},
|
||||
];
|
||||
runDelayRescheduled: [
|
||||
{
|
||||
time: Date;
|
||||
run: {
|
||||
id: string;
|
||||
status: TaskRunStatus;
|
||||
delayUntil: Date;
|
||||
updatedAt: Date;
|
||||
createdAt: Date;
|
||||
};
|
||||
organization: {
|
||||
id: string;
|
||||
};
|
||||
project: {
|
||||
id: string;
|
||||
};
|
||||
environment: {
|
||||
id: string;
|
||||
};
|
||||
},
|
||||
];
|
||||
runLocked: [
|
||||
{
|
||||
time: Date;
|
||||
run: {
|
||||
id: string;
|
||||
updatedAt: Date;
|
||||
status: TaskRunStatus;
|
||||
lockedAt: Date;
|
||||
lockedById: string;
|
||||
lockedToVersionId: string;
|
||||
lockedQueueId: string;
|
||||
startedAt: Date;
|
||||
baseCostInCents: number;
|
||||
machinePreset: string;
|
||||
taskVersion: string;
|
||||
sdkVersion: string;
|
||||
cliVersion: string;
|
||||
maxDurationInSeconds?: number;
|
||||
maxAttempts?: number;
|
||||
createdAt: Date;
|
||||
};
|
||||
organization: {
|
||||
id: string;
|
||||
};
|
||||
project: {
|
||||
id: string;
|
||||
};
|
||||
environment: {
|
||||
id: string;
|
||||
};
|
||||
},
|
||||
];
|
||||
runStatusChanged: [
|
||||
{
|
||||
time: Date;
|
||||
run: {
|
||||
id: string;
|
||||
status: TaskRunStatus;
|
||||
updatedAt: Date;
|
||||
createdAt: Date;
|
||||
};
|
||||
organization: {
|
||||
id?: string;
|
||||
};
|
||||
project: {
|
||||
id: string;
|
||||
};
|
||||
environment: {
|
||||
id: string;
|
||||
};
|
||||
},
|
||||
];
|
||||
runAttemptStarted: [
|
||||
{
|
||||
time: Date;
|
||||
run: {
|
||||
id: string;
|
||||
status: TaskRunStatus;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
attemptNumber: number;
|
||||
baseCostInCents: number;
|
||||
executedAt: Date | undefined;
|
||||
};
|
||||
organization: {
|
||||
id: string;
|
||||
};
|
||||
project: {
|
||||
id: string;
|
||||
};
|
||||
environment: {
|
||||
id: string;
|
||||
};
|
||||
},
|
||||
];
|
||||
runAttemptFailed: [
|
||||
@@ -29,6 +143,7 @@ export type EventBusEvents = {
|
||||
taskEventStore: string;
|
||||
createdAt: Date;
|
||||
completedAt: Date | null;
|
||||
updatedAt: Date;
|
||||
};
|
||||
},
|
||||
];
|
||||
@@ -37,11 +152,23 @@ export type EventBusEvents = {
|
||||
time: Date;
|
||||
run: {
|
||||
id: string;
|
||||
status: TaskRunStatus;
|
||||
spanId: string;
|
||||
ttl: string | null;
|
||||
taskEventStore: string;
|
||||
createdAt: Date;
|
||||
completedAt: Date | null;
|
||||
expiredAt: Date | null;
|
||||
updatedAt: Date;
|
||||
};
|
||||
organization: {
|
||||
id: string;
|
||||
};
|
||||
project: {
|
||||
id: string;
|
||||
};
|
||||
environment: {
|
||||
id: string;
|
||||
};
|
||||
},
|
||||
];
|
||||
@@ -50,12 +177,26 @@ export type EventBusEvents = {
|
||||
time: Date;
|
||||
run: {
|
||||
id: string;
|
||||
status: TaskRunStatus;
|
||||
spanId: string;
|
||||
output: string | undefined;
|
||||
outputType: string;
|
||||
taskEventStore: string;
|
||||
createdAt: Date;
|
||||
completedAt: Date | null;
|
||||
usageDurationMs: number;
|
||||
costInCents: number;
|
||||
updatedAt: Date;
|
||||
attemptNumber: number;
|
||||
};
|
||||
organization: {
|
||||
id: string;
|
||||
};
|
||||
project: {
|
||||
id: string;
|
||||
};
|
||||
environment: {
|
||||
id: string;
|
||||
};
|
||||
},
|
||||
];
|
||||
@@ -70,6 +211,19 @@ export type EventBusEvents = {
|
||||
taskEventStore: string;
|
||||
createdAt: Date;
|
||||
completedAt: Date | null;
|
||||
updatedAt: Date;
|
||||
attemptNumber: number;
|
||||
usageDurationMs: number;
|
||||
costInCents: number;
|
||||
};
|
||||
organization: {
|
||||
id: string;
|
||||
};
|
||||
project: {
|
||||
id: string;
|
||||
};
|
||||
environment: {
|
||||
id: string;
|
||||
};
|
||||
},
|
||||
];
|
||||
@@ -78,6 +232,7 @@ export type EventBusEvents = {
|
||||
time: Date;
|
||||
run: {
|
||||
id: string;
|
||||
status: TaskRunStatus;
|
||||
friendlyId: string;
|
||||
spanId: string;
|
||||
attemptNumber: number;
|
||||
@@ -86,6 +241,9 @@ export type EventBusEvents = {
|
||||
taskIdentifier: string;
|
||||
baseCostInCents: number;
|
||||
nextMachineAfterOOM?: string;
|
||||
updatedAt: Date;
|
||||
createdAt: Date;
|
||||
error: TaskRunError;
|
||||
};
|
||||
organization: {
|
||||
id: string;
|
||||
@@ -99,12 +257,24 @@ export type EventBusEvents = {
|
||||
time: Date;
|
||||
run: {
|
||||
id: string;
|
||||
status: TaskRunStatus;
|
||||
friendlyId: string;
|
||||
spanId: string;
|
||||
error: TaskRunError;
|
||||
taskEventStore: string;
|
||||
createdAt: Date;
|
||||
completedAt: Date | null;
|
||||
updatedAt: Date;
|
||||
attemptNumber: number;
|
||||
};
|
||||
organization: {
|
||||
id: string;
|
||||
};
|
||||
project: {
|
||||
id: string;
|
||||
};
|
||||
environment: {
|
||||
id: string;
|
||||
};
|
||||
},
|
||||
];
|
||||
|
||||
@@ -28,11 +28,7 @@ import { FairQueueSelectionStrategy } from "../run-queue/fairQueueSelectionStrat
|
||||
import { RunQueue } from "../run-queue/index.js";
|
||||
import { RunQueueFullKeyProducer } from "../run-queue/keyProducer.js";
|
||||
import { MinimalAuthenticatedEnvironment } from "../shared/index.js";
|
||||
import {
|
||||
NotImplementedError,
|
||||
RunDuplicateIdempotencyKeyError,
|
||||
ServiceValidationError,
|
||||
} from "./errors.js";
|
||||
import { NotImplementedError, RunDuplicateIdempotencyKeyError } from "./errors.js";
|
||||
import { EventBus, EventBusEvents } from "./eventBus.js";
|
||||
import { RunLocker } from "./locking.js";
|
||||
import { BatchSystem } from "./systems/batchSystem.js";
|
||||
@@ -369,6 +365,8 @@ export class RunEngine {
|
||||
runnerId,
|
||||
releaseConcurrency,
|
||||
runChainState,
|
||||
scheduleId,
|
||||
scheduleInstanceId,
|
||||
}: TriggerParams,
|
||||
tx?: PrismaClientOrTransaction
|
||||
): Promise<TaskRun> {
|
||||
@@ -407,6 +405,8 @@ export class RunEngine {
|
||||
number,
|
||||
friendlyId,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
environmentType: environment.type,
|
||||
organizationId: environment.organization.id,
|
||||
projectId: environment.project.id,
|
||||
idempotencyKey,
|
||||
idempotencyKeyExpiresAt,
|
||||
@@ -455,6 +455,8 @@ export class RunEngine {
|
||||
maxDurationInSeconds,
|
||||
machinePreset: machine,
|
||||
runChainState,
|
||||
scheduleId,
|
||||
scheduleInstanceId,
|
||||
executionSnapshots: {
|
||||
create: {
|
||||
engine: "V2",
|
||||
@@ -552,6 +554,11 @@ export class RunEngine {
|
||||
}
|
||||
});
|
||||
|
||||
this.eventBus.emit("runCreated", {
|
||||
time: new Date(),
|
||||
runId: taskRun.id,
|
||||
});
|
||||
|
||||
return taskRun;
|
||||
},
|
||||
{
|
||||
|
||||
@@ -143,6 +143,25 @@ export class CheckpointSystem {
|
||||
throw new ServiceValidationError("Run not found", 404);
|
||||
}
|
||||
|
||||
this.$.eventBus.emit("runStatusChanged", {
|
||||
time: new Date(),
|
||||
run: {
|
||||
id: runId,
|
||||
status: run.status,
|
||||
updatedAt: run.updatedAt,
|
||||
createdAt: run.createdAt,
|
||||
},
|
||||
organization: {
|
||||
id: run.runtimeEnvironment.organizationId,
|
||||
},
|
||||
project: {
|
||||
id: run.runtimeEnvironment.projectId,
|
||||
},
|
||||
environment: {
|
||||
id: run.runtimeEnvironment.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Create the checkpoint
|
||||
const taskRunCheckpoint = await prisma.taskRunCheckpoint.create({
|
||||
data: {
|
||||
@@ -261,6 +280,11 @@ export class CheckpointSystem {
|
||||
id: true,
|
||||
status: true,
|
||||
attemptNumber: true,
|
||||
organizationId: true,
|
||||
runtimeEnvironmentId: true,
|
||||
projectId: true,
|
||||
updatedAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -272,6 +296,25 @@ export class CheckpointSystem {
|
||||
throw new ServiceValidationError("Run not found", 404);
|
||||
}
|
||||
|
||||
this.$.eventBus.emit("runStatusChanged", {
|
||||
time: new Date(),
|
||||
run: {
|
||||
id: runId,
|
||||
status: run.status,
|
||||
updatedAt: run.updatedAt,
|
||||
createdAt: run.createdAt,
|
||||
},
|
||||
organization: {
|
||||
id: run.organizationId ?? undefined,
|
||||
},
|
||||
project: {
|
||||
id: run.projectId,
|
||||
},
|
||||
environment: {
|
||||
id: run.runtimeEnvironmentId,
|
||||
},
|
||||
});
|
||||
|
||||
const newSnapshot = await this.executionSnapshotSystem.createExecutionSnapshot(prisma, {
|
||||
run,
|
||||
snapshot: {
|
||||
|
||||
@@ -68,6 +68,26 @@ export class DelayedRunSystem {
|
||||
|
||||
await this.$.worker.reschedule(`enqueueDelayedRun:${updatedRun.id}`, delayUntil);
|
||||
|
||||
this.$.eventBus.emit("runDelayRescheduled", {
|
||||
time: new Date(),
|
||||
run: {
|
||||
id: updatedRun.id,
|
||||
status: updatedRun.status,
|
||||
delayUntil: delayUntil,
|
||||
updatedAt: updatedRun.updatedAt,
|
||||
createdAt: updatedRun.createdAt,
|
||||
},
|
||||
organization: {
|
||||
id: snapshot.organizationId,
|
||||
},
|
||||
project: {
|
||||
id: updatedRun.projectId,
|
||||
},
|
||||
environment: {
|
||||
id: updatedRun.runtimeEnvironmentId,
|
||||
},
|
||||
});
|
||||
|
||||
return updatedRun;
|
||||
});
|
||||
},
|
||||
@@ -101,11 +121,33 @@ export class DelayedRunSystem {
|
||||
batchId: run.batchId ?? undefined,
|
||||
});
|
||||
|
||||
await this.$.prisma.taskRun.update({
|
||||
const queuedAt = new Date();
|
||||
|
||||
const updatedRun = await this.$.prisma.taskRun.update({
|
||||
where: { id: runId },
|
||||
data: {
|
||||
status: "PENDING",
|
||||
queuedAt: new Date(),
|
||||
queuedAt,
|
||||
},
|
||||
});
|
||||
|
||||
this.$.eventBus.emit("runEnqueuedAfterDelay", {
|
||||
time: new Date(),
|
||||
run: {
|
||||
id: runId,
|
||||
status: "PENDING",
|
||||
queuedAt,
|
||||
updatedAt: updatedRun.updatedAt,
|
||||
createdAt: updatedRun.createdAt,
|
||||
},
|
||||
organization: {
|
||||
id: run.runtimeEnvironment.organizationId,
|
||||
},
|
||||
project: {
|
||||
id: run.runtimeEnvironment.projectId,
|
||||
},
|
||||
environment: {
|
||||
id: run.runtimeEnvironmentId,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -329,25 +329,29 @@ export class DequeueSystem {
|
||||
maxAttempts = parsedConfig.data?.maxAttempts;
|
||||
}
|
||||
//update the run
|
||||
const lockedAt = new Date();
|
||||
const startedAt = result.run.startedAt ?? lockedAt;
|
||||
const maxDurationInSeconds = getMaxDuration(
|
||||
result.run.maxDurationInSeconds,
|
||||
result.task.maxDurationInSeconds
|
||||
);
|
||||
|
||||
const lockedTaskRun = await prisma.taskRun.update({
|
||||
where: {
|
||||
id: runId,
|
||||
},
|
||||
data: {
|
||||
lockedAt: new Date(),
|
||||
lockedAt,
|
||||
lockedById: result.task.id,
|
||||
lockedToVersionId: result.worker.id,
|
||||
lockedQueueId: result.queue.id,
|
||||
startedAt: result.run.startedAt ?? new Date(),
|
||||
startedAt,
|
||||
baseCostInCents: this.options.machines.baseCostInCents,
|
||||
machinePreset: machinePreset.name,
|
||||
taskVersion: result.worker.version,
|
||||
sdkVersion: result.worker.sdkVersion,
|
||||
cliVersion: result.worker.cliVersion,
|
||||
maxDurationInSeconds: getMaxDuration(
|
||||
result.run.maxDurationInSeconds,
|
||||
result.task.maxDurationInSeconds
|
||||
),
|
||||
maxDurationInSeconds,
|
||||
maxAttempts: maxAttempts ?? undefined,
|
||||
},
|
||||
include: {
|
||||
@@ -356,6 +360,37 @@ export class DequeueSystem {
|
||||
},
|
||||
});
|
||||
|
||||
this.$.eventBus.emit("runLocked", {
|
||||
time: new Date(),
|
||||
run: {
|
||||
id: runId,
|
||||
status: lockedTaskRun.status,
|
||||
lockedAt,
|
||||
lockedById: result.task.id,
|
||||
lockedToVersionId: result.worker.id,
|
||||
lockedQueueId: result.queue.id,
|
||||
startedAt,
|
||||
baseCostInCents: this.options.machines.baseCostInCents,
|
||||
machinePreset: machinePreset.name,
|
||||
taskVersion: result.worker.version,
|
||||
sdkVersion: result.worker.sdkVersion,
|
||||
cliVersion: result.worker.cliVersion,
|
||||
maxDurationInSeconds: lockedTaskRun.maxDurationInSeconds ?? undefined,
|
||||
maxAttempts: lockedTaskRun.maxAttempts ?? undefined,
|
||||
updatedAt: lockedTaskRun.updatedAt,
|
||||
createdAt: lockedTaskRun.createdAt,
|
||||
},
|
||||
organization: {
|
||||
id: orgId,
|
||||
},
|
||||
project: {
|
||||
id: lockedTaskRun.projectId,
|
||||
},
|
||||
environment: {
|
||||
id: lockedTaskRun.runtimeEnvironmentId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!lockedTaskRun) {
|
||||
this.$.logger.error(
|
||||
"RunEngine.dequeueFromMasterQueue(): Failed to lock task run",
|
||||
@@ -539,6 +574,8 @@ export class DequeueSystem {
|
||||
id: true,
|
||||
status: true,
|
||||
attemptNumber: true,
|
||||
updatedAt: true,
|
||||
createdAt: true,
|
||||
runtimeEnvironment: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -573,6 +610,25 @@ export class DequeueSystem {
|
||||
|
||||
//we ack because when it's deployed it will be requeued
|
||||
await this.$.runQueue.acknowledgeMessage(orgId, runId);
|
||||
|
||||
this.$.eventBus.emit("runStatusChanged", {
|
||||
time: new Date(),
|
||||
run: {
|
||||
id: runId,
|
||||
status: run.status,
|
||||
updatedAt: run.updatedAt,
|
||||
createdAt: run.createdAt,
|
||||
},
|
||||
organization: {
|
||||
id: run.runtimeEnvironment.project.organizationId,
|
||||
},
|
||||
project: {
|
||||
id: run.runtimeEnvironment.projectId,
|
||||
},
|
||||
environment: {
|
||||
id: run.runtimeEnvironment.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
{
|
||||
|
||||
@@ -100,6 +100,25 @@ export class PendingVersionSystem {
|
||||
tx,
|
||||
});
|
||||
});
|
||||
|
||||
this.$.eventBus.emit("runStatusChanged", {
|
||||
time: new Date(),
|
||||
run: {
|
||||
id: run.id,
|
||||
status: "PENDING",
|
||||
updatedAt: run.updatedAt,
|
||||
createdAt: run.createdAt,
|
||||
},
|
||||
organization: {
|
||||
id: backgroundWorker.runtimeEnvironment.organizationId,
|
||||
},
|
||||
project: {
|
||||
id: backgroundWorker.runtimeEnvironment.projectId,
|
||||
},
|
||||
environment: {
|
||||
id: backgroundWorker.runtimeEnvironmentId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
//enqueue more if needed
|
||||
|
||||
@@ -175,18 +175,6 @@ export class RunAttemptSystem {
|
||||
throw new ServiceValidationError("Max attempts reached", 400);
|
||||
}
|
||||
|
||||
this.$.eventBus.emit("runAttemptStarted", {
|
||||
time: new Date(),
|
||||
run: {
|
||||
id: taskRun.id,
|
||||
attemptNumber: nextAttemptNumber,
|
||||
baseCostInCents: taskRun.baseCostInCents,
|
||||
},
|
||||
organization: {
|
||||
id: environment.organization.id,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await $transaction(
|
||||
prisma,
|
||||
async (tx) => {
|
||||
@@ -258,6 +246,28 @@ export class RunAttemptSystem {
|
||||
|
||||
const { run, snapshot } = result;
|
||||
|
||||
this.$.eventBus.emit("runAttemptStarted", {
|
||||
time: new Date(),
|
||||
run: {
|
||||
id: run.id,
|
||||
status: run.status,
|
||||
createdAt: run.createdAt,
|
||||
updatedAt: run.updatedAt,
|
||||
attemptNumber: nextAttemptNumber,
|
||||
baseCostInCents: run.baseCostInCents,
|
||||
executedAt: run.executedAt ?? undefined,
|
||||
},
|
||||
organization: {
|
||||
id: environment.organization.id,
|
||||
},
|
||||
project: {
|
||||
id: environment.project.id,
|
||||
},
|
||||
environment: {
|
||||
id: environment.id,
|
||||
},
|
||||
});
|
||||
|
||||
const machinePreset = getMachinePreset({
|
||||
machines: this.options.machines.machines,
|
||||
defaultMachine: this.options.machines.defaultMachine,
|
||||
@@ -455,6 +465,7 @@ export class RunAttemptSystem {
|
||||
status: true,
|
||||
attemptNumber: true,
|
||||
spanId: true,
|
||||
updatedAt: true,
|
||||
associatedWaitpoint: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -470,6 +481,10 @@ export class RunAttemptSystem {
|
||||
completedAt: true,
|
||||
taskEventStore: true,
|
||||
parentTaskRunId: true,
|
||||
usageDurationMs: true,
|
||||
costInCents: true,
|
||||
runtimeEnvironmentId: true,
|
||||
projectId: true,
|
||||
},
|
||||
});
|
||||
const newSnapshot = await getLatestExecutionSnapshot(prisma, runId);
|
||||
@@ -503,12 +518,26 @@ export class RunAttemptSystem {
|
||||
time: completedAt,
|
||||
run: {
|
||||
id: runId,
|
||||
status: run.status,
|
||||
spanId: run.spanId,
|
||||
output: completion.output,
|
||||
outputType: completion.outputType,
|
||||
createdAt: run.createdAt,
|
||||
completedAt: run.completedAt,
|
||||
taskEventStore: run.taskEventStore,
|
||||
usageDurationMs: run.usageDurationMs,
|
||||
costInCents: run.costInCents,
|
||||
updatedAt: run.updatedAt,
|
||||
attemptNumber: run.attemptNumber ?? 1,
|
||||
},
|
||||
organization: {
|
||||
id: run.project.organizationId,
|
||||
},
|
||||
project: {
|
||||
id: run.projectId,
|
||||
},
|
||||
environment: {
|
||||
id: run.runtimeEnvironmentId,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -593,6 +622,7 @@ export class RunAttemptSystem {
|
||||
taskEventStore: true,
|
||||
createdAt: true,
|
||||
completedAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -611,6 +641,7 @@ export class RunAttemptSystem {
|
||||
createdAt: minimalRun.createdAt,
|
||||
completedAt: minimalRun.completedAt,
|
||||
taskEventStore: minimalRun.taskEventStore,
|
||||
updatedAt: minimalRun.updatedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -679,6 +710,7 @@ export class RunAttemptSystem {
|
||||
createdAt: run.createdAt,
|
||||
completedAt: run.completedAt,
|
||||
taskEventStore: run.taskEventStore,
|
||||
updatedAt: run.updatedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -687,6 +719,7 @@ export class RunAttemptSystem {
|
||||
time: failedAt,
|
||||
run: {
|
||||
id: run.id,
|
||||
status: run.status,
|
||||
friendlyId: run.friendlyId,
|
||||
attemptNumber: nextAttemptNumber,
|
||||
queue: run.queue,
|
||||
@@ -695,6 +728,9 @@ export class RunAttemptSystem {
|
||||
baseCostInCents: run.baseCostInCents,
|
||||
spanId: run.spanId,
|
||||
nextMachineAfterOOM: retryResult.machine,
|
||||
updatedAt: run.updatedAt,
|
||||
error: completion.error,
|
||||
createdAt: run.createdAt,
|
||||
},
|
||||
organization: {
|
||||
id: run.runtimeEnvironment.organizationId,
|
||||
@@ -974,6 +1010,7 @@ export class RunAttemptSystem {
|
||||
taskEventStore: true,
|
||||
parentTaskRunId: true,
|
||||
delayUntil: true,
|
||||
updatedAt: true,
|
||||
runtimeEnvironment: {
|
||||
select: {
|
||||
organizationId: true,
|
||||
@@ -1056,12 +1093,24 @@ export class RunAttemptSystem {
|
||||
time: new Date(),
|
||||
run: {
|
||||
id: run.id,
|
||||
status: run.status,
|
||||
friendlyId: run.friendlyId,
|
||||
spanId: run.spanId,
|
||||
taskEventStore: run.taskEventStore,
|
||||
createdAt: run.createdAt,
|
||||
completedAt: run.completedAt,
|
||||
error,
|
||||
updatedAt: run.updatedAt,
|
||||
attemptNumber: run.attemptNumber ?? 1,
|
||||
},
|
||||
organization: {
|
||||
id: latestSnapshot.organizationId,
|
||||
},
|
||||
project: {
|
||||
id: latestSnapshot.projectId,
|
||||
},
|
||||
environment: {
|
||||
id: latestSnapshot.environmentId,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1121,6 +1170,9 @@ export class RunAttemptSystem {
|
||||
spanId: true,
|
||||
batchId: true,
|
||||
parentTaskRunId: true,
|
||||
updatedAt: true,
|
||||
usageDurationMs: true,
|
||||
costInCents: true,
|
||||
associatedWaitpoint: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -1181,6 +1233,19 @@ export class RunAttemptSystem {
|
||||
taskEventStore: run.taskEventStore,
|
||||
createdAt: run.createdAt,
|
||||
completedAt: run.completedAt,
|
||||
updatedAt: run.updatedAt,
|
||||
attemptNumber: run.attemptNumber ?? 1,
|
||||
usageDurationMs: run.usageDurationMs,
|
||||
costInCents: run.costInCents,
|
||||
},
|
||||
organization: {
|
||||
id: run.runtimeEnvironment.project.organizationId,
|
||||
},
|
||||
project: {
|
||||
id: run.runtimeEnvironment.project.id,
|
||||
},
|
||||
environment: {
|
||||
id: run.runtimeEnvironment.id,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { startSpan } from "@internal/tracing";
|
||||
import { SystemResources } from "./systems.js";
|
||||
import { PrismaClientOrTransaction, TaskRun } from "@trigger.dev/database";
|
||||
import { getLatestExecutionSnapshot } from "./executionSnapshotSystem.js";
|
||||
import { parseNaturalLanguageDuration } from "@trigger.dev/core/v3/isomorphic";
|
||||
import { TaskRunError } from "@trigger.dev/core/v3/schemas";
|
||||
import { PrismaClientOrTransaction } from "@trigger.dev/database";
|
||||
import { ServiceValidationError } from "../errors.js";
|
||||
import { isExecuting } from "../statuses.js";
|
||||
import { TaskRunError } from "@trigger.dev/core/v3/schemas";
|
||||
import { getLatestExecutionSnapshot } from "./executionSnapshotSystem.js";
|
||||
import { SystemResources } from "./systems.js";
|
||||
import { WaitpointSystem } from "./waitpointSystem.js";
|
||||
|
||||
export type TtlSystemOptions = {
|
||||
@@ -85,6 +84,7 @@ export class TtlSystem {
|
||||
id: true,
|
||||
spanId: true,
|
||||
ttl: true,
|
||||
updatedAt: true,
|
||||
associatedWaitpoint: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -93,12 +93,16 @@ export class TtlSystem {
|
||||
runtimeEnvironment: {
|
||||
select: {
|
||||
organizationId: true,
|
||||
projectId: true,
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
createdAt: true,
|
||||
completedAt: true,
|
||||
taskEventStore: true,
|
||||
parentTaskRunId: true,
|
||||
expiredAt: true,
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -113,7 +117,13 @@ export class TtlSystem {
|
||||
output: { value: JSON.stringify(error), isError: true },
|
||||
});
|
||||
|
||||
this.$.eventBus.emit("runExpired", { run: updatedRun, time: new Date() });
|
||||
this.$.eventBus.emit("runExpired", {
|
||||
run: updatedRun,
|
||||
time: new Date(),
|
||||
organization: { id: updatedRun.runtimeEnvironment.organizationId },
|
||||
project: { id: updatedRun.runtimeEnvironment.projectId },
|
||||
environment: { id: updatedRun.runtimeEnvironment.id },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -118,6 +118,8 @@ export type TriggerParams = {
|
||||
runnerId?: string;
|
||||
releaseConcurrency?: boolean;
|
||||
runChainState?: RunChainState;
|
||||
scheduleId?: string;
|
||||
scheduleInstanceId?: string;
|
||||
};
|
||||
|
||||
export type EngineWorker = Worker<typeof workerCatalog>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { RunEngine } from "./engine/index.js";
|
||||
export { RunDuplicateIdempotencyKeyError, RunOneTimeUseTokenError } from "./engine/errors.js";
|
||||
export type { EventBusEventArgs } from "./engine/eventBus.js";
|
||||
export type { EventBusEventArgs, EventBusEvents } from "./engine/eventBus.js";
|
||||
export type { AuthenticatedEnvironment } from "./shared/index.js";
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"dependencies": {
|
||||
"@clickhouse/client": "^1.11.1",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@trigger.dev/database": "workspace:*",
|
||||
"ioredis": "^5.3.2"
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { ClickHouseClient } from "@clickhouse/client";
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import {
|
||||
AbstractStartedContainer,
|
||||
GenericContainer,
|
||||
StartedTestContainer,
|
||||
Wait,
|
||||
} from "testcontainers";
|
||||
|
||||
const CLICKHOUSE_PORT = 9000;
|
||||
const CLICKHOUSE_HTTP_PORT = 8123;
|
||||
|
||||
export class ClickHouseContainer extends GenericContainer {
|
||||
private username = "test";
|
||||
private password = "test";
|
||||
private database = "test";
|
||||
|
||||
constructor(image = "clickhouse/clickhouse-server:25.4-alpine") {
|
||||
super(image);
|
||||
this.withExposedPorts(CLICKHOUSE_PORT, CLICKHOUSE_HTTP_PORT);
|
||||
this.withWaitStrategy(
|
||||
Wait.forHttp("/", CLICKHOUSE_HTTP_PORT).forResponsePredicate(
|
||||
(response) => response === "Ok.\n"
|
||||
)
|
||||
);
|
||||
this.withStartupTimeout(120_000);
|
||||
|
||||
// Setting this high ulimits value proactively prevents the "Too many open files" error,
|
||||
// especially under potentially heavy load during testing.
|
||||
this.withUlimits({
|
||||
nofile: {
|
||||
hard: 262144,
|
||||
soft: 262144,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public withDatabase(database: string): this {
|
||||
this.database = database;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withUsername(username: string): this {
|
||||
this.username = username;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withPassword(password: string): this {
|
||||
this.password = password;
|
||||
return this;
|
||||
}
|
||||
|
||||
public override async start(): Promise<StartedClickHouseContainer> {
|
||||
this.withEnvironment({
|
||||
CLICKHOUSE_USER: this.username,
|
||||
CLICKHOUSE_PASSWORD: this.password,
|
||||
CLICKHOUSE_DB: this.database,
|
||||
});
|
||||
|
||||
return new StartedClickHouseContainer(
|
||||
await super.start(),
|
||||
this.database,
|
||||
this.username,
|
||||
this.password
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class StartedClickHouseContainer extends AbstractStartedContainer {
|
||||
constructor(
|
||||
startedTestContainer: StartedTestContainer,
|
||||
private readonly database: string,
|
||||
private readonly username: string,
|
||||
private readonly password: string
|
||||
) {
|
||||
super(startedTestContainer);
|
||||
}
|
||||
|
||||
public getPort(): number {
|
||||
return super.getMappedPort(CLICKHOUSE_PORT);
|
||||
}
|
||||
|
||||
public getHttpPort(): number {
|
||||
return super.getMappedPort(CLICKHOUSE_HTTP_PORT);
|
||||
}
|
||||
|
||||
public getUsername(): string {
|
||||
return this.username;
|
||||
}
|
||||
|
||||
public getPassword(): string {
|
||||
return this.password;
|
||||
}
|
||||
|
||||
public getDatabase(): string {
|
||||
return this.database;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the base HTTP URL (protocol, host and mapped port) for the ClickHouse container's HTTP interface.
|
||||
* Example: `http://localhost:32768`
|
||||
*/
|
||||
public getHttpUrl(): string {
|
||||
const protocol = "http";
|
||||
const host = this.getHost();
|
||||
const port = this.getHttpPort();
|
||||
return `${protocol}://${host}:${port}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets configuration options suitable for passing directly to `createClient({...})`
|
||||
* from `@clickhouse/client`. Uses the HTTP interface.
|
||||
*/
|
||||
public getClientOptions(): {
|
||||
url?: string;
|
||||
username: string;
|
||||
password: string;
|
||||
database: string;
|
||||
} {
|
||||
return {
|
||||
url: this.getHttpUrl(),
|
||||
username: this.getUsername(),
|
||||
password: this.getPassword(),
|
||||
database: this.getDatabase(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a ClickHouse connection URL for the HTTP interface with format:
|
||||
* http://username:password@hostname:port/database
|
||||
* @returns The ClickHouse HTTP URL string.
|
||||
*/
|
||||
public getConnectionUrl(): string {
|
||||
const url = new URL(this.getHttpUrl());
|
||||
|
||||
url.username = this.getUsername();
|
||||
url.password = this.getPassword();
|
||||
|
||||
const dbName = this.getDatabase();
|
||||
url.pathname = dbName.startsWith("/") ? dbName : `/${dbName}`;
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
}
|
||||
|
||||
export async function runClickhouseMigrations(client: ClickHouseClient, migrationsPath: string) {
|
||||
// Get all the *.sql files in the migrations path
|
||||
const queries = await getAllClickhouseMigrationQueries(migrationsPath);
|
||||
|
||||
for (const query of queries) {
|
||||
await client.command({
|
||||
query,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function getAllClickhouseMigrationQueries(migrationsPath: string) {
|
||||
const queries: string[] = [];
|
||||
// Get all the *.sql files in the migrations path
|
||||
const migrations = await readdir(migrationsPath);
|
||||
|
||||
for (const migration of migrations) {
|
||||
const migrationPath = resolve(migrationsPath, migration);
|
||||
|
||||
const migrationContent = await readFile(migrationPath, "utf-8");
|
||||
|
||||
// Split content by goose markers
|
||||
const parts = migrationContent.split(/--\s*\+goose\s+(Up|Down)/i);
|
||||
|
||||
// The array will be: ["", "Up", "up queries", "Down", "down queries"]
|
||||
// We want the "up queries" part which is at index 2
|
||||
if (parts.length >= 3) {
|
||||
const upQueries = parts[2]!.trim();
|
||||
queries.push(
|
||||
...upQueries
|
||||
.split(";")
|
||||
.filter((q) => q.trim())
|
||||
.map((q) => q.trim())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return queries;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { RedisOptions } from "ioredis";
|
||||
import { Network, type StartedNetwork } from "testcontainers";
|
||||
import { TaskContext, test } from "vitest";
|
||||
import {
|
||||
createClickHouseContainer,
|
||||
createElectricContainer,
|
||||
createPostgresContainer,
|
||||
createRedisContainer,
|
||||
@@ -12,6 +13,8 @@ import {
|
||||
withContainerSetup,
|
||||
} from "./utils";
|
||||
import { getTaskMetadata, logCleanup, logSetup } from "./logs";
|
||||
import { StartedClickHouseContainer } from "./clickhouse";
|
||||
import { ClickHouseClient, createClient } from "@clickhouse/client";
|
||||
|
||||
export { assertNonNullable } from "./utils";
|
||||
export { StartedRedisContainer };
|
||||
@@ -32,7 +35,8 @@ type ElectricContext = {
|
||||
electricOrigin: string;
|
||||
};
|
||||
|
||||
type ContainerContext = NetworkContext & PostgresContext & RedisContext;
|
||||
type ContainerContext = NetworkContext & PostgresContext & RedisContext & ClickhouseContext;
|
||||
type PostgresAndRedisContext = NetworkContext & PostgresContext & RedisContext;
|
||||
type ContainerWithElectricAndRedisContext = ContainerContext & ElectricContext;
|
||||
type ContainerWithElectricContext = NetworkContext & PostgresContext & ElectricContext;
|
||||
|
||||
@@ -170,12 +174,61 @@ const electricOrigin = async (
|
||||
await useContainer("electricContainer", { container, task, use: () => use(origin) });
|
||||
};
|
||||
|
||||
const clickhouseContainer = async (
|
||||
{ network, task }: { network: StartedNetwork } & TaskContext,
|
||||
use: Use<StartedClickHouseContainer>
|
||||
) => {
|
||||
const { container, metadata } = await withContainerSetup({
|
||||
name: "clickhouseContainer",
|
||||
task,
|
||||
setup: createClickHouseContainer(network),
|
||||
});
|
||||
|
||||
await useContainer("clickhouseContainer", { container, task, use: () => use(container) });
|
||||
};
|
||||
|
||||
const clickhouseClient = async (
|
||||
{ clickhouseContainer, task }: { clickhouseContainer: StartedClickHouseContainer } & TaskContext,
|
||||
use: Use<ClickHouseClient>
|
||||
) => {
|
||||
const testName = task.name;
|
||||
const client = createClient({ url: clickhouseContainer.getConnectionUrl() });
|
||||
|
||||
try {
|
||||
await use(client);
|
||||
} finally {
|
||||
await logCleanup("clickhouseClient", client.close(), { testName });
|
||||
}
|
||||
};
|
||||
|
||||
type ClickhouseContext = {
|
||||
network: StartedNetwork;
|
||||
clickhouseContainer: StartedClickHouseContainer;
|
||||
clickhouseClient: ClickHouseClient;
|
||||
};
|
||||
|
||||
export const clickhouseTest = test.extend<ClickhouseContext>({
|
||||
network,
|
||||
clickhouseContainer,
|
||||
clickhouseClient,
|
||||
});
|
||||
|
||||
export const postgresAndRedisTest = test.extend<PostgresAndRedisContext>({
|
||||
network,
|
||||
postgresContainer,
|
||||
prisma,
|
||||
redisContainer,
|
||||
redisOptions,
|
||||
});
|
||||
|
||||
export const containerTest = test.extend<ContainerContext>({
|
||||
network,
|
||||
postgresContainer,
|
||||
prisma,
|
||||
redisContainer,
|
||||
redisOptions,
|
||||
clickhouseContainer,
|
||||
clickhouseClient,
|
||||
});
|
||||
|
||||
export const containerWithElectricTest = test.extend<ContainerWithElectricContext>({
|
||||
@@ -192,4 +245,6 @@ export const containerWithElectricAndRedisTest = test.extend<ContainerWithElectr
|
||||
redisContainer,
|
||||
redisOptions,
|
||||
electricOrigin,
|
||||
clickhouseContainer,
|
||||
clickhouseClient,
|
||||
});
|
||||
|
||||
@@ -9,6 +9,9 @@ import { x } from "tinyexec";
|
||||
import { expect, TaskContext } from "vitest";
|
||||
import { getContainerMetadata, getTaskMetadata, logCleanup } from "./logs";
|
||||
import { logSetup } from "./logs";
|
||||
import { ClickHouseContainer, runClickhouseMigrations } from "./clickhouse";
|
||||
import { createClient } from "@clickhouse/client";
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
|
||||
export async function createPostgresContainer(network: StartedNetwork) {
|
||||
const container = await new PostgreSqlContainer("docker.io/postgres:14")
|
||||
@@ -45,6 +48,27 @@ export async function createPostgresContainer(network: StartedNetwork) {
|
||||
return { url: container.getConnectionUri(), container, network };
|
||||
}
|
||||
|
||||
export async function createClickHouseContainer(network: StartedNetwork) {
|
||||
const container = await new ClickHouseContainer().withNetwork(network).start();
|
||||
|
||||
const client = createClient({
|
||||
url: container.getConnectionUrl(),
|
||||
});
|
||||
|
||||
await client.ping();
|
||||
|
||||
// Now we run the migrations
|
||||
const migrationsPath = path.resolve(__dirname, "../../clickhouse/schema");
|
||||
|
||||
await runClickhouseMigrations(client, migrationsPath);
|
||||
|
||||
return {
|
||||
url: container.getConnectionUrl(),
|
||||
container,
|
||||
network,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createRedisContainer({
|
||||
port,
|
||||
network,
|
||||
|
||||
@@ -45,6 +45,14 @@ export async function startSpan<T>(
|
||||
});
|
||||
}
|
||||
|
||||
export function recordSpanError(span: Span, error: Error) {
|
||||
span.recordException(error);
|
||||
span.setStatus({
|
||||
code: SpanStatusCode.ERROR,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
export async function emitDebugLog(
|
||||
logger: Logger,
|
||||
message: string,
|
||||
|
||||
+2
-2
@@ -22,7 +22,7 @@
|
||||
"lint": "turbo run lint",
|
||||
"docker": "docker compose -p triggerdotdev-docker -f docker/docker-compose.yml up -d --build --remove-orphans",
|
||||
"docker:stop": "docker compose -p triggerdotdev-docker -f docker/docker-compose.yml stop",
|
||||
"dev:docker": "docker compose -p triggerdotdev-dev-docker -f docker/dev-compose.yml up -d",
|
||||
"dev:docker": "docker compose -p triggerdotdev-dev-docker -f docker/dev-compose.yml up -d --build --remove-orphans",
|
||||
"dev:docker:build": "docker compose -p triggerdotdev-dev-docker -f docker/dev-compose.yml up -d --build",
|
||||
"dev:docker:stop": "docker compose -p triggerdotdev-dev-docker -f docker/dev-compose.yml stop",
|
||||
"test": "turbo run test --concurrency=1 -- --run",
|
||||
@@ -81,4 +81,4 @@
|
||||
"@kubernetes/client-node@1.0.0": "patches/@kubernetes__client-node@1.0.0.patch"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@ export function createAsyncIterableReadable<S, T>(
|
||||
|
||||
export function createAsyncIterableStreamFromAsyncIterable<T>(
|
||||
asyncIterable: AsyncIterable<T>,
|
||||
transformer: Transformer<T, T>,
|
||||
transformer?: Transformer<T, T>,
|
||||
signal?: AbortSignal
|
||||
): AsyncIterableStream<T> {
|
||||
const stream = new ReadableStream<T>({
|
||||
@@ -95,3 +95,11 @@ export function createAsyncIterableStreamFromAsyncIterable<T>(
|
||||
|
||||
return transformedStream as AsyncIterableStream<T>;
|
||||
}
|
||||
|
||||
export function createAsyncIterableStreamFromAsyncGenerator<T>(
|
||||
asyncGenerator: AsyncGenerator<T, void, unknown>,
|
||||
transformer: Transformer<T, T>,
|
||||
signal?: AbortSignal
|
||||
): AsyncIterableStream<T> {
|
||||
return createAsyncIterableStreamFromAsyncIterable(asyncGenerator, transformer, signal);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Types for the result object with discriminated union
|
||||
type Success<T> = [null, T];
|
||||
type Failure<E> = [E, null];
|
||||
export type Success<T> = [null, T];
|
||||
export type Failure<E> = [E, null];
|
||||
|
||||
type Result<T, E = Error> = Success<T> | Failure<E>;
|
||||
export type Result<T, E = Error> = Success<T> | Failure<E>;
|
||||
|
||||
// Main wrapper function
|
||||
export async function tryCatch<T, E = Error>(promise: Promise<T>): Promise<Result<T, E>> {
|
||||
|
||||
Generated
+911
-548
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@
|
||||
"zod": "3.23.8"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "trigger dev"
|
||||
"dev": "trigger dev",
|
||||
"deploy": "trigger deploy"
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
docker build -t local-triggerdotdev:latest -f docker/Dockerfile .
|
||||
image=local-triggerdotdev:latest
|
||||
src=/triggerdotdev
|
||||
dst=$(mktemp -d)
|
||||
|
||||
mkdir -p $dst
|
||||
|
||||
echo -e "Extracting image into $dst..."
|
||||
|
||||
container=$(docker create "$image")
|
||||
docker cp "$container:$src" "$dst"
|
||||
docker rm "$container"
|
||||
/Applications/Visual\ Studio\ Code.app/Contents/Resources/app/bin/code "$dst/triggerdotdev"
|
||||
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
prometheus --config.file=./.configs/prometheus.yml --storage.tsdb.path=/tmp/prom-data
|
||||
Reference in New Issue
Block a user